Linux_IO多路复用
该文介绍Linux的IO多路复用。
Linux_IO多路复用
1. IO分类
1.1. 阻塞IO
阻塞 I/O,是指进程发起调用后,会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就会一直被挂起。因此,当使用阻塞 I/O 时,需要使用多线程来处理多个文件描述符。
缺点:多线程切换有一定的开销,因此引入非阻塞 I/O。
1.2. 非阻塞IO
非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程里轮询多个文件描述符是否就绪。
缺点:每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。
2. IO多路复用
I/O 多路复用,可以通过一次系统调用,检查多个文件描述符的状态。
I/O 多路复用相当于将遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪的过程从用户线程移到了内核中,由内核来负责轮询。
进程可以通过 select
、poll
、epoll
发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。
I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。
3. select
3.1. 函数
1
2
3
4
5
6
7
8
9
10
#include <sys/select.h>
int select(int nfds,
fd_set *restrict readfds,
fd_set *restrict writefds,
fd_set *restrict errorfds,
struct timeval *restrict timeout);
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
3.1.1. fd_set
参数中的 fd_set
类型表示文件描述符的集合。
由于文件描述符 fd
是一个从 0 开始的无符号整数,所以可以使用 fd_set
的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set
长度为 1 字节,则一个 fd_set
变量最大可以表示 8 个文件描述符。当 select
返回 fd_set = 00010011
时,表示文件描述符 1
、2
、5
已经就绪。
3.2. 流程
- 用户线程调用select,将fd_set从用户空间拷贝到内核空间
- 内核在内核空间对fd_set遍历一遍,检查是否有就绪的socket描述符,如果没有的话,就会进入休眠,直到有就绪的socket描述符
- 内核返回select的结果给用户线程,即就绪的文件描述符数量
- 用户拿到就绪文件描述符数量后,再次对fd_set进行遍历,找出就绪的文件描述符
- 用户线程对就绪的文件描述符进行读写操作
3.3. 优点
- 所有平台都支持,良好的跨平台性
3.4. 缺点
- 性能开销大
- 调用
select
时会陷入内核,这时需要将参数中的fd_set
从用户空间拷贝到内核空间 - 内核需要遍历传递进来的所有
fd_set
的每一位,不管它们是否就绪
- 调用
- 同时能够监听的文件描述符数量太少。受限于
sizeof(fd_set)
的大小,在编译内核时就确定了且无法更改。一般是 1024,不同的操作系统不相同
4. poll
poll
和 select
几乎没有区别。poll
在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,没有最大数量的限制
4.1. 函数
1
2
3
4
5
6
7
8
9
/* Data structure describing a polling request. */
struct pollfd
{
int fd; /* File descriptor to poll. */
short int events; /* Types of events poller cares about. */
short int revents; /* Types of events that actually occurred. */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
4.2. 流程
- 用户线程调用
poll
系统调用,并将文件描述符链表拷贝到内核空间 - 内核对文件描述符遍历一遍,如果没有就绪的描述符,则内核开始休眠,直到有就绪的文件描述符
- 返回给用户线程就绪的文件描述符数量
- 用户线程再遍历一次文件描述符链表,找出就绪的文件描述符
- 用户线程对就绪的文件描述符进行读写操作
5. epoll
epoll
是对 select
和 poll
的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
- 使用红黑树存储文件描述符集合
- 使用队列存储就绪的文件描述符
- 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态
select
、poll
模型都只使用一个函数,而 epoll
模型使用三个函数:epoll_create
、epoll_ctl
和 epoll_wait
。
5.1. 函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <sys/epoll.h>
// 每一个epoll对象都有一个独立的eventpoll结构体
// 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
...
/*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表存储所有就绪的文件描述符*/
struct list_head rdlist;
...
};
// API
int epoll_create(int size); // 内核中间加一个 eventpoll 对象,把所有需要监听的 socket 都放到 eventpoll 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除、修改到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); // epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回
5.1.1. epoll_create
epoll_create
会创建一个 epoll
实例,同时返回一个引用该实例的文件描述符。
返回的文件描述符仅仅指向对应的 epoll
实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl
、epoll_wait
会使用这个文件描述符来操作相应的 epoll
实例。
当创建好 epoll
句柄后,它会占用一个 fd
值,在 linux
下查看 /proc/进程id/fd/
,就能够看到这个 fd
。所以在使用完 epoll
后,必须调用 close(epfd)
关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll
实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll
实例。
epoll
实例内部存储:
- 监听列表:所有要监听的文件描述符,使用红黑树
- 就绪列表:所有就绪的文件描述符,使用链表
5.1.2. epoll_ctl
epoll_ctl
会监听文件描述符 fd
上发生的 event
事件。
参数说明:
epfd
即epoll_create
返回的文件描述符,指向一个epoll
实例fd
表示要监听的目标文件描述符event
表示要监听的事件(可读、可写、发送错误…)op
表示要对fd
执行的操作,有以下几种:EPOLL_CTL_ADD
:为fd
添加一个监听事件event
EPOLL_CTL_MOD
:更改与fd
相关的事件(event
是一个结构体变量,这相当于变量event
本身没变,但是更改了其内部字段的值)EPOLL_CTL_DEL
:删除fd
的所有监听事件,这种情况下event
参数没用
返回值 0 或 -1,表示上述操作成功与否。
epoll_ctl
会将文件描述符 fd
添加到 epoll
实例的监听列表里,同时为 fd
设置一个回调函数,并监听事件 event
。当 fd
上发生相应事件时,会调用回调函数,将 fd
添加到 epoll
实例的就绪队列上。
5.1.3. epoll_wait
这是 epoll
模型的主要函数,功能相当于 select
。
参数说明:
epfd
即epoll_create
返回的文件描述符,指向一个epoll
实例events
是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请maxevents
指定events
的大小timeout
类似于select
中的timeout
。如果没有文件描述符就绪,即就绪队列为空,则epoll_wait
会阻塞timeout
毫秒。如果timeout
设为 -1,则epoll_wait
会一直阻塞,直到有文件描述符就绪;如果timeout
设为 0,则epoll_wait
会立即返回
返回值表示 events
中存储的就绪描述符个数,最大不超过 maxevents
。
5.2. 流程
6. 总结
6.1. 水平触发、边缘触发
select
只支持水平触发,epoll
支持水平触发和边缘触发。
水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。
6.2. 区别
select | poll | epoll | |
---|---|---|---|
底层数据结构 | 数组存储文件描述符 | 链表存储文件描述符 | 红黑树存储监控的文件描述符,双链表存储就绪的文件描述符 |
如何从fd数据中获取就绪的fd | 遍历fd_set | 遍历链表 | 回调 |
时间复杂度 | 获得就绪的文件描述符需要遍历fd数组,O(n) | 获得就绪的文件描述符需要遍历fd链表,O(n) | 当有就绪事件时,系统注册的回调函数就会被调用,将就绪的fd放入到就绪链表中。O(1) |
FD数据拷贝 | 每次调用select,需要将fd数据从用户空间拷贝到内核空间 | 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 | 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间 |
最大连接数 | 有限制,一般为1024 | 无限制 | 无限制 |
6.3. 场景
- 连接数较少并且都很活跃,用
select
和poll
效率更高 - 连接数较多并且都不很活跃,使用
epoll
效率更高