1.简介

I/O模型主要和其信息读取的方式有关,主要体现在两方面:

  • 同步还是异步
  • 阻塞还是非阻塞

1.1.同步与异步

同步与异步和消息的通知机制有关。同步指的是主动询问,异步指的是被动通知。

  • 当一个同步调用发生后,调用者要等待消息返回结果后,才能进行后续的执行
  • 当一个异步调用发生后,调用者不一定会立刻得到返回结果,而是在被调用者完成后,通过状态,通知和回调来告知调用者。

如果使用通知或回调,那么效率就会比状态通知高,因为执行部件不需要做其他额外的操作。

1.2.阻塞与非阻塞

阻塞与非阻塞主要是进程/线程等待通知的状态有关。

所谓阻塞就是进程/线程等待返回结果的时候会被挂起,不能执行其他业务。

非阻塞则常常与轮询配合使用。

2.I/O模型

2.1.同步阻塞

效率最低的模型,表现在用户进程执行系统调用之后会导致被挂起,直到内核将数据准备好之后,内核将这些数据复制到用户进程,然后进程再处理数据。

在这个过程中,进程被阻塞,不能处理别的网络IO,而是被挂起,因此效率很低。

常见的open,write,create就是同步阻塞函数。

2.2.同步非阻塞

非阻塞体现在:虽然进程不能立刻得到返回的数据,并进行处理,但是这个期间进程没有被挂起,可以继续去做别的事情。

进程采用轮询的方式反复发起系统调用,如果数据没有准备好,则返回error通知进程;反之,得到信息之后,就可以进行数据处理了。

如果通过open获得描述符,可以通过指定O_NONBLOCK标志。

对于一个已经打开的描述符,可以通过fcntl函数设置O_NONBLOCK标志。

  1. set_fl(STDOUT_FILENO,O_NONBLOCK);

2.3.I/O多路复用

多路复用的思想是,对于多个需要监听的文件描述符,与其让线程一对一的监听,不如让一个专门的线程同时轮询多个文件描述符。

selete,poll,epoll是常见的三个多路复用函数。

2.3.1.select

  1. #include <sys/select.h>
  2. int select(int maxfdp1,fd_set *restrict readfds,
  3. fd_set *restrict writefds,df_set *restrct exceptfds,
  4. struct timeval *restrict tvptr);

对于select函数,需要告诉内核

  • 关心的文件描述符
  • 对于每个描述符所关心的条件,如是否读或写文件描述符以及其异常条件
  • 愿意等待时间

当select返回时,内核告诉我们

  • 已经准备好的文件描述符数量
  • 对于读、写或异常,哪些文件描述符已经准备好

第一个参数为使用的最大的文件描述符集合加1,读、写、异常文件描述符集则通过四个函数进行操作:

  1. #include <sys/select.h>
  2. int FD_ISSET(int fd,fd_set *fd_set); // 若fd在文件描述符集中,则返回非0值;否则返回0
  3. void FD_CLR(int fd,fd_set *fd_set); // 在文件描述符集中清除该文件描述符
  4. void FD_SET(int fd,fd_set *fd_set); // 在文件描述符集中添加该文件描述符
  5. void FD_ZERO(fd_set *fd_set); // 清空文件描述符

调用方式为:

  1. fd_set readset,writeset;
  2. FD_ZERO(&readset);
  3. FD_ZERO(&writeset);
  4. FD_SET(0,&readset);
  5. FD_SET(3,&readset);
  6. FD_SET(1,&writeset);
  7. FD_SET(3,&writeset);
  8. select(4,&readset,&writeset,NULL,NULL);

准备好的具体含义是:

  • 写集:调用write不会阻塞
  • 读集:调用read不会阻塞
  • 异常条件集:有一个未决异常条件

2.3.2.poll

与select类似,但是接口有所不同。

  1. #include <poll.h>
  2. int poll(struct pollfd fdarray[],nfds_t nfds,int timeout);
  3. struct pollfd{
  4. int fd;
  5. short events;
  6. short revents;
  7. }

该函数不再为每个条件设置一个描述符集,而是创建一个文件描述符结构体,并在其中设置感兴趣的条件。元素数由nfds决定。

其中events和revents的值为下表之一。

2.3.3.select和poll的实现

实现的方法是:将文件放到一个文件描述符集合(select为bitsmap,poll以链表形式存储),然后将这个集合拷贝到内核中,让内核来循环遍历这个集合。
当有事件产生的时候,标记这个socket可读或可写,再将整个文件描述符拷贝回用户态里。但是这个时候用户态还需要通过遍历找到这个可读或可写的socket来进行处理。

因此select/poll会发生

  • 两次遍历集合,一次是在内核中轮询有无事件发生,一次是拷贝回用户态中查看是哪个端口发生事件
  • 两次文件描述符集合的拷贝,一次是从用户空间传入内核空间,一次是从内核空间传入用户空间

select采用固定长度的bitsmap来标识文件描述符集合,默认最大值为1024
poll采用动态数组,以链表的形式存储

时间复杂度都为IO模型 - 图1#card=math&code=O%28n%29),并且存在用户态/内核态之间的拷贝消耗。

2.3.4.epoll

epoll通过红黑树和内核维护的等待队列等结构实现的事件触发等机制实现的高并发,解决了select和poll未能解决的遍历所有fd来查找响应的缺陷,同时可以通过非阻塞IO模式实现更高的服务性能。

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

epoll_create用于创建一个epoll实例,返回标识符epfd。

epoll_ctl将监听的文件描述符添加到epoll实例中,实例代码为将标准输入文件描述符添加到epoll中。
第一个参数是epoll_create()的返回值。
第二个参数表示动作,用三个宏来表示:

  • EPOLL_CTL_ADD:注册新的fd到epfd中;
  • EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  • EPOLL_CTL_DEL:从epfd中删除一个fd;

第三个参数是需要监听的fd。
第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

  1. struct epoll_event {
  2. __uint32_t events; // Epoll events
  3. poll_data_t data; // User data variable
  4. };

events可以是以下几个宏的集合(常用的IN/OUT/ERR/ET):

  • EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
  • EPOLLOUT:表示对应的文件描述符可以写;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

epoll_data_t联合体定义如下:

  1. typedef union epoll_data
  2. {
  3. void *ptr; //可以传递任意类型数据,常用来传 回调函数
  4. int fd; //可以直接传递客户端的fd
  5. uint32_t u32;
  6. uint64_t u64;
  7. } epoll_data_t;

epoll_wait等待epoll事件从epoll实例中发生, 并返回事件总数以及传出对应文件描述符。参数events用来从内核得到事件的集合,参数maxevents表示每次能处理的最大事件数,告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,
参数timeout是超时时间。

epoll有的两种触发模式

  • 边缘触发:若文件描述符集合有事件发生,服务端只会苏醒一次
  • 水平触发:若文件描述符集合有事件发生,服务端不断从epollwait中苏醒,直到内核缓冲区数据被读取完成才结束

边缘触发模式由于IO事件发生时只会发生一次,我们不知道能读写多少数据,因此我们会循环的从内核缓冲区中读取数据。如果没有数据可读的时候,进程就会被阻塞在读写函数处,因此边缘触发一般与非阻塞IO搭配进行。
边缘触发的好处是比水平触发的效率要更好,因为边缘触发减少了epollwait的系统调用,减少了上下文的切换损耗。

2.3.5.对比

epoll解决了select、poll即使检测到可用文件描述符,但仍要进行扫描以确认具体是哪些文件描述符可用的问题。

但是,在可用文件描述符出现的比较密集时,epoll有基于红黑树的新增和删除的开销,因此性能反而不如select和poll。

2.4.异步非阻塞

相比于同步IO,异步IO不是顺序进行。体现在:用户进程进行读取的系统调用之后,无论数据是否准备好,都会直接返回给用户进程,然后用户进程去做其他事情。当内核数据准备好之后,内核直接发送数据给进程,然后向进程发送回调函数或信号进行通知。

因此,进程无需主动等待并向内核检查状态。

用户进程发起aio_read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal或执行一个基于线程的回调函数来完成这次IO处理过程,告诉它read操作完成了。在linux中通知的方式为信号。