「Linux 网络编程」基于 select 的 I/O 复用服务器
1 I/O 多路复用
之前在 「Linux 网络编程」多进程并发服务器 中实现了 基于多进程的并发服务器,这种并发服务器实现的主要思想就是——增加一个客户连接,就相应地创建一个新的进程来处理。
但是这种基于多进程的并发服务器实现方法显然存在缺陷。因为创建进程需要付出很大的代价,需要大量的运算和内存空间。每个进程都拥有独立的内存空间,因此进程间的数据交换也是相对复杂的。
那么,有没有什么解决方法,使得不额外创建进程的情况下,就可以同时对多个客户端提供服务?以下的 I/O 多路复用技术 就可以解决上述问题。
不过,I/O 多路复用技术 同样不是完美的,需要根据需要实现的服务器特点来采取不同的实现方法。😉
1.1 什么是 I/O 多路复用
I/O 多路复用(I/O Multiplexing) 的核心思想:用 1 个进程(或线程)来监视多个文件描述符(通常是网络连接 Socket),一旦某个描述符就绪(读就绪或写就绪),就能够通知程序进行相应的读写操作。
我们可以用 “餐厅服务员” 的例子来理解 I/O 多路复用:
假如有两种类型的餐厅,一种是 “多进程餐厅”(每个顾客一个服务员),另一种是 “I/O多路复用餐厅”(一个超级服务员)。
在 “多进程餐厅” 中,每个顾客都有一个专属的服务员。顾客点菜后,对应的专属服务员就一直在厨房门口等待,直到顾客点的菜做好。在此期间,这个服务员不能为其他任何顾客服务。 虽然这种方式简单直接,但是随着顾客增多,需要的服务员也越多(进程增多),估计后面服务员工资都不够发了…(资源消耗大)
在 “I/O 多路复用餐厅” 中,只有一个超级服务员。这个超级服务员负责照看所有顾客,他会先问一圈:“您需要点菜吗”,“某某顾客的菜做好了吗”。当某个顾客的菜做好了(数据就绪),厨房通知系统 提示这个超级服务员 “A顾客菜已做好”,服务员收到通知后将菜端给 A顾客。 这种方式大大减少了人力(资源消耗减少),不过也不是非常完美。假如某一个顾客上菜花费时间太长(处理一个很复杂的读写操作),其他顾客也都被暂时搁置了,因此这个超级服务员必须像超人一样迅速。
在上面的比喻中:
- 顾客 相当于 网络连接(Socket)
- 点菜、上菜 相当于 读写操作
- 菜已做好 就相当于 I/O 就绪(数据可读/可写)
- 超级服务员 相当于 I/O 多路复用的单个进程/线程
- 厨房通知系统 就相当于
select
(本文实现 I/O 多路复用的关键函数)
1.2 为什么需要 I/O 多路复用
在没有 I/O 多路复用时,处理多个网络连接有两种常见方法:
-
阻塞 I/O + 多进程/多线程:
为每个连接创建一个新的进程或线程。这种方式简单,但当连接数成千上万时,创建、销毁和调度进程/线程的开销巨大,会耗尽系统资源。
-
非阻塞 I/O + 轮询:
用一个循环不断地去询问每个连接:“有数据吗?有数据吗?”。这种方式虽然避免了多线程的开销,但 CPU 时间都浪费在了无意义的轮询上,效率很低。
I/O 多路复用解决了上述问题:
- 高并发:它可以用单个进程处理成千上万的网络连接。
- 资源效率高:避免了进程/线程切换的巨大开销,也避免了 CPU 空转。
不过,也正如前面 1.1 什么是 I/O 多路复用 所提到的,I/O 多路复用并不完美。很多时候,会采用 I/O 多路复用 + 工作线程池 的混合模型,本篇文章暂且不介绍。
1.3 I/O 多路复用的本质
I/O 多路复用的本质是:一个“消息通知”机制。它的核心工作不是同时处理多个 I/O,而是 高效地查看到哪个 I/O 已经准备就绪。
在 I/O 多路复用模型中,实际的读写操作通常仍然是串行的。当多个连接中有 I/O 事件就绪时,能够及时地通知应用程序,使其能在恰当的时机、用不阻塞的方式去处理这些 I/O。
相比 多进程/多线程 的并发服务器的 真正并行 的实现方式,I/O 多路复用实际上采用的是 时分复用 技术,通过高效地轮询和响应大量任务的事件。
总的来说:
-
多进程/多线程(真并发):多个 CPU 核心同时执行不同任务
1
2
3
4
5进程1: [监听]--[阻塞]--[处理]--[完成]
进程2: [监听]--[阻塞]--[处理]--[完成]
进程3: [监听]--[阻塞]--[处理]--[完成]
↑ ↑ ↑ ↑
同时 同时 同时 同时 -
I/O 多路复用并发:单个 CPU 核心快速切换处理多个 I/O 任务
1
2
3
4时间轴: |---A---|---B---|---C---|
线程: [监听ABC]-[处理A]-[处理B]-[处理C]
↑ ↑ ↑ ↑
注册所有 处理A 处理B 处理C
2 select 函数及使用
select
是 Linux 中一种 I/O 多路复用机制,允许程序 同时监视多个文件描述符,等待其中一个或多个变为"就绪"状态(可读、可写或出现异常)。
2.1 select 函数
函数原型:
1 |
|
参数说明:
nfds
: 要监视的文件描述符的 最大值 + 1(+1 是因为监听套接字还需要一个文件描述符)readfds
: 监视可读的文件描述符集合。将所有关注“是否存在待读取数据”的文件描述符注册到fd_set
变量,并传递其地址。writefds
: 监视可写的文件描述符集合。将所有关注“是否可传输无阻塞数据”的文件描述符注册到fd_set
变量,并传递其地址。exceptfds
: 监视异常的文件描述符集合。将所有关注“是否发生异常”的文件描述符注册到fd_set
变量,并传递其地址。timeout
: 超时时间,NULL
表示无限等待。为了防止调用select
函数后,陷入无限等待,传递超时信息。
在使用过程中,nfds
和 readfds
一般是必须要设置的,后面三个可以设置为 NULL
。
返回值:
- 成功:返回大于 0 的值,表示 发生事件的文件描述符数量。
- 失败:返回 -1
超时返回时返回 0。
2.2 fd_set 原理解析
2.2.1 fd_set 数据结构
fd_set
实际上是一个 位图(bit array),每个位代表一个文件描述符的状态:
1 |
|
2.2.2 位图工作原理
1 |
|
计算规则:
- 数组索引 = 文件描述符 / (8 * sizeof(unsigned long))
- 位位置 = 文件描述符 % (8 * sizeof(unsigned long))
计算示例:
1 |
|
在 fd_set
位图中,每一位代表一个文件描述符的状态。如果这一位为 0
,表示该文件描述符尚未处于就绪状态;为 1
,那就处于数据就绪状态。
2.3 select 宏操作
2.3.1 FD_ZERO: 清空集合
原理: 将所有位设置为0
1 |
|
示例:
1 |
|
2.3.2 FD_SET: 添加文件描述符
原理: 将对应位设置为1
1 |
|
示例演示:
1 |
|
2.3.3 FD_CLR: 移除文件描述符
原理: 将对应位设置为 0
1 |
|
示例:
1 |
|
2.3.4 FD_ISSET: 检查文件描述符
原理: 检查对应位是否为 1
1 |
|
示例:
1 |
|
2.4 select 宏操作测试
初始状态:
1 |
|
逐步添加文件描述符:
1 |
|
完整测试代码:
1 |
|
测试结果:
1 |
|
3 基于 select 的 I/O 复用服务器
3.1 头文件与宏定义
1 |
|
功能说明:
-
包含必要的系统头文件:
sys/select.h
:select 相关函数和宏sys/socket.h
:socket 编程接口netinet/in.h
:Internet 地址族定义arpa/inet.h
:地址转换函数
-
定义常量:
MAX_CLIENTS
:最大客户端连接数(受限于 FD_SETSIZE)BUFFER_SIZE
:数据缓冲区大小PORT
:服务器监听端口
3.2 变量声明和初始化
1 |
|
变量说明:
server_fd
:服务器监听 socket 的文件描述符client_fd
:新接受的客户端连接max_fd
:当前最大的文件描述符(select 需要)client_sockets[]
:存储所有客户端 socket 的数组client_addr
:获取的客户端地址信息buffer
:数据读写缓冲区readfds
:select 监视的读文件描述符集合
3.3 服务器 Socket 创建和配置
1 |
|
功能说明:
-
socket(AF_INET, SOCK_STREAM, 0)
:创建 TCP socketAF_INET
:IPv4 地址族SOCK_STREAM
:面向连接的 TCP socket
-
setsockopt(SO_REUSEADDR)
:设置地址重用,避免"client_addr already in use"错误。
3.4 地址绑定和监听
1 |
|
功能说明:
-
bind()
:将 socket 绑定到指定地址和端口INADDR_ANY
:监听所有可用网络接口htons(PORT)
:将端口号转换为网络字节序
-
listen(server_fd, 10)
:开始监听连接,backlog=10
3.5 主循环: 文件描述符集合设置
1 |
|
功能说明:
FD_ZERO(&readfds)
:清空文件描述符集合FD_SET(server_fd, &readfds)
:添加服务器 socket 到监视集合- 循环遍历所有客户端 socket,将有效的 socket 添加到集合中
- 更新
max_fd
为当前最大的文件描述符(select 要求)
3.6 select 调用和错误处理
1 |
|
参数说明:
max_fd + 1
:要监视的最大文件描述符+1&readfds
:读文件描述符集合- 三个
NULL
:不监视写集合、异常集合,无限等待超时 - 返回值
activity
:就绪的文件描述符数量,0 表示超时,-1 表示错误 EINTR
:处理信号中断的情况
3.7 处理新的客户端连接请求
1 |
|
功能说明:
FD_ISSET(server_fd, &readfds)
:检查服务器 socket 是否有新连接accept()
:接受新连接,返回客户端 socketinet_ntoa()
和ntohs()
:转换网络地址为可读格式send()
:向新客户端发送欢迎消息- 在客户端数组中寻找空位存储新连接的 socket
3.8 处理客户端数据通信
1 |
|
功能说明:
- 遍历所有客户端 socket,检查是否有数据可读
FD_ISSET(sd, &readfds)
:检查特定客户端 socket 是否就绪read(sd, buffer, BUFFER_SIZE) == 0
:客户端关闭连接getpeername()
:获取客户端地址信息close(sd)
:关闭 socket- 清空客户端数组中的对应位置
- 数据到达处理:
- 添加字符串结束符
- 回显数据给客户端
- 检查 “quit” 命令,主动关闭连接
3.9 完整实现代码
1 |
|
4 I/O 复用服务器测试
4.1 测试客户端代码
1 |
|
4.2 运行测试
编译和运行:
1 |
|
测试结果:
服务端输出:
1 |
|
客户端 1 输出:
1 |
|
客户端 2 输出:
1 |
|
客户端 3 输出:
1 |
|