为什么要使用 IO 多路复用的技术
一般来说,Linux 上运行一个 TCP 服务的流程如下,这里我们简化分析,假设请求建立后,客户端发送一小段数据,然后服务端返回一小段数据:
// 以下代码简化处理
// 向操作系统申请一个socket
serverfd = socket()
// 绑定到特定IP和端口
bind(serverfd, addr)
// 开始监听
listen(serverfd)
// 用一个 for 循环处理客户端连接请求
for(;;) {
// accept 阻塞,直到返回一个三次握手就绪的连接
clientfd = accept();
// 数据通信
read(clientfd);
write(clientfd);
// 关闭连接
close(clientfd);
}
以上简化的代码中,可以看到,总共有三个调用会阻塞(假设套接字是阻塞通信),accept()
会阻塞到有连接建立,read()
会阻塞到数据准备就绪,write()
会阻塞到数据可写。
用上面的代码处理多个连接,就会变成串行,只有一个处理完毕,才能处理下一个,事件模型大致如下图所示。
这种串行方法实际是几乎无法接受的,图中是简化的连接处理,如果连接处理逻辑比较复杂,单个连接维持时间长,后面的连接根本无法被处理。所以就有了一种优化方案,就是用多线程/多进程的方式实现并发处理,每有一个新的连接建立,就用一个新的进程/线程去处理,也就是主进程只负责监听和建立连接,accept() 返回新的连接套接字之后,就新启动一个进程/线程去处理,一段简化的代码如下,:
// 服务端建立监听 socket
// socket(), bind(), listen()
// 处理连接
for(;;){
// 连接建立
clientfd = accept();
// 建立新进程处理连接
if ((pid = fork()) == 0) {
close(serverfd);
read(clientfd);
write(clientfd);
close(clientfd);
}
close(clientfd);
}
多进程/线程的方法解决了串行的问题,在特定场景下,也是很有用的,但是当连接数非常多的时候,这种方式的内存占用会非常恐怖,上下文切换也会占用非常多的处理时间,性能堪忧。
而 IO 多路复用技术便是一种在单个进程上处理多个连接的一种行之有效的方法,如果对这项技术没有任何了解,可以参看知乎上通俗易懂的回答:
I/O多路复用技术(multiplexing)是什么? - 知乎用户的回答 - 知乎
https://www.zhihu.com/question/28594409/answer/52835876
不精确的通俗讲法,IO 多路复用就是让一个进程能够有效的处理多个套接字的 IO 请求。下面详细分析比较 Linux 中原生支持的三种 IO 多路复用模型,select
, poll
, epoll
。
Linux IO 多路复用
select
select()
原型如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
还有四个常用的操作 fd_set
的宏:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set);
void FD_ZERO(fd_set *set);
可以看到,与 select
调用密切相关的一个数据结构是 fd_set
,fd_set 是一个整数数组,其中每个整数的每一位对应一个关心的套接字描述符。具体细节无需关心,因为针对这个数据结构的操作都是通过以上四个宏来完成的。
select 包含 5 个参数:
nfds
表示需要测试的的描述符的个数,也就是最大描述符加 1;readfds
表示需要测试具有读条件的描述符集;- 如果不关心任何读条件,可以置为
NULL
;
- 如果不关心任何读条件,可以置为
writefds
表示需要测试具有写条件的描述符集;- 如果不关心任何写条件,可以置为
NULL
;
- 如果不关心任何写条件,可以置为
exceptfds
表示需要测试异常条件的描述符集;- 如果不关心任何异常条件,可以置为
NULL
;
- 如果不关心任何异常条件,可以置为
timeout
是一个表示时间段的机构体,置于不同的值时有不同的含义:NULL
空指针,表示一直等待,直到有一个描述符准备好;- 一个有效时间段,表示等待这个时间段这么久之后返回;
0
,表示立即返回,即轮询。
需要注意的是,其中三个描述符集,传递的是指针,他们在传入的时候,值为 1 的位表示关心的条件,调用返回时,其中值为 1 的就表示条件满足的socket。
select 的返回值有如下几种情形:
- 成功:
- 如果是某些描述符的关心的条件就绪,返回值等于三个描述符集中被值为 1 的位的个数;
- 如果是超时导致的返回,返回值为0;
- 失败:返回 -1;
在使用 select
时,我们可以只关心 读、写和异常条件中的任意个条件,不关心的置为 NULL,而当我们把三个条件的描述符集都置为 NULL, 而 timeout 设置为一个有效时间段时,可以当成一个阻塞的定时器使用,相对比较精确。
select 用于处理 TCP 套接字的一个简化代码框架如下:
// 服务端建立监听socket
// socket(), bind(), listen()
// int sockfd 存储服务端监听的 socket 描述符
// int clifds[MAX_CONN] 存储建立的连接的描述符
// int clinums 存储现有的连接描述符
// int maxfds 存储需要监听的最大描述符个数,
// 也就是 select 需要的第一个参数
// 假设我们只关心 read 条件
fd_set rfds, rdfstmp;
FD_ZERO(&rfds);
FD_ZERO(&rdfstmp);
// for 循环处理请求
for(;;) {
rfds = rfsdtmp;
FD_SET(sockfd, &rfds);
FD_ZERO(&rfdstmp);
// 不关心超时
select(maxfds, &rfds, NULL, NULL, NULL);
// 新的连接建立请求
if(FD_ISSET(sockfd, &rfds)) {
clifds[clinums] = accept();
// 新的连接也需要加入select关心的描述符集
FD_SET(sockfd, &rfdstmp);
if(clifds[clinums] >= maxfds) {
maxfds = clifds[clinums]+1;
}
clinums++;
}
// 读就绪测试并处理
for(int i = 0; i < clinums; i++) {
if(FD_ISSET(clifds[i])) {
read(clifds[i];
}
}
}
下图对 select 和 fd_set 原理进行简略示意:
从前面的分析和上图中,可以发现 select 有几个很明显的问题:
- fd_set 大小有限制,因为使用的是数组;
- 每次返回后,需要轮询所有的 socket,如果连接数很多,但是其中活跃的部分比较少,这种轮询方式必然效率比较低。
poll
select
的分析中,select
的不足之处之一是,fd_set 大小有内核规定,用户比较难于修改,即使某些实现放宽了这个限制,出于兼容性考虑,使用较大的fd_set 是需要再三考虑周全的。
而 poll 这种 IO 多路复用机制就解决了这个问题,下面进行具体分析。
poll
的原型如下:
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中 pollfd
的结构如下:
struct pollfd {
int fd;
short events;
short revent;
};
我们先来分析 pollfd
:
fd
存储套接字描述符;events
按位使用,一位或者多位的组合代表关心的测试条件,实际使用的时候有一些预定义的常值可以方便使用,比如POLLIN
常值表示普通或者优先级数据可读;revents
类似于 events,只不过只用于存储返回结果,poll
调用返回后,可以从这个值中获取条件满足的信息。
从 pollfd
结构体分析中, 可以发现和 select
不同的地方:
- pollfd 体现了一种类似封装的思想,每个套接字描述符的测试条件和返回结构与套接字本身存储在一个结构体中,不同套接字各自使用自己的
pollfd
,而select
是使用三个不同的fd_set
表示所有关心的套接字的三个不同测试条件; pollfd
中调用者关心的条件和内核返回的就绪条件是不同的值存储的,而不是像select
用同一个 fd_set 作为 调用值-返回参数。
接下来我们分析 poll
调用:
poll
共有三个参数:
- 一个
pollfd
类型的指针,实际是指向一个 pollfd 数组的首元素,从这里可以看出,poll
监听多少个套接字是调用者指定的,而不是内核预先规定的,这样就避免了select
调用的最大套接字描述符个数的限制; nfds
指定了fds
数组中的元素个数;timeout
指定了poll
等待多久返回,是一个毫秒值,可能的取值有:- INFTIM:永远等待;
- 0:立即返回,不阻塞进程;
- >0:等待指定数目的毫秒数。
poll
的返回值可能的取值有:
- -1:发生了某些错误;
- 0:timeout 描述的定时器到时是没有描述符条件就绪;
- >0:就绪的描述符个数。
poll 用于处理多个套接字的一个简化框架如下:
// 服务端建立监听socket
// socket(), bind(), listen()
// int sockfd 存储服务端监听的 socket 描述符
// 申请一个 MAX_CONN 大小的 pollfd 数组
struct pollfd clients[MAX_CONN];
// 把服务端 sockfd 加入 clients 数组
clients[0].fd = sockfd;
clients[0].events = POLLRDNORM;
for(int i = 1; i < MAX_CONN; i++) {
// fd 置为 -1 表示不测试这个 pollfd
clients[i].fd = -1;
}
for(;;) {
nready = poll(clients, MAX_CONN,INFTIM);
// 新连接建立
if(clients[0].revents & POLLRDNORM) {
connfd = accept();
for(int i = 1; i < MAX_CONN; i++) {
if(clients[i].fd < 0) {
clients[i].fd = conn;
clients[i].events = POLLRDNORM;
break;
}
}
if (--nready <= 0 ) {
continue;
}
}
for (int i = 1; i < MAX_CONN; i++ ) {
// 分别处理每个就绪套接字
process(clients[i]);
}
}
以上是一个简化框架,只是为了展示 poll 的调用过程,有诸多可以优化改进的地方。
下面给出一个简单的 poll 原理示意图:
从以上的分析中,可以得出,相比 select
,poll
有以下改进:
- 可以监听的
socket
描述符数量远多于select
,而且是由用户指定; - 结构体数组包装和参数-结果分离,相比原来的位图,可能编写代码会清晰和简单一点。
但是,和 select
一样,poll
还是需要在返回后遍历所有描述符来进行处理,特定场景下性能较差。
epoll
之前分析的 select
和 poll
模型是 posix 规范的,Unix 和 Linux 均实现的 IO 多路复用模型,而 epoll
是 Linux 特有的 IO 多路复用模型。epoll
主要是为了应对需要监听套接字数量非常巨大的场景, epoll
把 select
和 poll
处理时间的复杂度从 O(N) 降低到了约 O(log(N)),下面详细分析 epoll
的原理。
epoll
的原型如下:
#include<sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *event,
int maxevents, int timeout);
相关的两个数据结构如下:
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event
{
uint32_t events;
epoll_data_t data;
}
我们先分析 epoll_event
数据机构,其中:
events
表示关心的事件,可以用预先定义的常量表示;data
是一个 epoll_data_t 联合体:- 一般基本用途,我们直接指定其中的
fd
为我们关心的套接字描述符即可。
- 一般基本用途,我们直接指定其中的
可以看到,epoll
相关的系统调用函数有 3 个,下面具体分析:
-
epoll_create
和epoll_create1
功能类似,均是向操作系统请求 epfd 描述符,其差异在于:epoll_create
起初的size
参数是用来指定要监听的套接字描述符的数目,但是后来这种语义不继续使用,为了前向兼容,指定一个非负整数即可;epoll_create1
是改进版,去掉了size
这个无用参数,增加了指定相应功能的flags
参数。
-
epoll_ctl
是操作epoll_create
返回的epfd
描述符的函数,其参数的含义为:- epfd 传入申请的 efpd 描述符;
- op 传入三个预定义的常量,分别表示 添加,修改,删除 关心的套接字;
- fd 传入用户关心的描述符;
- event 传入用户关心的事件。
-
epoll_wait
是等待事件就绪的阻塞函数,具体参数为:- epfd 传入申请的 epfd 描述符;
- events 是用于接收事件返回的 buffer;
- maxenents 传入最大可接受的 events 数目,一般置为 events 数组的长度即可;
- timeout 传入最大等待时间,置为 -1 表示无限等待直到有事件就绪。
下面引用一张陶辉大神在 《深入理解Nginx:模块开发与架构解析(第二版)》中关于 epoll 的示意图:
我们结合上面三个函数分析,其实 epoll
的核心原理在于:
- 它维护了两个核心的数据结构,一个红黑树存储所有用户关心的套接字,一个链表存储所有就绪的需要返回的事件列表;
- 我们用
epoll_ctl
操作关心的套接字描述符的时候,是在操作图中的那个红黑树,其 插入/删除/修改 的复杂度都是 O(log(n)) - 套接字加入这个红黑树之后,会为每个套接字注册一个回调函数,如果有套接字就绪,这里会用这个回调函数把对应的事件加入一个就绪链表中,而不是像 select 和 poll 那样在请求列表中原地更改
我们假设一种场景,一个进程关心 10000 个套接字,一个较小的时间段内其中 100 个上产生了数据读写条件,那么:
select
不加修改的情况下,无法复用这么多的套接字,另外,即使用一些技巧突破默认上限,也会有下面和 poll 一样的问题;- poll 的问题在于,每次调用和返回时,都是用户和内核之间,拷贝整个描述符表,即 10000 个相关数据结构;
- epoll 只在初次添加的时候,需要拷贝完整的套接字列表,在后续 epoll_wait 返回的时候,只会复制对应的就绪列表,即 100 个,而不是 10000 个。
另外,select 和 poll 只支持水平触发,而 epoll 支持 水平和边沿触发 两种方式。