IO多路复用

IO 多路复用是一种让单个线程(或少量线程)能够同时监听多个文件描述符(File Descriptor,FD,比如网络连接、文件、管道)的技术,当某个 FD 有可读 / 可写事件发生时,系统会通知程序去处理对应的 IO 操作。

它解决的核心问题是:避免线程 / 进程阻塞在单个 IO 操作上,提升程序处理大量并发 IO 的能力(比如高并发的服务器)。

IO 多路复用的三种核心实现(Linux 下)

方式 特点 适用场景
select 监听的 FD 数量有限(默认 1024),每次调用需要拷贝 FD 集合,效率低 兼容性好,低并发场景
poll 突破 FD 数量限制,但仍需遍历所有 FD,高并发下效率一般 中等并发,需要兼容多系统
epoll 基于事件驱动,只处理有事件的 FD,无 FD 数量上限,效率最高 高并发场景(如百万连接)

select

函数原型

select 是 IO 多路复用的基础实现,内部基于线性表实现的,核心作用是委托内核批量检测多个文件描述符(fd)的读写 / 异常状态,且具备跨平台特性(Linux/Mac/Windows 均支持)。

内核检测的核心是文件描述符对应的缓冲区状态:

  • 读就绪:读缓冲区有数据(可读取)
  • 写就绪:写缓冲区有空闲容量(可写入)
  • 异常就绪:读写缓冲区出现异常(如连接中断、数据错误)

内核完成检测后,会通过 select 的参数将 “就绪的文件描述符” 按类型(读 / 写 / 异常)分到 3 个集合中返回,程序员只需处理这些就绪 fd 即可,无需对所有 fd 轮询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <sys/select.h>

// 超时时间结构体
struct timeval {
time_t tv_sec; /* 秒 */
suseconds_t tv_usec; /* 微秒 */
};

// select核心函数
int select(
int nfds, // 待检测fd的最大值 + 1
fd_set *readfds, // 检测读就绪的fd集合
fd_set *writefds, // 检测写就绪的fd集合
fd_set *exceptfds, // 检测异常的fd集合
struct timeval *timeout // 超时时长
);

参数详解

参数:

  • nfds:

    • 内核遍历 fd 集合的终止条件(需传入 “最大 fd 值 + 1”),若是不知道最大的fd值可以填1024,因为select能检测的最大的文件描述符的个数是1024
    • Windows 下无效,填 - 1 即可
  • readfds

    • 传入传出参数:传入待检测读状态的 fd 集合,返回仅包含读就绪的 fd 集合;必填
  • writefds

    • 传入传出参数:传入待检测写状态的 fd 集合,返回仅包含写就绪的 fd 集合;无需则填 NULL
  • exceptfds

    • 传入传出参数:传入待检测异常的 fd 集合,返回仅包含异常的 fd 集合;无需则填 NULL
  • timeout

    • 阻塞控制:

      1. NULL → 永久阻塞,直到有 fd 就绪

      2. 非 NULL(tv_sec/tv_usec>0)→ 超时后解除阻塞,返回 0

      3. tv_sec=tv_usec=0 → 非阻塞,立即返回

    • 这个结构体里两个参数秒和微秒都必须初始化,因为总结果是秒和微秒相加,如果不初始化将会是随机数

返回值

返回值 含义
> 0 成功,返回所有集合中就绪的 fd 总数(读 + 写 + 异常)
= -1 失败,可通过errno查看错误原因(如被信号中断、参数非法)
= 0 超时,无任何 fd 就绪

fd_set

fd_set 是系统提供的文件描述符集合类型,本质是一个位图(bit array) —— 集合中的每一个二进制位对应一个文件描述符,通过 “位的 0/1 状态” 标记该 fd 是否被加入集合。

1
sizeof(fd_set) = 128 字节 * 8 = 1024 bit      // int [32] 32个整型数,但不是按照整形来处理的

在 Linux 系统中,fd_set 的底层定义(简化版)如下:

1
2
3
4
5
6
7
8
9
10
// 头文件:sys/select.h
#define __FD_SETSIZE 1024 // 默认最大支持1024个fd
typedef long int __fd_mask; // 每个__fd_mask占8字节(64位),可存64个fd的位
#define __NFDBITS (8 * sizeof (__fd_mask)) // 每个mask的位数:64

// fd_set结构体:用多个__fd_mask数组拼接成位图
typedef struct
{
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS]; // 1024/64=16个元素,总位数1024
} fd_set;
  • 例如:fd=3 对应位图的第 3 位,fd=100 对应位图的第 100 位;
  • 位为 1 → 该 fd 被加入集合(待检测);位为 0 → 该 fd 不在集合中。

fd_set 核心操作函数

所有操作函数本质都是对 fd_set 位图的位操作,下面结合底层逻辑解释每个函数的作用:

函数原型 功能描述 底层位操作逻辑(示例)
void FD_ZERO(fd_set *set); 清空整个集合,所有位设为 0 遍历 fds_bits 数组,将每个元素赋值为 0
void FD_SET(int fd, fd_set *set); 将 fd 加入集合,对应位设为 1 计算 fd 所在的数组下标 i = fd / __NFDBITS,偏移位 b = fd % __NFDBITS
set->fds_bits[i] 的第 b 位设为 1
void FD_CLR(int fd, fd_set *set); 将 fd 移出集合,对应位设为 0 同上,将 set->fds_bits[i] 的第 b 位设为 0
int FD_ISSET(int fd, fd_set *set); 判断 fd 是否在集合中(位是否为 1),返回 1(在)/0(不在) 同上,读取 set->fds_bits[i] 的第 b 位,返回位值

select 函数完整工作原理(分阶段拆解)

select 的核心是用户进程委托内核批量检测文件描述符(fd)的就绪状态,整个过程分为「用户态准备」「内核态检测」「用户态处理」三个阶段,以下是逐阶段的详细解析:

一、阶段 1:用户态准备(调用 select 前的操作)

在调用 select() 前,程序员需要完成 fd 集合的初始化和参数准备,这是内核能正确检测的前提:

1.1 初始化 fd_set 集合

通过 FD_ZERO/FD_SET 构建待检测的 fd 集合(读 / 写 / 异常),本质是构建用户态的位图(每个位对应一个 fd,1 表示待检测,0 表示不检测)。

1
2
3
4
5
// 示例:初始化读集合,加入服务器fd和客户端fd
fd_set read_fds;
FD_ZERO(&read_fds); // 清空位图(所有位为0)
FD_SET(server_fd, &read_fds);// 服务器fd对应位设为1(检测新连接)
FD_SET(client_fd1, &read_fds);// 客户端fd1对应位设为1(检测读数据)

1.2 确定 nfds 参数

计算待检测 fd 的最大值 + 1(如最大 fd 是 10,则 nfds=11),这个值是内核遍历位图的终止条件(避免遍历无意义的高位)。

1.3 准备超时时间(可选)

初始化 struct timeval 结构体,决定 select 的阻塞策略:

  • timeout=NULL:永久阻塞,直到有 fd 就绪;
  • timeout={1, 0}:阻塞 1 秒后超时;
  • timeout={0, 0}:非阻塞,立即返回。

1.4 拷贝原始集合到临时集合

由于内核会修改传入的 fd_set 集合(仅保留就绪 fd 的位为 1),因此需拷贝原始集合到临时集合(如 tmp_fds = read_fds),避免原始集合被破坏。

二、阶段 2:内核态处理(select 函数的核心执行逻辑)

当用户进程调用 select() 后,会触发系统调用(从用户态切换到内核态),内核开始执行以下步骤:

步骤 1:参数校验与权限检查

内核首先检查:

  • nfds 是否合法(不能超过 FD_SETSIZE);

  • fd 是否有效(未关闭、有读写权限);

  • 超时时间是否合法(tv_sec/tv_usec 非负)。

    若参数非法,直接返回 -1,设置 errno 为对应错误码(如 EINVAL)。

步骤 2:用户态 fd 集合拷贝到内核态

内核会把用户态传入的 readfds/writefds/exceptfds 三个位图,完整拷贝到内核空间(这是 select 性能差的第一个原因:高并发下拷贝开销大)。

步骤 3:遍历检测 fd 就绪状态(核心)

当调用 select(maxfd+1, &rdtemp, NULL, NULL, NULL) 时,内核会做两件事:

  • 检查 rdtemp 中所有文件描述符(fd)的状态;
  • 清空 rdtemp,仅保留触发了事件的 fd 对应的位(置为 1)

内核以 0 ~ nfds-1 为范围,线性遍历每个 fd,逐个检测其对应的缓冲区状态
是否为就绪状态

  • 读集合:读缓冲区有数据
  • 写集合:写缓冲区可写
  • 异常集合:文件描述符有异常

检测规则(以读就绪为例,写 / 异常类似)

fd 类型 读就绪判定条件
监听 socket 有新的客户端连接(完成三次握手,accept 非阻塞)
已连接 socket 读缓冲区有数据(可读取);或客户端关闭连接(读缓冲区收到 FIN 包);或连接重置
普通文件 fd 读指针未到文件末尾(有数据可读取)

遍历过程(伪代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 内核遍历逻辑(简化版)
int ready_count = 0;
fd_set kernel_readfds = 拷贝自用户态readfds; // 内核态位图
for (int fd = 0; fd < nfds; fd++) {
if (FD_ISSET(fd, &kernel_readfds)) { // 该fd需要检测
if (fd读缓冲区就绪) {
ready_count++; // 就绪数+1
保留kernel_readfds中fd对应的位为1;
} else {
将kernel_readfds中fd对应的位设为0; // 未就绪,剔除
}
}
}
// 对writefds/exceptfds执行相同逻辑

⚠️ 关键:这是 select 性能差的第二个原因 ——fd 越多,遍历耗时越长(线性时间复杂度 O (n))。

步骤 4:阻塞等待(若暂无就绪 fd)

若遍历后无任何 fd 就绪,内核会:

  1. 将当前进程加入等待队列(对应 fd 的等待队列,如 socket 的读等待队列);

  2. 切换进程状态为 “睡眠态”(放弃 CPU 使用权),直到满足以下条件之一:

    • 某个 fd 就绪(如客户端发数据,内核唤醒进程);
    • 超时时间到达(内核定时器触发);
    • 进程收到信号(如 Ctrl+C,中断阻塞)。

步骤 5:内核态结果回写

当有 fd 就绪或超时 / 中断后,内核会:

  1. 若有就绪 fd:将内核态的 fd_set 位图(仅保留就绪 fd 的位为 1)拷贝回用户态的临时集合(如 tmp_fds);

  2. 若超时 / 中断:清空用户态的 fd_set 位图(所有位为 0);

  3. 设置 select 的返回值:

    • 就绪数 > 0:返回就绪 fd 总数;
    • 超时:返回 0;
    • 中断 / 错误:返回 -1。

三、阶段 3:用户态处理(select 返回后的操作)

select 从内核态返回用户态后,程序员需要处理就绪的 fd:

步骤 1:判断返回值

  • 返回 -1:处理错误(如被信号中断则重新调用 select);
  • 返回 0:处理超时(如重新调用 select);
  • 返回 >0:遍历临时集合找就绪 fd。

步骤 2:遍历临时集合,定位就绪 fd

通过 FD_ISSET 逐个检查临时集合中的 fd(从 0 到 nfds-1),判断哪些 fd 对应的位为 1(就绪):

1
2
3
4
5
6
7
8
9
10
// 示例:遍历读集合找就绪 fd
for (int fd = 0; fd < nfds; fd++) {
if (FD_ISSET(fd, &tmp_fds)) { // 该 fd 就绪
if (fd == server_fd) {
// 服务器 fd 就绪:处理新连接(accept)
} else {
// 客户端 fd 就绪:处理读数据(recv)
}
}
}

⚠️ 关键:这是 select 性能差的第三个原因 —— 用户态仍需线性遍历才能找到就绪 fd,无直接的就绪列表。

步骤 3:清理无效 fd(可选)

若某个 fd 对应的连接关闭(如 recv 返回 0),需通过 FD_CLR 将其从原始集合中移除,避免后续重复检测。

三、select 工作原理的核心特点(总结)

1. 核心流程

select

2. 性能瓶颈的根源

  • 拷贝开销:每次调用 select 都要拷贝 fd 集合(用户态→内核态→用户态),fd 越多拷贝越大;
  • 遍历开销:内核遍历所有待检测 fd,用户态遍历所有 fd 找就绪者,双重线性遍历;
  • 数量限制:受 FD_SETSIZE 限制(默认 1024),无法支持高并发。

3. 关键细节补充

  • fd_set 是 “传入传出参数”:内核会修改集合,因此必须备份原始集合;
  • 超时时间的修改:Linux 下内核会修改 struct timeval(记录剩余超时时间),因此重复调用 select 需重新初始化超时时间;
  • 阻塞的本质:select 的阻塞是 “进程睡眠”,而非空循环,因此不消耗 CPU 资源。

四、总结

  1. select 的核心是「用户态委托内核检测 fd 就绪状态」,全流程分为用户态准备、内核态检测、用户态处理三个阶段;
  2. 内核的核心操作是「拷贝 fd 集合→遍历检测→阻塞等待→回写结果」,其中 “拷贝 + 遍历” 是性能瓶颈;
  3. 用户态必须遍历临时集合才能找到就绪 fd,且需备份原始集合避免被内核修改,这是 select 使用的关键注意点。

理解这个流程后,你就能明白为什么 epoll 能优化 select:epoll 解决了 “重复拷贝”“线性遍历”“数量限制” 三大问题,本质是用 “红黑树 + 就绪链表” 替代了 “位图 + 线性遍历”。

基于select实现的服务器并发案例

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
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<string.h>
#include<sys/select.h>

int main()
{
int lfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in caddr;
caddr.sin_family = AF_INET;
caddr.sin_port = htons(10000);
caddr.sin_addr.s_addr = INADDR_ANY;


int bind_res = bind(lfd, (struct sockaddr*)&caddr, sizeof(caddr));

if (bind_res == -1) {
perror("bind failed");
close(lfd);
return -1;
}

listen(lfd, 128);

int maxfd = lfd;
fd_set rdset;
fd_set rdtemp;
FD_ZERO(&rdset);
FD_SET(lfd, &rdset);

while(1)
{
rdtemp = rdset;
select(maxfd + 1, &rdtemp, NULL, NULL, NULL);

if(FD_ISSET(lfd, &rdtemp)){
struct sockaddr_in caddr;
int caddr_len = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr*)&caddr, &caddr_len);
FD_SET(cfd, &rdset);
maxfd = cfd > maxfd ? cfd : maxfd;
}

for(int i = 0; i < maxfd + 1; ++i){
if(i != lfd && FD_ISSET(i, &rdtemp)){
char buffer[10];
int len = read(i, buffer, sizeof(buffer));
if(len > 0){
// printf("客户端:%s\n", buffer);
write(i, buffer, len);
}
else if(len == 0){
printf("客户端关闭连接...\n");
FD_CLR(i, &rdset);
close(i);
}
else{
perror("read");
}
}
}
}

close(lfd);
return 0;

}

一、代码核心功能

你编写的这段代码是一个基于 select 多路复用实现的TCP 回显服务器,核心能力是监听 10000 端口,同时处理多个客户端的连接请求,并将客户端发送的数据原样回显给客户端,客户端断开连接时会清理对应的资源。

二、代码逐行详细解析

1. 头文件引入

1
2
3
4
5
6
#include<stdio.h>      // 标准输入输出(perror、printf等)
#include<stdlib.h> // 标准库函数(exit等,代码中未直接用但建议保留)
#include<unistd.h> // 系统调用(close、read、write、accept等)
#include<arpa/inet.h> // 网络编程(htons、inet_addr、sockaddr_in等)
#include<string.h> // 字符串操作(memset等,代码中未直接用但建议保留)
#include<sys/select.h> // select多路复用相关(fd_set、FD_ZERO、select等)

作用:引入网络编程、文件操作、多路复用所需的核心头文件,是 Linux 下 TCP 编程的基础依赖。

2. 主函数与监听套接字创建

1
2
3
int main()
{
int lfd = socket(AF_INET, SOCK_STREAM, 0);
  • socket():创建套接字(文件描述符),返回值 lfd 称为监听套接字,用于监听客户端连接;

  • 参数说明:

    • AF_INET:使用 IPv4 协议族;
    • SOCK_STREAM:创建流式套接字(对应 TCP 协议,可靠、面向连接);
    • 0:使用协议族的默认协议(TCP);

3. 服务器地址结构体初始化

1
2
3
4
struct sockaddr_in caddr;
caddr.sin_family = AF_INET;
caddr.sin_port = htons(10000);
caddr.sin_addr.s_addr = INADDR_ANY;
  • struct sockaddr_in:IPv4 专用的地址结构体,替代通用的 struct sockaddr(更易操作);

  • 字段说明:

    • sin_family = AF_INET:指定地址类型为 IPv4,与 socket() 第一个参数一致;
    • sin_port = htons(10000):设置监听端口为 10000,htons() 把主机字节序转为网络字节序(网络字节序为大端,避免字节序混乱);
    • sin_addr.s_addr = INADDR_ANY:绑定本机所有网卡的 IP 地址(如本机有 192.168.1.100、127.0.0.1 等 IP,客户端可通过任意一个 IP 连接);

4. 绑定套接字到地址和端口

1
2
3
4
5
6
int bind_res = bind(lfd, (struct sockaddr*)&caddr, sizeof(caddr));
if (bind_res == -1) {
perror("bind failed");
close(lfd);
return -1;
}
  • bind():将监听套接字 lfd 与指定的 IP 和端口绑定,让客户端能通过该地址找到服务器;

  • 参数说明:

    • 第一个参数:监听套接字 lfd
    • 第二个参数:强制转换为通用地址结构体 struct sockaddr*(历史原因,Linux 网络 API 要求);
    • 第三个参数:地址结构体的大小;
  • 错误处理:绑定失败(如端口被占用)时,bind() 返回 -1,通过 perror() 打印错误原因,关闭套接字并退出程序,避免资源泄漏。

5. 开始监听端口

1
listen(lfd, 128);
  • listen():将 lfd 从 “主动套接字” 转为 “被动套接字”,开始监听客户端的连接请求;

  • 参数说明:

    • 第一个参数:监听套接字 lfd
    • 第二个参数:监听队列的最大长度(128),表示最多容纳 128 个未被 accept() 处理的连接请求;

6. 初始化 select 相关变量

1
2
3
4
5
int maxfd = lfd;
fd_set rdset;
fd_set rdtemp;
FD_ZERO(&rdset);
FD_SET(lfd, &rdset);
  • fd_set:文件描述符集合,本质是位图,用于 select 监控多个文件描述符的事件;

  • 核心操作宏:

    • FD_ZERO(&rdset):清空文件描述符集合 rdset,避免脏数据;
    • FD_SET(lfd, &rdset):将监听套接字 lfd 加入 rdset,表示要监控 lfd 的读事件(有新连接时触发);
  • maxfd:记录监控的最大文件描述符,select() 需要该值确定监控范围(只需监控 0 ~ maxfd 的描述符),初始值为 lfd(此时只有监听套接字);

  • rdtemp:临时文件描述符集合,因为 select() 会修改传入的集合(只保留触发事件的描述符),所以每次循环都要从 rdset 复制到 rdtemp

7. 主循环(处理连接和数据)

1
2
3
4
while(1)
{
rdtemp = rdset;
select(maxfd + 1, &rdtemp, NULL, NULL, NULL);
  • while(1):无限循环,让服务器一直运行,处理客户端请求;

  • rdtemp = rdset:每次循环将临时集合重置为原始集合(因为 select() 会修改 rdtemp);

  • select():多路复用核心函数,监控文件描述符的读 / 写 / 异常事件,此处只监控读事件;

  • 参数说明:

    • 第一个参数:maxfd + 1,表示监控的文件描述符范围是 0 ~ maxfd(select() 要求 + 1);
    • 第二个参数:&rdtemp,传入读事件监控集合,select() 返回后,该集合只保留触发读事件的描述符;
    • 第三 / 四个参数:NULL,不监控写事件、异常事件;
    • 第五个参数:NULL,永久阻塞,直到有描述符触发事件(也可设置超时时间);

8. 处理新客户端连接

1
2
3
4
5
6
7
if(FD_ISSET(lfd, &rdtemp)){
struct sockaddr_in caddr;
int caddr_len = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr*)&caddr, &caddr_len);
FD_SET(cfd, &rdset);
maxfd = cfd > maxfd ? cfd : maxfd;
}
  • FD_ISSET(lfd, &rdtemp):检查监听套接字 lfd 是否在触发读事件的集合中(即有新客户端连接);

  • accept():接受新连接,返回**客户端套接字 cfd**(用于和该客户端收发数据);

    • 参数说明:

      • 第一个参数:监听套接字 lfd
      • 第二个参数:存储客户端的地址信息(IP + 端口);
      • 第三个参数:客户端地址结构体的大小(传入传出参数);
  • FD_SET(cfd, &rdset):将新的客户端套接字 cfd 加入监控集合,select() 会监控该客户端的读事件(有数据发送时触发);

  • maxfd = cfd > maxfd ? cfd : maxfd:更新最大文件描述符,确保 select() 能监控到新的客户端套接字。

9. 处理客户端数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    for(int i = 0; i < maxfd + 1; ++i){
if(i != lfd && FD_ISSET(i, &rdtemp)){
char buffer[10];
int len = read(i, buffer, sizeof(buffer));
if(len > 0){
// printf("客户端:%s\n", buffer);
write(i, buffer, len);
}
else if(len == 0){
printf("客户端关闭连接...\n");
FD_CLR(i, &rdset);
close(i);
}
else{
perror("read");
}
}
}
}
  • 循环遍历:从 0 到 maxfd,检查每个文件描述符是否触发读事件;

  • i != lfd && FD_ISSET(i, &rdtemp):排除监听套接字 lfd,只处理客户端套接字的读事件;

  • 核心逻辑:

    1. char buffer[10]:创建 10 字节的缓冲区,用于存储客户端发送的数据;

    2. read(i, buffer, sizeof(buffer)):从客户端套接字 i 读取数据到缓冲区,返回值 len 是实际读取的字节数;

      • len > 0:读取到数据,通过 write(i, buffer, len) 将数据原样回显给客户端;
      • len == 0:客户端主动关闭连接(如客户端执行 close()),通过 FD_CLR(i, &rdset) 将该描述符从监控集合中移除,再 close(i) 关闭套接字,释放资源;
      • len < 0:读取数据失败(如客户端异常断开),通过 perror() 打印错误原因;

10. 关闭监听套接字

1
2
3
    close(lfd);
return 0;
}
  • close(lfd):关闭监听套接字,释放文件描述符资源;
  • 注意:由于主循环是 while(1) 无限循环,该代码行实际不会执行,仅为语法完整性。

三、核心知识点总结

  1. select 多路复用原理:通过一个系统调用监控多个文件描述符的事件(读 / 写 / 异常),避免为每个客户端创建进程 / 线程,节省系统资源;
  2. 文件描述符集合操作FD_ZERO(清空)、FD_SET(添加)、FD_ISSET(检查)、FD_CLR(移除)是操作 fd_set 的核心宏;
  3. TCP 服务器核心流程socket()(创建套接字)→ bind()(绑定地址)→ listen()(监听)→ select()(监控事件)→ accept()(接受连接)→ read/write(收发数据)→ close()(关闭套接字);
  4. 关键细节:网络字节序转换(htons())、地址结构体的强制转换、文件描述符的资源清理是 TCP 编程的易错点。

四、代码运行逻辑(无修改版)

  1. 服务器启动,创建监听套接字并绑定 10000 端口,开始监听;
  2. 初始化 select 监控集合,仅监控监听套接字 lfd
  3. 进入无限循环,select 阻塞等待事件触发;
  4. 有新客户端连接时,accept() 得到客户端套接字 cfd,将 cfd 加入监控集合;
  5. 客户端发送数据时,read() 读取数据并 write() 回显;
  6. 客户端关闭连接时,移除 cfd 并关闭套接字;
  7. 服务器持续运行,直到手动终止