文章目录


1 问题提出

// fd1, fd2, fd3 分别是以只读的方式打开的三个不同有名管道的描述符(a.fifo, b.fifo, c.fifo)

while (1) {
n = read(fd1, buf, 64);
write(STDOUT_FILENO, buf, n);

n = read(fd2, buf, 64);
write(STDOUT_FILENO, buf, n);

n = read(fd3, buf, 64);
write(STDOUT_FILENO, buf, n);
}

假设读写管道,只要写端没有写入数据,读端就永远阻塞。意味着上面的程序将会在三个 read 中任何一个或多个发生阻塞。

多进程和多线程可以解决,需要同步,异步 IO 可以搞定,使用非阻塞 IO也可以(轮循,浪费资源)

使用IO多路复用来解决。IO多路复用(I/O Multiplexing)就是一条 IO 通道,被划分成了多个 IO 通道,允许同时进行多个 IO 读写。

2 fd_set容器

2.1 fd_set的实现

对于 fd_set 来说,0 号隔间只能放 0 号描述符,1 号隔间只能放 1 号描述符……

实际上,fd_set 类型是利用整型数组实现的,每个元素中的每个 bit 位被置 1 就表示该位置保存了描述符,如果为 0 就表示没有该描述符。

Linux系统编程(九)--高级IO-多路复用_#include

一种 fd_set 的实现,fd_set 中保存了 5 号和 14 号描述符。

2.2 操作fd_set容器

#include <sys/select.h>

// 判断描述符 fd 是否在集合中
int FD_ISSET(int fd, fd_set *fdset);

// 将描述符 fd 从集合中删除
int FD_CLR(int fd, fd_set *fdset);

// 将描述符 fd 添加到集合中
int FD_SET(int fd, fd_set *fdset);

// 将集合清空(所有 bit 置 0)
int FD_ZERO(fd_set *fdset);

3 select函数

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

3.1 参数说明:

中间三个参数都是 fd_set 类型

  • readfds,监听集合中的描述符是否有数据可读。

  • writefds,监听集合中的描述符是否可写,写 IO 也会发生阻塞,比如缓冲区满了。

  • exceptfds,你想监听这个集合中的描述符是否发生异常。

如果三个集合都为空,nfds = 0,只给时间参数传值,相当于 sleep 函数,提供微秒精度。

参数 nfds ,传入参数的那三个集合中,最大的描述符的值 + 1。

最后一个参数是等待时间

struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 */
};

如果传空,表示永远等待,直到三个集合中的描述符有事件(有数据可读、有数据可写、有异常事件发生)到来。不为值,表示最长愿意等待多久。

3.2 select返回值

select 函数返回值体现在两方面:1、函数返回值;2、修改三个集合参数

1、函数返回值

对于函数返回值来说,主要有 3 种情况:

  • 返回值 < 0,表示函数执行出错,比如使用了不可用的描述符。select 被信号打断,也会返回 < 0。因为 select 函数是不支持自动重启动的,被信号打断会立即返回,然后把 errno 的值设置成 EINTR.。
  • 返回值 = 0,超时时间到了,还没有事件发生。
  • 返回值 > 0,表示监听的描述符中,有几个事件发生,累计。

2、修改参数

如何知道哪些描述符上发生了事件,修改三个传入的描述符集合。如果某个集合中的描述符上有事件到来,select 返回时,会保留该描述符,未发生事件的描述符清除。

对于超时参数来说,如果在超时时间到达前发生异常或有事件到来,该参数被被更新为剩余时间。

实验​:

程序 select.c 从标准输入、a.fifo 和 b.fifo 中读数据并打印到屏幕。

程序 writepipe.c 主要向管道文件写数据。

  • select.c
// select.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

#define PERR(msg) do { perror(msg); exit(1); } while(0);

int process(char* prompt, int fd) {
int n;
char buf[64];
char line[64];
n = read(fd, buf, 64);
if (n < 0) {
// read 执行出错
PERR("read");
}
else if (n == 0) {
// 如果对端关闭,read 返回 0
sprintf(line, "%s closed\n", prompt);
puts(line);
return 0;
}
else if (n > 0) {
buf[n] = 0;
sprintf(line, "%s say: %s", prompt, buf);
puts(line);
}
return n;
}

int main() {
int n, res;
char buf[64];

fd_set st;
FD_ZERO(&st);

int fd0 = STDIN_FILENO;
int fd1 = open("a.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fd1);
int fd2 = open("b.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fd2);

FD_SET(fd0, &st);
FD_SET(fd1, &st);
FD_SET(fd2, &st);

// 最后一个 open 的描述符值是最大的
int maxfd = fd2 + 1;

while (1) {
// 因为 tmpset 参数会被 select 修改,所以要重新赋值。
fd_set tmpset = st;
res = select(maxfd, &tmpset, NULL, NULL, NULL);

if (res < 0) {
// select 执行出错,对于被信号中断的,需要单独处理,这里暂时不考虑,后面的文章会讲
PERR("select");
}
else if (res == 0) {
// 超时,先不用管
continue;
}

// 判断返回的集合中是否包含对应的描述符,如果包含,说明的事件(可读)到来。
if (FD_ISSET(fd0, &tmpset)) {
n = process("fd0", fd0);
// 如果返回值为 0,表示对端关闭,后面的也一样。
if (n == 0) FD_CLR(fd0, &st);
}
if (FD_ISSET(fd1, &tmpset)) {
n = process("fd1", fd1);
if (n == 0) FD_CLR(fd1, &st);
}
if (FD_ISSET(fd2, &tmpset)) {
n = process("fd2", fd2);
if (n == 0) FD_CLR(fd2, &st);
}
}
}
  • writepipe.c

    writepipe 程序主要向管道文件写数据。它从命令行接收管道文件的名字。

// writepipe.c
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Usage: %s <fifoname>\n", argv[0]);
return 1;
}
char buf[64];
int n;
int fd = open(argv[1], O_WRONLY);
if (fd < 0) {
perror("open pipe");
return 1;
}
while (1) {
n = read(STDIN_FILENO, buf, 64);
write(fd, buf, n);
}
return 0;
}

编译

$ mkfifo a.fifo
$ mkfifo b.fifo

打开三个终端,分别运行:

$ ./select

$ ./writepipe a.fifo

$ ./writepipe b.fifo

Linux系统编程(九)--高级IO-多路复用_描述符_02

3.3 select与信号

select 函数可能会返回错误,比如使用了错误的描述符,或者被信号打断。返回值 < 0,同时 errno 会被置成 EINTR。使用 select 函数的时候,要处理被信号中断的情况。

// select_sig.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

#define PERR(msg) do { perror(msg); exit(1); } while(0);

void handler(int sig) {
if (sig == SIGALRM)
puts("Hello SIGALRM");
}

int process(char* prompt, int fd) {
int n;
char buf[64];
char line[64];
n = read(fd, buf, 64);
if (n < 0) {
// error
PERR("read");
}
else if (n == 0) {
// peer close
sprintf(line, "%s closed\n", prompt);
puts(line);
return 0;
}
else if (n > 0) {
buf[n] = 0;
sprintf(line, "%s say: %s", prompt, buf);
puts(line);
}
return n;
}

int main() {
int n, res, fd0, maxfd;
char buf[64];
struct sigaction sa;
fd_set st;

// 打印 pid
printf("pid = %d\n", getpid());

// 安装信号处理函数
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGALRM, &sa, NULL);

// 为了简化程序,这里只管理一个描述符
FD_ZERO(&st);
fd0 = STDIN_FILENO;
FD_SET(fd0, &st);

maxfd = fd0 + 1;

while (1) {
fd_set tmpset = st;
res = select(maxfd, &tmpset, NULL, NULL, NULL);

if (res < 0) {
// 如果被信号打断的,不让程序退出,直接 continue
if (errno == EINTR) {
perror("select");
continue;
}
// 其它情况的错误,直接让程序退出
PERR("select");
}
else if (res == 0) {
// timeout
continue;
}

if (FD_ISSET(fd0, &tmpset)) {
n = process("fd0", fd0);
if (n == 0) FD_CLR(fd0, &st);
}
}
}
  • 编译
$ gcc select_sig.c -o select_sig1
  • 运行

启动 select_sig 后,在另一个终端给它发 SIGALRM 信号。

Linux系统编程(九)--高级IO-多路复用_linux_03

4 poll函数

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

第一个参数是struct pollfd 数组,第二个参数是数组大小,第三个参数是超时时间。

struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监听的事件,比如可读事件,可写事件 */
short revents; /* poll 函数的返回结果,是可读还是可写 */
};

select将可读事件、可写事件描述符单独放进两个不同的集合,poll 函数分配一个结构体,一次性监听。

fd 表示要监听哪个描述符,如果是负数,poll 函数忽略。

events,使用 bit 位来保存你要监听什么事件,用“位或”操作为其赋值,可选值如下:

  • POLLIN: 监听是描述符是否可读。

  • POLLPRI:监听是否有紧急数据可读。

  • POLLOUT:监听是描述符是否可写。

  • POLLRDHUP:监听流式套接字对端是否关闭右半关闭。

如果有监听的事件到来,将事件类型保存到 revents 成员中,比如监听到了有数据可读,则 revents 的 bit 位中会保存 POLLIN。

一些异常事件不主动监听它也会发生,并保存到 revents 中:

  • POLLERR:硬件问题,少见。

  • POLLHUP:对端挂断,比如对于有名管道,其中一端关闭了。

  • POLLNVAL:使用了未打开的描述符

timeout 参数

  • = -1,表示永远等待,直到有事件发生。

  • = 0,不等待,立即返回。

  • > 0,等待 timeout 毫秒。

poll 与 select对比

所做的事件是一样的,但是它们也有区别:

  1. select 使用 fd_set 来存放描述符,poll 使用结构体数组。
  2. select 能够一次监听的描述符数量是受 fd_set 集合的限制的,通常最多放1024个描述符。poll 一次能够监听的描述符个数是数组大小决定的,要看 nfds_t 被定义成什么类型,如果是 unsigned long,4字节宽,poll 能监听 2​32​−1个描述符。

实验​:

程序 poll.c 只是对前面的 select.c 做了一点点修改,将 select 替换成了 poll 函数。另外,为了能演示异常事件,程序使用了一个未打开的描述符 fd3。poll 函数同样会被信号打断。

// poll.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

#define PERR(msg) do { perror(msg); exit(1); } while(0);

void handler(int sig) {
if (sig == SIGINT) {
puts("hello SIGINT");
}
}

int process(char* prompt, int fd) {
int n;
char buf[64];
char line[64];
n = read(fd, buf, 64);
if (n < 0) {
// error
PERR("read");
}
else if (n == 0) {
// peer close
sprintf(line, "%s closed\n", prompt);
puts(line);
return 0;
}
else if (n > 0) {
buf[n] = 0;
sprintf(line, "%s say: %s", prompt, buf);
puts(line);
}
return n;
}

int main() {
int i, n, res;
char buf[64];

struct pollfd fds[4];

if (SIG_ERR == signal(SIGINT, handler)) {
PERR("signal");
}

int fd0 = STDIN_FILENO;
int fd1 = open("a.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fd1);
int fd2 = open("b.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fd2);
int fd3 = 100;

fds[0].fd = fd0;
fds[1].fd = fd1;
fds[2].fd = fd2;
fds[3].fd = fd3;

for (i = 0; i < 4; ++i) {
fds[i].events = POLL_IN;
}


while (1) {
res = poll(fds, 4, -1);

if (res < 0) {
// error
if (errno == EINTR) {
perror("poll");
continue;
}
PERR("poll");
}
else if (res == 0) {
// timeout
continue;
}

for (i = 0; i < 4; ++i) {
if (fds[i].revents & POLLIN) {
sprintf(buf, "fd%d", i);
n = process(buf, fds[i].fd);
if (n == 0) fds[i].fd = -1;
}
if (fds[i].revents & POLLERR) {
printf("fd%d Error\n", i);
fds[i].fd = -1;
}
if (fds[i].revents & POLLHUP) {
printf("fd%d Hang up\n", i);
fds[i].fd = -1;
if (fds[i].revents & POLLNVAL) {
printf("fd%d Invalid request\n", i);
fds[i].fd = -1;
}
}
}
}
}
  • 编译
$ gcc poll.c -o poll
  • 运行

Linux系统编程(九)--高级IO-多路复用_数据_04

5 epoll函数

5.1 IO事件

事件是对于缓冲区来说的。如果缓冲区中的数据发生变化,说明有 IO 事件产生。

5.2 select 与 poll的缺点

1、需要自己判断哪个描述符发生事件

select 或 poll 返回后,需要一个一个去查询是哪个描述符上发生了 IO 事件。epoll 能将所有发生事件的描述符保存到数组中,没有发生事件的描述符不保存。

2、描述符复制

使用 select 或 poll ,每一次都需要将想要监听的描述符传递给它。每一次调用都需要将这些描述符复制到内核空间。而 epoll 只要事先复制一次。

5.3 使用epoll

使用 epoll 函数的步骤通常如下:

  1. 首先创建一个 epoll 对象,返回该对象的描述符

  2. 通过该对象的描述符,将要监听的描述符复制到内核

  3. 开始监听事件

三个步骤对应三个函数

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

5.4 epoll_create

int epoll_create(int size);

创建一个 epoll 对象,返回对象的描述符。参数 size表示你想监听几个描述符。

5.5 epoll_create

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* 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 事件 */
epoll_data_t data; /* 用户数据 */
};

参数epfd

epoll 对象的描述符,由 epoll_create 函数返回。

参数op​:

决定向 epoll 对象中添加、修改还是删除描述符。取值如下

含义

EPOLL_CTL_ADD

将参数fd指定的描述符添加到 epoll 对象中,同时将其关联到一个epoll 事件对象,即参数 event 所指定的值

EPOLL_CTL_MOD

修改描述符fd所关联的事件对象 event

EPOLL_CTL_DEL

将描述符fd从epoll对象中移除

参数 event

该参数是 struct epoll_event 结构体指针。event 参数关联到参数 fd 上,表示想监听描述符 fd 上的哪种 IO 事件,比如可读事件,可写事件。结构体成员 events有下面的值:

含义

EPOLLIN

监听 fd 是否可读

EPOLLOUT

监听 fd 是否可写

EPOLLRDHUP

监听流式套接字对象是否关闭或半关闭

EPOLLPRI

监听是否有紧急数据可读

EPOLLET

触发模式

EPOLLET​默认情况下为水平触发​(Level Triggered),另一种为边沿触发(Edge Triggered)。

5.6 epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

作用:监听所有描述符上是否有事件发生。这些描述符之前都由 epoll_ctl 添加。

如果都没有 IO 事件发生,阻塞,直到有事件到来。一旦有事件到来,epoll_wait 会返回发生事件的个数,所有发生的事件保存到数组events中,数组大小为 maxevents。 events是一个输出参数,充当返回值。如果发生事件的个数比maxevents大,下次再传。返回的 events 数组中,每个元素都表示一个事件,假设 epoll_wait 返回值是 3,就表示有 3 个事件发生,events[0]、events[1] 和 events[2]

用户数据data中的描述符 fd 表示在哪个描述符上发生

一些异常事件,即使不主动监听,如果发生了也会主动通知你:

含义

EPOLLERR

描述符有错误,这少见,比如硬件问题

EPOLLHUP

关联的描述符有一端挂断,比如管道一端关闭

参数 timeout,超时参数。

  • timeout = -1,永远等待。
  • timeout = 0,立即返回。
  • timeout > 0,最长等待 timeout 毫秒。

返回值

  • > 0,表示有几个事件发生。

  • = 0,表示超时时间到了。

  • < 0,则出错,同时设置 errno 的值。

实验​:

程序 epoll.c 仍然使用 select 和 poll 中的那个案例,这里只是改成了 epoll 的方式。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

#define PERR(msg) do { perror(msg); exit(1); } while(0);

// 信号处理函数,验证 epoll_wait 会被信号打断
void handler(int sig) {
if (sig == SIGINT) {
puts("hello SIGINT");
}
}

// 处理描述符上发生的事件
int process(char* prompt, int fd) {
int n;
char buf[64];
char line[64];
n = read(fd, buf, 63);
if (n < 0) {
// error
PERR("read");
}
else if (n == 0) {
// peer close
sprintf(line, "%s closed\n", prompt);
puts(line);
return 0;
}
else if (n > 0) {
buf[n] = 0;
sprintf(line, "%s say: %s", prompt, buf);
puts(line);
}
return n;
}

int main() {
int i, n, res;
char buf[64];
int fds[3];
int fd;

if (SIG_ERR == signal(SIGINT, handler)) {
PERR("signal");
}

fds[0] = STDIN_FILENO;
fds[1] = open("a.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fds[1]);
fds[2] = open("b.fifo", O_RDONLY);
printf("open pipe: fd = %d\n", fds[2]);


// 事件数组 evts 用来保存 epoll_wait 返回的事件
struct epoll_event evts[4];

// 创建一个 epoll 实例对象
int epfd = epoll_create(4);

// 添加你所关心的描述符到 epoll 实例对象中
for (i = 0; i < 3; ++i) {
struct epoll_event ev;
ev.data.fd = fds[i]; // 注意这个值必须要指定,不然 epoll_wait 返回了你也不知道是谁发生了事件
ev.events = EPOLLIN; // 想监听可读事件,因为没有指定 EPOLLET 选项,所以默认是水平触发
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev) < 0) {
PERR("epoll_ctl");
}
}


while (1) {
// 开始等待事件发生,res 表示发生了事件的个数
res = epoll_wait(epfd, evts, 4, -1);
printf("res = %d\n", res);

if (res < 0) {
// error
if (errno == EINTR) {
perror("epoll_wait");
continue;
}
PERR("epoll_wait");
}
else if (res == 0) {
// timeout
continue;
}

// 开始处理所有事件
for (i = 0; i < res; ++i) {
// 这个 fd 就是你一开始通过 event 的 data 成员传进去的。
fd = evts[i].data.fd;
if (evts[i].events & EPOLLIN) {
sprintf(buf, "fd%d", fd);
process(buf, fd);
}
// 这里我们根据没有监听可写事件,所以这种情况不会发生。
if (evts[i].events & EPOLLOUT) {
printf("fd%d can write\n", i);
}
// 下面这两个事件就算你没有监听,也可能会产生,需要单独处理
if (evts[i].events & EPOLLERR) {
printf("fd%d Error\n", i);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
if (evts[i].events & EPOLLHUP) {
printf("fd%d Hang up\n", i);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
}
}
}
  • 编译
gcc epoll.c -o epoll
  • 运行

Linux系统编程(九)--高级IO-多路复用_#include_05

6 epoll触发模式

6.1 两种触发

默认为水平触发,另一种为边沿触发。

如果为 edge-triggered 方式,只有在缓冲区发生变化的情况下,epoll_wait 函数才会返回。

如果为 level-triggered 方式,那么只要缓冲区有数据可读,或者缓冲区有空位可写, epoll_wait 就返回。

如何设置为edge-triggered 触发方式:ev.events |= EPOLLET;

实验​:

演示两种触发方式的不同,这里使用上一篇文章的代码,然后修改两处:

  • 第一个地方,添加 EPOLLET 触发模式
for (i = 0; i < 3; ++i) {
ev.data.fd = fds[i];
ev.events = EPOLLIN;
// 添加下面这一行,我们通过命令行传参的方式来控制是使用还是不使用 edge-triggered 方式
if (argc > 1) ev.events |= EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fds[i], &ev) < 0) {
PERR("epoll_ctl");
}
}
  • 第二个地方,process 函数
int process(char* prompt, int fd) {
int n;
// 用作 read 参数的 buf 缓冲区大小改成了 4 字节
char buf[4];
char line[64];
// 最多读 3 个字节,第 4 字节是用来保存 '\0' 字符的。
n = read(fd, buf, 3);
//...
}
  • 运行,level-triggered 实验

Linux系统编程(九)--高级IO-多路复用_描述符_06

在实验中,管道 a.fifo 的写端首先写入​​he​​这个单词,epoll_et 中最后通过 read 函数将缓冲区中的这两个字符完全读取,并打印。

接下来,又写入​​helloword​​​,此时描述符 fd3 对应的内核缓冲区中就有了 ​​helloword​​ 这一串。在 epoll_et 程序中,立即触发了 IO 事件,epoll_wait 返回了。进入了 process 函数。

因为 read 接收缓冲区 buf 大小只有 4,所以 read 的时候最多读入 3 个字节的数据,buf 第 4 字节是用来保存 ​​\0​​​ 字符的,所以不能被占用。从内核缓冲区读取 ​​hel​​ 三个字符后到 buf 后并打印在屏幕上,process 函数结束。

接下来又回到 epoll_wait,前方高能,因为此时还没有任何人向管道写数据,所以描述符 fd3 对应的内核缓冲区中还是只有数据 ​​loword​​,根据 level-triggered 的规则,只要缓冲区中有数据,就触发 IO 事件,因此 epoll_wait 又立即返回,后面的事件,其实都差不多了,就不重复了。

  • edge-triggered 实验

要使用 edge-triggered,需要在启动 epoll_et 的时候传入一个参数,随便什么都行,我就在后面加了个数字 1。

看图 3,这个过程比 level-triggered 方式要复杂,一步一步来看。首先,向 a.fifo 写入两个字符 ​​he​​​,然后 epoll_et 中 epoll_wait 返回,读取 ​​he​​ 打印。

第二次,向 a.fifo 写入了​​helloworld​​​,此时 fd3 对应的内核缓冲区由空变成了有数据 ​​helloworld​​​,触发了 IO 事件,因此 epoll_wait 函数返回,进入 process 函数后,从缓冲区读取了三个字符,但是,此时缓冲区中还有字符​​loworld​​,接下来又返回到了 epoll_wait,前方高能,虽然缓冲区中有数据,但是此时的触发方式是 edge-triggered!数据没有产生变化,也就是没有增多,因此不会触发 IO 事件,epoll_wait 阻塞了!

Linux系统编程(九)--高级IO-多路复用_linux_07

接下来,向管道仅仅写入一个字母 ​​a​​​,fd3 对应的内核缓冲区产生变化了,再由 ​​loworld​​​ 变成了 ​​loworlda​​​的那一瞬间之前(数据 a 在进入缓冲区的过程中,但是还没进入,凭什么这样说,待会儿有图 5 中可以看到),触发了 IO 事件,epoll_wait 函数返回,从缓冲区中读了 3 个字符 ​​low​​,接下来,epoll_wait 又阻塞了。

看图 5,后面又连续一个一个的输入 b, c, d, e,可以看到,最后一次输入 e 的时候,并不是打印 de,而是只打印了个 d,从这一点也可以证明,e 在进入缓冲区之前,IO 事件就已经触发了。

Linux系统编程(九)--高级IO-多路复用_#include_08

6.2 边沿触发 + 非阻塞

因为 read 的接收缓冲区太小,每次缓冲区的数据读取不完,从而在下次数据到来前,即使缓冲区还有剩余数据未读取,epoll_wait 函数也会阻塞。

解决方法:

使用 while 循环反复从缓冲区非阻塞的方式读,如果 read 返回值 < 0 同时 errno = EAGAIN 或 errno = EWOULDBLOCK 了,就说明缓冲区读完了,退出循环。不使用非阻塞读,一旦读完了,read 就会发生阻塞。

实验:

上一篇文章的代码只需要修改两个地方。

  • 修改以非阻塞的方式打开管道
fds[1] = open("a.fifo", O_RDONLY | O_NONBLOCK);
printf("open pipe: fd = %d\n", fds[1]);
fds[2] = open("b.fifo", O_RDONLY | O_NONBLOCK);
  • 修改 process 函数,以循环方式 read
int process(char* prompt, int fd) {
int n;
char buf[4];
char line[64];
sprintf(line, "%s say: ", prompt);

// 开始循环 read
while (1) {
n = read(fd, buf, 3);
if (n < 0) {
// 如果 errno 的值是 EAGAIN 或 EWOULDBLOCK,说明缓冲区数据读完了。
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
PERR("read");
}
else if (n == 0) {
// 对端关闭
sprintf(line, "%s closed\n", prompt);
puts(line);
return 0;
}
else if (n > 0) {
buf[n] = 0;
// 把从 read 读到的内容串接到 line 后面。
strcat(line, buf);
}
}
puts(line);
return n;
}

Linux系统编程(九)--高级IO-多路复用_描述符_09

两种触发模式的优缺点

假设每次 read 都不能一次性缓冲区数据读完。

首先从程序运行的角度上看,水平模式+阻塞读,只要缓冲区还有数据,每次都会触发 epoll_wait 函数返回。

边沿模式+非阻塞读,只触发一次 epoll_wait 返回,然后 read 循环读。

那么效率就体现在

  • while { epoll_wait + read }

  • epoll_wait + while {read}

目前还没有谁证明过后者比前者快。有很多大名鼎鼎的网络库或框架,都使用了水平触发模式,而 Nginx 使用了边沿触发模式。