背景:内核的阻塞和非阻塞

在使用 Linux 内核的 socket 的时候,我们需要考虑阻塞与非阻塞的问题。Kernel 内部维护了定长的 buffer 来保存需要传递的数据和收到的数据。因此,一般情况下,我们写这样的系统调用的时候,都是阻塞的:

int ret = read(fd, &buf, 1024);
int ret = write(fd, &buf, 1024);

这意味着,如果内核内部的 buffer 是空的,就会等到有数据时才返回。

但是网络请求与本地的数据传输不一样,网络是可能产生各种错误的。因此,如果采用纯阻塞的设计,会导致 CPU 被白白浪费掉,甚至造成死锁等更严重的问题。考虑到这一点,内核也提供了非阻塞的方式:

int sock = socket(AF_INET, SOCK_STEAM | SOCK_NONBLOCK, 0);
 
int ret = read(sock, &buffer, 1024);
 
if (ret > 0) {
	// read some bytes
} else if (ret == 0) {
	// end of file, other side done writing
} else if (errno == EAGAIN) {
	// empty buffer, no data from socket yet
} else {
	// error
}

问题 1:concurrency

如果使用上面的非阻塞接口,就需要有一个循环反复查询是否成功:

while (true) {
	ret = read(sock, &buffer, 1024);
	// handle sock1
 
	ret = read(sock2, &buffer, 1024);
	// handle sock2
}

这种 polling 的方式会非常消耗 CPU 时间,造成能效和性能的下降。这里是一个常规的 tradeoff,我们需要通过异步的方式来降低 polling 带来的开销,例如中断的机制。如果没有请求到来,最好的办法应该是让程序进入睡眠,腾出 CPU 来做别的工作。

解决方案 1:select

select(2) 的存在就是为了解决上面的问题:

NAME
       select — synchronous I/O multiplexing

SYNOPSIS
       #include <sys/select.h>

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

select 的机制是,应用可以将需要管理的一系列 fd 都传入 kernel,kernel 负责监听这些 fd,一旦某些 fd 已经准备好(e.g. 有数据可读),就返回应用。应用可以自己检查哪些 fd 需要响应,并执行对应的操作。

while (true) {  
    fd_set fds;
    FD_ZERO(&fds);
    FD_SET(fd, &fds);
    FD_SET(fd2, &fds);
  
    // wait
    select(FD_SETSIZE, &fds, nullptr, nullptr, nullptr);
 
    // react
}

当 kernel 返回时,可以通过检查每一个 fd,来确定哪些 fd 有事件发生:

// react  
if (FD_ISSET(sock, &fds)) {  
     ret = read(sock, &buffer, 1024);  
}  
if (FD_ISSET(sock2, &fds)) {  
    ret = read(sock2, &buffer, 1024);  
}

select 机制带来了良好的 I/O 多路复用能力,且不会造成性能上的副作用。然而,select 的最大问题是采用了定值作为 fd 集合的大小,这意味着如果我们在程序中需要处理超过 fd_set 所能承受的上限,select 就无法使用了。

解决方案 2:poll

poll(2) 就是为了解决这个问题而实现的:

NAME
       poll — input/output multiplexing

SYNOPSIS
       #include <poll.h>

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

poll 采用了动态长度的数组来处理 fd,同时在 API 的设计方面也有一些改进,例如引入了输入和输出两个事件,同时通过参数 revent 来表示产生的事件,从而提供了更细粒度的事件控制。

while (true) {  
    pollfd fds[2] = {  
            pollfd{.fd=sock, .events=POLLIN, .revents = 0},  
            pollfd{.fd=sock2, .events=POLLOUT, .revents = 0},  
    };  
  
    // wait  
    poll(fds, std::size(fds), -1);  
 
	// react
	if (fds[0].revents == POLLIN) {  
	    ret = read(sock, &buffer, 1024);  
	}  
	if (fds[1].revents == POLLOUT) {  
	    ret = write(sock2, &buffer, 1024);  
	}
}

然而,poll 也存在一个问题:每次处理事件,都需要遍历。内核拿到应用提供的 fd 数组,需要遍历检查哪些 fd 产生了事件;应用从内核返回时,也需要编译所有的数组来确定哪些 fd 产生的消息。当 fd 数组的数目变得非常大的时候,这显然会成为一个性能的瓶颈。

解决方案 3:epoll

Linux kernel 为了解决这个问题,提出了 epoll(2):

NAME
       epoll - I/O event notification facility

SYNOPSIS
       #include <sys/epoll.h>

epoll 在内核中维护了一个数据结构,用户与之交互来实现 fd 的插入和删除,两者保持数据的同步。这样,内核就能够直接返回哪些 fd 产生了事件。同时,epoll 也保持了 poll 所实现的事件分离机制,能够根据不同的事件细粒度地通知。

int epfd = epoll_create1(0);
 
epoll_event evts[2] = {
	epoll_event{
		.events = EPOLLIN,
		.data = epoll_data_t{.fd = sock},
	},
	epoll_event{
		.events = EPOLLOUT,
		.data = epoll_data_t{.fd = sock2},
	},
};
 
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &evts[0]);
epoll_ctl(epfd, EPOLL_CTL_ADD, sock2, &evts[1]);
 
while (true) {
	epoll_event evt;
	
	// wait
	epoll_wait(epfd, &evt, 1, -1);
	
	// react
    if (evt.data.fd == sock) {
		ret = read(sock, &buffer, 1024);
    }
    if (evt.data.fd == sock2) {
		ret = write(sock2, &buffer, 1024);
    }
}