烦恼一般都是想太多了。

0%

IO的阻塞与多路复用

对于大多数的编程场景来多,很多时候的任务都是在处理I/O,因为读写设备的不同,所以需要花很多的心思来整。从POSIX的标准来看,其提供了 select, poll来实现多个描述符的监控读写。而Linux自身还实现了一个更高效的 epoll。

POSIX中的I/O

在类Unix中,我们运用 open, read, write, lseek, close就能实现对文件的读写,而其哲学就是,所有的设备对象都是文件。所以实现了统一的读写处理。在多数的系统实现中,在设备-内核-应用之间都会有缓存。当我们对一个由open返回的文件描述符 fd 调用 read(fd, buffer, len)是,内核会从 fd 读取对应的数据,放到内核缓冲区,然后再返回到 应用程序的缓冲区 buffer。而在如果无法及时获得请求数据的时候,就会出现阻塞状态,整个程序流程将无法执行其他任务工作。调用 write时候一样。

对于这种问题的解决方法,就出现了两种不同的思路。一个是随着多线程支持而来的并发读写,已经内核实现的一种多描述符的检查机制。

select

select在大多数系统上都有了实现。其基本原理就是把想要监控的描述符放到一个描述符集合中,然后内核会对特定的事件进行监控,一旦对应描述集上有事件发生,则返回。接下来我们就必须通过轮询描述符集来检查,是哪个描述符发生了事件。


#include <sys/select.h>
// 操作描述符集的宏
#include <sys/select.h>

void
FD_CLR(fd, fd_set *fdset);

void
FD_COPY(fd_set *fdset_orig, fd_set *fdset_copy);

int
FD_ISSET(fd, fd_set *fdset);

void
FD_SET(fd, fd_set *fdset);

void
FD_ZERO(fd_set *fdset);
// 参数:描述符数量,读,写,错误,超时
int
select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds,
struct timeval *restrict timeout);

// 参数:描述符数量,读,写,意外,等待时间,信号屏蔽字
int pselect(int, fd_set * __restrict, fd_set * __restrict,
fd_set * __restrict, const struct timespec * __restrict,
const sigset_t * __restrict)

唯一需要注意的是,描述符数量 nfds 会是最大描述符 + 1,因为描述符是从0开始的。

我们可以让程序 阻塞在 select上,而一旦有描述符准备好读写,则会开始继续运行。

在返回的时候,select会修改描述符集,其中有事件产生的被置位,其返回值是发生了准备好事件的描述符数量,而如果一个描述符在读写都进行了测试的话,如果即可读也可写就会计算为两次。

这其中就会产生一个很头疼的问题,如果我们要监控1000个描述符,而其中只有一个描述符 999 准备好了的话,我们将不得不逐个的测试返回描述符集的结果才能知道是谁准备好了读写。这是一个巨大的浪费。因此我们有了另外一个方法。

poll

#include <poll.h>

struct pollfd {
int fd; /* file descriptor */
short events; /* events to look for */
short revents; /* events returned */
};

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

poll中,我们只需要把我们关心的描述符和事件放到一个结构中,然后把这些结构组成一个数组传递给 poll,在返回的时候,我们就不用去测试那些没有发生事件的描述了。但我们却也不得不检查每个传递过去的结构是不是发生了对应的事件。

还有没有更好的办法呢?如果我们能在返回的时候知道,其返回的描述符确实发生了特定的事件而直接进行操作的话,那不是就完美了么。

epoll

epoll所做的工作和 poll类似,但其可以进行水平触发或者边缘触发,主要是利用三个函数来进行的。

其工作方式是利用一个 epoll 描述符来管理其他的 文件描述符,并让文件描述符与对应的事件相关联。一旦所管理的描述符有事件发生,那么就会把发生了事件的描述符和事件一起进行返回。这样我们进行遍历返回的结构,就知道所有的描述符都是发生了事件的,不会出现浪费CPU时间的情况。

  • epoll_create(2) 创建一个引用 epoll 实例的 文件描述符。新的函数 epoll_create1(2)已经扩展了这个函数的功能和特性。
  • epoll_ctl(2) 注册我们要监控的描述符,这些描述符的集合有时候被称做 epoll集
  • epoll_wait(2) 等待事件,如果当前线程并无什么事件发生的话则会阻塞这个线程。

水平触发(LT)与边缘触发(ET)

这两者有如下不同。我们考虑一下这样种情况:

  1. 将一个管道的读端描述符 rfd 注册到 epoll 实例中
  2. 管道的写端写了 2KB 的数据
  3. 调用 epoll_wait(2),将会返回 rfd 已就绪可读
  4. 从读端读取 1KB 数据
  5. 再次调用 epoll_wait(2)

如果在添加 rfd 的时候,设置了 EPOLLET 标志的话,那么我们在第五步中,第二次调用 epoll_wait时就可能挂起,尽管,管道还有数据可读;同时,写端可能会根据其发出的数据而期望一个回应。这是因为 ET 设置只会在被监控的描述符上有事件发生时进行通知。因此,在步骤五中的调用,结果就是一直在等待已经在了管道中的数据。在上面的例子中,步骤二中 rfd 会产生一个事件,然后在步骤三中被消费。 但是在步骤四中并没有读取完所有的数据,而且接着也没有事件发生,那么步骤五的epoll_wait调用将会永远阻塞。

在使用EPOLLET标志的时候,必须使用非阻塞描述符来避免读写多个描述符程序的饿死。建议使用 ET 设置的方式如下:

  1. 使用非阻塞描述符。并且
  2. 只有在 read/write 返回 EAGAIN 后才进行事件的等待

对比来说,当使用 水平触发 LT 的时候,epoll就是一个更快的 poll而已(默认情况)。所有使用 poll的地方这时候都能使用 epoll,因为使用相似的语法。

在使用 ET 触发的时候,在接收到大块的数据时可能会产生多个事件,有一个选项可以让我们来设置在 epoll 在接收到一个事件后忽略其他事件。EPOLLONESHOT设置后,要想再继续接收其他事件的话,必须用 epoll_ctlEPOLL_CTL_MOD来重设文件描述符。

建立的使用方式

水平触发没有什么好说的,和 poll的用法差不多,而ET 触发就需要多说一些了。例子中,listener 是一个非阻塞的套接字。函数do_use_fd()使用就绪的描述符,直到 read/write 返回一个 EAGAIN 错误。在接收到 EAGAIN后,一个事件驱动的状态机程序应该记录其当前的状态,以便下次调用 do_use_fd()时能从其停止的地方继续。

#define MAX_EVENTS 10
int listen_sock, conn_sock, nfds, epollfd;

/* Code to set up listening socket, 'listen_sock',
(socket(), bind(), listen()) omitted */

epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILTURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;

if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock"):
exit(EXIT_FAILURE);
}

for ( ;; ) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}

for (n = 0; n < nfds; n++) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock, (struct sockaddr *) &addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}

setnonblocking(conn_sock);

ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
else {
do_use_fd(events[n].data.fd);
}
}
}

当配置 ET时,为了性能上的考虑,可以在 调用 epoll_ctl进行增加时(EPOLL_CTL_ADD)同时指定EPOLLIN | EPOLLOUT。这样就可以避免需要不停的调用 epoll_ctlEPOLL_CTL_MOD来不同的在两个标志间切换。