五种 I/O 模型
顺便用去食堂打饭这个例子来分别解释一下
阻塞 I/O(BIO)
常见 I/O 模型,是可能会使进程永远阻塞的一类系统调用。发起一次 I/O 操作后需要等待成功或失败,在此期间程序不能进行其他处理。阻塞 I/O 只对单个文件描述符进行处理
例如服务端采用单线程,
accept
一个 socket 后,在recv
或send
调用阻塞时,无法accept
其他 socket,即无法并发处理采用多线程,主线程
accept
一个 socket,利用工作线程对 socket 进行recv
或send
,可以并发处理。但是当 socket 过多,造成大量线程占用大量内存,且切换线程带来资源开销,产生浪费有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他坐在食堂,拿着饭盒看着后厨炒菜。终于菜好了,他打上饭回宿舍大快朵颐去了
非阻塞 I/O(NIO)
针对阻塞 I/O 的文件描述符做指定。针对这样的文件描述符的操作如果不能立刻完成,会出错返回。通常发生在 for 循环中,利用 for 循环完成轮询。非阻塞 I/O 只对单个文件描述符进行处理
服务端
accept
一个 socket 后,加入描述符集合fds
,轮询时对每一个fd
进行操作,没有数据则返回错误,在轮询这样的fd
时会浪费 CPU,大多数情况下fd
无数据可以读写对于给定的文件描述符,利用两种方式指定其为非阻塞 I/O
如果调用
open()
获得文件描述符,则可指定0_NONBLOCK
标志详情见《UNIX 环境高级编程》3.3 小节
对于已经打开的一个描述符,则可调用
fcntl()
,由该函数打开文件描述符的0_NONBLOCK
标志详情见《UNIX 环境高级编程》3.14 小节
有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他拿着饭盒回宿舍打游戏去了。过了十分钟又去打饭,阿姨说还没好,于是又回宿舍了……第五次去的时候菜终于好了,他打上饭回宿舍吃着来之不易的饭
I/O 多路复用
通过 Linux 下的
select()
、poll()
和epoll()
,对一组文件描述符进行相关事件的注册,然后阻塞等待某些事情的发生(准备好读、准备好写、异常条件待处理)或等待超时(参数timeout
)。I/O 多路复用值一个线程可以对多个文件描述符进行监视处理,一旦某个描述符就绪,通知应用程序进行相应读写操作有一个人前来打饭。他不止想打饭,还想买个可乐和大布丁雪糕,阿姨说这仨玩意儿现在都没有,要等一会儿。于是他回宿舍打游戏去了。过了一会他给阿姨打电话,阿姨说菜好了,大布丁也有了,但是可乐没有(可读条件)。于是他带着饭盒去打饭,买了个大布丁,回到宿舍开开心心的吃饭了
信号驱动 I/O
利用信号,使得内核在文件描述符就绪时发送特定信号通知应用程序。在这样的系统调用中,会立刻返回,进程继续工作;当文件描述符就绪时,内核发送信号到进程,让进程来决定是否处理以及如何处理
有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他拿着饭盒回宿舍打游戏去了,但是临走前跟阿姨说菜做好了麻烦给他打个电话。十分钟后阿姨打来电话(信号),他看了一眼游戏成功的结算页面,寻思着再来两把于是没去打饭而是继续征战 OR 他看了一眼游戏失败的结算页面,寻思着吃饭吧于是带着饭盒去食堂打好饭回宿舍大快朵颐了
异步 I/O
过程大体上与信号驱动 I/O 类似,但是不同点在于,信号驱动 I/O 在文件描述符就绪后,通过信号告知进程,并由进程决定是否处理以及如何处理;异步 I/O 则在调用时就决定如何处理(读的话从哪读?写的话写到哪?)并返回,内核在文件描述符就绪后直接进行处理,最后将处理结果告知进程
有一个人前来打饭。打饭阿姨告诉他菜还没有做好,于是他放下饭盒,告诉阿姨自己想吃什么菜,请阿姨到时候帮他打好(操作流程)并打个电话告诉他,然后就回宿舍了。十分钟后阿姨打来电话(信号),他来到食堂跟阿姨说了一声谢谢,带着饭盒回宿舍大快朵颐去了
I/O 多路复用
select
描述
1 |
|
参数:
使用人员关心的一组描述符
对所有监视的文件描述符中,选出最大的描述符,对其值 +1,作为参数传入;也可以设置为
FD_SETSIZE
1
2
3
4// /usr/include/linux/posix_types.h
// /usr/include/sys/select.h对于每个描述符所关心的条件
fd_set
结构体仅包含一个整形数组,其中每一位标记一个描述符,能容纳的描述符总量由FD_SETSIZE
指定,最大位 1024声明一个描述符集后,调用
FD_ZERO
对所有位设置为 0,再通过FD_SET
对需要的描述符设置位预设等待时间
timeout==NULL
:永远等待,捕捉到一个信号则中断等待timeout->tv_sec == 0 && timeout->tv_usec == 0
:不等待,查看所有指定描述符并立即返回timeout->tv_sec != 0 || timeout->tv_usec != 0
:等待指定的时间,当指定的时间内有描述符准备好或超过指定时间,则返回
1 | fd_set readset, writeset; |
针对描述符 0、3 监听是否可读;描述符 1、2 监听是否可写;不设置异常处理;永远等待,直到监听完成
通常应用程序采用while(true)
轮询每个描述符是否可读可写
返回值:
- 返回值-1 表示出错,没有一个描述符准备好时返回了一个信号
- 返回值 0 表示超过指定时间且没有描述符准备好,此时所有描述符集会置 0
- 一个正数返回值表示已经准备好的描述符数,是三个描述符集中准备好的描述符之和;如果同一个描述符准备好读和写,则计数两次
特点
- 每当调用
select()
时,当前进程进入阻塞态 - 每次调用
select()
,需要将描述符集从用户态拷贝到内核态,存在拷贝开销 - 每个进程最多监视 1024 个描述符
- 进程被唤醒后,通过
while(true)
线性轮询,扫描每一个描述符,开销大,浪费 CPU
通过select()
实现非阻塞连接
在编写服务器的过程中,考虑到accept()
属于阻塞连接,可以通过select()
实现非阻塞模式
1 | int |
也可以直接创建非阻塞的 socket
1 | socket_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); |
poll
描述
1 |
|
参数:
使用人员关心的一组描述符
与
select()
类似,但是通过pollfd
结构体的数组表示描述符集,每个数组元素中fd
表示描述符编号,events
表示期待的事件,内核将描述符发生的事件填充入revents
1
2
3
4
5struct pollfd {
int fd; // 文件描述符
short events; // 注册的事件
short revents; // 实际发生的事件,由内核填充
};数组
fds
的长度由nfds
指定预设等待时间
timeout == -1
:永远等待,捕捉到一个信号则中断等待timeout == 0
:不等待,查看所有指定描述符并立即返回timeout > 0
:等待timeout
毫秒,当指定的时间内有描述符准备好或超过指定时间,则返回
返回值:与select()
类似
特点
- 通过链表存储,没有最大连接数的限制
- 每次调用
poll()
,需要将描述符集从用户态拷贝到内核态,存在拷贝开销 - 进程被唤醒后,通过
while(true)
线性轮询,扫描每一个描述符,开销大,浪费 CPU
epoll
描述
1 |
|
epoll_create()
:创建一个eventpoll
结构体
1
2
3
4 struct eventpoll {
struct rb_root rbr;
struct list_head rdllist;
};
rbr
:红黑树的根结点,存储由epoll_ctl()
添加的、需要监控的所有事件
rdllist
:双向链表的根结点,存储满足条件的事件,由epoll_wait()
取出并返回给用户参数:
1.
int size
:epoll 对象需要处理的事件的大致个数,最新的一些内核版本中参数无意义返回值:
文件描述符,指向 epoll 对象,后续使用 epoll 需要这个文件描述符
epoll_ctl()
:向 epoll 对象中添加、修改和删除事件
参数:
1.
int epfd
:指向 epoll 对象的文件描述符
2.int op
:取值表示添加、修改或删除某一事件
1
2
3 EPOLL_CTL_ADD // 添加
EPOLL_CTL_MOD // 修改
EPOLL_CTL_DEL // 删除
3.int fd
:待监测的 socket
4.struct epoll_event *event
:感兴趣的事件
1
2
3
4
5
6
7
8
9
10
11 struct epoll_event {
__uint32_t events;
epoll_data_t data;
};
typedef union epoll_data {
void ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
events
:描述事件类型,与 poll 大致相同
data
:联合体,一般使用fd
关连到待监测的文件描述符;也可以使用ptr
指向用户数据。因为时联合体,只能使用其中一个返回值:
- 返回值 0 表示成功
- 返回值-1 表示失败,设置
errno
epoll_wait()
:收集在 epoll 所有监控的事件中已发生的事件,如果没有事件发生,等待timeout
毫秒后返回
参数:
1.
int epfd
:指向 epoll 对象的文件描述符
2.struct epoll_event *events
:一般为数组,由内核将rdllist
中收集的数据复制到数组中(events
不可以是空指针,应当分配好内存)
3.int maxevents
:本次可以返回的最大事件数目,一般与events
数组的大小相等
4.int timeout
:rdllist
为空时的等待时间(毫秒);当rdlllist
为空且时间设置为 0 则立刻返回返回值:
- 返回值大于 0 表示当前收集到的发生事件的个数
- 返回值为 0 表示本次调用没有返回事件
- 返回值-1 表示失败,设置
errno
特点
- 没有最大并发连接限制
- 内核中维护一个红黑树,通过
epoll_ctl()
往红黑树中添加、删除和修改事件,不需要每次都从用户态拷贝所有事件到内核态,减少了开销 - 维护一个双向链表,通过
epoll_wait()
收集所有已发送的事件,不像select()/poll()
那样轮询发生和未发生的事件 - 只能在 Linux 下工作
LT 模式与 ET 模式
LT 模式:默认的工作模式,相当于一个效率较高的poll
。当epoll_wait()
检测到事件发生时,会通知应用程序,应用程序可以不立即处理该事件;下次循环,epoll_wait()
会再次提醒,直到被处理
可以处理阻塞和非阻塞的 socket
ET 模式:当epoll_wait()
检测到事件发生时,会通知应用程序(且后续不会再通知),所以需要立即处理该事件。降低了同一个 epoll 事件被重复触发的次数,提高效率
只能处理非阻塞的 socket
三者比较
select | poll | epoll | |
---|---|---|---|
操作方式 | 轮询 | 轮询 | 回调 |
数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1204(32 位)|2048(64 位) | 无上限 | 无上限 |
最大支持文件描述符数 | 有最大值限制 | 65535 | 65535 |
文件描述符拷贝 | 每次轮询时将fds 从用户态拷贝到内核态 |
每次轮询时将fds 从用户态拷贝到内核态 |
eppol_ctl() 拷贝到内核的事件表中,eppol_wait() 直接读取事件表 |
工作模式 | LT | LT | 支持 ET |
工作效率 | 轮询, |
轮询, |
回调, |