目录

一、什么是多路转接

关于select

介绍select

输入输出型参数的体现:

相关接口 :

细节 : 

二、编码

SelectServer.hpp

版本一 :

整体思路 :

最初框架:

补充1 :

相关问题 :

补充2 :

补充3 :

补充4 :

运行结果:

select 多路转接的核心本质 :

三、select 的原理

select 的优缺点

四、基于事件通知派发机制的代码修改

版本二 :

五、总结


实现多路转接的系统调用有三个:select、poll、epoll。其中 poll 是在 select 基础上做了结构优化:select 用固定长度的位图,监控 fd 的数量有限且遍历效率低;poll 换成了数组结构,突破数量限制,但底层还是要全量遍历,只是比 select 好用一点。而 epoll 是 Linux 独有的,是全新的高效实现,解决了 select/poll 效率低、上限低的问题。

接下来我们先从最基础、最经典的 select 开始讲,它是理解多路转接思想最好的入口,之后再对比 poll、epoll 进行讲解。

一、什么是多路转接

select / poll / epoll 是什么? 

我们先回忆 IO 的本质:一次 IO 操作可以拆成等待数据就绪和拷贝数据两个环节,也就是 IO = 等 + 拷贝。而 select、poll、epoll 这三种多路转接接口,只负责其中 “等” 的环节,并不负责真正的读写拷贝。

关于select

什么是 select? 为什么有 select? 

本篇文章我们先以 select 为主进行讲解,select 的核心能力是同时等待监控多个文件描述符,帮我们检测这些 fd 的读写是否就绪:读事件就绪就是等待内核接收缓冲区收到数据;写事件就绪就是等待内核发送缓冲区有空间。只要任意一个文件描述符的读写事件就绪,select 就会立刻返回,通知上层程序可以执行后续 IO。

这里要特别注意:select 只是告诉你我们 fd 可以读写了,并不会帮我们完成读写操作,后续仍然需要调用 read、recvfrom 这类系统调用去完成真正的数据拷贝。也就是说 select 只管 “等” 和 “检测就绪”,实际的读写工作还是由传统的 IO 接口完成。

引入 select 的根本目的是优化 IO 效率:我们之前非阻塞轮询需要挨个不停询问每个 fd,而 select 可以让进程一次等待多个 fd,不用频繁空转轮询,从而大幅减少单位时间里无效等待的开销,把 CPU 资源节省下来做其他事情。

介绍select

我们现在来看 select 系统调用的接口 :

我们先讲第一个参数 nfds 和最后一个参数 timeout。首先要记住,select 只负责等待文件描述符就绪,不会真正读写数据,并且可以一次性同时监控多个文件描述符。

第一个参数 nfds,是我们要监控的所有文件描述符里最大的那个文件 fd 值再 + 1。比如我们同时要监控 1、2、3、4、5 这五个文件描述符,其中最大的是 5,那 nfds 就填 5+1=6,内核会根据这个值,确定需要遍历检查的 fd 范围。

最后一个参数 timeout 用来控制等待模式,总共有三种取值:

  • NULL 表示永久阻塞:程序执行到 select 时,如果没有任何文件描述符就绪,就会一直卡在这行,进程直接休眠,什么都不做,直到有 fd 事件就绪,内核唤醒进程,select 才会返回。
  • 0 就是非阻塞不等待:不管有没有 fd 就绪,select 都会立刻检查一遍 fd 然后马上返回,不会阻塞等待。如果想用它做轮询,确实必须套在while 循环里,反复调用,才能持续巡检 IO 事件。
  • 传具体的 timeval 时间结构体,就是限时阻塞等待:在指定的时间范围内,有事件就立刻返回;超时还没事件,就主动返回 0,不会无限卡死。

返回值规则也很清晰:调用出错返回 -1,错误信息存放在 errno;等待成功则返回就绪的文件描述符总个数,select 会同时监控所有的 fd,当有事件就绪时,它会统计有多少个 fd 变成了就绪状态,这个数字就是返回值;返回 0 代表等待超时,没有任何事件就绪。


select 中间三个参数比较重要,这里我们单独讲:

再讲之前我们需要强调几个概念,文件描述符 fd 上的事件:

(1). 读事件就绪 : 本质是内核接收缓冲区里已经有数据到达,此时调用 read 读取数据不会阻塞;

(2). 写事件就绪 : 本质是内核发送缓冲区还有空闲空间,此时调用 write 发送数据不会阻塞;

(3). 异常事件就绪 : 代表文件描述符上发生了异常情况,比如 TCP 连接异常断开、带外数据到来等,需要程序单独处理。

                 1正是因为要区分这三类事件,select 在设计时,用中间三个参数分别存放对应事件的文件描述符集合:readfds 是读文件描述符集合,我们把需要监控读事件的 fd 放进去;writefds 是写文件描述符集合,存放需要监控写事件的 fd;exceptfds 是异常文件描述符集合,存放需要监控异常事件的 fd。

在使用时,我们可以自由组合:如果我们只关心 3 号 fd 的读事件,就只把 3 号 fd 加入 readfds,另外两个参数传 NULL 即可;如果要同时监控某个 fd 的读和写,就分别加入 readfds 和 writefds;如果要监控读写 + 异常,三个集合都要添加该 fd。

当 select 返回时,内核会修改这三个集合:只保留已经就绪的 fd,我们遍历集合就能知道哪些 fd、对应哪种事件已经就绪,再去执行真正的读写操作。简单来说,这三个参数就是告诉内核要帮我们盯着哪些 fd、分别关心它们的读 / 写 / 异常事件。

这里我们补充并区分一下 : select 通过文件描述符 (fd) 来检测对应缓冲区的状态,以此判断读、写事件是否就绪。当把 fd 加入 readfds 或 writefds 集合后,内核会根据这个 fd,直接检查它关联的内核缓冲区:读事件就绪的本质,就是接收缓冲区中已有数据;写事件就绪的本质,则是发送缓冲区还有空闲空间,整个过程只和缓冲区状态有关。

我们之前讲过的 fcntl 设置非阻塞标志位,和 select 本身是完全独立的两个机制,两者之间没有直接关联。O_NONBLOCK 标志控制的是 read、write 这类 IO 调用的行为 —— 如果不设置非阻塞,缓冲区为空时调用 read 会阻塞等待;设置之后,缓冲区为空时 read 会立刻返回。但 select 作为事件检测接口,它的职责只是 “查询缓冲区(事件)是否就绪”,无论文件描述符本身是阻塞还是非阻塞,select 都能正常工作,不受标志位的影响。

在实际使用中,我们常常会把两者搭配起来:先用 select 监控到某个 fd 的读事件就绪,再调用非阻塞的 read 去读取数据。

select 只负责帮我们检测缓冲区是否就绪,不管 fd 是阻塞还是非阻塞都能正常工作。但在工程实践里,select 几乎和非阻塞 fd 搭配使用。select 帮我们等待事件就绪,内核通知 “数据来了、可以读了”,然后调用 read。如果 fd 是阻塞的,绝大多数情况确实能正常读完,但存在极端风险:select 告诉就绪后,别的程序抢先把数据读走,缓冲区又空了,这时阻塞 read 就会卡住进程。而搭配非阻塞 fd,就能规避这个问题:select 负责等待事件、让我们不用傻傻轮询;非阻塞 read 保证就算意外没数据,也不会卡死。

下来我们来看 select 中间三个参数的类型,都是 fd_set*,我们以 readfds 为例来讲清楚。

select 可以同时等待多个文件描述符,这个 “多个” 正是靠 fd_set 这个类型实现的。fd_set 叫做文件描述符集合,本质是一个位图 (bit 位标记),用每一位的 0 和 1,来标记要不要监控对应编号的文件描述符。使用时,我们把要关心的 fd 设置进集合中,告诉内核请帮我监控这些 fd 的读事件;select 返回时,内核会修改这个位图,只保留已经就绪的 fd 位,我们再遍历 fd_set 就能知道哪些 fd 事件就绪了。

简单总结:fd_set 就是用位图批量管理 fd,让 select 能一次监控一堆 fd,但受限于固定大小,最大只能监控 1024 个文件描述符,这也是 select 很明显的短板。

位图:位图是逻辑概念,数组是底层的物理存储实现,fd_set 就是用数组实现的位图。

fd_set (位图) 底层是一个固定大小的数组结构,系统默认 FD_SETSIZE 是 1024,我们在代码里用  sizeof(fd_set) 得到 128 字节,128×8 正好等于 1024 位,也就意味着select 最多只能监控 0~1023 号 fd,这是它的上限。

输入输出型参数的体现:

这里很重要的一点就是 readfds、writefds、exceptfds 这三个中间参数是输入输出型参数,这三个 fd_set 集合会在调用前后被内核双向修改,select 系统调用整体不是输入输出参数。

这里我们就以第二个参数 readfds 为例进行讲解,我们分调用前、调用后两个阶段理解。

作为输入参数时,本质是用户通过位图向内核传递约定:因为位图的底层实现是数组,所以位图的下标位置代表文件描述符 fd 的编号,位图的值代表是否关心该 fd 事件 : 1 表示关心、0 表示不关心。比如我们要让内核监控 1、3、5、7 这四个 fd 的读事件,就会在对应编号的位上置 1,形成类似 1010 1010 的位图,把这个集合传给 select,告诉内核只需要关注这几个 fd 的读就绪状态。

作为输出参数时,内核会修改同一张位图,用来反馈就绪结果:下标位置依旧对应 fd 编号,1 代表该 fd 事件已经就绪,0 代表未就绪。延续上面的例子,如果只有 1 和 7 号 fd 读事件就绪,内核就会把位图改成 1000 0010返回给用户,我们就能判断出哪些 fd 的读事件就绪了,就可以开始读取了。

简单总结就是:多路转接靠一张位图完成双向沟通,输入时用户告诉内核要关心哪些 fd,输出时内核告诉用户哪些 fd 已经就绪。

同时还有两个关键细节:如果等待超时了还没有 fd 就绪,那内核就会直接把整张位图全部清零;另外内核只会修改我们提前标记关心过的 fd,就算有其他未被关注的 fd 就绪,也不会被置 1;最后就是就绪的 fd 永远只会是我们传入集合的子集。

相关接口 :

因为 fd_set 是操作系统底层的位图结构,用户不能直接通过位运算去操作它,所以OS内核就必须专门提供一套接口,用来安全地添加、删除、检查文件描述符,接口如下 :

(1). FD_ZERO 是集合初始化接口,作用是把整个 fd_set 位图全部清零,确保集合一开始是空的,没有任何文件描述符被标记为关心。每次调用 select 前,我们都要先用它清空集合,再添加新的 fd,避免上一次调用的状态残留。

(2). FD_SET 用来把指定的文件描述符 fd 添加到集合中,也就是把位图中对应 fd 编号的位置设为 1,表示我们要让内核关心这个 fd 的事件。比如 FD_SET(3, &readfds) 就表示要监控 3 号文件描述符的读事件。

(3). FD_CLR 是和 FD_SET 相反的操作,用来把指定的 fd 从集合中移除,把对应位设为 0,表示不再关心这个 fd 的事件。

(4). FD_ISSET 用来检查某个 fd 是否在集合中,尤其是 select 返回后,我们用它来判断哪些 fd 已经就绪。如果 FD_ISSET(fd, &readfds) 返回非 0,就说明这个 fd 的读事件已经就绪,可以执行读取操作了。

这里需要注意的是只有 FD_ISSET 接口有返回值,用来判断 select 返回后哪些 fd 就绪;其余三个都是修改位图的操作,没有返回值。

最后补充一下事件和集合的对应关系:把 fd 设置到 readfds 集合,就表示只关心它的读事件;设置到 writefds 集合,就表示只关心它的写事件;如果想同时监控读写事件,就把 fd 同时添加到这两个集合中,内核会分别检测它的读就绪和写就绪状态。

细节 : 

在使用 select 时,还有几个关键的细节需要注意:

1. 首先在实际开发中,我们一般不会同时对同一个文件描述符持续关心读写事件,更常见的模式是 “先关注读后关注写” 或者 “先关注写后关注读”,根据业务流程分阶段监控,避免同时监听带来的逻辑混乱。

2. 其次我们要分清读事件和写事件的默认就绪状态。对任意一个文件描述符来说,读事件默认是未就绪的 —— 因为接收缓冲区一开始是空的,没有数据可读;但写事件默认就是就绪的 —— 因为发送缓冲区刚建立时一定有空闲空间,直接写通常不会阻塞。正因如此,writefds 参数很少会一直设置,而是按需添加,因为如果一直监听写事件,select 会因为发送缓冲区一直有空间而反复返回,造成空转浪费 CPU。这也是为什么我们通常优先设置读事件,毕竟读事件是否就绪才是不确定的,而 select 的核心价值,正是帮我们等待这些不确定的事件。

3. 最后,为了后面简化我们的编码,我们就先只关注读事件,把 exceptfds(异常事件集合)直接设为 NULL,先不处理异常情况,等基础逻辑跑通后再补充异常处理逻辑。

二、编码

现在我们要写一个 echoserver 服务端代码,一个基于 select 多路转接通信的服务器代码 :

SelectServer.hpp

版本一 :

整体思路 :

我们要写单进程、不用多线程的多路转接服务器,全程只用 select 统一 “等待事件”,核心流程分 5 步:

  • 创建监听套接字 listen_sock,完成 socket、bind、listen,得到最开始唯一的 fd。
  • 初始化 fd_set,把监听套接字 listen_sock 加入读事件集合,告诉内核:帮我监控这个 fd 的读事件(有新连接到来 = 读事件就绪)。
  • 进入 while 死循环,循环里调用 select 阻塞等待任意 fd 就绪。
  • select 返回后遍历集合:
    • 如果是 listen_sock 读就绪:调用 accept 拿到新连接 fd,把新 fd 也加入读集合,继续让 select 监控。
    • 如果是 普通客户端 fd 读就绪:调用 recv 读数据、echo 回发;如果连接断开,就关闭 fd、从集合中移除。
  • 每次循环前必须重新初始化 fd_set,因为 select 会修改位图,只保留就绪 fd。

下面有几个细节我们需要知道:

1. 这里我们不用多线程,如果开多线程,一个线程管一个 fd,那就不需要多路转接了;我们现在就是单进程/单线程 + select,靠 select 统一等待所有 fd。

2. TCP 服务器一开始只有监听套接字 listen_sock 文件的 fd,随着客户端连接进来,accept 会不断产生新 fd,fd 集合才会越来越多。

3. accept 本身也是 IO(等 + 拷贝):等新连接到来 + 拿到新 fd 拷贝到用户态。如果直接在 Loop 函数的 while 循环里裸写 accept(),没有连接时它会阻塞卡死,这就违背了多路转接的目的。所以绝对不能直接循环调用 accept。                

4. 今天我们写的是 select 服务器,等待的工作是由 select 完成的,如果最开始的唯一一个监听套接字 fd 我们还让 accept 等待的话,那和我们今天讲的 selec t就没有关系了。所以我们的结论就是这个最开始唯一的监听套接字 listen_sock 不能直接调用 accept(_listensock→Accepter()),因为最开始还有没新的连接和新的 fd,如果让监听套接字等的话就直接阻塞了,这不是我们想要的。

5. 所以我们应该把原本属于监听套接字的等待工作交给 select 去做,让 select 去阻塞等待,去获取新的套接字 fd,因为 select 的核心工作就是“等待监听”,去检测核心事件是否就绪。所以我们把获取新连接归位读事件就绪。

6. 我们之前敢直接用监听套接字获取新连接是因为有多线程,多进程,以及线程池技术,"等"的代价是让线程帮我们做了,而现在我们用了 select,等待的工作就交给 select,就不用线程了。

总结 :

1. 首先套接字本质上就是 Linux 系统中的文件,无论是我们创建的监听套接字,还是 accept 返回的客户端套接字,在内核中都会被分配一个唯一的文件描述符 fd 来标识。和普通文件一样,每个套接字 fd 都会对应内核中独立的接收缓冲区与发送缓冲区(TCP的收发缓冲区),操作系统通过 fd 就能精准找到对应的缓冲区;监听套接字虽然不收发业务数据,但它有自己的连接等待队列,同样由 fd 关联管理,所有网络 IO 最终都通过 fd 操作对应的内核缓冲区完成。

2. 其次,原生的 accept 函数本身包含阻塞等待和获取连接两个步骤:没有新连接时,它会阻塞线程等待,直到有客户端完成三次握手。而搭配 select 使用后,等待工作完全交给了 select:我们把监听 fd 加入读集合,让 select 监控连接事件,只有当它返回、确认监听 fd 读就绪时,才会调用accept,此时内核连接队列里一定有新连接,accept 一定不会阻塞,只需要单纯完成从内核中取出新连接、返回客户端 fd 的工作。

3. 最后,监听套接字 listen_sock 的读事件就绪,和普通客户端 fd 的读事件就绪含义不一样。普通文件 fd、普通客户端 socket 的读事件就绪,是因为内核接收缓冲区里有数据到达,调用 recv/read 就能读到内容;但监听套接字 listen_sock 没有收发业务数据的缓冲区,它的读事件就绪 = 内核收到新连接请求,完成了三次握手,有新连接正在排队等待被 accept 拿走。也就是说我们把监听 fd 放进 readfds 让 select 监控,并不是为了读数据,而是等新连接到来。当 select 检测到监听 fd 读就绪,就代表此时调用 accept 一定能立刻拿到新客户端 fd,不会阻塞;而普通 fd 读就绪,代表此时 recv 一定能读到数据。写事件两者逻辑倒是一致:都是发送缓冲区是否有空闲空间,只是监听 fd 几乎不会用到写事件。

所以当 select 检测到某个 fd 就绪并返回后,我们要先用 FD_ISSET 判断是哪个 fd,然后严格区分监听套接字和普通客户端套接字分别处理

   (1). 如果就绪的是监听套接字 listen_fd,说明它的读事件就绪,代表有新 TCP 连接到来,此时必须调用 accept 从内核连接队列取出新客户端 fd,再把这个新 fd 加入 select 监控集合;

   (2). 如果就绪的是普通客户端 fd,说明是收发缓冲区事件就绪,读就绪就调用 recv 读取业务数据,写就绪就调用 send 发送数据。

select 只负责告诉你 “谁准备好了”,后续逻辑必须我们自己分类型执行。


这就相当于我们用户告诉内核,让内核去关心这个文件描述符集rfds里的所有文件描述符的读事件是否就绪,因为我们这里只传了第二个参数。

最初框架:

首先看 SelectServer 类的结构:成员变量包含端口号 _port、监听套接字 _listensock,以及退出标记 _quit;构造函数接收端口参数,内部会创建并初始化 TCP 监听套接字,完成 socket、bind、listen的流程,打印监听 fd 创建成功的日志。

核心的 Loop 函数是服务器主循环,它在 while(!_quit) 死循环里持续工作:每次循环都会先用FD_ZERO 清空读文件描述符集合rfds,再用 FD_SET 把监听套接字 fd 加入集合;接着设置timeout 为 3 秒,调用 select 开始等待事件。select 的第一个参数是监听 fd+1,只监控读事件集合,写和异常集合传 nullptr。之后根据返回值分情况处理:返回 0 代表超时,打印超时日志;返回 - 1 代表调用出错,打印错误信息;返回大于 0,就代表有 fd 事件就绪,调用HandlerEvent 统一处理就绪事件。

运行结果:

设置 3 秒超时后,没有客户端连接时,服务器每隔 3 秒就会触发超时日志,和代码逻辑完全对应;

如果为 0 就会一瞬间刷,但是一有 fd 就绪就会显示,但是我们后面就设为 nullptr,表示阻塞等待,程序执行到 select 时,如果没有任何文件描述符就绪,就会一直卡在这行,进程直接休眠,什么都不做,直到有 fd 事件就绪,内核唤醒进程,select 才会返回。。

当我们用 telnet 发起一个客户端连接,完成 TCP 三次握手后成功建立连接,此时监听套接字的读事件就绪,因为 select 此时检测的是监听套接字文件的 fd 的读事件,所以此时 select 就会立即返回并且返回值 n=1,表示监听套接字的 fd 的读事件已经就绪了, 又因为监听套接字的读事件就是成功与客户端进行三次握手后建立连接,所以此时服务器就会识别到有新连接的到来,开始执行后续事件处理。


补充1 :

首先要明白,select 只负责 “等事件就绪”,如果我们检测到就绪后不做任何处理,内核会一直认为这个 fd 的事件还没被处理,导致 select 每次循环都立刻返回就绪状态,出现死循环打印的情况。所以我们必须在 select 返回后,调用专门的事件处理函数,也就是下图中的 HandlerEvent,来完成后续的 “拷贝” 操作,让内核知道这个事件已经被处理了。

这里的 HandlerEvent 函数接收 rfds 作为参数,而这里的 rfds 是 select 的输出型参数,里面已经包含了所有就绪的 fd 信息,因为已经 select 等待检测过了,返回值返回后就表示文件描述符集合已经被OS内核设置好后带出来了。

进入函数后,我们先用 FD_ISSET 判断是不是监听套接字就绪:如果 FD_ISSET(_listensock->Sockfd(), &rfds) 为真,就说明是新连接事件就绪了。此时调用 Accept 获取新连接,accept 完全不用担心阻塞 —— 因为 select 已经帮我们确认了监听套接字读事件就绪了,内核连接队列里一定有新连接,Accept 可以直接拿到新客户端的 fd,不会卡在这一步。

运行一下 :

获取新连接后,内核会认为我们已经处理了这次就绪事件,select 就不会再反复报告这个监听套接字就绪了,所以运行时不会再出现死循环打印的情况。这个流程也验证了我们之前的结论:select 只负责等待和通知,真正的业务处理(比如 Accept 获取连接)必须由应用层完成,两者配合才能让多路转接正常工作。

相关问题 :

随之而来的,就是select使用过程中必须解决的几个关键问题,这些也正是它的局限性所在。

1. 首先随着新连接不断接入,我们的文件描述符 fd 数量会越来越多,而 select 的第一个参数 nfds要求传入 “当前所有监控 fd 中的最大值 + 1”,此时我们就不能再一直用监听套接字 + 1 了,必须维护一个变量,每次新增 fd 时都更新这个最大值,确保 select 能正确扫描所有需要监控的 fd。

2. 其次,rfds作为输入输出型参数,在 select 调用前后会被内核修改。如果一次 select 调用超时,没有任何 fd 就绪,内核会把整个 rfds 位图全部清零,导致我们原本关心的监听 fd 也被清空,下一次循环时就失去了对它的监控。

3. 更重要的是,select返回后,位图里只会保留本次就绪的 fd,那些我们关心但这次没有就绪的 fd,会被内核从位图中清除。比如我们同时关心 1、3、5、7 四个 fd,本次只有 1 和 7 就绪,那么返回的位图里就只剩下这两个 fd,3 和 5 的位被清 0 了。但下一次循环,我们依然要继续关心 3 和 5 的事件,可此时它们已经不在位图里了,如果不重新设置,select就再也不会监控它们,导致这些 fd 永远无法被处理。

正是因为这些问题,select存在一个明显的缺点:每次调用前,我们都必须重新构建整个 fd 集合,把所有关心的 fd(包括监听 fd 和所有客户端 fd)重新FD_SET一遍,还要手动维护最大 fd 值,使用起来非常繁琐,这也是它在高并发场景下逐渐被poll、epoll取代的重要原因之一。

补充2 :

那解决方法是什么?

为了解决 select 每次调用后位图被修改、无法自动保留所有待监控 fd 的问题,我们需要引入一个辅助数组,把所有合法的、需要持续监控的文件描述符保存起来,这也是 select 服务器的标准做法。

我们可以用数组、链表或者 vector 来实现,但通常会选择数组,因为它足够简单,也和 select 最大支持 1024 个 fd 的限制天然契合。数组的容量直接设为 sizeof (fd_set) * 8,也就是 1024,刚好对应 select 能监控的最大 fd 数量。我们还定义一个默认值 -1,用来标记数组中未使用的位置。

在服务器初始化时,我们会先把这个辅助数组全部初始化为 -1,再把监听套接字的 fd 放到数组的 0 号位置。这样一来,数组里就存下了所有需要 select 持续关注的 fd,后续的循环中,我们不再直接操作单个 fd,而是以这个数组为基准来构建 fd_set 集合。

现在我们在Loop主循环里,每次调用 select 之前,都会先处理辅助数组 _rfdset。首先用 FD_ZERO 清空读文件描述符集合 rfds,再定义一个变量 maxfd 用来记录当前所有待监控 fd 里的最大值。接着遍历整个辅助数组,遇到值为 -1 的无效位置直接跳过;遇到合法 fd,就用 FD_SET把它重新加入 rfds 位图,同时和 maxfd 比较,实时更新最大文件描述符。

这样一来,不管上一轮 select 把位图改成什么样,每一轮都会根据辅助数组重新构建完整的监控集合,既不会漏掉需要持续关注的 fd,也解决了之前要手动维护监听 fd、更新最大 fd 的问题。最后把更新好的 maxfd+1 作为第一个参数传给select,保证内核能完整扫描所有我们关心的文件描述符。

补充3 :

获取到新连接的客户端 fd 之后,下来我们能直接对新连接的文件 fd 读吗?

不能,我们并不能直接调用 read 读取数据。因为此时我们无法确定客户端是否已经发送了数据,对应的内核接收缓冲区是空还是有内容,如果直接读,就可能因为缓冲区为空而阻塞进程。正确的做法是把这个新的客户端 fd 也交给 select 来托管,让 select 帮我们检测它的读事件是否就绪,只有当 select 通知就绪后,再去执行读取操作。

现在我们接着看 HandlerEvent 里的新连接处理逻辑,重点是怎么把新客户端 fd 托管给 select,以及 select 在这里暴露的局限性。

Loop 函数中当 select 的返回值 > 0,也就是 select 检测到已经有文件 fd 的读事件就绪了,下来我们就应该进入 HandlerEvevnt 函数中进行处理了。

当 select 返回,并且我们通过 FD_ISSET 确认是监听套接字就绪时,就会调用 Accept 获取新客户端的 fd。拿到这个 fd 之后,我们不能直接去读数据,而是要把它交给 select 来后续监控。怎么托管呢?就是把这个新 fd 添加到我们之前定义的辅助数组 _rfdset 里。

具体做法是遍历这个数组,找到第一个值为 -1(也就是gdefaultfd)的空位置,把新的 sockfd 填进去。如果遍历完整个数组都没找到空位,说明当前服务器已经达到 select 的最大连接数上限 1024 了,这时就会打印 “server is full!” 的警告,并直接关闭新连接。这也是 select 的一个明显缺点:它天生受限于 FD_SETSIZE,最多只能同时监控 1024 个文件描述符,超过就无法处理了。

把新 fd 添加到辅助数组之后,下一次进入 Loop 循环时,select 就会把它加入监控集合。这样一来,所有新连接都会被 select 统一管理,既解决了之前 “每次循环都要重新设置 fd 集合” 的问题,也让服务器能同时处理多个客户端的连接和数据收发了。

补充4 :

还有一个问题是现在我们再看 HandlerEvent 传进来的参数 rfds,这个参数就是将来调用 select 作为输出参数给我们带出来的就绪的 fd,但是我们现在函数中判断的是监听套接字 fd,那如果就绪的不是监听套接字呢?

如下:

现在我们来看普通客户端 fd 就绪时的处理逻辑,也就是HandlerEvent函数里监听套接字之外的分支。

当 select 返回后,rfds 里已经包含了所有就绪的 fd,如果 fd 不是监听套接字文件 fd,那就是我们要进行正常读写的普通文件 fd 了,因为 select 已经确认读事件就绪,所以此时调用 recv 不会阻塞,我们可以直接读取客户端发来的数据。

读取数据后,我们要根据 recv 的返回值做不同处理:

  1. 当返回值 n > 0时,说明成功读到了数据,我们可以在这里做业务处理,比如实现 echo 服务器的 “收到什么就回发什么”,直接用 send 把数据回传给客户端。这里需要注意,send 也可能出现发送缓冲区满的情况,导致数据无法一次性全部发送,但我们现在的代码先简化处理,只关注读事件,后续在 poll 或 epoll 中再完善写事件的监控。
  2. 当返回值 n == 0时,说明客户端主动断开了连接,此时我们要先关闭这个 fd,再把它从辅助数组中移除(设为-1),这样下一次循环就不会再监控这个无效 fd 了。
  3. 当返回值 n < 0时,说明读取过程中出现了错误,我们同样需要关闭 fd 并从辅助数组中移除,避免后续操作引发问题。

简单来说,这部分逻辑就是对就绪的普通客户端 fd 做完整的读事件处理,包括读取数据、处理断开和错误情况,同时维护辅助数组,确保服务器始终只监控有效的 fd。

补充一下 : recv 返回值为 0,在 TCP 套接字里有唯一、标准含义:代表对方(客户端)正常关闭连接,对端已经调用 close() 断开了 TCP 连接。当客户端正常退出、关闭 telnet 连接时,会向服务器发送 FIN 报文,TCP 连接进入半关闭状态;服务器这边的内核会收到这个关闭信号,此时调用 recv 读取时,不会阻塞、不会报错,直接返回 0。这不是出错,而是 TCP 协议约定的正常关闭标记,所以我们的代码里看到 n == 0,就要执行:关闭 fd、把辅助数组对应位置置为-1,不再监控这个 fd。

我们可以补充一个打印函数 PrintFd,让这个函数遍历我们的辅助数组 _rfdset,跳过值为 -1 的无效位置,把当前所有正在被 select 监控的合法 fd 全部打印出来。我们可以把它加在 Loop 循环里、每次 select 调用之前执行,这样每次循环前你都能清晰看到:现在辅助数组里存了哪些 fd、监听 fd 和客户端 fd 有没有被正确添加 / 删除,能帮我们快速排查是不是新连接没存进去、断开的 fd 没被清掉等问题。


总结:

#pragma once

#include <iostream>
#include <cstdio>
#include <cstdint>
#include <string>
#include <memory>
#include <sys/select.h>
#include "Logger.hpp"
#include "Socket.hpp"

using namespace NS_LOG_MODULE;
using namespace NS_SOCKET_MODULE;

static const int gfdnum = sizeof(fd_set) * 8;
static const int gdefaultfd = -1;

class SelectServer
{
public:
    SelectServer(uint16_t port = 8080)
        : _port(port),
          _listensock(std::make_unique<TcpSocket>()),
          _quit(false)
    {
        _listensock->BuildTcpSocketMethod(port);
        LOG(LogLevel::DEBUG) << "create listensock success, fd : " << _listensock->Sockfd();

        for (int i = 0; i < gfdnum; i++)
            _rfdset[i] = gdefaultfd;

        // 默认直接把刚开始的fd,直接添加到数组中
        _rfdset[0] = _listensock->Sockfd();
    }

    void HandlerEvent(fd_set &rfds)
    {
        for (int i = 0; i < gfdnum; i++)
        {
            if (_rfdset[i] == gdefaultfd)
                continue;
            else
            {
                // fd是合法的
                if (FD_ISSET(_rfdset[i], &rfds))
                {
                    if (_rfdset[0] == _listensock->Sockfd())
                    {
                        // 监听socket就绪
                        LOG(LogLevel::WARNING) << "listensockfd event ready!";
                        InetAddr clientaddr;
                        int sockfd = _listensock->Accepter(clientaddr); // 这里还会卡主吗??不会了!!!
                        LOG(LogLevel::WARNING) << "get a new link, sockfd is :" << sockfd << clientaddr.ToString();

                        // 我们能直接读取sockfd吗??不能直接读,而应该托管给select!!如何托管??只要把这个sockfd添加到辅助数组中即可!!
                        int pos = 0;
                        for (; pos < gfdnum; pos++)
                        {
                            if (_rfdset[pos] == gdefaultfd)
                                break;
                        }
                        if (pos == gfdnum)
                        {
                            LOG(LogLevel::WARNING) << "server is full!";
                            close(sockfd);
                        }
                        else
                        {
                            _rfdset[pos] = sockfd;
                        }
                    }
                    else
                    {
                        // 普通fd就绪了!
                        char buffer[1024];
                        ssize_t n = recv(_rfdset[i], buffer, sizeof(buffer), 0); // 这里读取会阻塞吗??不会!!
                        if (n > 0)
                        {
                            buffer[n] = 0;
                            LOG(LogLevel::DEBUG) << "buffer : " << buffer;
                            std::string echo_string = "echo #";
                            echo_string += buffer;
                            // 我们可以直接发送数据吗?
                            send(_rfdset[i], echo_string.c_str(), echo_string.size(), 0);
                        }
                        else if (n == 0)
                        {
                            LOG(LogLevel::INFO) << "client quit: " << _rfdset[i];
                            // 0. 关闭fd
                            close(_rfdset[i]);
                            // 1. 从select移除掉fd
                            _rfdset[i] = gdefaultfd;
                            break;
                        }
                        else
                        {
                            LOG(LogLevel::WARNING) << "recv error: " << _rfdset[i];
                            // 0. 关闭fd
                            close(_rfdset[i]);
                            // 1. 从select移除掉fd
                            _rfdset[i] = gdefaultfd;
                            break;
                        }
                    }
                }
            }
        }
    }

    void Loop()
    {
        while (!_quit)
        {
            fd_set rfds; // 读文件描述符集
            FD_ZERO(&rfds);
            int maxfd = gdefaultfd;
            for (int i = 0; i < gfdnum; i++)
            {
                if (_rfdset[i] == gdefaultfd)
                {
                    continue;
                }
                else
                {
                    if (_rfdset[i] == gdefaultfd)
                        continue;
                    // 一定是一个合法的fd
                    // 1. 把fd添加到rfds位图中,重置位图
                    FD_SET(_rfdset[i], &rfds);

                    // 2. 更新最大的fd
                    if (maxfd < _rfdset[i])
                        maxfd = _rfdset[i];
                }
            }

            // FD_SET(_listensock->Sockfd(), &rfds);
            //  struct timeval timeout = {0, 0};

            // 只负责等待
            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            switch (n)
            {
            case 0: // 对应返回值超时
                LOG(LogLevel::INFO) << "timeout";
                break;
            case -1: // 返回值失败
                LOG(LogLevel::WARNING) << "select error";
                break;
            default: //>0 : 说明事件就绪了 ready
                LOG(LogLevel::WARNING) << "event ready! n = " << n;
                HandlerEvent(rfds); // 不仅仅处理新链接,还要处理普通的IO事件!!
                break;
            }
        }
    }
    ~SelectServer()
    {
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listensock;
    bool _quit;

    // select 服务器,往往需要结合一个辅助数组,才能完成工作
    int _rfdset[gfdnum]; // 保存所有合法文件fd
};

    HandlerEvent 函数会先遍历辅助数组,跳过值为 -1 的无效位置,只处理合法 fd;再通过 FD_ISSET 判断该 fd 是否在本次 select 就绪的位图中,若是就绪 fd 再做分流处理:如果是监听套接字就绪,就调用 accept 获取新连接 fd,并将其存入作为类私有成员、生命周期贯穿整个服务器运行全程、不会随循环作用域销毁的辅助数组中实现托管;如果是普通客户端 fd 就绪,就执行 recv 读取数据、实现回显业务,若客户端断开或读取出错,则关闭 fd 并在辅助数组中将其标记为 -1。处理完成后回到 Loop 的 while 循环,每轮都会先清空位图,再遍历这份保存所有合法 fd的辅助数组,其中既包含初始的监听 fd,也包含之前 HandlerEvent 里通过 accept 存入的客户端 fd,我们会把这些合法 fd 全部重新 FD_SET 到位图中,同时更新最大 fd 值 maxfd,再调用 select 等待事件。

    简单来说,辅助数组是长期保存 fd 的永久清单,位图是每轮循环临时生成、传给内核的临时监控副本,新存入辅助数组的客户端 fd 会在下一轮循环中被同步设置到位图,内核就能持续监听所有连接,由此完成单进程 + select + 辅助数组的完整多路转接服务器流程。

    运行结果:

    select 多路转接的核心本质 :

    归根结底,select 承担了所有阻塞等待的工作:它替我们等待监听套接字的新连接事件(也就是原本 accept 要阻塞等待新套接字的过程),也替我们等待普通客户端 fd 的读就绪事件 (原本 recv 要阻塞等待数据的过程)。等内核检测到任意 fd 就绪后,select 才返回,我们只需要根据返回值,直接调用 accept 获取新连接、调用 recv 读取数据即可,全程不会发生阻塞 —— 所有 “等待” 全由 select 统一完成,accept 和 recv 只负责执行非阻塞的拷贝操作,这正是 select 实现单进程高并发 IO 多路转接的核心逻辑。


    三、select 的原理

    在写完代码后,我们再回过头来看 select 的底层原理 :

    我们的代码中出现了多个遍历,所以 select 绕不开遍历,所以时间复杂度偏高。那 select 是怎么知道多个 fd 中哪个 fd 就绪了呢?答案就是 select 的底层也是采用遍历的方式进行检测 fd 是否就绪的,因为它要遍历,就需要遍历完,这也是为什么 select 的第一个参数 maxfd+1 的原因 : 这也是 select 第一个参数必须传入maxfd+1的原因 —— 内核需要知道遍历的边界,从 0 号 fd 一直遍历到最大 fd,逐个检查状态。

    在内核中,每个 fd 都会对应一个struct file文件结构体,该结构体内部持有 file_operations 类型的函数指针表 f_op,在这个操作表中就有一个方法,用来检测文件描述符 fd 是否就绪的方法,叫*poll 方法,需要注意的是这个 poll 方法和我们多路转接中的 poll 完全是两回事。

    在网络场景下,这个 poll 方法的作用就是非阻塞地检查 socket 对应的内核收发队列 (由 sk_buff 结构体构成,逻辑上我们称为收发缓冲区) : 若接收队列存在数据、或发送队列有空闲空间,就判定该 fd 就绪,随后将 fd_set 位图中对应位置标记为 1;反之则标记为未就绪。整个内核检测队列状态的过程全程不会阻塞,只会快速完成判断,最终修改位图后返回给用户态,完成一次事件等待。


    当我们调用 select 后,内核底层首先会执行 for 循环,从 0 遍历到 maxfd,对每个 fd 调用它底层的 poll 方法,逐个检测 fd 是否就绪。遍历完成后分两种情况:

      (1). 如果有 fd 就绪,内核就把就绪 fd 标记到位图中,直接返回就绪数量,进程继续往下执行,不会阻塞;

       (2). 如果遍历完,发现没有任何 fd 就绪,进程就会进入阻塞状态:内核会把当前进程 (task_struct) 从 CPU 运行队列中移出,不再调度它运行,同时把这个进程添加到每一个被监控 fd 的等待队列里,让进程在这些 fd 上休眠等待。

    之后只要任意一个 fd 对应的内核缓冲区(接收 / 发送队列)出现就绪事件,内核就会通过 fd 的等待队列唤醒这个休眠的进程;进程被唤醒后,会再次重新全量遍历所有 fd、调用 poll 检测,收集所有就绪 fd 更新到位图,最后返回给用户态,完成一次完整的 select 等待。

    简单来说:select 先主动遍历一轮 fd,没就绪就休眠;等事件来了再被唤醒、再遍历一轮,全程依赖全量遍历 + 等待队列实现阻塞等待。这就是select的原理。

    我们可以用 AI 帮我们生成 select 的底层为代码 : 

    select系统调用实现原理(伪代码):

    select 的底层双循环中内层循环就是遍历检测就绪,外层循环是如果之前因为没有 fd 就绪而导致的阻塞,那再次唤醒时会在原位置进入下一轮外层循环,继续检测等待遍历。

    select 的优缺点

    select 的核心优势,是让单进程就能同时管理多个文件描述符的 IO 事件,通过时间通知机制聚合 IO 操作,减少进程阻塞等待的时间占比,从而提升整体处理效率,这也是多路复用模型最基础的价值所在。

    但它的缺点也十分突出,这些缺陷直接限制了它在高并发场景下的性能表现:

    1. select 的 fd 集合是输入输出型参数,没有分离,就会导致调用前后内核会修改位图,导致每次调用前都必须重新手动构建 fd 集合,使用上非常繁琐;

    2. 每次调用都需要把用户态的 fd 集合拷贝到内核态,fd 数量越多,拷贝开销就越大;同时,内核每次都要全量遍历所有传入的 fd 来检测就绪状态,用户态后续也需要遍历集合判断就绪 fd,双重遍历的开销会随着 fd 数量增长急剧上升;

    3. select 天生受限于 FD_SETSIZE,最多只能同时监控 1024 个文件描述符,即便抛开这个限制,fd 数量过多也会让遍历周期变长,反而降低处理效率,无法真正实现 “fd 越多效率越高” 的理想情况。

    四、基于事件通知派发机制的代码修改

    版本二 :

    select 最大的意义就是内核检测到 fd 就绪后,向上层做事件通知,通知我们上层代码去处理对应业务,这就是事件通知;而有了事件通知,就必须做事件派发,把不同事件交给对应的逻辑处理。

    所以我们在原来的代码基础上做了上层封装,核心逻辑没变,只是把之前统一的 HandlerE vent 改成了 Dispatcher 做事件派发,再进一步封装出两个独立函数:把监听 fd 获取新连接的逻辑封装成 Accepter,把普通 fd 读写 IO 的逻辑封装成 Recver。运行时 select 作为事件监听器等待事件,事件就绪后由 Dispatcher 遍历辅助数组,区分是监听 fd 还是普通 fd,再分别派发给 Accepter 连接管理器、RecverIO 处理器执行。

    这么改造后代码模块化更清晰,也更贴近上层用户视角,这正是基于事件的编程模式,我们只需要响应就绪事件、处理派发任务,不用关心底层等待与遍历细节。

    后续我们也可以用函数回调指针,把监听 fd 的连接逻辑、普通 fd 的读写 IO 逻辑彻底封装成独立模块,再通过回调注册的方式和 Dispatcher 解耦。Dispatcher 只负责 “检测到哪个 fd 就绪”,不写任何业务逻辑;我们提前给不同类型 fd 注册对应的回调函数 —— 监听 fd 就绪就触发连接回调,普通 fd 就绪就触发读写回调。这样事件通知、事件派发、业务处理完全分开,select/Dispatcher 只做底层事件分发,Accepter、IO 处理各自独立,耦合度降到最低,后续想换业务逻辑、增删功能都不用动事件循环的核心代码,这正是成熟事件驱动框架(比如 Reactor)的核心思想。这个我们后面写。

    五、总结

    本文详细介绍了Linux下的多路转接技术,重点讲解了select系统调用的原理与实现。文章首先分析了select的核心功能是同时监控多个文件描述符的IO事件,通过事件通知机制减少进程等待时间。然后通过一个echo服务器示例,展示了select的具体使用方式,包括如何初始化fd_set、设置超时、处理就绪事件等关键步骤。文章深入剖析了select的底层实现原理,解释了其通过遍历检测和等待队列实现事件通知的机制。最后指出了select的优缺点,包括其1024个fd的限制和双重遍历的性能问题,并提出了基于事件通知机制的改进思路,为后续介绍更高效的poll和epoll技术奠定了基础。

    谢谢大家的观看!

    Logo

    openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

    更多推荐