Linux C 多进程并发 TCP 服务器:从阻塞模型到 `fork()`、`SIGCHLD` 与 `EINTR`

文章目录

  • [Linux C 多进程并发 TCP 服务器:从阻塞模型到 `fork()`、`SIGCHLD` 与 `EINTR`](#Linux C 多进程并发 TCP 服务器:从阻塞模型到 fork()SIGCHLDEINTR)
  • 二、多进程并发服务器的职责划分
  • [三、必须先分清 `lfd` 和 `cfd`](#三、必须先分清 lfdcfd)
    • [1. `lfd`:监听套接字](#1. lfd:监听套接字)
    • [2. `cfd`:已连接套接字](#2. cfd:已连接套接字)
  • 四、`listen()`、半连接队列和全连接队列
    • [1. 半连接阶段](#1. 半连接阶段)
    • [2. 全连接阶段](#2. 全连接阶段)
  • [五、`fork()` 之后文件描述符发生了什么](#五、fork() 之后文件描述符发生了什么)
    • [1. 子进程为什么必须关闭 `lfd`](#1. 子进程为什么必须关闭 lfd)
    • [2. 父进程为什么必须关闭 `cfd`](#2. 父进程为什么必须关闭 cfd)
  • 六、原始代码中的主要问题
    • [问题一:在循环内部反复注册 `SIGCHLD`](#问题一:在循环内部反复注册 SIGCHLD)
    • [问题二:信号处理函数中调用 `printf()`](#问题二:信号处理函数中调用 printf())
    • [问题三:没有完整初始化 `struct sigaction`](#问题三:没有完整初始化 struct sigaction)
    • [问题四:认为 `accept()` 被信号打断后必然返回 `EINTR`](#问题四:认为 accept() 被信号打断后必然返回 EINTR)
    • [问题五:直接使用 `%s` 打印 `read()` 的结果](#问题五:直接使用 %s 打印 read() 的结果)
    • [问题六:认为一次 `write()` 一定写完全部数据](#问题六:认为一次 write() 一定写完全部数据)
    • [问题七:没有处理 `SIGPIPE`](#问题七:没有处理 SIGPIPE)
    • [问题八:信号处理函数没有保存 `errno`](#问题八:信号处理函数没有保存 errno)
    • [问题九:子进程中更适合使用 `_exit()`](#问题九:子进程中更适合使用 _exit())
    • [问题十:`Readline()` 中的静态缓冲区不是通用实现](#问题十:Readline() 中的静态缓冲区不是通用实现)
  • [七、完整多进程 TCP 回射服务器](#七、完整多进程 TCP 回射服务器)
  • 八、配套测试客户端
  • 九、Makefile
  • 十、运行和测试
    • [1. 启动服务器](#1. 启动服务器)
    • [2. 启动第一个客户端](#2. 启动第一个客户端)
    • [3. 再启动一个客户端](#3. 再启动一个客户端)
  • [十一、为什么 `SIGCHLD` 处理函数必须循环调用 `waitpid()`](#十一、为什么 SIGCHLD 处理函数必须循环调用 waitpid())
    • [`waitpid()` 返回值的意义](#waitpid() 返回值的意义)
  • [十二、`accept()` 被 `SIGCHLD` 打断的准确过程](#十二、accept()SIGCHLD 打断的准确过程)
  • [十三、为什么还要在 `accept_client()` 中处理 `EINTR`](#十三、为什么还要在 accept_client() 中处理 EINTR)
    • [1. `SA_RESTART` 不会重启所有系统调用](#1. SA_RESTART 不会重启所有系统调用)
    • [2. socket 设置超时后可能改变重启行为](#2. socket 设置超时后可能改变重启行为)
    • [3. 防御式编程](#3. 防御式编程)
  • [十四、为什么 `accept()` 还要处理其他瞬时网络错误](#十四、为什么 accept() 还要处理其他瞬时网络错误)
  • [十五、TCP 为什么需要应用层协议](#十五、TCP 为什么需要应用层协议)
  • 十六、多进程模型的优点和缺点
    • 优点
      • [1. 编程模型直观](#1. 编程模型直观)
      • [2. 隔离性较好](#2. 隔离性较好)
      • [3. 可以利用多核 CPU](#3. 可以利用多核 CPU)
    • 缺点
      • [1. 创建进程成本较高](#1. 创建进程成本较高)
      • [2. 每个进程都有独立虚拟地址空间](#2. 每个进程都有独立虚拟地址空间)
      • [3. 进程间共享状态较麻烦](#3. 进程间共享状态较麻烦)
      • [4. 不适合超大规模长连接](#4. 不适合超大规模长连接)
  • 十七、常见问题
    • [1. `accept()` 是否负责三次握手](#1. accept() 是否负责三次握手)
    • [2. `accept()` 返回的是监听套接字本身吗](#2. accept() 返回的是监听套接字本身吗)
    • [3. 子进程关闭 `lfd` 会不会影响父进程](#3. 子进程关闭 lfd 会不会影响父进程)
    • [4. 父进程关闭 `cfd` 后,子进程还能通信吗](#4. 父进程关闭 cfd 后,子进程还能通信吗)
    • [5. 为什么 `read()` 返回 0](#5. 为什么 read() 返回 0)
    • [6. 为什么不能只调用一次 `write()`](#6. 为什么不能只调用一次 write())
    • [7. 为什么不能在信号处理函数中打印日志](#7. 为什么不能在信号处理函数中打印日志)
    • [8. 能不能直接忽略 `SIGCHLD`](#8. 能不能直接忽略 SIGCHLD)
  • 十八、总结

Linux C 多进程并发 TCP 服务器:从阻塞模型到 fork()SIGCHLDEINTR

运行环境:Linux

编程语言:C11

网络模型:IPv4 + TCP + 阻塞 I/O

并发模型:一个客户端对应一个子进程

示例功能:TCP 回射服务器(Echo Server)


一、为什么普通阻塞服务器无法并发处理多个客户端

一个最基础的 TCP 服务器通常按照下面的流程运行:

text 复制代码
socket()
   ↓
bind()
   ↓
listen()
   ↓
accept()
   ↓
read()/write()
   ↓
close()

对应的简化代码如下:

c 复制代码
int cfd = accept(lfd, ...);

while (1) {
    int n = read(cfd, buf, sizeof(buf));

    if (n > 0) {
        write(cfd, buf, n);
    } else {
        break;
    }
}

这段代码能够服务一个客户端,但不能在当前写法下同时服务多个客户端。

原因不是"单进程天生不能处理多个连接",而是:

一个采用阻塞 I/O、串行执行的单进程,在同一时刻只能阻塞在一个系统调用中。

服务器主要存在三个可能阻塞的位置:

1. accept() 可能阻塞

当监听套接字的待处理连接队列为空时,阻塞式 accept() 会让当前进程休眠,直到出现可接受的连接。accept() 成功后会返回一个新的已连接套接字,而原监听套接字保持不变。

2. read() 可能阻塞

当已连接套接字的接收缓冲区中暂时没有数据,并且对端也没有关闭连接时,阻塞式 read() 会等待数据到来。

3. write()send() 也可能阻塞

当套接字发送缓冲区没有足够空间时,阻塞式写操作可能等待缓冲区腾出空间。

因此,下面两个需求会发生冲突:

text 复制代码
父流程需要阻塞在 accept():
等待新的客户端连接

当前客户端处理流程需要阻塞在 read():
等待已连接客户端发送数据

一个执行流无法同时阻塞在两个位置。

需要注意,这并不意味着"单进程绝对无法实现并发服务器"。使用非阻塞 I/O 配合 selectpollepoll,单进程同样能够管理大量连接。

本文讨论的是另一种更容易理解的并发模型:

父进程负责监听和接受连接,每建立一个连接,就创建一个子进程负责通信。


二、多进程并发服务器的职责划分

多进程服务器中主要有两种角色。

进程 主要职责
父进程 创建监听套接字、循环调用 accept()、创建子进程、回收退出的子进程
子进程 使用已连接套接字与一个客户端通信,通信结束后退出

整体流程如下:

text 复制代码
                           ┌─────────────────┐
                           │    父进程启动    │
                           └────────┬────────┘
                                    │
                  socket → bind → listen
                                    │
                             阻塞在 accept
                                    │
                        有客户端完成连接建立
                                    │
                         accept 返回通信套接字
                                    │
                                  fork
                          ┌─────────┴─────────┐
                          │                   │
                      父进程               子进程
                          │                   │
                    close(cfd)          close(lfd)
                          │                   │
                 继续调用 accept       read/send 通信
                          │                   │
                      接受新连接          close(cfd)
                                              │
                                            _exit
                                              │
                                      向父进程产生 SIGCHLD
                                              │
                                   父进程调用 waitpid 回收

三、必须先分清 lfdcfd

在服务器代码中通常会出现两个文件描述符:

c 复制代码
int lfd;
int cfd;

它们的意义完全不同。

1. lfd:监听套接字

lfd 一般来自:

c 复制代码
lfd = socket(AF_INET, SOCK_STREAM, 0);
bind(lfd, ...);
listen(lfd, 128);

它的职责是:

  • 绑定服务器 IP 和端口;
  • 进入监听状态;
  • 保存等待应用程序接受的连接;
  • 被传递给 accept()

它不是用来和某个具体客户端收发业务数据的。


2. cfd:已连接套接字

cfd 来自:

c 复制代码
cfd = accept(lfd, ...);

accept() 会从监听套接字的待处理连接队列中取出一个连接,并创建一个新的已连接套接字,然后返回指向它的新文件描述符。监听套接字本身不会因为 accept() 而消失,也不会变成通信套接字。

因此:

text 复制代码
lfd → 监听套接字
cfd → 某个具体 TCP 连接

当服务器同时连接三个客户端时,逻辑结构类似:

text 复制代码
lfd
 ├── accept() → cfd1 → 客户端1
 ├── accept() → cfd2 → 客户端2
 └── accept() → cfd3 → 客户端3

每个 cfd 都表示一条独立的 TCP 连接。


四、listen()、半连接队列和全连接队列

在概念上,可以将 TCP 监听端的连接处理划分为两个阶段。

1. 半连接阶段

服务器收到客户端的 SYN,但三次握手尚未完成。

连接一般处于:

text 复制代码
SYN_RECV

这类连接通常被称为半连接或未完成连接。

2. 全连接阶段

三次握手已经完成,但应用程序还没有调用 accept() 将连接取走。

连接通常已经处于:

text 复制代码
ESTABLISHED

它会等待应用程序调用 accept()

可以简化为:

text 复制代码
客户端                       服务器内核

   SYN  ---------------------->
              进入半连接阶段

        <----------------  SYN + ACK

   ACK  ---------------------->
              三次握手完成
              进入待 accept 队列

                                 应用程序调用 accept()
                                 返回新的 cfd

在 Linux 2.2 及之后,listen()backlog 参数主要控制已经完成连接、等待 accept() 的队列长度;半连接相关限制还会受到 tcp_max_syn_backlog 等内核参数影响。若 backlog 大于 /proc/sys/net/core/somaxconn,Linux 会将其静默截断。

因此:

c 复制代码
listen(lfd, 128);

不应简单理解为:

半连接队列固定 128,全连接队列也固定 128。

它更准确的含义是:

请求内核为等待应用程序接受的连接设置一个 backlog 上限,但最终有效值还受到系统参数和内核实现约束。


五、fork() 之后文件描述符发生了什么

假设父进程已经执行:

c 复制代码
int cfd = accept(lfd, ...);
pid_t pid = fork();

执行 fork() 后,子进程会继承父进程当前打开的文件描述符。

但"继承文件描述符"不能理解成内核重新创建了一套完全独立的 socket。

Linux 手册明确说明:子进程得到父进程文件描述符的副本,但父子进程中对应的文件描述符指向同一个 open file description。

简化后的结构如下:

text 复制代码
fork() 之前:

父进程
fd 3 ─────→ 监听 socket
fd 4 ─────→ 已连接 socket


fork() 之后:

父进程                         子进程
fd 3 ─────┐               ┌──── fd 3
          └→ 监听 socket ←┘

fd 4 ─────┐               ┌──── fd 4
          └→ 已连接 socket ←┘

父子进程的文件描述符表彼此独立,但其中的条目仍然引用相同的底层内核对象。


1. 子进程为什么必须关闭 lfd

子进程只负责当前客户端通信,不负责接受新连接,因此应当执行:

c 复制代码
close(lfd);

这不是在关闭父进程的 lfd

子进程关闭的只是自己文件描述符表中的那一份引用。父进程仍然持有监听套接字,仍然可以继续调用:

c 复制代码
accept(lfd, ...);

如果所有继承了监听套接字的进程都不关闭它,那么即使原父进程退出,监听 socket 也可能仍然因为其他进程持有引用而存在。


2. 父进程为什么必须关闭 cfd

父进程不负责和当前客户端通信,因此应当执行:

c 复制代码
close(cfd);

通信任务由子进程完成,子进程仍然保留自己的 cfd

父进程关闭 cfd 后,底层 TCP 连接不会立刻关闭,因为子进程仍然引用该连接。

如果父进程一直不关闭每次 accept() 得到的 cfd,就会造成:

  • 父进程文件描述符持续增加;
  • socket 引用无法及时释放;
  • 即使通信子进程退出,连接也可能因为父进程仍持有引用而无法完成预期的关闭流程;
  • 最终可能耗尽进程可用文件描述符。

这里不能简单断言"一定会产生 CLOSE_WAIT"。

具体 TCP 状态取决于哪一端先关闭、是否收到 FIN、应用程序是否已经关闭本地 socket 等因素。确定的是:父进程多余的引用会延长该 socket 内核对象的生命周期。


六、原始代码中的主要问题

在给出的原始实现中,整体思路是正确的,但存在一些需要修正的地方。

问题一:在循环内部反复注册 SIGCHLD

原代码在父进程分支中执行:

c 复制代码
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = free_process;
sigemptyset(&act.sa_mask);
sigaction(SIGCHLD, &act, NULL);

这意味着每接受一个连接,都重新注册一次相同的信号处理函数。

没有必要。

SIGCHLD 的处理方式属于进程级配置,应当在进入 accept() 循环之前安装一次:

c 复制代码
install_signal_handlers();

while (1) {
    accept(...);
    fork();
}

这样还可以避免子进程非常快地退出,而父进程尚未设置处理方式的时间窗口。


问题二:信号处理函数中调用 printf()

原代码在信号处理函数中执行:

c 复制代码
printf("child pid = %d\n", pid);

这并不安全。

printf() 属于带缓冲的标准 I/O 函数,不是异步信号安全函数。假设主程序正在执行另一次 printf(),信号突然到达并再次进入 printf(),内部缓冲区状态可能处于修改到一半的状态,从而产生不可预测行为。

信号处理函数应尽量只完成最小操作:

c 复制代码
while (waitpid(-1, NULL, WNOHANG) > 0) {
}

waitpid() 属于 POSIX 要求的异步信号安全函数,可以在处理函数中调用。处理函数还应保存和恢复 errno,避免影响被信号打断的主流程。


问题三:没有完整初始化 struct sigaction

原代码只给部分字段赋值:

c 复制代码
struct sigaction act;
act.sa_flags = 0;
act.sa_handler = free_process;
sigemptyset(&act.sa_mask);

更稳妥的方式是先整体清零:

c 复制代码
struct sigaction act;

memset(&act, 0, sizeof(act));
act.sa_handler = reap_children;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_RESTART | SA_NOCLDSTOP;

这样可以避免结构体中未显式设置的字段保留未初始化值。


问题四:认为 accept() 被信号打断后必然返回 EINTR

原文认为:

子进程退出产生 SIGCHLD,信号处理结束后,accept() 必然返回 -1,并设置 errno=EINTR

这个结论不完整。

阻塞系统调用被信号处理函数打断后,可能发生两种情况:

  1. 系统调用自动重新开始;
  2. 系统调用返回 -1,并设置 errno=EINTR

具体行为取决于:

  • 被打断的是哪个接口;
  • 信号处理函数是否使用 SA_RESTART
  • socket 是否设置了某些超时参数;
  • 操作系统的具体语义。

在 Linux 中,如果处理函数设置了 SA_RESTART,阻塞式 accept() 通常会在处理函数返回后自动重新开始;如果没有设置,则可能返回 EINTR。如果 socket 设置了接收超时,情况还会不同。

因此推荐同时采用两层防御:

c 复制代码
sa.sa_flags = SA_RESTART;

并且:

c 复制代码
if (errno == EINTR) {
    continue;
}

前者减少无意义的中断返回,后者保证代码即使收到 EINTR 也能正确恢复。


问题五:直接使用 %s 打印 read() 的结果

原代码:

c 复制代码
char buf[1024] = "";

int n = read(cfd, buf, sizeof(buf));

if (n > 0) {
    printf("%s\n", buf);
}

这里存在越界读取风险。

read() 只负责读取字节,不负责在数据末尾添加:

c 复制代码
'\0'

假设一次正好读取了 1024 字节:

c 复制代码
n == sizeof(buf)

此时缓冲区中没有额外空间写入字符串结束符。printf("%s") 会继续向缓冲区外读取,直到偶然遇到一个零字节。

如果明确处理文本,应预留一个字节:

c 复制代码
char buf[1024];

ssize_t n = read(cfd, buf, sizeof(buf) - 1);
if (n > 0) {
    buf[n] = '\0';
    printf("%s", buf);
}

但回射服务器不应假设收到的一定是字符串。更通用的做法是:

c 复制代码
printf("received %zd bytes\n", n);
send_all(cfd, buf, (size_t)n);

这样可以正确处理包含 '\0' 的二进制数据。


问题六:认为一次 write() 一定写完全部数据

原代码:

c 复制代码
write(cfd, buf, n);

即使 write()send() 返回成功,也不保证返回值一定等于请求发送的字节数。

成功的写操作可能只传输部分数据。调用方必须根据返回值继续发送剩余部分。

因此需要实现:

c 复制代码
send_all(cfd, buf, n);

基本逻辑如下:

c 复制代码
while (还有数据未发送) {
    ret = send(...);

    if (ret > 0) {
        移动指针;
        减少剩余长度;
    } else if (errno == EINTR) {
        重试;
    } else {
        处理错误;
    }
}

问题七:没有处理 SIGPIPE

如果对端已经关闭连接,本端继续向 socket 写数据,写操作可能:

  • 返回 -1
  • 设置 errno=EPIPE
  • 同时向进程发送 SIGPIPE

SIGPIPE 的默认动作是终止进程。

在 Linux socket 程序中,可以使用:

c 复制代码
send(fd, buf, len, MSG_NOSIGNAL);

这样即使对端已经关闭,也不会因为这次发送而触发 SIGPIPE 终止当前子进程。


问题八:信号处理函数没有保存 errno

信号可能在主流程刚刚发生错误、尚未检查 errno 时到达。

信号处理函数中的:

c 复制代码
waitpid()

可能修改 errno

因此正确写法是:

c 复制代码
static void reap_children(int signo)
{
    int saved_errno = errno;

    while (waitpid(-1, NULL, WNOHANG) > 0) {
    }

    errno = saved_errno;
}

Linux 手册明确建议:信号处理函数若访问 errno,应在入口保存,并在返回前恢复。


问题九:子进程中更适合使用 _exit()

原代码在 fork() 后的子进程中使用:

c 复制代码
exit(0);

exit() 会:

  • 刷新标准 I/O 缓冲区;
  • 调用通过 atexit() 注册的函数;
  • 执行用户态清理流程。

fork() 会复制父进程当前的用户态缓冲区状态。如果父进程在 fork() 前存在尚未刷新的输出缓冲区,子进程调用 exit() 可能再次刷新复制过来的数据,造成重复输出。

因此,完成子进程任务后更适合执行:

c 复制代码
_exit(EXIT_SUCCESS);

_exit() 直接结束进程,不执行普通 exit() 的用户态清理流程。


问题十:Readline() 中的静态缓冲区不是通用实现

原始封装中的 my_read() 使用:

c 复制代码
static int read_cnt;
static char *read_ptr;
static char read_buf[100];

这意味着所有调用共享同一份状态。

它存在以下限制:

  • 不可重入;
  • 不能安全用于多线程;
  • 同一个进程内交替读取不同文件描述符时会混淆数据;
  • 无法为每个连接维护独立缓冲状态。

在当前"一条连接一个子进程"的模型中,每个子进程通常只操作一个 cfd,问题不容易暴露,但该实现不能作为通用多连接读取函数。


七、完整多进程 TCP 回射服务器

下面给出修正后的完整代码。

文件名:

text 复制代码
process_tcp_server.c
c 复制代码
#define _POSIX_C_SOURCE 200809L

#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define DEFAULT_PORT 8008
#define LISTEN_BACKLOG 128
#define BUFFER_SIZE 4096

static void die(const char *message)
{
    perror(message);
    exit(EXIT_FAILURE);
}

/*
 * SIGCHLD 处理函数:回收所有已经终止的子进程。
 *
 * 信号处理函数中只能调用异步信号安全函数,不能使用 printf()、malloc()
 * 等普通库函数。errno 可能被 waitpid() 改写,因此进入时保存、退出前恢复。
 */
static void reap_children(int signo)
{
    int saved_errno = errno;

    (void)signo;

    for (;;) {
        pid_t pid = waitpid(-1, NULL, WNOHANG);

        if (pid > 0) {
            continue;
        }

        if (pid == -1 && errno == EINTR) {
            continue;
        }

        break;
    }

    errno = saved_errno;
}

static void install_signal_handlers(void)
{
    struct sigaction sa;

    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = reap_children;
    sigemptyset(&sa.sa_mask);

    /*
     * SA_RESTART:
     * 某些阻塞系统调用被 SIGCHLD 打断后自动重新开始。
     *
     * SA_NOCLDSTOP:
     * 子进程暂停或恢复时不产生 SIGCHLD,只关心子进程终止。
     */
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;

    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        die("sigaction(SIGCHLD)");
    }
}

static int create_listen_socket(const char *ip, uint16_t port)
{
    int lfd;
    int reuse = 1;
    struct sockaddr_in server_addr;

    lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd == -1) {
        die("socket");
    }

    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR,
                   &reuse, sizeof(reuse)) == -1) {
        int saved_errno = errno;

        close(lfd);
        errno = saved_errno;
        die("setsockopt(SO_REUSEADDR)");
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(port);

    if (ip == NULL ||
        strcmp(ip, "0.0.0.0") == 0 ||
        strcmp(ip, "*") == 0) {
        server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    } else {
        int ret = inet_pton(AF_INET, ip, &server_addr.sin_addr);

        if (ret == 0) {
            fprintf(stderr, "invalid IPv4 address: %s\n", ip);
            close(lfd);
            exit(EXIT_FAILURE);
        }

        if (ret == -1) {
            int saved_errno = errno;

            close(lfd);
            errno = saved_errno;
            die("inet_pton");
        }
    }

    if (bind(lfd,
             (const struct sockaddr *)&server_addr,
             sizeof(server_addr)) == -1) {
        int saved_errno = errno;

        close(lfd);
        errno = saved_errno;
        die("bind");
    }

    if (listen(lfd, LISTEN_BACKLOG) == -1) {
        int saved_errno = errno;

        close(lfd);
        errno = saved_errno;
        die("listen");
    }

    return lfd;
}

/*
 * Linux 的 accept() 可能直接返回新连接上已经挂起的网络错误。
 * 对于部分瞬时错误,可靠服务器通常重新调用 accept()。
 */
static int is_transient_accept_error(int error_number)
{
    switch (error_number) {
    case EINTR:
    case ECONNABORTED:
    case ENETDOWN:
    case EPROTO:
    case ENOPROTOOPT:
    case EHOSTDOWN:
    case ENONET:
    case EHOSTUNREACH:
    case EOPNOTSUPP:
    case ENETUNREACH:
        return 1;

    default:
        return 0;
    }
}

static int accept_client(int lfd,
                         struct sockaddr_in *client_addr,
                         socklen_t *client_addr_len)
{
    for (;;) {
        int cfd = accept(
            lfd,
            (struct sockaddr *)client_addr,
            client_addr_len
        );

        if (cfd >= 0) {
            return cfd;
        }

        if (is_transient_accept_error(errno)) {
            *client_addr_len = sizeof(*client_addr);
            continue;
        }

        return -1;
    }
}

static ssize_t read_retry(int fd, void *buffer, size_t size)
{
    for (;;) {
        ssize_t n = read(fd, buffer, size);

        if (n >= 0) {
            return n;
        }

        if (errno == EINTR) {
            continue;
        }

        return -1;
    }
}

/*
 * 一次 send() 可能只发送部分数据。
 * 循环发送,直到所有数据发送完毕,或者出现不可恢复错误。
 */
static int send_all(int fd, const void *buffer, size_t size)
{
    const unsigned char *cursor = buffer;
    size_t remaining = size;

    while (remaining > 0) {
        ssize_t n = send(
            fd,
            cursor,
            remaining,
            MSG_NOSIGNAL
        );

        if (n > 0) {
            cursor += (size_t)n;
            remaining -= (size_t)n;
            continue;
        }

        if (n == -1 && errno == EINTR) {
            continue;
        }

        return -1;
    }

    return 0;
}

static void handle_client(int cfd,
                          const char *client_ip,
                          uint16_t client_port)
{
    unsigned char buffer[BUFFER_SIZE];

    for (;;) {
        ssize_t n = read_retry(
            cfd,
            buffer,
            sizeof(buffer)
        );

        if (n > 0) {
            /*
             * 不使用 %s 打印 buffer。
             * read() 返回的是原始字节,不保证以 '\0' 结束。
             */
            printf(
                "[child %ld] received %zd bytes from %s:%u\n",
                (long)getpid(),
                n,
                client_ip,
                (unsigned int)client_port
            );

            if (send_all(cfd, buffer, (size_t)n) == -1) {
                if (errno != EPIPE &&
                    errno != ECONNRESET) {
                    perror("send");
                }

                break;
            }

            continue;
        }

        if (n == 0) {
            /*
             * 对 TCP socket 而言,read() 返回 0 表示
             * 对端已经执行有序关闭,本端读取到了 EOF。
             */
            printf(
                "[child %ld] client %s:%u closed the connection\n",
                (long)getpid(),
                client_ip,
                (unsigned int)client_port
            );
        } else if (errno != ECONNRESET) {
            perror("read");
        }

        break;
    }
}

static uint16_t parse_port(const char *text)
{
    char *end = NULL;
    long value;

    errno = 0;
    value = strtol(text, &end, 10);

    if (errno != 0 ||
        end == text ||
        *end != '\0' ||
        value < 1 ||
        value > 65535) {
        fprintf(stderr, "invalid port: %s\n", text);
        exit(EXIT_FAILURE);
    }

    return (uint16_t)value;
}

int main(int argc, char *argv[])
{
    const char *listen_ip = "0.0.0.0";
    uint16_t listen_port = DEFAULT_PORT;
    int lfd;

    if (argc > 3) {
        fprintf(
            stderr,
            "usage: %s [IPv4 address] [port]\n",
            argv[0]
        );

        return EXIT_FAILURE;
    }

    if (argc >= 2) {
        listen_ip = argv[1];
    }

    if (argc == 3) {
        listen_port = parse_port(argv[2]);
    }

    /*
     * 设置为行缓冲,减少 fork() 复制未刷新输出缓冲区
     * 所引起的重复或延迟输出问题。
     */
    setvbuf(stdout, NULL, _IOLBF, 0);

    /*
     * 在创建任何子进程之前安装 SIGCHLD 处理函数。
     */
    install_signal_handlers();

    lfd = create_listen_socket(
        listen_ip,
        listen_port
    );

    printf(
        "server listening on %s:%u, pid=%ld\n",
        listen_ip,
        (unsigned int)listen_port,
        (long)getpid()
    );

    for (;;) {
        struct sockaddr_in client_addr;
        socklen_t client_addr_len = sizeof(client_addr);
        char client_ip[INET_ADDRSTRLEN] = {0};
        uint16_t client_port;
        int cfd;
        pid_t pid;

        memset(&client_addr, 0, sizeof(client_addr));

        cfd = accept_client(
            lfd,
            &client_addr,
            &client_addr_len
        );

        if (cfd == -1) {
            perror("accept");
            continue;
        }

        if (inet_ntop(
                AF_INET,
                &client_addr.sin_addr,
                client_ip,
                sizeof(client_ip)
            ) == NULL) {
            strncpy(
                client_ip,
                "<unknown>",
                sizeof(client_ip) - 1
            );
        }

        client_port = ntohs(client_addr.sin_port);

        printf(
            "[parent %ld] new client %s:%u, cfd=%d\n",
            (long)getpid(),
            client_ip,
            (unsigned int)client_port,
            cfd
        );

        pid = fork();

        if (pid == -1) {
            perror("fork");

            /*
             * fork 失败后,这个连接还属于父进程。
             * 必须关闭,否则会泄漏 cfd。
             */
            close(cfd);
            continue;
        }

        if (pid == 0) {
            /*
             * 子进程只负责当前客户端,不负责监听。
             */
            close(lfd);

            handle_client(
                cfd,
                client_ip,
                client_port
            );

            close(cfd);

            /*
             * fork 后使用 _exit(),避免刷新从父进程
             * 复制而来的 stdio 缓冲区。
             */
            _exit(EXIT_SUCCESS);
        }

        /*
         * 父进程只负责监听。
         * 子进程已经继承 cfd,因此父进程关闭自己的引用。
         */
        close(cfd);
    }
}

八、配套测试客户端

文件名:

text 复制代码
tcp_echo_client.c
c 复制代码
#define _POSIX_C_SOURCE 200809L

#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define DEFAULT_SERVER_IP "127.0.0.1"
#define DEFAULT_SERVER_PORT 8008
#define BUFFER_SIZE 4096

static void die(const char *message)
{
    perror(message);
    exit(EXIT_FAILURE);
}

static uint16_t parse_port(const char *text)
{
    char *end = NULL;
    long value;

    errno = 0;
    value = strtol(text, &end, 10);

    if (errno != 0 ||
        end == text ||
        *end != '\0' ||
        value < 1 ||
        value > 65535) {
        fprintf(stderr, "invalid port: %s\n", text);
        exit(EXIT_FAILURE);
    }

    return (uint16_t)value;
}

static int send_all(int fd,
                    const void *buffer,
                    size_t size)
{
    const unsigned char *cursor = buffer;
    size_t remaining = size;

    while (remaining > 0) {
        ssize_t n = send(
            fd,
            cursor,
            remaining,
            MSG_NOSIGNAL
        );

        if (n > 0) {
            cursor += (size_t)n;
            remaining -= (size_t)n;
        } else if (n == -1 && errno == EINTR) {
            continue;
        } else {
            return -1;
        }
    }

    return 0;
}

/*
 * 客户端知道回射数据长度等于发送长度,
 * 因此循环读取,直到收满本轮预期字节数。
 */
static int receive_exactly(int fd,
                           void *buffer,
                           size_t size)
{
    unsigned char *cursor = buffer;
    size_t remaining = size;

    while (remaining > 0) {
        ssize_t n = read(
            fd,
            cursor,
            remaining
        );

        if (n > 0) {
            cursor += (size_t)n;
            remaining -= (size_t)n;
        } else if (n == 0) {
            return 0;
        } else if (errno == EINTR) {
            continue;
        } else {
            return -1;
        }
    }

    return 1;
}

int main(int argc, char *argv[])
{
    const char *server_ip = DEFAULT_SERVER_IP;
    uint16_t server_port = DEFAULT_SERVER_PORT;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    int fd;

    if (argc > 3) {
        fprintf(
            stderr,
            "usage: %s [server IPv4 address] [port]\n",
            argv[0]
        );

        return EXIT_FAILURE;
    }

    if (argc >= 2) {
        server_ip = argv[1];
    }

    if (argc == 3) {
        server_port = parse_port(argv[2]);
    }

    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) {
        die("socket");
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port);

    if (inet_pton(
            AF_INET,
            server_ip,
            &server_addr.sin_addr
        ) != 1) {
        fprintf(
            stderr,
            "invalid IPv4 address: %s\n",
            server_ip
        );

        close(fd);
        return EXIT_FAILURE;
    }

    if (connect(
            fd,
            (const struct sockaddr *)&server_addr,
            sizeof(server_addr)
        ) == -1) {
        int saved_errno = errno;

        close(fd);
        errno = saved_errno;
        die("connect");
    }

    printf(
        "connected to %s:%u\n",
        server_ip,
        (unsigned int)server_port
    );

    printf("enter text, Ctrl+D to quit\n");

    while (fgets(buffer, sizeof(buffer), stdin) != NULL) {
        size_t length = strlen(buffer);
        int result;

        if (send_all(fd, buffer, length) == -1) {
            perror("send");
            break;
        }

        result = receive_exactly(
            fd,
            buffer,
            length
        );

        if (result == -1) {
            perror("read");
            break;
        }

        if (result == 0) {
            fprintf(
                stderr,
                "server closed the connection\n"
            );

            break;
        }

        printf("echo: ");
        fwrite(buffer, 1, length, stdout);
    }

    close(fd);
    return EXIT_SUCCESS;
}

九、Makefile

文件名:

text 复制代码
Makefile
makefile 复制代码
CC := gcc

CFLAGS := \
	-std=c11 \
	-Wall \
	-Wextra \
	-Wpedantic \
	-Wconversion \
	-Wshadow \
	-O2

SERVER := process_tcp_server
CLIENT := tcp_echo_client

.PHONY: all clean

all: $(SERVER) $(CLIENT)

$(SERVER): process_tcp_server.c
	$(CC) $(CFLAGS) $< -o $@

$(CLIENT): tcp_echo_client.c
	$(CC) $(CFLAGS) $< -o $@

clean:
	rm -f $(SERVER) $(CLIENT)

编译:

bash 复制代码
make

或者直接执行:

bash 复制代码
gcc -std=c11 \
    -Wall \
    -Wextra \
    -Wpedantic \
    -Wconversion \
    -Wshadow \
    -O2 \
    process_tcp_server.c \
    -o process_tcp_server

客户端:

bash 复制代码
gcc -std=c11 \
    -Wall \
    -Wextra \
    -Wpedantic \
    -Wconversion \
    -Wshadow \
    -O2 \
    tcp_echo_client.c \
    -o tcp_echo_client

十、运行和测试

1. 启动服务器

使用默认配置:

bash 复制代码
./process_tcp_server

默认监听:

text 复制代码
0.0.0.0:8008

也可以显式指定 IP 和端口:

bash 复制代码
./process_tcp_server 127.0.0.1 8008

服务器输出:

text 复制代码
server listening on 127.0.0.1:8008, pid=15230

2. 启动第一个客户端

bash 复制代码
./tcp_echo_client 127.0.0.1 8008

输入:

text 复制代码
hello

客户端输出:

text 复制代码
echo: hello

服务器输出类似:

text 复制代码
[parent 15230] new client 127.0.0.1:53426, cfd=4
[child 15251] received 6 bytes from 127.0.0.1:53426

3. 再启动一个客户端

打开另一个终端:

bash 复制代码
./tcp_echo_client 127.0.0.1 8008

服务器可能输出:

text 复制代码
[parent 15230] new client 127.0.0.1:53432, cfd=4
[child 15274] received 12 bytes from 127.0.0.1:53432

这里两个客户端分别由两个子进程处理:

text 复制代码
父进程 15230
 ├── 子进程 15251 → 客户端1
 └── 子进程 15274 → 客户端2

其中父进程继续阻塞在 accept(),两个子进程分别阻塞在自己的 read() 上,互不影响。


十一、为什么 SIGCHLD 处理函数必须循环调用 waitpid()

子进程退出后,内核仍然需要保留部分退出信息,等待父进程读取。

如果父进程一直不调用:

c 复制代码
wait();

或:

c 复制代码
waitpid();

已经退出的子进程会保持僵尸状态。wait() 系列函数完成回收后,内核才可以释放与已终止子进程相关的剩余资源。

信号处理函数不能只调用一次:

c 复制代码
waitpid(-1, NULL, WNOHANG);

而应该循环:

c 复制代码
while (waitpid(-1, NULL, WNOHANG) > 0) {
}

原因是标准信号不会像实时信号那样为每次发生都保存一个独立排队实例。当同一种标准信号处于待处理状态时,后续同类信号可能合并。

例如三个子进程短时间内退出:

text 复制代码
子进程1退出 ─┐
子进程2退出 ─┼── 父进程可能只观察到一次待处理的 SIGCHLD
子进程3退出 ─┘

处理一次 SIGCHLD 时,需要把当前所有已经退出的子进程全部回收。


waitpid() 返回值的意义

代码:

c 复制代码
pid_t pid = waitpid(-1, NULL, WNOHANG);

其中:

text 复制代码
pid > 0

表示成功回收了一个子进程,返回值是该子进程 PID。

text 复制代码
pid == 0

表示当前还有子进程存在,但没有已经退出且等待回收的子进程。

text 复制代码
pid == -1

表示调用失败。常见情况包括:

  • ECHILD:已经没有可等待的子进程;
  • EINTR:调用被其他信号中断。

WNOHANG 的作用是:

如果当前没有已退出的子进程,立即返回,而不是阻塞在信号处理函数中。


十二、accept()SIGCHLD 打断的准确过程

假设父进程正在执行:

c 复制代码
int cfd = accept(lfd, ...);

此时没有新连接,因此父进程睡眠等待。

与此同时,某个通信子进程退出,内核为父进程产生 SIGCHLD

大致流程如下:

text 复制代码
父进程调用 accept()
        │
        ▼
进入内核,发现没有待接受连接
        │
        ▼
父进程进入可中断睡眠状态
        │
        ▼
某个子进程退出
        │
        ▼
父进程出现待处理 SIGCHLD
        │
        ▼
内核安排执行 SIGCHLD 处理函数
        │
        ▼
reap_children() 调用 waitpid()
        │
        ▼
信号处理函数返回
        │
        ├── 设置了 SA_RESTART
        │      └── accept 通常自动恢复等待
        │
        └── 没设置 SA_RESTART
               └── accept 可能返回 -1,errno=EINTR

因此,"信号处理结束后 accept() 一定返回 EINTR"是不准确的。

更准确的结论是:

阻塞系统调用遇到信号后,是自动重新开始还是返回 EINTR,由系统调用类型、SA_RESTART 和 socket 配置共同决定。


十三、为什么还要在 accept_client() 中处理 EINTR

代码已经设置:

c 复制代码
sa.sa_flags = SA_RESTART;

但仍然保留:

c 复制代码
if (errno == EINTR) {
    continue;
}

原因有三个。

1. SA_RESTART 不会重启所有系统调用

部分接口无论是否设置 SA_RESTART,被信号打断后都会返回 EINTR

2. socket 设置超时后可能改变重启行为

例如设置了某些 socket 接收超时后,相关阻塞接口可能不再自动重启。

3. 防御式编程

底层封装不应完全依赖调用方是否正确配置了信号处理方式。

因此:

c 复制代码
SA_RESTART

和:

c 复制代码
if (errno == EINTR) {
    continue;
}

并不冲突,而是两层保护。


十四、为什么 accept() 还要处理其他瞬时网络错误

Linux 的 accept() 有一个需要特别注意的行为:

新连接上已经挂起的部分网络错误,可能直接作为 accept() 的错误返回。

Linux 手册建议,对于 TCP/IP 的部分网络错误,可靠程序可以将它们视为暂时性错误并重新调用 accept()

因此代码中处理了:

c 复制代码
ENETDOWN
EPROTO
ENOPROTOOPT
EHOSTDOWN
ENONET
EHOSTUNREACH
EOPNOTSUPP
ENETUNREACH

以及:

c 复制代码
EINTR
ECONNABORTED

对于教学代码,只处理:

c 复制代码
EINTR
ECONNABORTED

也可以工作。

本文给出的版本更接近健壮服务器的处理方式。


十五、TCP 为什么需要应用层协议

TCP 提供的是连续字节流,而不是一条条独立消息。

假设客户端连续执行:

c 复制代码
send(fd, "hello", 5, 0);
send(fd, "world", 5, 0);

服务器不能假设两次 read() 一定分别得到:

text 复制代码
hello
world

服务器可能读到:

text 复制代码
helloworld

也可能读到:

text 复制代码
hel
lowor
ld

这取决于:

  • 发送缓冲区;
  • 接收缓冲区;
  • TCP 分段与合并;
  • 网络传输;
  • 调度时机;
  • 每次 read() 请求的长度。

因此,一次 read() 的返回值只表示:

这一次从当前字节流中取出了多少字节。

它不表示:

接收了一条完整业务消息。

真实应用需要设计消息边界,常见方式包括:

固定长度

每条消息固定为 128 字节。

特殊分隔符

例如每条消息以换行符结束:

text 复制代码
hello\n

长度字段加消息体

text 复制代码
┌──────────────┬────────────────────┐
│ 4字节消息长度 │       消息体        │
└──────────────┴────────────────────┘

这是网络协议中非常常见的设计。

本文的回射服务器不解析业务消息,只把每次读取到的字节原样发送回去,因此不需要定义复杂的应用层协议。


十六、多进程模型的优点和缺点

优点

1. 编程模型直观

每个子进程只负责一个客户端:

text 复制代码
一个连接
    ↓
一个子进程
    ↓
循环 read/send

不需要处理复杂的事件分发逻辑。

2. 隔离性较好

不同客户端运行在不同进程地址空间中。

某个子进程发生普通内存错误时,一般不会直接破坏其他通信子进程的用户态内存。

3. 可以利用多核 CPU

多个子进程可以被调度到不同 CPU 核心上并行执行。


缺点

1. 创建进程成本较高

每个连接都调用:

c 复制代码
fork();

会带来进程创建、页表处理和调度成本。

2. 每个进程都有独立虚拟地址空间

虽然 Linux 使用写时复制降低 fork() 的初始内存开销,但大量进程仍会消耗可观的内核和用户态资源。

3. 进程间共享状态较麻烦

如果多个客户端需要共享:

  • 在线用户表;
  • 缓存;
  • 房间信息;
  • 任务队列;
  • 数据统计;

则需要引入:

  • 共享内存;
  • 管道;
  • 消息队列;
  • Unix 域套接字;
  • 信号量或进程共享锁。

4. 不适合超大规模长连接

连接数很大时,"一个连接一个进程"通常不具备良好的伸缩性。

更高并发的服务端通常采用:

text 复制代码
非阻塞 socket
    +
epoll
    +
线程池或任务池

十七、常见问题

1. accept() 是否负责三次握手

不是由用户态 accept() 亲自收发三次握手报文。

三次握手主要由内核 TCP 协议栈处理。

应用程序调用:

c 复制代码
accept();

是在获取已经进入待接受状态的连接。


2. accept() 返回的是监听套接字本身吗

不是。

c 复制代码
int cfd = accept(lfd, ...);

执行后:

text 复制代码
lfd 仍然是监听套接字
cfd 是新的已连接套接字

二者可以同时存在。


3. 子进程关闭 lfd 会不会影响父进程

不会。

fork() 后父子进程有各自的文件描述符表。子进程的:

c 复制代码
close(lfd);

只移除子进程自己的引用。

父进程仍然可以使用自己的 lfd


4. 父进程关闭 cfd 后,子进程还能通信吗

可以。

父进程关闭的是自己的文件描述符引用,子进程仍然持有它继承的 cfd

只有当所有相关引用都被关闭后,底层 socket 才可能被真正释放。


5. 为什么 read() 返回 0

对于已连接 TCP socket:

c 复制代码
read(...) == 0

通常表示已经读到字节流末尾,即对端已经进行了有序关闭,本端接收完对端关闭前发送的所有数据。

这不是普通的"暂时没有数据"。

阻塞 socket 暂时没有数据时,read() 一般会等待,而不是返回 0。


6. 为什么不能只调用一次 write()

因为一次成功的 write()send() 可能只处理部分数据。

正确做法是根据返回值循环发送剩余字节。


7. 为什么不能在信号处理函数中打印日志

因为 printf() 等标准 I/O 函数不是异步信号安全函数,信号处理函数中再次进入这些函数可能破坏其内部状态。

需要日志时,可以:

  • 在处理函数中设置 volatile sig_atomic_t 标志;
  • 在主循环中检查标志并输出;
  • 使用 self-pipe 技巧;
  • 使用 signalfd 将信号转成文件描述符事件。

8. 能不能直接忽略 SIGCHLD

在 Linux 上,显式将 SIGCHLD 设置为 SIG_IGN,或者使用 SA_NOCLDWAIT,可以让系统不为终止的子进程保留普通僵尸状态。

但这也意味着父进程无法通过常规 waitpid() 获取子进程退出状态。

为了完整展示子进程回收机制,本文选择:

c 复制代码
sigaction(SIGCHLD, ...);
waitpid(..., WNOHANG);

十八、总结

多进程 TCP 并发服务器的核心并不只是调用一次 fork(),而是理解父进程、子进程、文件描述符和信号之间的关系。

需要掌握以下关键点:

  1. 阻塞式串行服务器无法同时停留在 accept() 和多个客户端的 read() 中。

  2. 父进程负责监听和创建子进程,子进程负责一个具体客户端。

  3. accept() 返回新的已连接套接字,监听套接字仍然保留。

  4. fork() 后父子进程拥有独立的文件描述符表,但对应描述符仍引用相同的底层内核对象。

  5. 子进程必须关闭不需要的 lfd,父进程必须关闭不需要的 cfd

  6. 子进程退出后,父进程需要通过 waitpid() 回收,避免产生僵尸进程。

  7. 信号处理函数中不能调用 printf() 等非异步信号安全函数。

  8. 标准信号可能合并,因此收到一次 SIGCHLD 时要循环回收所有已退出子进程。

  9. accept() 被信号打断后不一定返回 EINTR,其行为受到 SA_RESTART 等配置影响。

  10. read() 返回的数据不保证以 '\0' 结尾,不能直接假设为 C 字符串。

  11. 一次 write()send() 不保证发送全部数据,必须处理短写入。

  12. TCP 是字节流协议,应用程序必须自行定义业务消息边界。

多进程模型适合学习 Linux 进程、信号和 socket 的协作机制,也适合连接数不高、逻辑隔离要求明确的服务端程序。对于大规模长连接服务,则应继续学习非阻塞 I/O、epoll、Reactor 模型以及线程池。

相关推荐
会飞的小新1 小时前
《深入理解 DDR Memory Margin》系列之第一章:从 DDR 到 Memory Margin——为什么服务器需要进行内存裕量测试?
linux·服务器
治愈系贝壳1 小时前
ARM ---day1
linux·arm开发·学习
汤愈韬1 小时前
KYCA运维认证
运维·服务器·网络
晚风吹长发1 小时前
探索软件测试——用例篇
大数据·linux·服务器·压力测试·测试·测试方法
ZENERGY-众壹1 小时前
50MW分布式VPP接入实战:调度中心要求的1分钟频率如何不卡死API
运维·服务器·分布式·光伏运维
程序猿编码1 小时前
没有AI框架,没有GPU,一个C语言文件跑通大模型
c语言·开发语言·人工智能·深度学习·ai·大模型
梦想的旅途21 小时前
企业微信API 实现企业微信消息的自动发送?
java·服务器·前端
CHANG_THE_WORLD2 小时前
Linux C 多线程 TCP 并发服务器:从 `accept()` 到资源管理深度剖析
linux·服务器·c语言
运维大师2 小时前
【Linux运维极简教程】10-日志管理与故障排查
linux·运维·服务器
聚焦前沿2 小时前
2026服务器热管理阀门选型白皮书
运维·服务器