阻塞 VS 非阻塞
阻塞 VS 非阻塞
当应用程序调用阻塞 I/O 完成某个操作时,应用程序会被挂起,等待内核完成操作,感觉上应用程序像是被“阻塞”了一样。实际上,内核所做的事情是将 CPU 时间切换给其他有需要的进程,网络应用程序在这种情况下就会得不到 CPU 时间做该做的事情。
非阻塞 I/O 则不然,当应用程序调用非阻塞 I/O 完成某个操作时,内核立即返回,不会把 CPU 时间切换给其他进程,应用程序在返回后,可以得到足够的 CPU 时间继续完成其他事情。
非阻塞 I/O
读操作
如果套接字对应的接收缓冲区没有数据可读,在非阻塞情况下 read 调用会立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出错信息。在这种情况下,出错信息是需要小心处理,比如后面再次调用 read 操作,而不是直接作为错误直接返回。
写操作
- 在阻塞 I/O 情况下,write 函数返回的字节数,和输入的参数总是一样的。如果返回值总是和输入的数据大小一样。
- 在非阻塞 I/O 的情况下,如果套接字的发送缓冲区已达到了极限,不能容纳更多的字节,那么操作系统内核会尽最大可能从应用程序拷贝数据到发送缓冲区中,并立即从 write 等函数调用中返回。返回值可能为0。
也就是说:
- 阻塞 I/O 的流程:拷贝→直到所有数据拷贝至发送缓冲区完成→返回。
- 非阻塞 I/O 的流程:拷贝→返回→再拷贝→再返回。
循环写入:
1 | /* 向文件描述符 fd 写入 n 字节数 */ |
下表总结了 read 和 write 在阻塞模式和非阻塞模式下的不同行为特性:
阻塞模式下的 write 有个特例, 就是对方主动关闭了套接字,这个时候 write 调用会立即返回,并通过返回值告诉应用程序实际写入的字节数,如果再次对这样的套接字进行 write 操作,就会返回失败。失败是通过返回值 -1 来通知到应用程序的。
accept
有必要将监听套接字设置为非阻塞的,对于 accept 的返回值,需要正确地处理各种看似异常的错误,例如忽略 EWOULDBLOCK、EAGAIN 等。
connect
在非阻塞 TCP 套接字上调用 connect 函数,会立即返回一个 EINPROGRESS 错误。TCP 三次握手会正常进行,应用程序可以继续做其他初始化的事情。当该连接建立成功或者失败时,通过 I/O 多路复用 select、poll 等可以进行连接的状态检测。
epoll
下面这张图来自 The Linux Programming Interface(No Starch Press)。这张图直观地为我们展示了 select、poll、epoll 几种不同的 I/O 复用技术在面对不同文件描述符大小时的表现差异。
epoll 的性能是最好的,而随着文件描述符的增大,常规的 select 和 poll 方法性能逐渐变得很差。
本质上 epoll 还是一种 I/O 多路复用技术, epoll 通过监控注册的多个描述字,来进行 I/O 事件的分发处理。不同于 poll 的是,epoll 不仅提供了默认的 level-triggered(条件触发)机制,还提供了性能更为强劲的 edge-triggered(边缘触发)机制。
使用 epoll 进行网络程序的编写,需要三个步骤,分别是 epoll_create,epoll_ctl 和 epoll_wait。
epoll_create
1 | int epoll_create(int size); |
epoll_create() 方法创建了一个 epoll 实例,这个 epoll 实例被用来调用 epoll_ctl 和 epoll_wait,如果这个 epoll 实例不再需要,比如服务器正常关机,需要调用 close() 方法释放 epoll 实例,这样系统内核可以回收 epoll 实例所分配使用的内核资源。
在一开始的 epoll_create 实现中,size
是用来告知内核期望监控的文件描述字大小,然后内核使用这部分的信息来初始化内核数据结构,在新的实现中,这个参数不再被需要,因为内核可以动态分配需要的内核数据结构。
epoll_ctl
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件。函数 epll_ctl 有 4 个入口参数。
第一个参数 epfd
是刚刚调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。
第二个参数 op
表示增加还是删除一个监控事件,它有三个选项可供选择:
- EPOLL_CTL_ADD: 向 epoll 实例注册 fd 对应的事件;
- EPOLL_CTL_DEL:向 epoll 实例删除 fd 对应的事件;
- EPOLL_CTL_MOD: 修改 fd 对应的事件。
第三个参数 fd
是注册的事件的文件描述符,比如一个监听套接字。
第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
1 | typedef union epoll_data { |
其中 events 成员描述事件类型。epoll 支持的事件类型和 poll 基本相同,都是基于 mask 的事件类型。表示epoll事件类型的宏是在poll对应的宏前加上“E”,比如epoll的数据可读事件是EPOLLIN。但epoll有两个额外的事件类型——EPOLLET和EPOLLONESHOT。
- EPOLLIN:表示对应的文件描述字可以读;
- EPOLLOUT:表示对应的文件描述字可以写;
- EPOLLRDHUP:表示套接字的一端已经关闭,或者半关闭;
- EPOLLHUP:表示对应的文件描述字被挂起;
- EPOLLET:设置为 edge-triggered,默认为 level-triggered。
epoll_wait
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
epoll_wait() 函数与 poll 和 select 函数类似,调用者进程被挂起,在等待内核 I/O 事件的分发。
第一个参数是 epoll 实例描述字,也就是 epoll 句柄。
第二个参数返回给用户空间需要处理的 I/O 事件,这是一个数组,数组的大小由 epoll_wait 的返回值决定,这个数组的每个元素都是一个需要待处理的 I/O 事件,其中 events 表示具体的事件类型,事件类型取值和 epoll_ctl 可设置的值一样,这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data,也就是用户空间和内核空间调用时需要的数据。
epoll_wait 函数如果检测到事件,就将所有就绪的事件从内核事件表(由 epfd 参数指定)中复制到它的第二个参数 events
指向的数组中。这个数组只用于输出 epoll_wait 检测到的就绪事件,而不像 select 和 poll 的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。
第三个参数 maxevents
是一个大于 0 的整数,指定最多监听多少个事件,表示 epoll_wait 可以返回的最大事件值。
第四个参数 timeout
是 epoll_wait 阻塞调用的超时值,如果这个值设置为 -1,表示不超时;如果设置为 0 则立即返回,即使没有任何 I/O 事件发生。
poll 和 epoll在使用上的差别
1 | /*如何索引 poll 返回的就绪文件描述符*/ |
LT和ET模式
edge-triggered :边缘触发
边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
level-triggered:条件触发
条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户。
一般我们认为,边缘触发的效率比条件触发的效率要高。
epoll 通过改进的接口设计,避免了用户态 - 内核态频繁的数据拷贝,大大提高了系统性能。在使用 epoll 的时候,我们一定要理解条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。