Linux网络——IO模型和多路转接
通常所谓的IO,其本质就是等待通信和进行通信,即IO = 等 + 拷贝。
那么想要做到高效的IO,就要在单位时间内,减少“等”的比重。
一.五种IO模型
- 阻塞 IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式。阻塞 IO 是最常见的 IO 模型。
- 非阻塞 IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK 错误码. 非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对 CPU 来说是较大的浪费, 一般只有特定场景下才使用。
- 信号驱动 IO: 内核将数据准备好的时候, 使用 SIGIO 信号通知应用程序进行 IO 操作.
- IO 多路转接: 和阻塞 IO 类似. 核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。
- 异步 IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
此处展开分享一下非阻塞IO。
二.非阻塞IO
系统和网络中的文件描述符,其默认情况下都是阻塞IO,下面来看怎么将其设置为非阻塞IO。
1.fcntl函数
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl 函数有 5 种功能:
- 复制一个现有的描述符(cmd=F_DUPFD).
- 获得/设置文件描述符标记(cmd=F_GETFD 或 F_SETFD).
- 获得/设置文件状态标记(cmd=F_GETFL 或 F_SETFL).
- 获得/设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN).
- 获得/设置记录锁(cmd=F_GETLK,F_SETLK 或 F_SETLKW).
此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞。
2.代码实现非阻塞
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图)。
然后再使用 F_SETFL 将文件描述符设置回去. 设置回去的同时, 加上一个 O_NONBLOCK 参数,表示为非阻塞。
前边提到,在非阻塞IO下,如果数据没有就绪,那么IO就会以出错的形式返回,那么如何区分到底是数据没有就绪,还是真的出错了呢???
通过判断errno错误码,如果错误码为EWOULDBLOCK,表示数据没有就绪,此时可以设计程序去做其他事,并通过轮询方式去检测数据是否就绪,反之则为真的出错,程序退出。
此外,如果进程长期阻塞,可能会收到系统的信号,中断程序运行,此时返回的错误码为EINTR,所以如果不想程序被系统中断,就可以通过此错误码在做判断。
三.多路转接
多路转接,即等待多个fd上的新事件就绪,然后通知程序员,事件已经就绪,可以进行IO拷贝了。
1.select
(1)概述
系统提供 select 函数来实现多路复用输入/输出模型。
- select 系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
- 程序会停在 select 这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
IO = 等 + 拷贝,select负责的就是等待,并且是等待多个新事件的到了。
(2)接口
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数:
- nfds :是需要监视的最大的文件描述符值+1;
- rdset,wrset,exset :分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合,是输入输出型参数。
- timeout :为结构体 timeval类型,用来设置 select()的等待时间。
fd_set结构:
这个结构就是一个整数数组, 更严格的说,是一个 "位图",使用位图中对应的位来表示要监视的文件描述符。
- 输入时,比特位的位置表示文件描述符的编号,比特位的内容表示是否关心该fd事件。
- 输出时,比特位的位置表示文件描述符的编号,比特位的内容表示对应的fd事件是否发生。
下面是OS提供了一组操作 fd_set 的接口, 来比较方便的操作位图:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组 set 中相关 fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组 set 中相关 fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组 set 中相关 fd 的位
void FD_ZERO(fd_set *set); // 用来清除描述词组 set 的全部位
timeval结构:
struct timeval
{
__time_t tv_sec;//秒
__suseconds_t tv_usec;//微秒
}
timeval 结构用于描述一段时间长度,比如(5,0)则表示在0-5秒内;如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为 0。
参数 timeout 取值:
- nullptr:则表示 select()没有 timeout,select 将一直被阻塞,直到某个文件描述符上发生了事件;
- 0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生,即按照非阻塞轮询的方式。
- 特定的时间值:如果在指定的时间段里没有事件发生,select 将超时返回。
函数返回值:
- 执行成功则返回文件描述词状态已改变的个数。
- 如果返回 0 代表在描述词状态改变前已超过 timeout 时间,没有返回。
- 当有错误发生时则返回-1,错误原因存于 errno,此时参数 readfds,writefds, exceptfds 和 timeout 的值变成不可预测。
(3)缺点
每次调用 select,都需要手动设置 fd 集合, 从接口使用角度来说也非常不便。
每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大。
同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。
select 支持的文件描述符数量太小。
2.poll
(1)概述
poll的作用与select完全相同,也是等待多个fd,等待fd上的新事件就绪,随后派发事件,可以理解为是select的优化版本。
(2)接口
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
-
fds是一个 poll 函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合。
-
nfds 表示 fds 数组的长度。
- timeout:以毫秒为单位,设定的超时时间,设为0表示非阻塞,-1表示阻塞。
pollfd结构体:
struct pollfd {
int
fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
同样是位图结构,short16位,每一位可代表一个事件,events和revents的取值:
这些事件都是宏,且分别表示为不同的二进制位,因此可以自由组合搭配,形成事件集合。
返回值:
- 大于0,表示有几个fd就绪。
- 等于0,超时。
- 小于0,poll出错。
(3)优点
pollfd 结构包含了要监视的 event 和发生的 event,不再使用 select“参数-值”传递的方式. 接口使用比 select 更方便。
poll 并没有最大数量限制 (但是数量过大后性能也是会下降)。
3.epoll
(1)概述
epoll是除了select和poll之外,公认为 Linux 下性能最好的多路 I/O 就绪通知方法。
(2)接口
使用epoll接口需要包含头文件 #include<sys/epoll.h>。
int epoll_create(int size);
创建一个 epoll 的句柄.
- size 参数可以被忽略。
- 用完之后, 必须调用 close()关闭。
返回值epfd供接下来的函数使用。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll 的事件注册函数. 它不同于 select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型。
epoll_ctl在底层会将用户让内核关心的fd及其事件添加进由内核构成的红黑树中进行维护。
- 第一个参数是 epoll_create()的返回值(epoll 的句柄).
- 第二个参数表示动作,用三个宏来表示.
- 第三个参数是需要监听的 fd.
- 第四个参数是告诉内核需要监听什么事.
第二个参数的取值:
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
struct epoll_event 结构如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;struct epoll_event {
uint32_t events;/* Epoll events */
epoll_data_t data;/* User data variable */
};
需要关注一下epoll_data_t结构体中的fd成员,其要存放事件的fd,当后续事件就绪时,需要通过该fd来获取事件。
其中events同样为位图结构,可以是以下几个宏的集合:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
收集在 epoll 监控的事件中已经发送的事件.
epoll_wait会检测内核中构成的就绪队列中是否有事件已经就绪, 并将已经就绪的事件按照严格顺序放入我们定义的用户缓冲区数组中。
- 参数 events 是分配好的 epoll_event 结构体数组. epoll 将会把发生的事件赋值到 events 数组中 (events 不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存).
- maxevents 告诉内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create()时的 size.
- 参数 timeout 是超时时间 (毫秒,0 会立即返回,-1 是永久阻塞).
- 如果函数调用成功,返回对应 I/O 上已准备好的文件描述符数目,如返回 0 表示已超时, 返回小于 0 表示函数失败.
对应的事件节点,会同时包含红黑树和就绪队列两个指针,从而使得该节点既可以存在于红黑树中,也可以存在于就绪队列中,从而无需新建新节点来进行转移。
(3)LT工作模式
LT即水平触发 Level Triggered 工作模式。
epoll 默认状态下就是 LT 工作模式.
当 epoll 检测到 socket 上事件就绪的时候, 可以不立刻进行处理,或者只处理一部分,当缓冲区还有事件未处理时,epoll_wait 会不断地立刻返回并通知 socket 读事件就绪,直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回。
支持阻塞读写和非阻塞读写。
(4)ET工作模式
ET即边缘触发 Edge Triggered 工作模式。
在第 1 步将 socket 添加到 epoll 描述符的时候使用 EPOLLET 标志, epoll 将进入 ET 工作模式。
当 epoll 检测到 socket 上事件就绪时, 必须立刻处理。如果未处理或未一次性处理完,在第二次调用epoll_wait 的时候, epoll_wait 不会再返回了。
也就是说, ET 模式下, 文件描述符上的事件就绪后, 只有一次处理机会。
ET 的性能比 LT 性能更高( epoll_wait 返回的次数少了很多). Nginx 默认采用 ET 模式使用 epoll。
只支持非阻塞的读写。
LT 是 epoll 的默认行为.
使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
另一方面, ET 的代码复杂程度更高。