多路转接技术
在之前学习五种 IO 模型时,我们认识到了 IO 的本质是等待 + 拷贝,而多路转接技术可以让等待的过程重叠,即同时等待多个文件描述符的就绪状态。本文将介绍三种实现多路转接的系统调用接口,实际最常用的是 epoll,一些老旧机器上只兼容 select,而 poll 并不常用。
1. select
select 是系统提供的一个多路转接接口。
- select 系统调用可以让程序同时监视多个文件描述符上的事件是否就绪。
- select 的核心工作就是等待,当监视的多个文件描述符中有一个或多个事件就绪时,select 才会成功返回并将对应文件描述符的就绪事件告知调用者。
1.1 select 系统调用及参数介绍
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监视的文件描述符中,最大的文件描述符值 +1(select 底层使用 for 循环遍历实现,该值是为了界定遍历范围)。
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪。
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪。
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪。
- timeout:输入输出型参数,调用时由用户设置 select 的等待时间,返回时表示 timeout 的剩余时间。
参数 timeout 的取值:
- NULL/nullptr:select 调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪。
- 0:select 调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select 检测后都会立即返回。
- 特定的时间值:select 调用后在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后 select 进行超时返回。
返回值说明:
- 如果函数调用成功,则返回有事件就绪的文件描述符个数。
- 如果 timeout 时间耗尽,则返回 0。
- 如果函数调用失败,则返回 -1,同时错误码会被设置。
select 调用失败时,错误码可能被设置为:EBADF、EINTR、EINVAL、ENOMEM。
(1)fd_set 类型
fd_set 类型可以理解为一个位图,每个比特位位置代表是哪个文件描述符,值代表是否就绪。
调用 select 函数之前就需要用 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 的全部位
(2)timeval 结构
传入 select 函数的最后一个参数 timeout 是一个指向 timeval 结构的指针,timeval 结构用于描述一段时间长度,包含 tv_sec(秒)和 tv_usec(微秒)两个成员。
(3)socket 就绪条件
读就绪
- socket 内核接收缓冲区中的字节数大于等于低水位标记 SO_RCVLOWAT。
- TCP 通信中对端关闭连接,对该 socket 读返回 0。
- 监听的 socket 上有新的连接请求。
- socket 上有未处理的错误。
写就绪
- socket 内核发送缓冲区中的可用字节数大于等于低水位标记 SO_SNDLOWAT。
- socket 的写操作被关闭,触发 SIGPIPE 信号。
- socket 使用非阻塞 connect 连接成功或失败之后。
- socket 上有未读取的错误。
1.2 select 基本工作流程
利用 select 多路转接实现一个简单的 Echo 服务器,该服务器要做的就是读取客户端发来的数据并进行打印:
- 先初始化服务器,完成套接字的创建、绑定和监听。
- 定义一个 fd_array 辅助数组用于保存监听套接字和已建立连接的套接字,初始时将监听套接字添加到 fd_array 数组当中。
- 服务器开始循环调用 select 函数,检测读事件是否就绪,如果就绪则执行对应的操作。
- 每次调用 select 函数之前,都需要定义一个读文件描述符集 readfds,并将 fd_array 辅助数组当中保存的文件描述符依次设置进 readfds 当中。
- 当 select 检测到数据就绪时会将读事件就绪的文件描述符设置进 readfds 当中,此时通过提取 readfds 中的信息得知哪些文件描述符已经就绪。
- 如果读事件就绪的是监听套接字,则调用 accept 函数获取新连接,并将该连接对应的套接字添加到 fd_array 数组当中。
- 如果读事件就绪的是与客户端建立连接的套接字,则调用 read 函数读取客户端发来的数据并打印输出。
- 如果客户端将连接关闭,服务器应调用 close 关闭该套接字,并从 fd_array 辅助数组当中清除。
为什么要有辅助数组?
- 随着客户端连接的建立和断开,需要监听的文件描述符集合会动态变化。select 调用后,只有发生事件的文件描述符会被保留,未发生事件的会被清除。
- 因此,每次调用 select 之前,都需要重新构建文件描述符集合。辅助数组可以用来存储当前所有需要监听的文件描述符。
说明
- 服务器刚开始运行时,fd_array 数组当中只有监听套接字,后续每次调用 accept 获取到新连接后,都会将新连接对应的套接字添加到 fd_array 当中。
- 由于调用 select 时还需要传入被监视的文件描述符中最大文件描述符值 +1,因此每次在遍历 fd_array 对 readfds 进行重新设置时,还需要记录最大文件描述符值。
1.3 select 技术实现 Echo 服务器
(1)构造服务器
对于一个 Tcp 服务器来说,首先需要创建一个 listen 套接字,然后在该 listen 套接字上等待获取新连接。我们将获取新连接的行为看作读事件,所以在构造时,创建完 listen 套接字后,还需要将 listen 套接字放入辅助数组。
SelectServer(uint16_t port) : _port(port), _listensock(std::make_unique<TcpSocket>()) {
InetAddr addr("0", _port);
_listensock->BuildListenSocket(addr);
for (int i = 0; i < N; i++) {
_fd_array[i] = defaultfd;
}
// listensocket 等待新连接到来,等价于对方给我发送数据!我们作为读事件统一处理
// 新连接到来 等价于 读事件就绪!
// 首先要将 listensock 添加到 select 中!
_fd_array[0] = _listensock->SockFd();
}
(2)服务器核心逻辑 Loop
服务器首先必须要有一个 Loop() 方法,是服务器执行的主逻辑,服务器就是一个死循环。
我们需要利用 select 监视读事件,所以每次循环都需要首先初始化出来一个 fd_set 结构,并利用 FD_ZERO 将该文件描述符集内容清空,然后将辅助数组中保存的就绪的文件描述符通过 FD_SET 函数赋值给文件描述符集,注意更新最大文件描述符的值。(这里我们只考虑读事件)。
然后根据 select 的返回值打印日志,当 select 返回值 > 0 时,证明监视的套接字中有读事件发生,此时对读事件进行处理。
void Loop() {
while (true) {
fd_set rfds;
FD_ZERO(&rfds);
int max_fd = defaultfd;
for (int i = 0; i < N; i++) {
if (_fd_array[i] == defaultfd) continue;
FD_SET(_fd_array[i], &rfds);
if (max_fd < _fd_array[i]) {
max_fd = _fd_array[i];
}
}
struct timeval timeout = {0, 0};
// select 同时等待的 fd,是有上限的。因为 fd_set 是具体的数据类型,有自己的大小!
// rfds 是一个输入输出型参数,每次调用,都要对 rfds 进行重新设定!
int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
switch (n) {
case 0: LOG(INFO, "timeout, %d.%d\n", timeout.tv_sec, timeout.tv_usec); break;
case -1: LOG(ERROR, "select error...\n"); break;
default: LOG(DEBUG, "Event Happen. n : %d\n", n); HandlerEvent(rfds); break;
}
}
}
(3)对读事件进行处理 HandlerEvent
调用该函数时,证明此时 rfds 文件描述符集已经被设定,文件描述符集中就是哪些文件描述符发生读事件。我们需要遍历辅助数组中保存的文件描述符,检测他们在 rfds 中是否被设定,如果被设定证明在该文件描述符上有事件发生需要进行处理。
void HandlerEvent(fd_set &rfds) {
for (int i = 0; i < N; i++) {
if (_fd_array[i] == defaultfd) continue;
if (FD_ISSET(_fd_array[i], &rfds)) {
// 处理具体事件逻辑
}
}
}
2. poll
poll 是另一种多路转接接口,了解即可。
2.1 poll 系统调用及参数介绍
2.2 poll 技术实现 Echo 服务器
2.3 poll 优缺点
3. epoll
epoll 是 Linux 下性能更好的多路转接接口。
3.1 epoll 系统调用及参数介绍
3.2 epoll 工作原理
- 回调机制


