I/O 多路复用
2021-07-20 02:50:09 #网络编程 

五种 I/O 模型

顺便用去食堂打饭这个例子来分别解释一下

  1. 阻塞 I/O(BIO)

    常见 I/O 模型,是可能会使进程永远阻塞的一类系统调用。发起一次 I/O 操作后需要等待成功或失败,在此期间程序不能进行其他处理。阻塞 I/O 只对单个文件描述符进行处理

    例如服务端采用单线程,accept一个 socket 后,在recvsend调用阻塞时,无法accept其他 socket,即无法并发处理

    采用多线程,主线程accept一个 socket,利用工作线程对 socket 进行recvsend,可以并发处理。但是当 socket 过多,造成大量线程占用大量内存,且切换线程带来资源开销,产生浪费

    image-20210718094834264

    有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他坐在食堂,拿着饭盒看着后厨炒菜。终于菜好了,他打上饭回宿舍大快朵颐去了

  2. 非阻塞 I/O(NIO)

    针对阻塞 I/O 的文件描述符做指定。针对这样的文件描述符的操作如果不能立刻完成,会出错返回。通常发生在 for 循环中,利用 for 循环完成轮询。非阻塞 I/O 只对单个文件描述符进行处理

    服务端accept一个 socket 后,加入描述符集合fds,轮询时对每一个fd进行操作,没有数据则返回错误,在轮询这样的fd时会浪费 CPU,大多数情况下fd无数据可以读写

    对于给定的文件描述符,利用两种方式指定其为非阻塞 I/O

    • 如果调用open()获得文件描述符,则可指定0_NONBLOCK标志

      详情见《UNIX 环境高级编程》3.3 小节

    • 对于已经打开的一个描述符,则可调用fcntl(),由该函数打开文件描述符的0_NONBLOCK标志

      详情见《UNIX 环境高级编程》3.14 小节

    image-20210718094855976

    有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他拿着饭盒回宿舍打游戏去了。过了十分钟又去打饭,阿姨说还没好,于是又回宿舍了……第五次去的时候菜终于好了,他打上饭回宿舍吃着来之不易的饭

  3. I/O 多路复用

    通过 Linux 下的select()poll()epoll(),对一组文件描述符进行相关事件的注册,然后阻塞等待某些事情的发生(准备好读、准备好写、异常条件待处理)或等待超时(参数timeout)。I/O 多路复用值一个线程可以对多个文件描述符进行监视处理,一旦某个描述符就绪,通知应用程序进行相应读写操作

    image-20210718094947961

    有一个人前来打饭。他不止想打饭,还想买个可乐和大布丁雪糕,阿姨说这仨玩意儿现在都没有,要等一会儿。于是他回宿舍打游戏去了。过了一会他给阿姨打电话,阿姨说菜好了,大布丁也有了,但是可乐没有(可读条件)。于是他带着饭盒去打饭,买了个大布丁,回到宿舍开开心心的吃饭了

  4. 信号驱动 I/O

    利用信号,使得内核在文件描述符就绪时发送特定信号通知应用程序。在这样的系统调用中,会立刻返回,进程继续工作;当文件描述符就绪时,内核发送信号到进程,让进程来决定是否处理以及如何处理

    image-20210729082929427

    有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他拿着饭盒回宿舍打游戏去了,但是临走前跟阿姨说菜做好了麻烦给他打个电话。十分钟后阿姨打来电话(信号),他看了一眼游戏成功的结算页面,寻思着再来两把于是没去打饭而是继续征战 OR 他看了一眼游戏失败的结算页面,寻思着吃饭吧于是带着饭盒去食堂打好饭回宿舍大快朵颐了

  5. 异步 I/O

    过程大体上与信号驱动 I/O 类似,但是不同点在于,信号驱动 I/O 在文件描述符就绪后,通过信号告知进程,并由进程决定是否处理以及如何处理;异步 I/O 则在调用时就决定如何处理(读的话从哪读?写的话写到哪?)并返回,内核在文件描述符就绪后直接进行处理,最后将处理结果告知进程

    image-20210729083331415

    有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他放下饭盒,告诉阿姨自己想吃什么菜,请阿姨到时候帮他打好(操作流程)并打个电话告诉他,然后就回宿舍了。十分钟后阿姨打来电话(信号),他来到食堂跟阿姨说了一声谢谢,带着饭盒回宿舍大快朵颐去了


I/O 多路复用

select

描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdpl, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timeval *timeout);

void FD_ZERO(fd_set *fdset); // 所有位设置为0
void FD_SET(int fd, fd_set *fdset); // 开启描述符集中的某一个描述符
void FD_CLR(int fd, fd_set *fdset); // 清除描述符集中的某一个描述符
int FD_ISSET(int fd, fd_set *fdset); // 检测描述符集中的某一个描述符

struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};

参数:

  1. 使用人员关心的一组描述符

    对所有监视的文件描述符中,选出最大的描述符,对其值 +1,作为参数传入;也可以设置为FD_SETSIZE

    1
    2
    3
    4
    // /usr/include/linux/posix_types.h
    #define __FD_SETSIZE 1024
    // /usr/include/sys/select.h
    #define FD_SETSIZE __FD_SETSIZE
  2. 对于每个描述符所关心的条件

    fd_set结构体仅包含一个整形数组,其中每一位标记一个描述符,能容纳的描述符总量由FD_SETSIZE指定,最大位 1024

    image-20210718103136371

    声明一个描述符集后,调用FD_ZERO对所有位设置为 0,再通过FD_SET对需要的描述符设置位

  3. 预设等待时间

    timeout==NULL:永远等待,捕捉到一个信号则中断等待

    timeout->tv_sec == 0 && timeout->tv_usec == 0:不等待,查看所有指定描述符并立即返回

    timeout->tv_sec != 0 || timeout->tv_usec != 0:等待指定的时间,当指定的时间内有描述符准备好或超过指定时间,则返回

1
2
3
4
5
6
7
8
9
10
11
12
fd_set readset, writeset;
FD_ZERO(&readset);
FD_ZERO(&writeset);
FD_SET(0, &readset);
FD_SET(3, &readset);
FD_SET(1, &writeset);
FD_SET(2, &writeset);
select(4, &readset, &writeset, NULL, NULL);
// 假设描述符3可读
if(FD_ISSET(3, &readset)){
...
}

针对描述符 0、3 监听是否可读;描述符 1、2 监听是否可写;不设置异常处理;永远等待,直到监听完成

通常应用程序采用while(true)轮询每个描述符是否可读可写

返回值:

  1. 返回值-1 表示出错,没有一个描述符准备好时返回了一个信号
  2. 返回值 0 表示超过指定时间且没有描述符准备好,此时所有描述符集会置 0
  3. 一个正数返回值表示已经准备好的描述符数,是三个描述符集中准备好的描述符之和;如果同一个描述符准备好读和写,则计数两次

特点

  • 每当调用select()时,当前进程进入阻塞态
  • 每次调用select(),需要将描述符集从用户态拷贝到内核态,存在拷贝开销
  • 每个进程最多监视 1024 个描述符
  • 进程被唤醒后,通过while(true)线性轮询,扫描每一个描述符,开销大,浪费 CPU

通过select()实现非阻塞连接

在编写服务器的过程中,考虑到accept()属于阻塞连接,可以通过select()实现非阻塞模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int
accept(sockaddr_in &client_address)
{
socklen_t client_address_length = sizeof(client_address);
memset(&client_address, 0, client_address_length);

// 通过 select() 实现非阻塞 accept()
fd_set select_fd;
FD_ZERO(&select_fd);
FD_SET(socket_fd_, &select_fd);
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 0;

// 如果监听队列中无连接,则返回 -1
if (select(socket_fd_ + 1, &select_fd, nullptr, nullptr, &timeout) <= 0)
return -1;

int connection_fd = ::accept(socket_fd_, (struct sockaddr *)&client_address, &client_address_length);

return connection_fd;
}

也可以直接创建非阻塞的 socket

1
socket_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

poll

描述

1
2
3
#include <poll.h>

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

参数:

  1. 使用人员关心的一组描述符

    select()类似,但是通过pollfd结构体的数组表示描述符集,每个数组元素中fd表示描述符编号,events表示期待的事件,内核将描述符发生的事件填充入revents

    1
    2
    3
    4
    5
    struct pollfd {
    int fd; // 文件描述符
    short events; // 注册的事件
    short revents; // 实际发生的事件,由内核填充
    };

    image-20210718134749925

  2. 数组fds的长度由nfds指定

  3. 预设等待时间

    timeout == -1:永远等待,捕捉到一个信号则中断等待

    timeout == 0:不等待,查看所有指定描述符并立即返回

    timeout > 0:等待timeout毫秒,当指定的时间内有描述符准备好或超过指定时间,则返回

返回值:select()类似


特点

  • 通过链表存储,没有最大连接数的限制
  • 每次调用poll(),需要将描述符集从用户态拷贝到内核态,存在拷贝开销
  • 进程被唤醒后,通过while(true)线性轮询,扫描每一个描述符,开销大,浪费 CPU

epoll

描述

1
2
3
4
5
#include <sys/epoll.h>

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_create():创建一个eventpoll结构体

1
2
3
4
struct eventpoll {
struct rb_root rbr;
struct list_head rdllist;
};

rbr:红黑树的根结点,存储由epoll_ctl()添加的、需要监控的所有事件

rdllist:双向链表的根结点,存储满足条件的事件,由epoll_wait()取出并返回给用户

参数:

1.int size:epoll 对象需要处理的事件的大致个数,最新的一些内核版本中参数无意义

返回值:

文件描述符,指向 epoll 对象,后续使用 epoll 需要这个文件描述符

epoll_ctl():向 epoll 对象中添加、修改和删除事件

参数:

1.int epfd:指向 epoll 对象的文件描述符
2.int op:取值表示添加、修改或删除某一事件

1
2
3
EPOLL_CTL_ADD	// 添加
EPOLL_CTL_MOD // 修改
EPOLL_CTL_DEL // 删除

3.int fd:待监测的 socket
4.struct epoll_event *event:感兴趣的事件
1
2
3
4
5
6
7
8
9
10
11
struct epoll_event {
__uint32_t events;
epoll_data_t data;
};

typedef union epoll_data {
void ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

events:描述事件类型,与 poll 大致相同
image-20210721141531839
data:联合体,一般使用fd关连到待监测的文件描述符;也可以使用ptr指向用户数据。因为时联合体,只能使用其中一个

返回值:

  1. 返回值 0 表示成功
  2. 返回值-1 表示失败,设置errno

epoll_wait():收集在 epoll 所有监控的事件中已发生的事件,如果没有事件发生,等待timeout毫秒后返回

参数:

1.int epfd:指向 epoll 对象的文件描述符
2.struct epoll_event *events:一般为数组,由内核将rdllist中收集的数据复制到数组中(events不可以是空指针,应当分配好内存)
3.int maxevents:本次可以返回的最大事件数目,一般与events数组的大小相等
4.int timeoutrdllist为空时的等待时间(毫秒);当rdlllist为空且时间设置为 0 则立刻返回

返回值:

  1. 返回值大于 0 表示当前收集到的发生事件的个数
  2. 返回值为 0 表示本次调用没有返回事件
  3. 返回值-1 表示失败,设置errno

特点

  • 没有最大并发连接限制
  • 内核中维护一个红黑树,通过epoll_ctl()往红黑树中添加、删除和修改事件,不需要每次都从用户态拷贝所有事件到内核态,减少了开销
  • 维护一个双向链表,通过epoll_wait()收集所有已发送的事件,不像select()/poll()那样轮询发生和未发生的事件
  • 只能在 Linux 下工作

LT 模式与 ET 模式

LT 模式:默认的工作模式,相当于一个效率较高的poll。当epoll_wait()检测到事件发生时,会通知应用程序,应用程序可以不立即处理该事件;下次循环,epoll_wait()会再次提醒,直到被处理

可以处理阻塞和非阻塞的 socket

ET 模式:epoll_wait()检测到事件发生时,会通知应用程序(且后续不会再通知),所以需要立即处理该事件。降低了同一个 epoll 事件被重复触发的次数,提高效率

只能处理非阻塞的 socket


三者比较

select poll epoll
操作方式 轮询 轮询 回调
数据结构 bitmap 数组 红黑树
最大连接数 1204(32 位)|2048(64 位) 无上限 无上限
最大支持文件描述符数 有最大值限制 65535 65535
文件描述符拷贝 每次轮询时将fds从用户态拷贝到内核态 每次轮询时将fds从用户态拷贝到内核态 eppol_ctl()拷贝到内核的事件表中,eppol_wait()直接读取事件表
工作模式 LT LT 支持 ET
工作效率 轮询, 轮询, 回调,