接上篇 linux IO —— 非阻塞IO

用非阻塞IO来实现单进程处理多条连接的IO,需要用户态不断轮询判断,即使没有数据到来的时候也要消耗CPU,这样会导致CPU被占满,进程没办法处理其它任务了,整个系统的处理能力也会下降。

所以linux提供了一种机制,可以批量监视多条连接,在无数据到来时,阻塞进程,在有数据到来时,通过某种方式通知进程。称之为多路复用机制,多路指多路IO,复用指复用一个进程。

随着机制的不断优化,支持了 select、poll、epoll 三种方法,在监视方式和通知方式上有所不同。

select

select原理

image.png
非阻塞IO的实验中发现,虽然无限遍历可以达到多路IO复用一个进程的目的,但是CPU也被循环和判断吃完了,得不偿失。

所以内核提供了一种机制,即select机制。 如上图所示,可以让用户通过系统调用,将进程放到被监视的多个socket的等待列表中,然后将进程从工作队列中移除,进入阻塞状态。

当这多个socket中,有一个或多个socket收到数据时,网卡通过中断程序,让CPU将数据从网卡的缓冲区拷贝到各个socket在内核中的缓冲区(也有不用CPU拷贝的技术方案),并标记哪些socket收到了数据,然后将进程放回工作队列,即进程从阻塞状态中退出,恢复执行。

进程恢复执行后,即可遍历 所有 被监视socket,并查看他们的标记,是否收到数据,如果是,则可以读取数据,执行业务逻辑,处理完毕后,再次调用 select 监视这多个 socket,如此往复。

select函数和文档

select 函数的定义

1
2
3
4
5
6
7
int select(
int nfds, //fd最大值+1,提升内核扫描时的性能
fd_set *restrict readfds, // 需要监视读事件的fdset,默认长度为1024bit,下同
fd_set *restrict writefds, //需要监视写事件的fdset
fd_set *restrict exceptfds, //需要监视带外数据或者异常事件的fdset
struct timeval *restrict timeout //超时时间
)
;

这里要注意的是,设置给select的fd_set在select返回后会被修改,使用时最好保存副本,不然每次select都要重新设置。
select的man文档。

select其它特性

我尝试写了一些练习代码来验证 select 的功能,中间也想了几个常见问题,记录一下吧:

多路复用,还需要非阻塞IO吗?

对于服务端的监听socket和数据socket,不使用非阻塞socket是ok的。因为使用select时,并不是直接调用accept函数去接收连接、recv/write函数去读写数据,而是由select通知真正有连接或数据到来/连接可写了,才真正去读写数据,这样多数场景下就不会产生阻塞了。

对于客户端client,还是需要使用nonblocking模式,因为在设置给select监视前, socket需要先connect一次。这样如果哪个socket连接不上,就会阻塞整个进程,影响整体的处理能力。

当然,全都使用非阻塞模式是最好的,这样就能完全避免阻塞,例如accept/connect握手过程缓慢造成的阻塞、读写缓冲区满造成的阻塞等。

select能监视的socket数量有限制吗

可以看一下前面的select函数参数,它是用位图来记录哪些fd被监视的,所以select能监视的fd数量受位图大小限制。一般情况下fd默认最大值是1024,即FD_SETSIZE的值。
当然,这个限制也是可以突破的,如 这篇文章 说的方法,nfds 传入更大的值,并且通过malloc/alloc为 fd_set 分配更大的空间。

select的性能问题

多路复用三兄弟里边,select性能可算是最差的了,原因如下:

  • 每次调用内核都要重新设置等待列表,fd多了之后效率很低
  • 每次唤醒都需要把进程从每个fd的等待列表移除
  • 用户态不知道哪些fd可以读写数据,每次select完了都要遍历、判断

这些特性导致了select在IO路数多了之后,性能会很低。

练习代码

select示例代码
image.png

观察客户端结果可以看到,select为多路IO复用了一个进程,且观察CPU是没吃满的。
把fd_set的bit位打出来了,能看到select完毕后,在fd_set上设置的被激活的bit。

poll

poll原理

和select比较相似,原理是上没有本质区别。使用上,不再使用多个 fd_set 来管理被监视的fd列表,而是使用结构体 pollfd 来管理。这样监视的fd数量就不再受fd_set的尺寸影响了, 不过也没啥大用,反正fd多了之后性能仍然是低。

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

poll函数和文档

poll 函数的定义

1
2
3
4
5
int poll(
struct pollfd *fds, //需要监视的poll fd描述结构体数组
nfds_t nfds, //被监视的fd的数量
int timeout //阻塞超时时间
)
;

poll的man文档。

练习代码

poll的练习代码

epoll

epoll原理

select和poll在性能上都有比较严重的问题,比如每次调用都要向内核传输一堆数据,拿到结果之后客户端也需要遍历。epoll从机制上解决了这些问题。

数据传输优化

image.png

如上图,每个箭头代表一次系统调用(即select/poll/epoll调用),并会产生阻塞。

select/poll在每次调用之前,都会添加一次等待队列,还有个没画出来的,每次调用结束后,还会去掉一次等待队列。这样每次添加的过程,都涉及到用户态-内核态的数据传输,移除的过程,也涉及到内核的数据操作。

而右边epoll的机制则是,创建一个eventpollfd用于存储相关信息,将添加/移除被监视fd的函数,和开始监视的函数,进行分离。这样只需要一次性添加所有需要监视的fd,后续每次只需要调用开始监视的函数就行了,省去每次调用的数据传输和清理。

用户态遍历流程优化

image.png
select/poll 每次调用结束后,都需要用户态去遍历所有fd,并判断哪些fd上有事件发生,这个效率是非常低的。

epoll在epollfd上存储了一个ready list,每次阻塞结束前将有事件发生的fd放到ready list中,这样用户态每次就能精准获取到有事件发生的fd了。

整体工作流程

根据上面的优化,epoll的整体工作流程如下:

  1. epoll_create,创建eventpollfd

image.png

  1. epoll_ctl,添加被监视的fd

image.png

  1. epoll_wait,进程从CPU工作队列移除,进入阻塞

image.png

  1. 有事件发生,为eventpollfd添加readylist

image.png

  1. 进程从eventpollfd中移除,放到CPU工作队列中,恢复执行

image.png

为了插入删除性能,epoll内部通过一个红黑树来维护fd列表,整个流程用张彦飞大佬这张图来看比较清晰:

image.png

epoll函数和文档

epoll相关函数的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//创建一个epoll fd,size是一个给内核参考的监听fd个数参数,已被忽略,随便传个大于0的值就行了
int epoll_create(int size);

//向epollfd绑定、解绑定目标fd
int epoll_ctl(
int epfd, // epoll fd
int op, //要做的操作,添加还是删除
int fd, //目标fd
struct epoll_event *event //绑定到目标fd上的epoll_event对象
)
;


//阻塞等待目标fd列表上的io事件发生,返回有io事件发生的fd的个数
int epoll_wait(
int epfd, //epoll fd
struct epoll_event *events, //存放有事件发生的fd的epoll_events
int maxevents, //一次最多返回多少个 epoll_events
int timeout //阻塞超时时间
)
;

fd绑定和解绑定的操作,只需要用epoll_ctl执行一次就行了。

epoll的LT模式和ET模式

简单理解一下。例如我们要通过epoll监测fd的可读/可写状态,那么当我们调用epoll_wait时,只要fd处于可读/可写状态,epoll_wait立即返回,那么就是LT模式,即水平触发模式;如果只有可读/可写状态发生变化,例如从不可写到可写,不可读到可读,此时才触发,后续未发生变化则不再触发,那么就是ET模式,即边缘触发。

画一张状态变化图理解一下:

image.png

可以看到,ET模式可以大大减少触发次数,也就减少了用户态/内核态切换的次数,把工作交给用户态业务逻辑自己解决,这样效率更高。

不过ET模式下写代码要注意一下,如果fd是可读的,需要将fd中的内容读取完毕,在读取完毕之前fd永远也不会触发可读事件,这样可能导致缓冲区满,客户端的数据再也写不进来。

练习代码

epoll示例代码

哪些fd可以使用多路复用监视?

我自己试验过的,socketfd、eventfd、signalfd,都可以用多路复用监视,具体一共有多少种,这个不清楚。
但是本地文件fd是不能用多路复用监视的。

原理可参考 这篇文章

只有file_operations实现了poll接口的文件,才能被多路复用监视,否则在add到epoll的时候会报错,而普通ext4格式的文件并没有实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const struct file_operations ext4_file_operations = {
.llseek = generic_file_llseek,
.read = do_sync_read,
.write = do_sync_write,
.aio_read = generic_file_aio_read,
.aio_write = ext4_file_write,
.unlocked_ioctl = ext4_ioctl,
.mmap = ext4_file_mmap,
.open = ext4_file_open,
.release = ext4_release_file,
.fsync = ext4_sync_file,
.splice_read = generic_file_splice_read,
.splice_write = generic_file_splice_write,
};

但是也有曲线救国的方法,通过异步io,可以将文件读写的过程用 signalfd、eventfd(参考linux的 kernal aio、posix aio) 来监听,而这两个fd是可以用多路复用来监视的。

参考文章:

☞ 参与评论