8 高级IO
本节对应第十四章高级IO
IO模型分为五种:
阻塞io
非阻塞io
信号驱动
多路转接
异步io
8.0 IO过程
我们要将内存中的数据写入到磁盘的话,主体会是什么呢?主体可能是一个应用程序,比如一个Java进程(假设网络传来二进制流,一个Java进程可以把它写入到磁盘)。
操作系统负责计算机的资源管理和进程的调度。应用程序要把数据写入磁盘,或者从磁盘读取数据,只能通过调用操作系统开放出来的API来操作。
应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用是由进程(应用程序的运行态)发起,而IO执行是操作系统内核的工作。此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。
应用程序发起的一次IO操作包含两个阶段:
IO调用:应用程序进程向操作系统内核发起调用。
IO执行:操作系统内核完成IO操作。
操作系统内核完成IO操作还包括两个过程:
准备数据阶段:内核等待I/O设备准备好数据
拷贝数据阶段:将数据从内核缓冲区(内核空间)拷贝到用户进程缓冲区(用户空间)
其实IO就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket通讯的网卡。
8.1 BIO和NIO
阻塞IO:当资源不可用的时候,应用程序就会挂起。当资源可用的时候,唤醒任务。
阻塞IO图示如下,应用程序调用 read 函数从设备中读取数据,当设备不可用或数据未准备好的时候就会进入到休眠态。等设备可用的时候就会从休眠态唤醒,然后从设备中读取数据返回给应用程序。
之前学习过的IO都是阻塞IO。阻塞IO要阻塞一次,即等待数据和拷贝数据这个过程。
优缺点
优点:应用的程序开发非常简单;在阻塞等待数据期间,用户线程挂起。在阻塞期间,用户线程基本不会占用CPU资源。
缺点:一般情况下,会为每个连接配备一个独立的线程;反过来说,就是一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。但是,当在高并发的应用场景下,需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大。因此,基本上阻塞IO模型在高并发应用场景下是不可用的。
非阻塞IO:当资源不可用的时候,应用程序轮询查看,或放弃,会有超时处理机制。
非阻塞IO图示如下,可以看出,应用程序使用非阻塞访问方式从设备读取数据,当设备不可用或数据未准备好的时候会立即向内核返回一个错误码,表示数据读取失败。应用程序会再次重新读取数据,这样一直往复循环,直到数据读取成功。
非阻塞io也要阻塞一次,等待数据不用阻塞(内核马上返回未准备好),而从内核拷贝数据到用户区需要阻塞。
缺点:依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的CPU资源。可以考虑IO复用模型,去解决这个问题。
8.2 有限状态机
需求:有左右两个设备,第一个任务为读左设备,写右设备,第二个任务读右设备,写左设备。
代码实现
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#define TTY1 "/dev/tty3"
#define TTY2 "/dev/tty4"
#define BUFSIZE 1024
// 有限状态机的状态枚举
enum {
STATE_R = 1, // 读
STATE_W, // 写
STATE_Ex, // 异常
STATE_T // 终止
};
struct fsm_st {
int state;
int sfd;
int dfd;
int len; // 读取到的数据
int pos; // buf的偏移量
char buf[BUFSIZE]; // 缓冲区
char *errstr; // 报错信息
};
/* 状态机驱动 */
static void fsm_driver(struct fsm_st *fsm) {
int ret;
// 根据当前状态,决定下一步动作
switch(fsm->state) {
// 当前状态为读状态
case STATE_R:
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if(fsm->len == 0) { // 读完文件
fsm->state = STATE_T;
}else if(fsm->len < 0) {
if(errno == EAGAIN) { // 数据没有准备好
fsm->state = STATE_R;
} else { // 真错
fsm->errstr = "read()";
fsm->state = STATE_Ex;
}
}else { // 转换为写状态
fsm->pos = 0;
fsm->state = STATE_W;
}
break;
// 当前状态为写状态
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if(ret < 0) {
if(errno == EAGAIN) {
fsm->state = STATE_W;
} else {
fsm->errstr = "write()";
fsm->state = STATE_Ex;
}
}else {
fsm->pos += ret;
fsm->len -= ret;
if(fsm->len == 0) { // 写够len个字节
fsm->state = STATE_R;
} else { // 没有写够len个字节
fsm->state = STATE_W;
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
break;
default:
abort();
break;
}
}
static void relay(int fd1, int fd2) {
int fd1_save, fd2_save;
struct fsm_st fsm12, fsm21;
// 获取文件状态选项
fd1_save = fcntl(fd1, F_GETFL);
// 设置文件状态选项,添加非阻塞模式
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
// 设置状态机
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1;
// 轮询
while(fsm12.state != STATE_T || fsm21.state != STATE_T) {
fsm_driver(&fsm12);
fsm_driver(&fsm21);
}
// 恢复用户设置的文件状态
fcntl(fd1, F_SETFL, fd1_save);
fcntl(fd2, F_SETFL, fd2_save);
}
// 模拟用户的设置
int main(void) {
int fd1, fd2; // 左设备和右设备
// 假设用户不以非阻塞的方式打开文件
if((fd1 = open(TTY1, O_RDWR)) < 0) {
perror("open()");
exit(1);
}
write(fd1,"TTY1\n",5);
// 假设用户以非阻塞的方式打开文件
if((fd2 = open(TTY1, O_RDWR|O_NONBLOCK)) < 0) {
perror("open()");
exit(1);
}
write(fd2,"TTY2\n",5);
relay(fd1, fd2);
close(fd2);
close(fd1);
exit(0);
}
上述代码存在忙等现象,会使得CPU利用率占满,原因在于:
进入循环后:
while(fsm12.state != STATE_T || fsm21.state != STATE_T) {
fsm_driver(&fsm12);
fsm_driver(&fsm21);
}
如果设备没有准备好数据,则进入fsm_driver后,执行read调用时,内核立即会返回(非阻塞),是一个假错,执行:
if(errno == EAGAIN) { // 通常在执行非阻塞io时引发EAGAIN,这意味着“现在没有可用的数据,以后再试一次” 。
fsm->state = STATE_R;
}
状态不变,跳出case语句和驱动函数后,继续循环,所以导致cpu利用率高。
8.3 linux终端
8.3.1 终端,控制台和tty
tty全称teletypewriter,即是电传打字机,它通过两根电缆连接计算机,一根用于向计算机发送指令,一根用于接收计算机的输出,输出结果是打印在纸上的。它是最早出现的一种终端设备。
最初tty是指连接到Unix系统上的物理或者虚拟终端。终端是一种字符型设备,通常使用tty来统称各种类型的终端设备。随着时间的推移,当通过串行口能够建立起终端连接后,这个名字也用来指任何的串口设备。它还有多种类,例如串口(ttySn、ttySACn、ttyOn)、USB到串口的转换器(ttyUSBn)等。tty虚拟设备支持虚拟控制台,它能通过键盘及网络连接或者通过xterm会话登录到计算机上。
终端(terminal)为主机提供了人机接口,每个人都通过终端使用主机的资源。终端有字符终端和图形终端两种,一台主机可以连很多终端。
控制台(console)是一种特殊的人机接口,是人控制主机的第一人机接口,而主机对于控制台的信任度高于其他终端。
个人计算机只有控制台,没有终端。当然愿意的话,可以在串口上连一两台字符哑终端。但是linux按POSIX标准把个人计算机当成小型机来用,在控制台上通过软件虚拟了六个字符哑终端(或者叫虚拟控制台终端tty1-tty6)和一个图型终端,在虚拟图形终端中又可以通过软件再虚拟无限多个伪终端(pts/0等)。但这全是虚拟的,虽然用起来一样,但实际上没有物理实体。所以在个人计算机上,只有一个实际的控制台,没有终端,所有终端都是在控制台上用软件模拟的。要把个人计算机当主机再通过串口或网卡外连真正的物理终端也可以,论成本,谁会怎么做呢。
linux的终端设备一般分为以下几种:
8.3.2 控制台
① 系统控制台
/dev/console是系统控制台,是与操作系统交互的设备。
② 当前控制台
/dev/tty是当前控制台,它会映射到当前设备(使用命令tty可以查看它具体对应哪个实际物理控制台设备)。
如果在控制台界面下(即字符界面下)那么dev/tty就是映射到dev/tty1-6之间的一个,但是如果现在是在图形界面(Xwindows),那么你会发现现在的/dev/tty映射到的是/dev/pts的伪终端上。
③ 虚拟控制台
/dev/ttyn是进程虚拟控制台,他们共享同一个真实的物理控制台。
在PC上,用户可以使用alt+Fn切换控制台,看起来感觉存在多个屏幕,这种虚拟控制台对应tty1~n,其中 ,/dev/tty1等代表第一个虚拟控制台。
例如当使用ALT+F2进行切换时,系统的虚拟控制台为/dev/tty2 ,当前控制台(/dev/tty)则指向/dev/tty2;
比较特殊的是/dev/tty0,他代表当前虚拟控制台,是当前所使用虚拟控制台的一个别名。因此不管当前正在使用哪个虚拟控制台(注意:这里是虚拟控制台,不包括伪终端),系统信息都会发送到/dev/tty0上。
8.3.3 伪终端
伪终端(Pseudo Terminal,或者pty, pseudo-tty)是终端的发展,为满足现在需求(比如网络登陆、xwindow窗口的管理)。它是成对出现的逻辑终端设备(即master和slave设备,对master的操作会反映到slave上),多用于模拟终端程序,是远程登陆(telnet、ssh、xterm等)后创建的控制台设备。
在XWindow下打开的终端或使用telnet 或ssh等方式登录Linux主机,此时均通过pty设备。例如,如果某人在网上使用telnet程序连接到计算机上,则telnet程序就可能会打开/dev/ptmx设备获取一个fd。此时一个getty程序就应该运行在对应的/dev/pts/*上。当telnet从远端获取了一个字符时,该字符就会通过ptmx、pts/*传递给getty程序,而getty程序就会通过pts/*、ptmx和telnet程序往网络上返回login:字符串信息。这样,登录程序与telnet程序就通过“伪终端”进行通信。
8.4 IO多路转接
IO多路转接也称为IO多路复用。
IO复用模型核心思路:系统给我们提供一类函数(select、poll、epoll函数),它们可以同时监控多个fd的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用。
8.4.1 select
应用进程通过调用select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备就绪了,select函数就会返回可读状态,这时应用进程再发起recvfrom请求去读取数据。
图示1
图示2
IO多路转接需要阻塞两次,第一次是在select处阻塞(内核为我们轮询fd的变化情况),第二次是select返回后,拷贝数据发起系统调用(不管是读还是写)时需要再次阻塞。
函数原型:
/* According to POSIX.1-2001 */
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 删除 set 中的给定的文件描述符
void FD_CLR(int fd, fd_set *set);
// 测试文件描述符 fd 是否在 set 集合中
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符 fd 添加到 set 中
void FD_SET(int fd, fd_set *set);
// 清空 set 中的文件描述符
void FD_ZERO(fd_set *set);
注意select调用本身是阻塞的。
select参数含义:
nfds:最大的文件描述符 + 1;
readfds:需要监视的输入文件描述符集合,底层采用数组存储。
writefds:需要监视的输出文件描述符集合;
exceptfds:需要监视的会发生异常的文件描述符集合;
timeout:等待的超时时间,如果时间超时依然没有文件描述符状态发生变化那么就返回。设置为 0 会立即返回,设置为 NULL 则一直阻塞等待,不会超时。
返回值:错误返回-1,超时返回0。当关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
代码示例
利用select重构9.2小节的代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#include <sys/select.h>
#define TTY1 "/dev/tty3"
#define TTY2 "/dev/tty4"
#define BUFSIZE 1024
// 有限状态机的状态枚举
enum {
STATE_R = 1, // 读
STATE_W, // 写
STATE_AUTO,
STATE_Ex, // 异常
STATE_T // 终止
};
static void relay(int fd1, int fd2) {
int fd1_save, fd2_save;
struct fsm_st fsm12, fsm21;
fd_set rset, wset;
// 获取文件状态选项
fd1_save = fcntl(fd1, F_GETFL);
// 设置文件状态选项,添加非阻塞模式
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
// 设置状态机
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1;
while(fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务,清空fd_set
FD_ZERO(&rset);
FD_ZERO(&wset);
if(fsm12.state == STATE_R) { // 当前为读态,即读sfd
FD_SET(fsm12.sfd, &rset); // 我们关心sfd是否可读,因此将其加入到rset中
}
if(fsm12.state == STATE_W) { // 当前为写态,即写dfd
FD_SET(fsm12.dfd, &wset);// 我们关心dfd是否可写,因此将其加入到wset中
}
if(fsm21.state == STATE_R) {
FD_SET(fsm21.sfd, &rset);
}
if(fsm21.state == STATE_W) {
FD_SET(fsm21.dfd, &wset);
}
// 监视
if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) {
// 阻塞监视
if(select(max(fd1, fd2) + 1, &rset, &wset, NULL, NULL) < 0) {
if(errno == EINTR) { // 阻塞调用被信号打断,假错
continue;
}
perror("select()"); // 真错
exit(1);
}
}
// 查看监视结果
if(FD_ISSET(fd1, &rset) || FD_ISSET(fd2, &wset) || fsm12.state > STATE_AUTO) { // 读fd1准备好 或 写fd2准备好 或 其他状态
fsm_driver(&fsm12);
}
if(FD_ISSET(fd2, &rset) || FD_ISSET(fd1, &wset) || fsm21.state > STATE_AUTO) { // 读fd2准备好 或 写fd1准备好 或 其他状态
fsm_driver(&fsm21);
}
}
// 恢复用户设置的文件状态
fcntl(fd1, F_SETFL, fd1_save);
fcntl(fd2, F_SETFL, fd2_save);
}
上面的代码不会出现盲等现象,原因在于:
调用select函数后会在此处阻塞,同时内核监控rset和wset中的文件描述符的变化(准备数据)。
当发生变化时(数据准备好时),唤醒线程,select函数返回值大于0,继续向下执行,推动状态机运行(进行读写时还会再次阻塞,例如read或write,因为明确了数据已经准备好)。
综上,不会产生盲等。
select的缺点:
监听的IO最大连接数有限,在Linux系统上一般为1024。
select函数返回后,是通过遍历fdset,找到就绪的描述符fd。(仅知道有I/O事件发生,却不知是哪几个流,所以遍历所有流) ,如果同时连接的大量客户端,在一时刻可能只有极少处于就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。
内存拷贝:需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
8.4.2 poll
select 和 poll 系统调用的本质一样,poll 的机制与 select 类似,与 select 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制(数量过大后性能也是会下降)。poll 和 select 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
函数原型:
// poll - wait for some event on a file descriptor
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 需要监视的文件描述符 */
short events; /* 要监视的事件 */
short revents; /* 该文件描述符发生了的事件 */
};
参数含义:
fds:实际上是一个结构体数组的首地址,因为 poll 可以帮助我们监视多个文件描述符,而一个文件描述放到一个 struct pollfd 结构体中,多个文件描述符就需要一个数组来存储了(一个文件描述符对应一个结构体)。底层采用链表存储。
nfds:fds 这个数组的长度。
timeout:阻塞等待的超时时间。传入 -1 则始终阻塞,不超时。
返回值:
成功时,poll 返回结构体中 revents 域不为 0 的文件描述符个数;如果在超时前没有任何事件发生,poll返回 0;
失败时,poll 返回 -1,并设置 errno
结构体中的事件events和revents可以指定下面七种事件,同时监视多个事件可以使用按位或(|)添加:
事件 | 描述 |
POLLIN | 文件描述符可读 |
POLLPRI | 可以非阻塞的读高优先级的数据 |
POLLOUT | 文件描述符可写 |
POLLRDHUP | 流式套接字连接点关闭,或者关闭写半连接。 |
POLLERR | 已出错 |
POLLHUP | 已挂断(一般指设备) |
POLLNVAL | 参数非法 |
程序实例
static void relay(int fd1, int fd2) {
int fd1_save, fd2_save;
struct fsm_st fsm12, fsm21;
struct pollfd pfd[2];
// 获取文件状态选项
fd1_save = fcntl(fd1, F_GETFL);
// 设置文件状态选项,添加非阻塞模式
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
// 设置状态机
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1;
pfd[0].fd = fd1;
pfd[1].fd = fd2;
while(fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务
pfd[0].events = 0; // 清空监视的事件
pfd[1].events = 0;
if(fsm12.state == STATE_R) {
pfd[0].events |= POLLIN; // 监视是否可读
}
if(fsm12.state == STATE_W) {
pfd[1].events |= POLLOUT;
}
if(fsm21.state == STATE_R) {
pfd[1].events |= POLLIN;
}
if(fsm21.state == STATE_W) {
pfd[0].events |= POLLOUT;
}
// 监视
if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) {
if(poll(pfd, 2, -1) < 0) { // 阻塞监视
if(errno == EINTR) {
continue;
}
perror("poll()");
exit(1);
}
}
// poll返回后,查看监视结果
// 用按位与
if(pfd[0].revents & POLLIN || pfd[1].revents & POLLOUT || fsm12.state > STATE_AUTO) {
fsm_driver(&fsm12);
}
if(pfd[1].revents & POLLIN || pfd[0].revents & POLLOUT || fsm21.state > STATE_AUTO) {
fsm_driver(&fsm21);
}
}
// 恢复用户设置的文件状态
fcntl(fd1, F_SETFL, fd1_save);
fcntl(fd2, F_SETFL, fd2_save);
}
同样不会出现盲等现象。
8.4.3 epoll
select和poll在需要我们在用户态创建监视文件描述符的集合(fd_set和pollfd,底层分别采用数组和链表存储,因此前者有大小限制,后者没有),调用时,需要将该集合复制到内核空间中,这样内核才能帮助我们轮询fd,这个过程具有一定开销。
epoll则只提供这个集合创建、控制相关的接口,调用时,直接在内核空间创建监视fd的集合,因此去除了复制过程开销。过程如下:
相关调用:epoll_create,epoll_ctl,epoll_wait
epoll_create
#include <sys/epoll.h>
int epoll_create(int size);
参数:调用 epoll_create 时最初 size 参数给传入多少,内核在建立数组的时候就是多少个元素。后来改进为只要 size 传入一个正整数即可,内核不会再根据传入的 size 直接作为数组的长度,因为内核是使用 hash 来管理要监视的文件描述符的。
作用和返回值:该函数会创建一个 epoll 实例(或epoll对象),同时返回一个引用该实例的文件描述符。返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例,需要手动释放这个文件描述符。
一个epoll对象都有一个独立的eventpoll结构体,结构体如下:
struct eventpoll {
...
/*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表存储所有就绪的文件描述符*/
struct list_head rdlist;
...
};
epoll 实例内部存储:
监听列表:所有要监听的文件描述符,使用红黑树,由 epoll_ctl 传来
就绪列表:所有就绪的文件描述符,使用双向链表
epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
struct epoll_event {
uint32_t events; /* Epoll 监视的事件,这些事件与 poll 能监视的事件差不多,只是宏名前面加了个E */
epoll_data_t data; /* 用户数据,除了能保存文件描述符以外,还能让你保存一些其它有关数据,比如你这个文件描述符是嵌在一棵树上的,你在使用它的时候不知道它是树的哪个节点,则可以在布置监视任务的时候将相关的位置都保存下来。这个联合体成员就是 epoll 设计的精髓。 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
参数和返回值:
epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
fd 表示要监听的目标文件描述符
event 表示要监听的事件(可读、可写、发送错误…)
op 表示要对 fd 执行的操作,有以下几种:EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
EPOLL_CTL_MOD:event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值
EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用
返回值 0 或 -1,表示上述操作成功与否。
作用:epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event,如果红黑树中已经存在立刻返回。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数和返回值:
epfd:要操作的 epoll 实例;
events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
maxevents 指定 events 的大小
timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。
代码示例
static void relay(int fd1, int fd2) {
int fd1_save, fd2_save;
struct fsm_st fsm12, fsm21;
int epfd; // epoll对象的文件描述符
struct epoll_event ev;
// 获取文件状态选项
fd1_save = fcntl(fd1, F_GETFL);
// 设置文件状态选项,添加非阻塞模式
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
// 设置状态机
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1;
epfd = epoll_create(10);
if(epfd < 0) {
perror("epoll_create()");
exit(1);
}
ev.events = 0; // 清空监听的事件
ev.data.fd = fd1; // 设置监听的文件描述符
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev); // 将fd1添加到epfd的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event
ev.events = 0;
ev.data.fd = fd2;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);
while(fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务
ev.events = 0;
ev.data.fd = fd1;
if(fsm12.state == STATE_R) {
ev.events |= EPOLLIN;
}
if(fsm21.state == STATE_W) {
ev.events |= EPOLLOUT;
}
epoll_ctl(epfd, EPOLL_CTL_MOD, fd1, &ev);
ev.events = 0;
ev.data.fd = fd2;
if(fsm12.state == STATE_W) {
ev.events |= EPOLLOUT;
}
if(fsm21.state == STATE_R) {
ev.events |= EPOLLIN;
}
epoll_ctl(epfd, EPOLL_CTL_MOD, fd2, &ev);
// 监视
if(fsm12.state < STATE_AUTO || fsm21.state < STATE_AUTO) {
if(epoll_wait(epfd, &ev, 1, -1) < 0) { // 阻塞监视
if(errno == EINTR) {
continue;
}
perror("epoll_wait()");
exit(1);
}
}
// 查看监视结果
if(ev.data.fd == fd1 && ev.events & EPOLLIN \
|| ev.data.fd == fd2 && ev.events & EPOLLOUT \
|| fsm12.state > STATE_AUTO) {
fsm_driver(&fsm12);
}
if(ev.data.fd == fd2 && ev.events & EPOLLIN \
|| ev.data.fd == fd1 && ev.events & EPOLLIN \
|| fsm12.state > STATE_AUTO) {
fsm_driver(&fsm21);
}
}
// 恢复用户设置的文件状态
fcntl(fd1, F_SETFL, fd1_save);
fcntl(fd2, F_SETFL, fd2_save);
close(epfd);
}
三者区别
select | poll | epoll | |
底层数据结构 | 数组存储文件描述符 | 链表存储文件描述符 | 红黑树存储监控的文件描述符,双链表存储就绪的文件描述符 |
如何从fd数据中获取就绪的fd | 遍历fd_set | 遍历链表 | 回调 |
时间复杂度 | 获得就绪的文件描述符需要遍历fd数组,O(n) | 获得就绪的文件描述符需要遍历fd链表,O(n) | 当有就绪事件时,系统注册的回调函数就会被调用,将就绪的fd放入到就绪链表中。O(1) |
FD数据拷贝 | 每次调用select,需要将fd数据从用户空间拷贝到内核空间 | 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 | 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间 |
最大连接数 | 有限制,一般为1024 | 无限制 | 无限制 |