「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 多路复用时,处理多个网络连接有两种常见方法:

  1. 阻塞 I/O + 多进程/多线程:

    为每个连接创建一个新的进程或线程。这种方式简单,但当连接数成千上万时,创建、销毁和调度进程/线程的开销巨大,会耗尽系统资源。

  2. 非阻塞 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
2
3
4
#include <sys/select.h>

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

参数说明

  • nfds: 要监视的文件描述符的 最大值 + 1(+1 是因为监听套接字还需要一个文件描述符)
  • readfds: 监视可读的文件描述符集合。将所有关注“是否存在待读取数据”的文件描述符注册到 fd_set 变量,并传递其地址。
  • writefds: 监视可写的文件描述符集合。将所有关注“是否可传输无阻塞数据”的文件描述符注册到 fd_set 变量,并传递其地址。
  • exceptfds: 监视异常的文件描述符集合。将所有关注“是否发生异常”的文件描述符注册到 fd_set 变量,并传递其地址。
  • timeout: 超时时间,NULL 表示无限等待。为了防止调用 select 函数后,陷入无限等待,传递超时信息。

在使用过程中,nfdsreadfds 一般是必须要设置的,后面三个可以设置为 NULL

返回值

  • 成功:返回大于 0 的值,表示 发生事件的文件描述符数量
  • 失败:返回 -1

超时返回时返回 0。


2.2 fd_set 原理解析

2.2.1 fd_set 数据结构

fd_set 实际上是一个 位图(bit array),每个位代表一个文件描述符的状态:

1
2
3
4
5
6
7
8
9
// fd_set 的简化实现
typedef struct {
unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;

// 在大多数系统中:
// FD_SETSIZE = 1024
// sizeof(unsigned long) = 8 bytes = 64 bits
// 因此数组大小 = 1024 / 64 = 16个unsigned long

2.2.2 位图工作原理

1
2
3
4
5
文件描述符: fd0  fd1  fd2  fd3  fd4  fd5   ... fd1023
↓ ↓ ↓ ↓ ↓ ↓ ↓
位图索引: [0] [0] [0] [0] [0] [0] ... [15]
| | | | | | |
位位置: 0位 1位 2位 3位 4位 5位 ... 63位

计算规则

  • 数组索引 = 文件描述符 / (8 * sizeof(unsigned long))
  • 位位置 = 文件描述符 % (8 * sizeof(unsigned long))

计算示例

1
2
3
4
5
6
7
8
9
// 对于文件描述符 5:
数组索引 = 5 / 64 = 0
位位置 = 5 % 64 = 5
也就是位图数组中第 0 个数中从低到高第 5 个比特位

// 对于文件描述符 100:
数组索引 = 100 / 64 = 1
位位置 = 100 % 64 = 36
也就是位图数组中第 1 个数中从低到高第 36 个比特位

fd_set 位图中,每一位代表一个文件描述符的状态。如果这一位为 0,表示该文件描述符尚未处于就绪状态;为 1,那就处于数据就绪状态


2.3 select 宏操作

2.3.1 FD_ZERO: 清空集合

原理: 将所有位设置为0

1
2
3
4
5
6
#define FD_ZERO(set) do { \
unsigned long *__arr = (set)->fds_bits; \
int __size = sizeof(fd_set) / sizeof(unsigned long); \
for (int __i = 0; __i < __size; __i++) \
__arr[__i] = 0; \
} while (0)

示例

1
2
3
4
5
fd_set readfds;
FD_ZERO(&readfds);

// 执行前: [xxxxxxxx xxxxxxxx ... xxxxxxxx] (随机值)
// 执行后: [00000000 00000000 ... 00000000] (全零)

2.3.2 FD_SET: 添加文件描述符

原理: 将对应位设置为1

1
2
3
4
5
6
7
#define FD_SET(fd, set) do { \
unsigned long *__arr = (set)->fds_bits; \
int __fd = (fd); \
int __idx = __fd / (8 * sizeof(unsigned long)); \
int __bit = __fd % (8 * sizeof(unsigned long)); \
__arr[__idx] |= (1UL << __bit); \
} while (0)

示例演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fd_set readfds;
FD_ZERO(&readfds); // 初始状态:全0

// 添加文件描述符 3
FD_SET(3, &readfds);

// 计算过程:
// 数组索引 = 3 / 64 = 0
// 位位置 = 3 % 64 = 3
// 操作:fds_bits[0] |= (1 << 3)

// 位图变化:
// 执行前:[00000000 00000000 ... 00000000]
// 执行后:[00001000 00000000 ... 00000000]
// ↑
// 第3位被置1

// 再添加文件描述符 67
FD_SET(67, &readfds);
// 数组索引 = 67 / 64 = 1
// 位位置 = 67 % 64 = 3
// 操作:fds_bits[1] |= (1 << 3)

// 位图变化:
// [00001000 00001000 ... 00000000]

2.3.3 FD_CLR: 移除文件描述符

原理: 将对应位设置为 0

1
2
3
4
5
6
7
#define FD_CLR(fd, set) do { \
unsigned long *__arr = (set)->fds_bits; \
int __fd = (fd); \
int __idx = __fd / (8 * sizeof(unsigned long)); \
int __bit = __fd % (8 * sizeof(unsigned long)); \
__arr[__idx] &= ~(1UL << __bit); \
} while (0)

示例:

1
2
3
4
5
6
7
8
// 当前状态:[00001000 00001000 ... 00000000]
// 包含文件描述符 3 和 67

// 移除文件描述符 3
FD_CLR(3, &readfds);

// 操作:fds_bits[0] &= ~(1 << 3)
// 结果:[00000000 00001000 ... 00000000]

2.3.4 FD_ISSET: 检查文件描述符

原理: 检查对应位是否为 1

1
2
3
4
5
6
7
#define FD_ISSET(fd, set) ({ \
unsigned long *__arr = (set)->fds_bits; \
int __fd = (fd); \
int __idx = __fd / (8 * sizeof(unsigned long)); \
int __bit = __fd % (8 * sizeof(unsigned long)); \
(__arr[__idx] & (1UL << __bit)) != 0; \
})

示例:

1
2
3
4
5
6
7
8
9
10
// 当前状态:[00000000 00001000 ... 00000000]
// 包含文件描述符 67

if (FD_ISSET(3, &readfds)) {
printf("fd 3 is set\n"); // 不会执行
}

if (FD_ISSET(67, &readfds)) {
printf("fd 67 is set\n"); // 会执行
}

2.4 select 宏操作测试

初始状态

1
2
文件描述符集合:空集合
位图状态:[00000000 00000000 00000000 ... 00000000] (16个元素,全0)

逐步添加文件描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fd_set testfds;
FD_ZERO(&testfds);

// 步骤1:添加 fd 0, 1, 2
FD_SET(0, &testfds);
FD_SET(1, &testfds);
FD_SET(2, &testfds);

// 位图变化:
// [00000111 00000000 ... 00000000]
// ↑↑↑
// 210 (从右往左数)

// 步骤2:添加 fd 64
FD_SET(64, &testfds);
// 数组索引 = 64 / 64 = 1
// 位位置 = 64 % 64 = 0
// [00000111 00000001 ... 00000000]
// ↑
// 第0位

完整测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <stdio.h>
#include <sys/select.h>

void print_fd_set(const char *name, fd_set *set) {
printf("%s: ", name);
for (int i = 0; i < 5; i++) { // 只打印前5个文件描述符作为示例
if (FD_ISSET(i, set)) {
printf("fd%d ", i);
}
}
printf("\n");
}

int main() {
fd_set readfds;

printf("=== FD_SET 操作演示 ===\n");

// 1. 初始化
FD_ZERO(&readfds);
print_fd_set("初始化后", &readfds);

// 2. 添加一些文件描述符
FD_SET(0, &readfds); // 标准输入
FD_SET(3, &readfds); // 假设的socket
FD_SET(5, &readfds); // 另一个socket
print_fd_set("添加 0,3,5 后", &readfds);

// 3. 检查文件描述符状态
printf("检查结果:\n");
for (int i = 0; i < 6; i++) {
if (FD_ISSET(i, &readfds)) {
printf(" fd%d: 在集合中\n", i);
} else {
printf(" fd%d: 不在集合中\n", i);
}
}

// 4. 移除文件描述符
FD_CLR(3, &readfds);
print_fd_set("移除 fd3 后", &readfds);

// 5. 模拟 select 使用场景
printf("\n=== Select 使用场景模拟 ===\n");

fd_set testfds;
FD_ZERO(&testfds);

// 假设我们有多个socket
int sockets[] = {3, 5, 7, 9};
int num_sockets = 4;

// 添加到监视集合
for (int i = 0; i < num_sockets; i++) {
FD_SET(sockets[i], &testfds);
}

printf("监视的文件描述符: ");
for (int i = 0; i < 10; i++) {
if (FD_ISSET(i, &testfds)) {
printf("%d ", i);
}
}
printf("\n");

// 模拟 select 返回后的结果(只有 fd5 和 fd9 就绪)
fd_set resultfds;
FD_ZERO(&resultfds);
FD_SET(5, &resultfds);
FD_SET(9, &resultfds);

printf("就绪的文件描述符: ");
for (int i = 0; i < 10; i++) {
if (FD_ISSET(i, &resultfds)) {
printf("%d ", i);
}
}
printf("\n");

return 0;
}

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
=== FD_SET 操作演示 ===
初始化后:
添加 0,3,5 后: fd0 fd3 fd5
检查结果:
fd0: 在集合中
fd1: 不在集合中
fd2: 不在集合中
fd3: 在集合中
fd4: 不在集合中
fd5: 在集合中
移除 fd3 后: fd0 fd5

=== Select 使用场景模拟 ===
监视的文件描述符: 3 5 7 9
就绪的文件描述符: 5 9


3 基于 select 的 I/O 复用服务器

3.1 头文件与宏定义

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.h>

#define MAX_CLIENTS 1024 // 最大客户端连接数(受限于 FD_SETSIZE)
#define BUFFER_SIZE 1024
#define PORT 8080

功能说明:

  • 包含必要的系统头文件:

    • 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
2
3
4
5
6
7
// 服务器监听 socket 的文件描述符, 新接受的客户端连接, 当前最大的文件描述符(select 需要)
int server_fd, client_fd, max_fd;
int client_sockets[MAX_CLIENTS] = {0}; // 存储所有客户端 socket 的数组
struct sockaddr_in client_addr; // 获取的客户端地址信息
socklen_t addr_len = sizeof(client_addr);
char buffer[BUFFER_SIZE]; // 数据 I/O 缓冲区
fd_set readfds; // select 监视的读文件描述符集合

变量说明:

  • server_fd:服务器监听 socket 的文件描述符
  • client_fd:新接受的客户端连接
  • max_fd:当前最大的文件描述符(select 需要)
  • client_sockets[]:存储所有客户端 socket 的数组
  • client_addr:获取的客户端地址信息
  • buffer:数据读写缓冲区
  • readfds:select 监视的读文件描述符集合

3.3 服务器 Socket 创建和配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int server_fd;   // 服务器端套接字文件描述符
struct sockaddr_in server_addr; // 配置的服务器地址

// 创建服务器 socket
if((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}

// 设置 SO_REUSEADDR 选项,实现服务器迅速重启,避免 TIME_WAIT 导致地址不可用
int opt = 1;
if(setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}

功能说明:

  • socket(AF_INET, SOCK_STREAM, 0):创建 TCP socket

    • AF_INET:IPv4 地址族
    • SOCK_STREAM:面向连接的 TCP socket
  • setsockopt(SO_REUSEADDR):设置地址重用,避免"client_addr already in use"错误。


3.4 地址绑定和监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 设置地址
client_addr.sin_family = AF_INET;
client_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
client_addr.sin_port = htons(PORT); // 端口号(主机字节序转网络字节序)

// 绑定地址
if(bind(server_fd, (struct sockaddr *)&client_addr, sizeof(client_addr)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}

// 开始监听,监听上限为 10
if(listen(server_fd, 10) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}

功能说明:

  • bind():将 socket 绑定到指定地址和端口

    • INADDR_ANY:监听所有可用网络接口
    • htons(PORT):将端口号转换为网络字节序
  • listen(server_fd, 10):开始监听连接,backlog=10


3.5 主循环: 文件描述符集合设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
while(1) {
// 清空文件描述符集合
FD_ZERO(&readfds);

// 添加服务器 socket 到集合
FD_SET(server_fd, &readfds);
max_fd = server_fd;

// 添加客户端 sockets 到集合
for(i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if(sd > 0) {
FD_SET(sd, &readfds);
}
if(sd > max_fd) {
max_fd = sd;
}
}

// 后续处理操作
...
}

功能说明:

  • FD_ZERO(&readfds):清空文件描述符集合
  • FD_SET(server_fd, &readfds):添加服务器 socket 到监视集合
  • 循环遍历所有客户端 socket,将有效的 socket 添加到集合中
  • 更新 max_fd 为当前最大的文件描述符(select 要求)

3.6 select 调用和错误处理

1
2
3
4
5
6
// 使用 select 等待活动。activity 为当前就绪的文件描述符数量
int activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);

if((activity < 0) && (errno != EINTR)) {
perror("select error");
}

参数说明:

  • max_fd + 1:要监视的最大文件描述符+1
  • &readfds:读文件描述符集合
  • 三个 NULL:不监视写集合、异常集合,无限等待超时
  • 返回值 activity:就绪的文件描述符数量,0 表示超时,-1 表示错误
  • EINTR:处理信号中断的情况

3.7 处理新的客户端连接请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 检查服务器 socket 是否有新的连接
if(FD_ISSET(server_fd, &readfds)) {
if((client_fd = accept(server_fd, (struct sockaddr *)&client_addr,
(socklen_t*)&addr_len)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}

printf("New connection, socket fd: %d, IP: %s, port: %d\n",
client_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

// 发送欢迎消息
char* welcome = "Welcome to the select server!\n";
send(client_fd, welcome, strlen(welcome), 0);

// 添加新的 socket 到客户端数组
for(i = 0; i < MAX_CLIENTS; i ++ ) {
if(client_sockets[i] == 0) {
client_sockets[i] = client_fd;
printf("Adding to list of sockets at index %d\n", i);
break;
}
}
}

功能说明:

  • FD_ISSET(server_fd, &readfds):检查服务器 socket 是否有新连接
  • accept():接受新连接,返回客户端 socket
  • inet_ntoa()ntohs():转换网络地址为可读格式
  • send():向新客户端发送欢迎消息
  • 在客户端数组中寻找空位存储新连接的 socket

3.8 处理客户端数据通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 检查客户端 sockets 的 I/O 操作
for(i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];

if(FD_ISSET(sd, &readfds)) {
// 检查是连接关闭还是数据到达
if((valread = read(sd, buffer, BUFFER_SIZE)) == 0) {
// 客户端断开连接
getpeername(sd, (struct sockaddr*)&client_addr, (socklen_t*)&addr_len);
printf("Client disconnected, IP: %s, port: %d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

close(sd);
client_sockets[i] = 0; // 清空客户端数组中的对应位置
} else {
// 处理接收到的数据
buffer[valread] = '\0';

// 如果是 "quit" 命令,关闭连接
if(strncmp("quit", buffer, 4) == 0) {
printf("Client requested to quit\n");
close(sd);
client_sockets[i] = 0;
continue;
}

// 回显数据给客户端
send(sd, buffer, strlen(buffer), 0);
}
}
}

功能说明:

  • 遍历所有客户端 socket,检查是否有数据可读
  • FD_ISSET(sd, &readfds):检查特定客户端 socket 是否就绪
  • read(sd, buffer, BUFFER_SIZE) == 0:客户端关闭连接
    • getpeername():获取客户端地址信息
    • close(sd):关闭 socket
    • 清空客户端数组中的对应位置
  • 数据到达处理
    • 添加字符串结束符
    • 回显数据给客户端
    • 检查 “quit” 命令,主动关闭连接

3.9 完整实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <errno.h>

#define MAX_CLIENTS 1024 // 最大客户端连接数(受限于 FD_SETSIZE)
#define BUFFER_SIZE 1024
#define PORT 8080

int server_init(int port) {
int server_fd; // 服务器端套接字文件描述符
struct sockaddr_in server_addr; // 配置的服务器地址

// 创建socket, IPv4, TCP
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket 创建失败");
exit(EXIT_FAILURE);
}

// 设置 socket 选项
// TCP 服务器被杀死后尝试重启,一段时间处于 TIME_WAIT 状态
// SO_REUSEADDR 可以使得服务器快速重启,避免服务器重启绑定同一端口出现失败
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
perror("setsockopt 失败");
close(server_fd);
exit(EXIT_FAILURE);
}

// 配置服务器地址
server_addr.sin_family = AF_INET; // IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听任何端口
server_addr.sin_port = htons(port);

// 绑定地址
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind 失败");
close(server_fd);
exit(EXIT_FAILURE);
}

// 开始监听
if (listen(server_fd, 10) == -1) { // 设置监听数量上限
perror("listen 失败");
close(server_fd);
exit(EXIT_FAILURE);
}

return server_fd;
}

int main() {
// 服务器监听 socket 的文件描述符, 新接受的客户端连接, 当前最大的文件描述符(select 需要)
int server_fd, client_fd, max_fd;
int client_sockets[MAX_CLIENTS] = {0}; // 存储所有客户端 socket 的数组
struct sockaddr_in client_addr; // 获取的客户端地址信息
socklen_t addr_len = sizeof(client_addr);
char buffer[BUFFER_SIZE]; // 数据 I/O 缓冲区
fd_set readfds; // select 监视的读文件描述符集合

printf("启动基于 select 的 I/O 复用服务器,端口: %d\n", PORT);

server_fd = server_init(PORT);

printf("服务器准备就绪,等待连接...\n");

int i; // 遍历 sockets 数组
while(1) {
// 清空文件描述符集合
FD_ZERO(&readfds);

// 添加服务器 socket 到集合
FD_SET(server_fd, &readfds);
max_fd = server_fd;

// 添加客户端 sockets 到集合
for(i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];
if(sd > 0) {
FD_SET(sd, &readfds);
}
if(sd > max_fd) {
max_fd = sd;
}
}

// 使用 select 等待活动。activity 为当前就绪的文件描述符数量
int activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if((activity < 0) && (errno != EINTR)) {
perror("select error");
}

// 检查服务器 socket 是否有新的连接
if(FD_ISSET(server_fd, &readfds)) {
if((client_fd = accept(server_fd, (struct sockaddr *)&client_addr,
(socklen_t*)&addr_len)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}

printf("新的客户端连接, socket fd: %d, IP: %s, port: %d\n",
client_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

// 发送欢迎消息
const char *welcome_msg = "欢迎连接到TCP服务器!输入 'quit' 退出连接。\n";
send(client_fd, welcome_msg, strlen(welcome_msg), 0);

// 添加新的 socket 到客户端数组
for(i = 0; i < MAX_CLIENTS; i ++ ) {
if(client_sockets[i] == 0) {
client_sockets[i] = client_fd;
printf("添加到客户端 sockets 数组,位置在 %d\n", i);
break;
}
}
}

// 检查客户端 sockets 的 I/O 操作
for(i = 0; i < MAX_CLIENTS; i++) {
int sd = client_sockets[i];

if(FD_ISSET(sd, &readfds)) {
memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区

ssize_t bytes_received = recv(sd, buffer, BUFFER_SIZE - 1, 0);

if(bytes_received == 0) {
// 客户端断开连接处理
getpeername(sd, (struct sockaddr*)&client_addr, (socklen_t*)&addr_len);
printf("客户端断开连接, IP: %s, port: %d\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(sd);
client_sockets[i] = 0;
} else if(bytes_received > 0) {
// 去除换行符
buffer[strcspn(buffer, "\r\n")] = '\0';

printf("接收到客户端信息 %d: %s\n", sd, buffer);

// 如果是 "quit" 命令,关闭连接
if (strncmp("quit", buffer, 4) == 0) {
printf("客户端请求断开连接\n");
close(sd);
client_sockets[i] = 0;
continue;
}

// 回显数据
send(sd, buffer, strlen(buffer), 0);
}
}
}
}

return 0;
}


4 I/O 复用服务器测试

4.1 测试客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define SERVER_IP "127.0.0.1"
#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
int sock;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];

// 创建socket
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket 创建失败");
exit(1);
}

// 配置服务器地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);

// 连接服务器
if (connect(sock, (struct sockaddr*)&server_addr,
sizeof(server_addr)) == -1) {
perror("连接服务器失败");
close(sock);
exit(1);
}

printf("已连接到服务器 %s:%d\n", SERVER_IP, PORT);

// 接收欢迎消息
memset(buffer, 0, BUFFER_SIZE);
recv(sock, buffer, BUFFER_SIZE - 1, 0);
printf("%s", buffer);

// 与服务器交互
while (1) {
printf("请输入消息: ");
fgets(buffer, BUFFER_SIZE, stdin);

// 发送消息
send(sock, buffer, strlen(buffer), 0);

// 检查是否退出
if (strncmp(buffer, "quit", 4) == 0) {
break;
}

// 接收回复
memset(buffer, 0, BUFFER_SIZE);
ssize_t bytes_received = recv(sock, buffer, BUFFER_SIZE - 1, 0);
if (bytes_received > 0) {
buffer[bytes_received] = '\0';
printf("服务器回复: %s\n", buffer);
}
}

close(sock);
printf("连接已关闭\n");

return 0;
}

4.2 运行测试

编译和运行

1
2
3
4
5
6
7
8
gcc select_server.c -o select_server
gcc client.c -o client

# 在终端运行服务器
./select_server

# 在多个其他终端中运行客户端
./client

测试结果

服务端输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
marisa@marisa-virtual-machine:~/select_test$ ./select_server 
启动基于 select 的 I/O 复用服务器,端口: 8080
服务器准备就绪,等待连接...
新的客户端连接, socket fd: 4, IP: 127.0.0.1, port: 59398
添加到客户端 sockets 数组,位置在 0
新的客户端连接, socket fd: 5, IP: 127.0.0.1, port: 59404
添加到客户端 sockets 数组,位置在 1
接收到客户端信息 4: Hi, I am Alice.
接收到客户端信息 5: This is MARISA.
接收到客户端信息 5: quit
客户端请求断开连接
接收到客户端信息 4: One
新的客户端连接, socket fd: 5, IP: 127.0.0.1, port: 57944
添加到客户端 sockets 数组,位置在 1
接收到客户端信息 5: Two
接收到客户端信息 4: Three
接收到客户端信息 5: Four
接收到客户端信息 4: quit
客户端请求断开连接
接收到客户端信息 5: quit
客户端请求断开连接
^C

客户端 1 输出

1
2
3
4
5
6
7
8
9
10
11
marisa@marisa-virtual-machine:~/select_test$ ./client
已连接到服务器 127.0.0.1:8080
欢迎连接到TCP服务器!输入 'quit' 退出连接。
请输入消息: Hi, I am Alice.
服务器回复: Hi, I am Alice.
请输入消息: One
服务器回复: One
请输入消息: Three
服务器回复: Three
请输入消息: quit
连接已关闭

客户端 2 输出

1
2
3
4
5
6
7
marisa@marisa-virtual-machine:~/select_test$ ./client
已连接到服务器 127.0.0.1:8080
欢迎连接到TCP服务器!输入 'quit' 退出连接。
请输入消息: This is MARISA.
服务器回复: This is MARISA.
请输入消息: quit
连接已关闭

客户端 3 输出

1
2
3
4
5
6
7
8
9
marisa@marisa-virtual-machine:~/select_test$ ./client
已连接到服务器 127.0.0.1:8080
欢迎连接到TCP服务器!输入 'quit' 退出连接。
请输入消息: Two
服务器回复: Two
请输入消息: Four
服务器回复: Four
请输入消息: quit
连接已关闭

「Linux 网络编程」基于 select 的 I/O 复用服务器
https://marisamagic.github.io/2025/10/13/20251013/
作者
MarisaMagic
发布于
2025年10月13日
许可协议