上一篇文章我们处理 UDP 时发现:UDP 是"无连接"的,每个 datagram(数据报文,一条固定大小的数据)是独立的,A 给 B 发、B 给 A 回,两边谁都不需要事先"搭上线"。可现实里的很多服务——网页、文件传输、远程登录——都需要"先建立连接,再长时间稳定地聊天"。这就轮到 TCP 上场了。

这篇文章我们不绕弯子,直接实现一个最经典的 TCP 服务:英译汉 / 回显(echo)服务器。它的逻辑和之前 UDP 类似,但底层完全是另一套玩法。我们会把 TCP 的四个核心系统调用 socket、bind、listen、accept 和客户端的 connect 一个一个掰开揉碎,再一步步把"只能服务一个客户端"的单线程服务器,升级成能同时服务一堆客户端的多进程版本、多线程版本,最后聊到线程池。中间会踩进几个经典无比的大坑——比如 listen 的 backlog、accept 返回的新套接字、"读到 0 就是对端关闭"、fork 出来的孤儿进程和僵尸进程。这些坑你今天躲过去,以后写任何网络服务都不会再掉进去。

你应该有的知识准备

在读之前,建议你对下面几点有概念,都是前几篇打下的底子:

  • socket 文件描述符:socket() 成功后会返回一个普通文件描述符,你可以用 read/write 当作读写文件一样在网络上收发数据。这个"一切都是文件"的哲学是理解全文的地基。
  • struct sockaddr_in:IPv4 地址结构体,里面存"地址族 + IP + 端口"。前面的博文已经见过,这里我们会用到它。
  • 三次握手:TCP 建立连接时要走 SYN、SYN+ACK、ACK 三趟。这篇文章不从 TCP 报文头讲,而是聚焦一个更实用的问题——这三趟握手,在系统调用层面是谁触发的、谁等谁。
  • 进程 fork / 线程 pthread:多进程、多线程服务器会用到,没学过也没关系,我会在对应小节讲清用到的那个函数。

如果你是带着这些基础进来的,直接往下读;哪条不熟,跳回去补一眼再回来。

TCP 的流式特性与 SOCK_STREAM

先解决一个最关键的概念:UDP 用的是 SOCK_DGRAM(datagram,数据报),而 TCP 必须用 SOCK_STREAM。STREAM 是"流"的意思,流式 socket 指的就是:TCP 把数据当成一条"没有边界的水流"来看待。

你在客户端调一次 write(sockfd, "hello", 5),服务器那边调一次 read 并不一定就刚好拿到那同一份 "hello"。数据可能被拆开成好几片到达,也可能几段写到一起顺路到达。TCP 只保证"到达的顺序跟你发送的顺序一致、内容一个字节不丢",但它不承诺一次 read 就对应你的一次 write。

打个比方:你往水龙头里灌水,水流到另一头再接出来,你不能保证"我这次拧开接到的,刚好是我第一次倒进去的那一勺"。这个"流式"特性,会在我们写回声服务时反复提到,因为它意味着我们收到的内容可能一次多、一次少,被迫用"读到 0 表示对方结束"而不是"读到固定多少字节就结束"来判断边界。

这句话请先刻进脑子,后面会用它解释很多现象:

面向流的协议(SOCK_STREAM)只管"顺序和完整性",不管"一次读写多少"。

socket():创建一个网络通讯端点

所有网络编程的第一步都是 socket()。它属于 sys/socket.h:

int socket(int family, int type, int protocol);
  • family(地址族):IPv4 就写 AF_INET。
  • type(套接字类型):TCP 就写 SOCK_STREAM,表示"面向流的传输协议";UDP 写 SOCK_DGRAM。
  • protocol(协议):通常填 0,让内核根据 family+type 自动选协议。TCP/UDP 填 0 即可。

调用成功的话,它像 open() 一样返回一个文件描述符;失败返回 -1 并设置全局变量 errno。因为这个描述符本质就是"一个文件",所以我们后面才能用 read/write 直接在网络上收发数据——这正是 Unix"一切皆文件"哲学在网络上的体现。

bind():把端口占住

服务器要提供服务,得先有一个固定的"门牌号",也就是固定的 IP 和端口,客户端才能找到它。这个动作叫 bind():

int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);

它的作用是把 sockfd 这个网络描述符,和 myaddr 描述的地址 + 端口绑定在一起,之后这个 socket 就"监听"这个端口。第三个参数 addrlen 必须传结构体的实际长度——因为 struct sockaddr * 是个通用指针,它能接受多种协议的地址结构(IPv4 的 sockaddr_in、IPv6 的 sockaddr_in6 等),它们的长度各不相同,所以要用第三个参数告诉内核"这次到底传了多长的结构体"。

课件的初始化套路是固定的四步,我们现在用纯 C 把它写清楚:

struct sockaddr_in server;              // 定义一个 IPv4 地址结构体
memset(&server, 0, sizeof(server));     // 第 1 步:整个结构体清零
server.sin_family = AF_INET;            // 第 2 步:地址族填 IPv4
server.sin_addr.s_addr = htonl(INADDR_ANY); // 第 3 步:本机任意 IP
server.sin_port = htons(SERV_PORT);     // 第 4 步:写死端口号

几个名词解释:

  • INADDR_ANY:一个宏,表示"本机的任意一个 IP"。服务器可能有多个网卡,每个网卡还可能有多个 IP,写成这个值就等于"不管客户端访问我哪个 IP,我都在这个端口上等你"。只有等到某个客户端真的连上来那一刻,内核才最终确定这次用的是哪个具体 IP。刚学的时候直接 htonl(INADDR_ANY) 是最省心的写法。
  • htonl / htons:host to network long / short,把主机字节序转成网络字节序。因为不同 CPU 的整数在内存里高低字节排放顺序可能不同,而网络协议规定 IP/端口在线上要用"网络字节序"(大端)。INADDR_ANY 是整数需要用 htonl,端口是 16 位的 short 用 htons。程序员可以偷懒记成"凡是往 sockaddr 里填 IP/端口,都要经 hton* 转一次"。

补充一个重要事实:客户端通常并不调用 bind。客户端的端口由内核在 connect 的时候自动分配一个随机的空闲端口,这就够了(课件原话:"客户端不是不允许调用 bind,只是没必要")。反过来,服务器如果不调 bind,内核也会自动给它分端口——但每次启动分配的都不一样,客户端根本不知道今天该连哪个端口,等于没法用。所以规则是:服务器必须显式 bind,客户端一般不必显式 bind。同一台机器上想开多个客户端连同一个服务器,也只互不冲突,因为每个客户端自己拥有一个随机端口。

listen():进入监听状态

bind 只是"把端口占下",此时 socket 还不能接受连接。要转入监听状态必须 listen():

int listen(int sockfd, int backlog);
  • 它声明 sockfd 处于监听状态。
  • backlog 参数表示"最多允许有 backlog 个连接排队等待被 accept",如果超过这个数,新的连接请求会被内核忽略(通常表现为连接被拒或超时)。

这里埋着一个经典考点,讲清楚:backlog 到底是"哪个队列"的长度? 在 Linux 2.2 以前,backlog 指"半连接队列"(三次握手还没完成的连接)的长度;从 Linux 2.2 起,内核里其实有两条队列——syn queue(半连接,SYN 已收、ACK 未回)和 accept queue(三次握手已完成、正等应用层 accept)。现代语义下 backlog 主要约束的是已完成三次握手、排队等待 accept 的那个队列,并且实际生效值会被内核上限 somaxconn 截断:min(backlog, /proc/sys/net/core/somaxconn)。课件里说"这里设置一般不会太大(比如 5)",你可以理解成这是经典教材的常用值;而课件代码里 default_backlog = 6 是指在应用层默认排 6 个。

千万别被"忽略多余的"吓到——客户端 connect 时如果没人立刻 accept,连接并不会失败,它会安静地躺在 accept queue 里排队,直到服务器有空 accept 它。这一点用到就是考点,我们把它放进思考题。

listen() 成功返回 0,失败返回 -1。

accept():拿走一个排好队的连接

三次握手的完成,是内核在幕后替应用做的,应用看不到任何报文。握手的这三次,其实分配在三个系统调用里:

  1. 客户端一调用 connect(),内核发出 SYN;
  2. 服务器内核收到 SYN,回 SYN+ACK;
  3. 客户端内核收到后回 ACK,此时三次握手完成,连接进入 accept queue 排队等应用。

accept() 就是服务器"从队列里取走一个已经完成握手的连接"的那个动作:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:必须是那个已经 listen 过的监听套接字。
  • addr:传出参数,函数返回时会把"这个客户端具体是谁"(IP 和端口)填进去。传 NULL 表示"我不关心是谁连上来的"。
  • addrlen:传入传出参数(value-result argument)。传进来的是你准备的 addr 那块缓冲区的长度(防止 addr 缓冲区不够大导致溢出),函数返回时会改成"客户端地址结构体的实际长度"(可能没有填满你准备的缓冲区)。所以你通常要写成:
struct sockaddr_in client;
socklen_t len = sizeof(client);          // 传入:缓冲区大小
int clifd = accept(listenfd, (struct sockaddr*)&client, &len);
// 现在 len 可能已经被改成实际长度,client 里是客户端的 IP/端口

两个最容易被新手搞混的点,必须一次讲透:

第一,accept 是阻塞的。如果 accept() 被调用时,队列里还没有任何一个完成握手的连接,调用就会卡在那里(阻塞在等待),直到有客户端连上来才返回。这不是 bug,正是服务器"苦等客人上门"的设计。

第二,accept 返回的不是原来那个 socket! 它返回一个全新的连接套接字,专门用于和这一个客户端通信。原来的 listenfd 依然保持监听状态,继续等着接纳下一个客人。这就产生了两个术语:

  • 监听套接字(listening socket):调用 socket + bind + listen 得到,它只负责"接客",一辈子不用于收发数据;
  • 连接套接字(connected socket):accept 返回的、和某个具体客户端绑定的 socket,数据读写都走它。

课件里用了一个很好的"饭店拉客"比喻:监听套接字就是站在门口拉客的服务员,它一次只干"引客人进门"一件事;每来一个客人,它就喊后厨(accept)开出一张专属于这位客人的小桌子(连接套接字),之后这位客人的点菜、上菜都在这张小桌子上进行,门口那个领位的服务员还继续站在门口拉下一个客人。一个监听 socket,可以写出无数个连接 socket。

connect():客户端发起连接

客户端这边要连接服务器,调 connect():

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

注意一个对比,这是课件反复强调的:bind 和 connect 的参数形式一模一样,区别在于 bind 填的是"自己的地址",connect 填的是"对方的地址"。 bind 是把自己占住的端口告诉内核;connect 是告诉内核"我要去找服务器 IP:端口"。

  • 客户端不需要自己 bind,内核会在 connect 时自动分配本地端口并完成本地绑定(课件原话:"自动进行 bind 哦")。
  • 客户端 connect 也不产生新的 socket——后续通信就直接用这个 connect 成功的 sockfd。
  • connect() 成功返回 0,失败返回 -1。

客户端填地址还有个经典写法,用 inet_pton 把"点分十进制 IP 字符串"转成网络序的二进制 IP:

struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(atoi(argv[2]));            // 端口:文本转数字再转网络序
inet_pton(AF_INET, argv[1], &server.sin_addr);     // IP:字符串转网络序二进制

inet_pton 的 p 是 presentation(文本表示),n 是 network,合起来就是"把文本形式的 IP 转成网络字节序的四字节二进制"。它的好兄弟 inet_ntoa 是反向操作(网络序二进制转文本字符串),课件里的 InetAddr 就是用 inet_ntoa(_addr.sin_addr) 取出可打印的 IP 字符串。这两个函数以后写日志打印地址时天天用。

V1:第一个可运行的 Echo Server

理论说够了,上完整能编译运行的代码。这是课件 V1 的等价 C 实现:一个"只能服务一个客户端"的简陋版,故意先做成这样,是为了让后面的坑对比明显。

服务器 tcp_echo_server.c:

// tcp_echo_server.c —— 单进程、单连接版 TCP 回显服务器(对应课件 V1)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
#define SERV_PORT 9999          // 固定的服务端口
#define BACKLOG 5               // 最多允许排队的连接数
 
int main()
{
    int listenfd, clifd;                    // 监听套接字 + 连接套接字
    struct sockaddr_in server, client;      // 本地地址 + 客户端地址
    socklen_t len = sizeof(client);
    char buf[1024];
    ssize_t n;
 
    // 第 1 步:创建 socket,SOCK_STREAM 表示流式 TCP
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) { perror("socket"); exit(1); }
 
    // 开启端口复用,避免服务器重启时 bind 失败(后面专门讲)
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    // 第 2 步:填本地地址并 bind
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;                        // IPv4
    server.sin_addr.s_addr = htonl(INADDR_ANY);         // 任意本机 IP
    server.sin_port = htons(SERV_PORT);                 // 端口转网络序
    if (bind(listenfd, (struct sockaddr*)&server, sizeof(server)) < 0)
    { perror("bind"); exit(1); }
 
    // 第 3 步:进入监听状态,最大 backlog 个连接排队
    if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); }
    printf("server listening on port %d ...\n", SERV_PORT);
 
    // 第 4 步:accept 接受一个连接(阻塞,没人连就停在这)
    clifd = accept(listenfd, (struct sockaddr*)&client, &len);
    if (clifd < 0) { perror("accept"); exit(1); }
    printf("got connection from %s:%d\n",
           inet_ntoa(client.sin_addr), ntohs(client.sin_port));
 
    // 第 5 步:echo 服务,一直读到对端关闭为止
    while ((n = read(clifd, buf, sizeof(buf) - 1)) > 0)
    {
        buf[n] = '\0';              // 手动补上字符串结束符,方便按字符串打印
        printf("client says: %s\n", buf);
        write(clifd, buf, n);       // 原样回给客户端
    }
    // n == 0 表示客户端关闭了连接,跳出循环
    close(clifd);
    close(listenfd);
    return 0;
}

客户端 tcp_echo_client.c:

// tcp_echo_client.c —— TCP 回显客户端(对应课件 TcpClient)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        printf("usage: %s <server_ip> <server_port>\n", argv[0]);
        return 1;
    }
 
    int sockfd;
    struct sockaddr_in server;
    char buf[1024];
    ssize_t n;
 
    // 第 1 步:创建 socket,不需要 bind,端口由内核自动分配
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) { perror("socket"); return 1; }
 
    // 第 2 步:填服务器的地址,发起 connect
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(atoi(argv[2]));          // 端口:文本转网络序
    inet_pton(AF_INET, argv[1], &server.sin_addr);   // IP:文本转二进制
    if (connect(sockfd, (struct sockaddr*)&server, sizeof(server)) < 0)
    { perror("connect"); return 1; }
 
    // 第 3 步:循环发送一行 -> 等待回显打印
    while (1)
    {
        printf("please enter# ");
        if (fgets(buf, sizeof(buf), stdin) == NULL) break; // 输入 EOF 结束
        size_t len = strlen(buf);
        write(sockfd, buf, len);                  // 发送给服务器
        n = read(sockfd, buf, sizeof(buf) - 1);   // 等服务器回显
        if (n <= 0) break;                        // 0:对端关闭;<0:出错
        buf[n] = '\0';
        printf("echo -> %s", buf);
    }
    close(sockfd);
    return 0;
}

编译与运行(两个终端分别执行):

# 终端 A:编译并启动服务器
gcc -o tcp_echo_server tcp_echo_server.c
./tcp_echo_server
 
# 终端 B:编译并启动客户端(连本机 127.0.0.1)
gcc -o tcp_echo_client tcp_echo_client.c
./tcp_echo_client 127.0.0.1 9999

客户端每敲一行,服务器打印 client says: <你敲的>,客户端打印 echo -> <你自己敲的原文>。一个能跑的回声服务就这么成了。

你可能会注意到服务器那行 while ((n = read(...)) > 0),这正是我们前面"流式特性"的直接体现:我们没办法保证一次 read 就恰好读完客户端一次 write 的数据,所以用一个循环反复读,直到读到 0(对端关闭)为止,把读取和"判断结束"用返回值来界定。

测试多个连接:V1 为什么只能服务一个客户端

现在你去开第二个客户端,再连一次,会发现它连不上 / 不能正常通信。这是特别值得停下来分析的现象。原因课件讲得很清楚:

因为我们 accept 了一个请求之后,就一直 while 循环尝试 read,没有继续调用 accept,导致不能接受新的请求。

我们的主程序流程是:accept 一次 → 拿到一个 clifd → 进入 while 循环 read。这个 while 循环不结束(客户端不关闭),accept 就永远没机会被第二次调用。于是第二个客户端的连接虽然完成了三次握手、排进了 accept queue,却没有任何人去把它取走 —— 它只能躺在队列里,直到 queue 满。

再叠加我们刚学过的 backlog:排队上限就这么几个位置。要是第二、第三个客户端一直没人 accept,队列塞满之后,新来的连接请求就会被内核丢弃,客户端那边表现为 connect 迟迟不成功或直接报错。

一句话总结 V1 的病根:服务器在"服务一个客户端"期间,把"接客"这件事完全停掉了。 一个只能同时服务一个连接的服务器,在生产环境里等于不能用。接下来我们就开始"边接客边服务"的改造 —— 先是多进程版本。

V2:多进程服务器,用 fork 一个连接一个子进程

核心思路一句话:服务器主进程只负责 accept "接客",每接到一个连接 socket,就 fork 出一个子进程,让子进程去伺候这位客户,主进程立刻回到 accept 接着接下一个客人。 这样"接客"和"服务"就彻底解耦了,每个客户配一个独立的子进程,谁都不耽误谁。

fork() 的行为:调用一次,返回两次。父进程里返回子进程的 PID;子进程里返回 0。我们正是利用这个双重返回来分流:

pid_t pid = fork();        // 父、子进程都从这行继续执行
if (pid == 0)
{
    // 这是子进程:伺候这位客户
    Service(clifd);        // 读写,直到客户端关闭
    close(clifd);          // 服务完关掉连接套接字
    close(listenfd);       // 子进程把监听套接字也关掉(下面解释)
    exit(0);
}
// 走到这里的一定是父进程
close(clifd);              // 父进程自己不伺候客户,把连接套接字关掉

这里有两个极其重要的细节,课件代码里有但没有展开,必须讲透:

第一,父子进程为什么要互相 close 对方"没用"的 fd? fork 之后,父子进程各自独立拥有一份完整的文件描述符表,所以 clifd、listenfd 在父进程和子进程里"同时存在"(它们指向内核里同一个 socket 文件对象)。但父进程只负责接客,不负责收发数据,就该把 clifd 关掉;子进程只管自己这位客户,就把 listenfd 关掉。这么做的真正原因在于"引用计数": 一个 socket 只有当所有引用它的 fd 都被关闭时才会真正释放。如果父进程不关 clifd,那么即使子进程服务完、关了自己的 clifd,内核里这个连接 socket 仍被父进程"紧握着"不放,连接就迟迟不能彻底关闭。反过来子进程不关 listenfd 更严重——它会一直握着监听端口不放手,主进程都没法正常管理监听socket了。所以这种"各自关掉对方那份"的写法不是洁癖,是必须。

第二,也是最值得背下来的考点:抛出"孤儿进程"来彻底摆脱僵尸进程。 直接 fork 一个子进程去干活其实有个隐患——当客户端断开、子进程 exit 之后,它并不会立刻从系统消失,而会变成一个"僵尸进程(zombie)",留着吃一小块内核资源,直到它的父进程调用 wait/waitpid 去把它回收。父进程如果在 while 循环里同步 waitpid 回收(就像课件 V2 注释里写的 waitpid(id, nullptr, 0) 那样),那父进程的 accept 循环就会被卡住,又退回"只能服务一个"的老路。怎么既要回收、又不阻塞 accept?

课件 V2 用的是"卖两次 fork(fork 出孙子进程)"的经典招数:

pid_t pid = fork();
if (pid == 0)
{
    // 子进程
    if (fork() > 0)          // 子进程再 fork 一个"孙子"进程
        exit(0);             // 然后子进程自己立刻退出
    // 孙子进程在这里继续对外服务
    service(clifd);
    close(clifd);
    exit(0);
}
// 父进程:waitpid 回收那个"立即退出的子进程"即可,立刻返回,不阻塞 accept
close(clifd);
waitpid(pid, nullptr, 0);

原理是这样的:父进程 fork 出子进程,子进程再 fork 出孙子进程,然后子进程立即自己 exit(0)。父进程 waitpid 回收这个子进程时,它不是去等一个正在干活的进程,而是立刻能收到子进程已退出的通知,所以父进程几乎不会阻塞,马上回到 accept 继续接客。而那个孙子进程,因为它的父亲(子进程)已经死了,就会被系统领养,交给 PID 为 1 的 init 进程(现代 Linux 是 systemd)代为回收——这样等孙子进程服务完客户再退出时,会有系统进程负责收尸,因此不会产生无人回收的僵尸。

广义上,这种"父亲已死、在世的进程"就叫孤儿进程(orphan)。孤儿进程会被 init 领养,init 会负责在它退出后回收它。这正是"卖两次 fork"能避开僵尸进程的根本原因——把"收尸"的责任甩给 init。

下面给出一份可直接编译运行的 V2 多进程服务器(把 Service 逻辑内联,省掉课件里的 Log/前提封装,突出核心):

// tcp_echo_server_fork.c —— 多进程 TCP 回显服务器(对应课件 V2)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
#define SERV_PORT 9999
#define BACKLOG 5
 
// 子进程/孙子进程真正干活的地方:回显
void service(int clifd)
{
    char buf[1024];
    ssize_t n;
    while ((n = read(clifd, buf, sizeof(buf) - 1)) > 0)
    {
        buf[n] = '\0';
        printf("client says: %s\n", buf);
        write(clifd, buf, n);           // 原样回显
    }
    // n == 0: 客户端关闭连接
}
 
int main()
{
    int listenfd, clifd;
    struct sockaddr_in server, client;
    socklen_t len = sizeof(client);
 
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) { perror("socket"); exit(1); }
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = htonl(INADDR_ANY);
    server.sin_port = htons(SERV_PORT);
    if (bind(listenfd, (struct sockaddr*)&server, sizeof(server)) < 0)
    { perror("bind"); exit(1); }
    if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); }
 
    printf("fork server listening on %d ...\n", SERV_PORT);
 
    // 主循环:只负责接客
    while (1)
    {
        clifd = accept(listenfd, (struct sockaddr*)&client, &len);
        if (clifd < 0) { perror("accept"); continue; }   // 出错继续接下一个
        printf("new connection from %s:%d\n",
               inet_ntoa(client.sin_addr), ntohs(client.sin_port));
 
        pid_t pid = fork();
        if (pid < 0) { close(clifd); continue; }         // fork 失败就放弃这位
        if (pid == 0)
        {
            // 子进程:再 fork 出孙子进程,然后自己先退,解决僵尸问题
            close(listenfd);                             // 子进程不碰监听
            if (fork() > 0) exit(0);                     // 子进程即刻退出,孙子接手
            service(clifd);                              // 孙子打工
            close(clifd);
            exit(0);
        }
        // 父进程:关掉连接套接字,回收立刻退出的子进程,然后继续接客
        close(clifd);
        waitpid(pid, NULL, 0);                           // 回收已经退出的子进程
    }
    (void)server;
    return 0;
}

现在开任意多个客户端连它,每个都能独立回显——第二个客户不再被晾在队里。这背后每个客户对应一个(孙子)进程,互不影响,这正是"多进程服务器"的形态。

一个更稳的替代方案:用 SIGCHLD 信号回收,替代"卡住的 waitpid"

上面"卖两次 fork"很巧妙,但它有代价(多开一层进程,资源略多)。生产里更常见的做法是不阻塞式等待 + 信号回收:内核在子进程终止时会给父进程发 SIGCHLD 信号,父进程注册一个信号处理函数,在里面循环 waitpid(-1, ..., WNOHANG),把已经退出的子进程全部回收干净。

注意这里有两个经典大坑,正是任务里点名要讲的:

  • 死循环 fork 不能阻塞回收:如果父进程在 accept 循环里用 waitpid(pid, NULL, 0) 这种"带 PID、阻塞式"回收,服务器就又会卡住接不了新客。(我们上面的写法因为子进程"秒退",waitpid 立即返回,等价于不阻塞;但更优雅的是根本不用阻塞 waitpid,而是走信号。)
// 信号处理函数:回收所有已退出的子进程
void sigchld_handler(int sig)
{
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;   // 循环收尸,直到没有僵尸为止
}
 
// 在 main 里注册:
signal(SIGCHLD, sigchld_handler);

waitpid(-1, ...) 表示"等待任意子进程",WNOHANG 表示"没有可回收的也立即返回而不阻塞"。这样主进程的 accept 循环一次都不会被卡住,子进程退出时的收尸工作完全交给信号处理函数。

  • 信号处理函数里不能用可能阻塞/不可重入的调用,但 waitpid 是例外:waitpid/read 这类被 POSIX 明确标记为"可在信号处理器里安全调用"的系统调用(async-signal-safe)可以用。新手最容易犯的错是在信号处理函数里用 printf 打日志——printf 不可重入,可能在中断的瞬间和主程序的 printf 一起用同一块缓冲导致输出错乱甚至崩溃。安全的做法是用 write(原始系统调用)代替,或者仅仅置个标志位、等下轮主循环再打日志。

信号方式是生产服务器的标准姿势,fork 两次是课程里"先用最少的概念讲透孤儿进程"的教学捷径。两种都掌握,面试里别人问你就都能接上话。

CLOSE 与端口复用:SO_REUSEADDR

接着讲一个开发中几乎必踩的坑:服务器重启时报 bind: Address already in use。

原因藏在对 close() 的深层理解里。TCP 断开连接不是"立刻消失",它要挥手道别(四次挥手)。主动关闭的那一方会进入一个叫 TIME_WAIT 的状态,这个状态要让连接再存活大约 2 倍的 MSL(报文最大生存时间,通常一共约 1~4 分钟),目的是保证最后那个 ACK 不会丢、以及让网络里残留的旧报文自然消散。

具体到服务器:如果服务器是主动 close 的那一方(比如它先关连接再退出),那么它绑定的那个端口会在一段时间内处于 TIME_WAIT,此时马上重启想再次 bind 同一个端口就会失败。更典型的是:服务器进程退出后,端口上还残留着 TIME_WAIT 状态的历史连接,新进程 bind 这个端口就会被内核拒绝。

解决方案就是我在每个示例开头都写的那两行:

int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
  • SO_REUSEADDR 允许 socket 与一个"虽然被占用、但处于 TIME_WAIT 状态"的本地端口重新绑定。它解决的是"time-wait 占着端口导致重启 bind 失败"。所以这句话也被记住:SO_REUSEADDR 不是允许你同时绑同一个端口,而是允许重用处于 TIME_WAIT 的连接所占的端口。
  • 课件里写的 SO_REUSEADDR | SO_REUSEPORT 中,SO_REUSEPORT 是更大的能力:它允许多个 socket 绑定在完全相同的 IP:端口上,由内核做负载均衡般地分发连接。二者用途不同,别混淆。服务器一般至少开 SO_REUSEADDR。

V3:多线程服务器

多进程方案有个天然缺点:创建进程的开销大(要复制地址空间、重开文件描述符表等),连接多了进程数爆炸,系统压力很大。于是有了多线程版本。

思路和多进程几乎一模一样,只是把"fork 一个子进程"换成"创建一个新线程"去服务这位客户,主线程继续 accept。多线程的优势是轻量、创建快;代价是线程共享同一份内存,代码要小心并发竞争。

一个完整可跑的 V3 多线程 server 示例(为了演示线程参数的生命周期,我们采用"堆上分配参数,线程内自行释放"的标准姿势):

// tcp_echo_server_thread.c —— 多线程 TCP 回显服务器(对应课件 V3)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
#define SERV_PORT 9999
#define BACKLOG 5
 
// 每线程的参数:连接套接字 + 客户端地址,堆上分配,线程自己释放
typedef struct {
    int clifd;
    struct sockaddr_in peer;
} ThreadData;
 
// 线程回调:pthread_create 要求签名固定为 void* 返回、单 void* 参数
void* service(void* arg)
{
    // 线程把自己分离,结束时自动回收自身资源,主线程不用 join
    pthread_detach(pthread_self());
 
    ThreadData* td = (ThreadData*)arg;
    int clifd = td->clifd;                 // 取出连接套接字
    char buf[1024];
    ssize_t n;
    printf("thread %ld serving %s:%d\n", pthread_self(),
           inet_ntoa(td->peer.sin_addr), ntohs(td->peer.sin_port));
 
    while ((n = read(clifd, buf, sizeof(buf) - 1)) > 0)
    {
        buf[n] = '\0';
        printf("[%ld] client says: %s\n", pthread_self(), buf);
        write(clifd, buf, n);              // 回显
    }
    // n == 0: 客户端关闭
    close(clifd);                          // 关掉连接套接字
    free(td);                              // 释放堆上的参数
    return NULL;
}
 
int main()
{
    int listenfd, clifd;
    struct sockaddr_in server, client;
    socklen_t len = sizeof(client);
 
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) { perror("socket"); exit(1); }
    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
 
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = htonl(INADDR_ANY);
    server.sin_port = htons(SERV_PORT);
    if (bind(listenfd, (struct sockaddr*)&server, sizeof(server)) < 0)
    { perror("bind"); exit(1); }
    if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); }
 
    printf("thread server listening on %d ...\n", SERV_PORT);
 
    while (1)
    {
        clifd = accept(listenfd, (struct sockaddr*)&client, &len);
        if (clifd < 0) { perror("accept"); continue; }
 
        // 在堆上分配参数,避免"传栈上变量、线程启动后栈变量已失效"的悬垂
        ThreadData* td = (ThreadData*)malloc(sizeof(ThreadData));
        td->clifd = clifd;
        td->peer = client;
 
        pthread_t tid;
        pthread_create(&tid, NULL, service, td);   // 创建线程去伺候这位客户
    }
    return 0;
}

编译要链接 pthread 库:

gcc -o tcp_echo_server_thread tcp_echo_server_thread.c -pthread

几个线程服务的考点,逐个说明:

  • pthread_detach(pthread_self()):把当前线程标记为"分离态"。分离态的含义是"本线程结束后自动回收自身线程资源,主线程无需也不允许再 join 它"。因为我们每个连接都是"自生自灭"的短线程,不分离就会堆积一堆"未 join 的结束线程",跟多进程里的僵尸进程是一个道理。对应课件 threadExcute 第一行 pthread_detach(pthread_self())。
  • 为什么参数要 new/malloc 到堆上:如果直接把局部变量 client 的地址传给新线程,主线程下一秒就回到 accept 循环,栈上的变量马上被下一轮 accept 覆盖——新线程去读就拿到脏数据。所以必须把参数拷到堆上(new / malloc),由线程自己负责释放。这是多线程传参的经典陷阱,课件里用 new ThreadData 正是在做这件事。
  • 回调函数必须返回 void* 且是静态/全局函数:pthread_create 要求回调是"一个参数是 void*、返回 void* 的自由函数"。类成员函数要加 static(课件里正是这么做的),否则传进去的是隐含的 this 参数的成员函数,签名不符。

一个选学的小业务:多线程远程命令执行

课件在 V3 之后插了一个小练习(V3-1):把 echo 改成"客户端发来一条命令,服务器执行并把结果回传"。核心是一个命令白名单类,防止恶意命令被远程执行:

int IsSafe(const char* cmd);   // 只允许 ls / pwd / who / whoami 等白名单命令

原理就两点:

  • 白名单校验:用户每发来一条命令,先查是否在白名单集合里,不在就回 "unsafe",绝不直接执行。这是避免"远程命令注入"(把 rm -rf 之类塞进来执行)的最小保护。
  • popen(cmd, "r") 执行并读回:popen 会 fork 一个子进程去执行 shell 命令,并把命令的输出接到一个管道里,fgets 逐行读出来拼成结果字符串,最后 pclose 关闭。命令若没有输出(比如 touch),就补发一个 "done" 给客户端,保证客户端不至于永远等不到回包。

这个练习真正想让你 get 的是:网络服务的业务逻辑和"收发数据"是两件事,可以像拼乐高一样自由组合——换一个 Service 的实现,服务器就从"回显"变成了"远程命令执行"。这也为后面接入 HTTP 铺了路。

V4:线程池(选学,点到为止)

多线程每次来一个连接就 new 一个线程,高并发时"建线程 + 销毁线程"本身的成本就不小。更优的做法是预先开好一批常驻的工作线程,把"来了一个连接"当成一个任务丢进一个任务队列,由空闲线程去取任务执行,这就是线程池。

课件 V4 的做法是:主循环 accept 拿到连接后,不再 pthread_create,而是把 "服务这个连接" 包成一个 std::function 任务,Push 进线程池的任务队列,线程池内部的工作线程去取出来执行。核心改用 std::bind(把 Service(this, sockfd, addr) 绑定成一个无参可调用对象)塞进去,然后由线程池并发消费。这样线程的总数是固定的,不会随着连接数无限增长,符合"受控并发"的工程直觉。

因课件标注为选学、且线程池本身是另一个大话题(任务队列、互斥锁、条件变量、工作线程循环),这里只建立概念,不做完整实现——我们在讲多线程服务器那节把"每连接一线程"已经跑通,线程池只是把"线程"换成"固定的工人队伍"。

半关闭:深入 connect 后双方各自收发的边界

到这里你已经能写一个像样的服务器了,但 TCP 还有一个很隐蔽、却面试必问的概念值得补上——半关闭(half-close)。

TCP 的"关闭"是分方向的。close() 默认会关闭"读写两个方向";但系统提供了 shutdown(),它可以只关一个方向:

int shutdown(int sockfd, int how);
// how:
//   SHUT_WR: 关闭本端写方向,但仍可读
//   SHUT_RD: 关闭本端读方向,但仍可写
//   SHUT_RDWR: 两个方向都关(等价于 close 的方向层面)

为什么需要半关闭?经典场景是"客户端发完所有数据,想告诉服务器:我没话要说了,但你还得把结果发给我"。此时客户端关掉写方向(SHUT_WR),服务器在 read 上就会读到 0(EOF),明白"对方已经不再发数据了",于是可以安心处理完并回传结果。这正是很多协议(比如 HTTP 的某些请求模式)的底层设计。

注意一个极易混淆的细节:read(sockfd) 返回 0 表示"对端关闭了写方向"(EOF),这既可能是对方 close 了整个连接,也可能只是 shutdown 了写方向。所以服务器端正确地收尾并不一定要立刻 close——它可能会先把要回的数据写完,再关闭自己的方向。服务器收到 EOF 后还继续 write 是被允许的(这正是半关闭的双向不对称性),只是总有一天双方都要把两个方向都关掉才算出清。

错误与边界情况清单(附经验)

把全文里出现的所有"坑"整理成一张速查表,方便你以后对照:

  1. accept 返回的是新连接,不是 listenfd:误用 listenfd 去收发数据,是新手最常见的翻车点。连接的收发永远用 accept 返回的那个 fd。
  2. listen 的 backlog 有上限且语义特殊:它是"已完成握手、排队等 accept"的队列长度,且会被 somaxconn 截断。超出的连接请求会被内核丢弃。
  3. 客户端直接 connect 但不发数据:连接照样能建立成功,服务器阻塞在 read 上干等,谁也不会报错。这属于"健康但空闲"的状态,除非用非阻塞 IO 或超时机制,否则没有直观现象。
  4. 读到 0 表示对端关闭:绝不能把 read 只看 n > 0 就继续,而要显式处理 n == 0(正常对外关闭)和 n < 0(出错)两个分支,否则要么死循环、要么把错误当数据。
  5. close 不等于立刻释放字节:它可能只是把引用计数减一,且主动关闭方会进入 TIME_WAIT。多个 fork/线程共享 fd 时,要确保所有副本都 close。
  6. 服务器重启 bind 失败:多半是 TIME_WAIT 占端口,用 SO_REUSEADDR 解决。
  7. 死循环 fork 导致的进程堆积与僵尸:要么用 waitpid 非阻塞回收 + SIGCHLD 信号,要么用"fork 两次养出孤儿进程"甩给 init。
  8. 多线程传参指针悬垂:参数要拷到堆上、由线程自己释放。

思考题与详解

思考题 1:为什么服务器 bind 之后还必须调用 listen,才能被客户端 connect?

详解:bind 只是把"端口占住",此时该 socket 处于"未监听"状态,内核根本不知道这个 socket 是要拿来"接连接"的。listen 才把 socket 标记为"监听套接字",在内核里为它建立连接队列、开启接收三次握手的能力。只有监听态 socket 才能被 accept 取走连接。如果只 bind 不 listen,客户端 connect 发来的 SYN 会被内核直接丢弃,连接无法建立。可以类比:bind 是"盘下了店面门牌号",listen 是"挂上营业中招牌正式开门"。

思考题 2:accept 返回的 fd 和监听 socket 有什么区别?为什么叫"监听套接字/连接套接字"?

详解:监听套接字(listenfd)只负责接客,它始终处于监听态,从不用来收发任何应用数据,可以持续不断地 accept 出很多个连接;连接套接字(accept 返回的 clifd)则和某一个具体客户端绑定,专用于和它收发数据。二者是"一个老板、无数张桌子"的关系——一张小桌子(一个连接套接字)对应一位客人,老板(监听套接字)继续站在门口招呼下一位。辨别办法:哪个端口对外服务并调用过 listen 的,就是监听套接字;accept 返回的都不带监听职责。

思考题 3:课件 V1 的服务器,为什么第二个客户端连不上?

详解:V1 主流程是 accept 一次后立刻进入 while 循环 read,在第一个客户端不关闭之前,代码永远不会第二次走到 accept。第二个客户端虽然三次握手成功、进入了 accept queue 排队等候,却没有 accept 去取它;当排队人数超过 backlog 后,新来的连接请求被内核丢弃,于是后续客户端表现为连不上或迟迟连不上。本质上:服务器把"接客"和"服务"串行化了,服务期间无人接客。V2 之后就靠"主进程只接客、子进程/线程去服务"解决了这个问题。

思考题 4:read() 返回 0 到底表示什么?

详解:对面向流的 TCP socket,read 返回 0 表示"这一端的流到了 EOF(文件结尾)",即对端已关闭了它的写方向(可能是 close 了整条连接,也可能只是 shutdown 了写方向)。这意味着"对方不会再有任何数据发来"了,是正常的结束信号,必须当作"正常结束"处理而不是报错。负值才表示出错(比如 EINTR 中断、ECONNRESET 对端异常重置等)。所以读循环的正确姿势就是 while ((n = read(...)) > 0) 处理数据,n == 0 收尾关闭,n < 0 按 errno 区分。

思考题 5:客户端 connect 成功,但连上之后一不发数据、二不关闭,服务器端会发生什么?

详解:服务器 accept 会立刻返回这个连接,然后阻塞在 read 上等待数据。因为客户端不发数据也不关,服务器就一直在 read 上挂着,不返回、不报错——这是一个"健康但空闲"的状态,服务器不会崩溃,也没有任何报错现象。这也引出了真实工程里必须面对的问题:一个连接可能长时间不做事,单靠阻塞 IO 无法感知它是否活着,所以大型服务会用非阻塞 IO + 超时/心跳机制来探测和清理这种"僵尸连接"。就目前学的阻塞模型而言,这种现象是被设计成"安静地等"的。

思考题 6:V2 多进程服务器为什么要"fork 两次"(养出孙子进程)?

详解:直接 fork 一个子进程去服务,等子进程退出时如果不被父进程回收,它就成了占用内核资源的僵尸进程。而父进程如果阻塞 waitpid 去逐个回收,又会让 accept 循环卡住,退回"只能服务一个连接"。fork 两次的方案是:子进程 fork 完孙子进程后自己立即 exit(0),父进程 waitpid 回收这个"秒退"的子进程几乎不阻塞、马上回到 accept;而孙子进程因其父已死成为孤儿进程,被 init(PID 1)收养,等它服务完退出时由系统代收尸,不会产生无人认领的僵尸。核心就是把"收尸责任"甩给 init,从而既回收干净又不阻塞接客。

思考题 7:SO_REUSEADDR 解决什么问题?为什么服务器重启会 bind 失败?

详解:主动关闭连接的一方会进入 TIME_WAIT 状态约 2 倍 MSL 的时间,这段时间内那个(IP, 端口)组合还被内核"占用"着。服务器若在仍有 TIME_WAIT 残留连接时重启并再次 bind 同一个端口,内核默认拒绝,报 Address already in use。SO_REUSEADDR 允许该 socket 与处于 TIME_WAIT 的连接所占的本地端口重新绑定,从而保证服务器能快速重启。注意它真正的语义是"重用 TIME_WAIT 状态的连接所占的端口",不是"让两个活的进程同时抢占同一端口"(那是 SO_REUSEPORT 干的事)。

思考题 8:backlog 到底是哪个队列?客户端 connect 时没有 accept 在等,连接会失败吗?

详解:Linux 2.2 起内核有两个队列——半连接队列(syn queue,SYN 已收、三次握手未完成)和已完成队列(accept queue,三次握手已完成、等待应用 accept)。backlog 主要约束 accept queue(已完成队列),且实际值取 min(backlog, somaxconn)。客户端 connect 时只要最终走到了 accept queue 排队,连接就算"建立成功",服务器此刻有没有在 accept 都无所谓——它会安静排队,等服务器有空 accept 时取走。所以"没人 accept"一般不会让已经在队列里的连接失败,只有队列排满之后新来的连接才会被内核丢弃(表现为连接超时或被拒)。

思考题 9:多进程服务器里,SIGCHLD 信号回收和阻塞 waitpid 回收有什么本质区别?

详解:阻塞式 waitpid(pid, ...) 会让当前进程停在那里等某个子进程结束,若在 accept 主循环里这么用,就会卡死接客流程——所以要么配合"秒退子进程"(fork 两次),要么根本不这么用。SIGCHLD 方案则是:内核一检测到子进程终止就给父进程发 SIGCHLD,父进程注册处理函数,在其中用 waitpid(-1, NULL, WNOHANG) 循环回收所有已退出的子进程然后立即返回,主机进程的 accept 循环一次都不被打断。本质区别:阻塞 waitpid 是"主动、逐个、同步地等",适合确实要拿到某个子进程结束结果的场景;SIGCHLD + WNOHANG 是"被动、批量、不等候地收",适合"只求回收干净、且不能阻塞主循环"的高并发服务器场景。

写在最后

从 socket 到 bind,再到 listen、accept,最后到客户端的 connect —— 我们把 TCP 从"无连接"的 UDP 世界里拉了出来,看到了流式 socket 的"顺序完整但无边界",也看到了三次握手的三个关键动作怎样被内核悄悄完成、又怎样在系统调用层面弹出 accept 这一个"取货窗口"。接着我们用一只"只能服务一个客户"的 V1 服务器,亲手演示了把"接客"和"服务"串行化的恶果;再用 fork 出子进程甚至孙子进程、再用线程、再到线程池,一步步把服务器从"单客"升级成"多客"。

这一路真正让你长本事的东西,其实不是那几个系统调用本身,而是它们背后被我们反复抠过的那些"直觉":监听套接字和连接套接字是两个世界、读到 0 才算是对方真的把话说完了、close 和僵尸与 TIME_WAIT 纠缠不清、backlog 不是你想排多少就排多少。这些判断力和对边界的嗅觉,才是网络编程里比 API 更值钱的部分。

而且要注意,我们今天拿 echo 或"远程命令"当练习,是因为它足够小、能让你把注意力集中在"连接如何建立、数据如何流动、结束如何判定"上。可真正到了生产,还有一个绕不开的大课题——应用层协议:TCP 是流式的,我们收发"一行数据"其实没划定边界,那怎么知道"一次完整的消息"到没到齐?这就要靠定长度、定分隔符、或者自定义包格式了。它会是我们下一篇博客(HTTP 与更进阶的收发模型)要啃的第一根硬骨头。先把这个"服务器为什么能同时接待很多人"想透,我们下篇再见。