linux的5种io模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO
前四种都是属于异步IO,最后一种是同步IO,异步和同步的概念是他们本身的属性。本文只介绍其中的阻塞IO,非阻塞IO,IO多路复用。
重点介绍IO多路复用。

阻塞io:

image.png
用户调用了recvfrom这个系统调用之后,kernel就会去读取数据,这个过程是需要时间的,此时线程就会处于阻塞状态,然后磁盘准备好后,内核拷贝到内核空间也是需要时间的在这两个过程线程都处于阻塞状态。不会去占用cpu

非阻塞io:

image.png
当用户调用了recfrom系统调用时,如果kernel中的数据还没有准备好。那么它并不会block进程,而是立刻返回一个error,从用户角度来说,发起调用后,并不需要等待立即就得到了一个结果,在判断是error后就会一直请求。直到数据准备完成。在次过程中,需要一直占用cpu资源

IO多路复用:

通过一种机制来监控多个文件描述符,一旦某个描述符就绪,就通知相应的程序来进行操作。
分为三种,selectpollepoll

image.png

使用IO多路复用的进程,在调用select,poll,和epoll这三种系统调用的时候,进程会被阻塞,直到有文件描述符准备就绪,内核返回信息给进程,才解除阻塞状态。但是I/O多路复用的内部是非阻塞的。因为内部会遍历集合中的每个文件描述符,判断其是否就绪:

  1. for fd in read_set
  2. if readable(fd) ) // 判断 fd 是否就绪
  3. count++
  4. FDSET(fd, &res_rset) // 将 fd 添加到就绪集合中
  5. break
  6. ...
  7. return count

倘若使用阻塞IO的话就会导致如果某个文件描述符(也就是连接的套接字)数据没有准备好,则会一直等待它准备好。而集合后面的描述符准备好的话得不到及时处理,所以复用I/O内部使用的是非阻塞的。

select:

函数签名与参数:

int select(int nfds,
            fd_set *restrict readfds,
            fd_set *restrict writefds,
            fd_set *restrict errorfds,
            struct timeval *restrict timeout);
//select方法具体的底层函数

readfds,writefds和errorfds是由进程传到内核空间的文件描述符集合。内核会通过select函数取遍历每个集合的前nfds个描述符。分别找到可读,可写,或是发生错误的描述符。统称为就绪的描述符,然后用找到的子集替换参数中的对应集合。这样的目的是让集合中的文件描述符都是可以操作的/返回所有就绪描述符状态的总数。
timeout参数表示调用select时的阻塞时长,如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的timeout后,返回。如果timeout参数设为null,会无线阻塞直到某个文件描述符就绪,如果timeout的参数为0,会立即返回,不阻塞。

什么是文件描述符?

文件描述符是一个大于0的整数,用来表示每个进程打开或者创建的文件。在内核空间会为每个进程创建一个文件描述符表。而文件描述符就是这些文件的索引例如每个进程最少都会有三个文件描述符。包括0 stdin。1stdout。2stderror。
每当进程打开一个文件后,都会在文件描述符表上新增一个序列用来标示这个文件。可以在/proc/pid号/fd目录下可以查看每个进程的文件描述符。
image.png
linux系统下默认每个进程能拥有最多1024个文件描述符。
image.png

socket和fd的关系

socket,即套接字文件可以用于不同主机进程间的通信,也可以用于一台主机间
进程的通信。一个sockret包含地址,类型和通信协议等信息。通过sockrt函数创建

int socket(int domain, int type, int protocol)
//domain 是域有AF_INET和AF_UNIX
//type 表示套接字的类型 有流套接字,和数据包套接字
//protocol 表示套接字的通信类型,有TCP和UDP协议

当服务端调用accept()函数接收了客户端的连接请求后。就会新建一个属于它们两个连接的套接字,数据的传输就通过这个套接字的读取实现。理论上一个进程可以调用accept()函数创建多个套接字。套接字由五元组(服ip,服port,客ip,客port,和protocol协议)来标识一个套接字。这也是单个进程能处理多个连接请求的原因之一。

fd_set文件描述符集合

还记得select函数的c语言实现吗?里面有一个fd_set参数。表示的是fd_set类型表示文件描述符的集合。
由于文件描述符是一个从0开始的无符号整数。所以可以用fd_set的二进制每一位来表示一个文件描述符。某一位为1则说明对应的文件描述符已经准备就绪。比如fd_set长度为1字节,则一个fd_set变量最大可用表示8个文件描述符。当select返回00010011表示1,2,5文件描述符已经就绪。
那么fd_set的涉及到了以下几个api:

#include <sys/select.h>   
int FD_ZERO(int fd, fd_set *fdset);  // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset);   // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set);  // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

select使用示例

  1. 先声明一个fd_set类型的变量readFDs。(意思是表明要监听多少个文件描述符)
  2. 调用FD_ZERO将readFDs的所有位置设置为0。(重置)
  3. 调用FD_SET,将readFDs感兴趣的位置置为1,表示要监听者几个文件描述符
  4. 将readFDs传给select,调用select。此时进程阻塞,由内核去执行这个监督的任务
  5. select会将readFDs中就绪的位置1,未就绪的未置0,返回就绪的文件描述符的数量//由内核去检测套接字的文件是否准备就绪。如果就绪了就会将相关的位置置为1,并返回就绪文件描述符的数量
  6. 当select返回后,调用FD_ISSET检测给定位是否为1。表示对应文件描述符是否就绪。

比如进程想监听 1、2、5 这三个文件描述符,就将 readFDs 设置为 00010011,然后调用 select。
如果 fd=1、fd=2 就绪,而 fd=5 未就绪,select 会将 readFDs 设置为 00000011 并返回 2。
如果每个文件描述符都未就绪,select 会阻塞 timeout 时长,再返回。这期间,如果 readFDs 监听的某个文件描述符上发生可读事件,则 select 会将对应位置 1,并立即返回。

select缺点:

  1. 性能开销大。

    调用select会陷入内核态,此时会将参数中的fd_set从用户空间拷贝到内核空间<br />        内核需要遍历所以传递进来的fd_set的每一位。不管它们是否就绪
    
  2. 同时监听的描述符数量太少,受限于sizeof(fd_set)的大小,在编译内核时就已经确定了。一般是1024.

POLL

poll和select一样。只不过在用户态通过数组方式传递文件描述符。在内核会转为链表方式存储。没有最大的数量限制。

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

epoll

epoll是对select和poll的改进,避免了性能开销大,和文件描述符数量少两个缺点
epoll由以下几个特点:

  • 使用红黑树存储文件描述符集合
  • 使用队列存储就绪的文件描述符
  • 每个文件描述符只需要在添加时传入一次,通过事件更改文件描述符的状态。

epoll用到了三个函数:

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:

int epoll_create(int size);

epoll_create会创建一个epoll实例,同时返回一个引用该实例的文件描述符。
(就是在建立连接时通过epoll_create创建一个epoll实例,并且返回一个代表实例的文件描述符)
返回的文件描述符仅仅指向对应的epoll实例,并不表示真实的磁盘文件节点,其他api如epoll_ctl,epll_wait会使用这个文件描述符来操作对饮的epoll实例

当创建好epoll句柄后,它会占用一个fd值,所以在使用完epoll后,必须调用close(epfd)关闭对应的文件描述符,否则就会导致fd背号京,当指向同一个epoll实例的所有文件描述符都被关闭后,操作系统会销毁这个epoll实例
epoll实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树
  • 就绪列表,所以就绪的文件描述符,使用链表。

epoll_ctl:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll_ctl会监听文件描述符fd上发生的event事件。
参数说明:
epfd:表示创建的一个epoll实例的文件描述符。指向一个epoll实例
fd:表示要监听的目标文件描述符
event:表示要监听的事件(可读可写发生错误)
op:表示对fd执行的操作,有以下几种。
epoll_ctl_add:为fd田间一个监听事件event
epoll_ctl_mod:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
epoll_ctl_del:删除fd的所有监听事件,这种情况下event参数没用
返回0或-1,表示上述操作成功与否

epoll_ctl会将文件描述符fd添加到epoll实例的监听列表里,同时为fd设置一个回调函数,并监听event,当fd上发生相应时间时,会调用回调函数。将fd添加到epoll实例的就绪队列上。(这个是避免了遍历监听fd的根本办法)

epoll_wait:

int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

这个epoll模型的主要函数,功能相当于select。
参数说明:
epfd:是epoll_create返回的文件描述符,指向一个epoll实例
events:是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
maxevents:指定event的数量大小
timeout类似于select的中的timeout。如果events为空,则epoll_wait会阻塞timeout毫秒,如果timeout设为-1,则会一直阻塞,如果timeout为0,则epollwait会立即返回。

返回值表示event中存储的就绪描述符个数。最大不超过maxevents

epoll的优点:

  1. 采用红黑树存储文件描述符,监听的文件描述符数量大
  2. epoll_ctl中为每个文件描述符指定了回调函数,并在就绪时将其加入到events表中。因此不需要遍历每个文件描述符。只需要判断就绪队列是否为空。

时间复杂度为O(1)

水平触发、边缘触发

select 只支持水平触发,epoll 支持水平触发和边缘触发。
水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。

为什么边缘触发必须使用非阻塞I/O?

每次通过 read 系统调用读取数据时,最多只能读取缓冲区大小的字节数;如果某个文件描述符一次性收到的数据超过了缓冲区的大小,那么需要对其 read 多次才能全部读取完毕
select 可以使用阻塞 I/O。通过 select 获取到所有可读的文件描述符后,遍历每个文件描述符,read 一次数据(见上文 select 示例
这些文件描述符都是可读的,因此即使 read 是阻塞 I/O,也一定可以读到数据,不会一直阻塞下去
select 采用水平触发模式,因此如果第一次 read 没有读取完全部数据,那么下次调用 select 时依然会返回这个文件描述符,可以再次 read
select 也可以使用非阻塞 I/O。当遍历某个可读文件描述符时,使用 for 循环调用 read 多次,直到读取完所有数据为止(返回 EWOULDBLOCK)。这样做会多一次 read 调用,但可以减少调用 select 的次数
在 epoll 的边缘触发模式下,只会在文件描述符的可读/可写状态发生切换时,才会收到操作系统的通知
因此,如果使用 epoll 的边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用 read 或 write 多次,直到返回 EWOULDBLOCK 为止,然后再调用 epoll_wait 等待操作系统的下一次通知
如果没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态没有发生改变,将不会再发起通知,调用 epoll_wait 会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完
这样做的好处是每次调用 epoll_wait 都是有效的——保证数据全部读写完毕了,等待下次通知。在水平触发模式下,如果调用 epoll_wait 时数据没有读/写完毕,会直接返回,再次通知。因此边缘触发能显著减少事件被触发的次数

为什么 epoll 的边缘触发模式不能使用阻塞 I/O?很显然,边缘触发模式需要循环读/写一个文件描述符的所有数据。如果使用阻塞 I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束

三者对比:

  • select:调用开销大(需要复制集合),集合大小有限制,需要遍历整个集合找到就绪的描述符
  • poll:采用链表的方式存储文件描述符,没有最大存储数量的限制,其他方面和select没有区别
  • epoll:调用开销小,不需要复制,集合大小无限制,采用回调机制,不需要遍历整个集合。
  • select、poll 都是在用户态维护文件描述符集合,因此每次需要将完整集合传给内核;epoll 由操作系统在内核中维护文件描述符集合,因此只需要在创建的时候传入文件描述符。
  • select只支持水平触发,而epoll支持水平和边缘触发