learn.ochresign
阶段 0 · 补前置

任务 5:跑通阻塞版 echo server,亲眼看到它的问题

逐行讲解一个 40 行的 echo 服务器:每个系统调用做了什么、每个参数是什么意思、为什么要这么写。然后开三个终端做实验,亲眼看到第二个客户端为什么没反应。

前提: 完成 任务 2(会编译)和 任务 4(知道端口和三次握手)。

这篇做完你会: 读懂并自己写出一个 TCP 服务器;知道 socket、bind、listen、accept、read、write、close 每一个是干什么的;理解“阻塞”到底卡在哪里。

预计用时: 3 小时。

echo server 是什么

echo 的意思是“回声”。echo server 是最简单的服务器:客户端发来什么,它就原样发回去。它没有任何业务逻辑,正好用来学习网络编程本身。

我们要写的这个版本叫阻塞版,它一次只能服务一个客户端。我们会先跑通它,再亲眼看到它的问题,下一个任务再解决。

第 1 步:先把代码敲出来

建目录:

mkdir -p ~/code/cpp-practice/task5
cd ~/code/cpp-practice/task5
code .

新建 blocking_echo.cpp,一个字一个字地敲,注释可以不敲:

blocking_echo.cpp 下载

// 第一课:最简单的阻塞 echo server,一次只能服务一个客户端
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>

int main() {
    // 1. socket:向系统要一个“插座”(文件描述符 fd)
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. bind:把插座绑定到 本机任意IP:8888
    sockaddr_in addr{};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(8888);
    if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }

    // 3. listen:开始营业,等客人上门
    listen(listen_fd, 128);
    printf("listening on 8888\n");

    while (true) {
        // 4. accept:接待一个客人,拿到和他专属的 conn_fd(没人来就一直卡在这)
        int conn_fd = accept(listen_fd, nullptr, nullptr);
        printf("new client fd=%d\n", conn_fd);

        char buf[1024];
        while (true) {
            // 5. read:等客人说话(不说话就一直卡在这)
            ssize_t n = read(conn_fd, buf, sizeof(buf));
            if (n <= 0) break;            // 0 = 客人挂断,<0 = 出错
            write(conn_fd, buf, n);       // 6. write:原样说回去
        }
        close(conn_fd);
        printf("client fd=%d closed\n", conn_fd);
    }
}

编译:

g++ -Wall -Wextra -o blocking_echo blocking_echo.cpp

没有输出就是编译成功了。先别运行,我们一行一行看懂它。

第 2 步:逐行看懂

头文件(第 2–7 行)

头文件 提供了什么
<arpa/inet.h> htons、htonl 这些字节序转换函数
<netinet/in.h> sockaddr_in 结构体、INADDR_ANY 常量
<sys/socket.h> socket、bind、listen、accept、setsockopt
<unistd.h> read、write、close
<cstdio> printf、perror

这些都是 Linux 系统的头文件,Windows 上没有,所以这份代码只能在 Linux 上编译。

socket:要一个“插座”(第 11 行)

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

socket() 向操作系统申请一个网络通信的端点,返回一个整数,叫文件描述符(file descriptor,简称 fd)。

fd 是什么? Linux 里“一切皆文件”:打开的文件、网络连接、管道,在程序看来都是一个整数编号。你拿着这个编号去调用 read、write、close,操作系统就知道你要操作的是哪个东西。每个进程一启动就有 0、1、2 三个 fd(标准输入、标准输出、标准错误),所以新申请的 fd 一般从 3 开始。

三个参数:

  • AF_INET:用 IPv4 地址。(IPv6 是 AF_INET6。)
  • SOCK_STREAM:用流式传输,也就是 TCP。(UDP 是 SOCK_DGRAM。)
  • 0:协议号,0 表示根据前两个参数自动选择,这里就是 TCP。

setsockopt:允许端口复用(第 12–13 行)

int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

给这个 socket 设置一个选项:SO_REUSEADDR 等于 1(打开)。

作用是:服务器重启时,即使 8888 端口上还有处于 TIME_WAIT 状态的旧连接,也允许 bind 成功。没有这一行,你按 Ctrl + C 关掉服务器再马上重启,经常会看到 bind: Address already in use,要等一分钟才能启动。后面第 5 步会做这个实验。

填地址(第 16–19 行)

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);

sockaddr_in 是一个结构体,用来表示一个 IPv4 地址加端口。{} 把它的所有字段初始化为 0,这一步不能省,结构体里还有一些填充字段,不清零可能是垃圾值。

  • sin_family = AF_INET:这是一个 IPv4 地址。
  • sin_addr.s_addr = htonl(INADDR_ANY):INADDR_ANY 就是 0.0.0.0,意思是“本机的所有网卡”。这样不管客户端是从 127.0.0.1 连过来,还是从局域网 IP 连过来,都能连上。
  • sin_port = htons(8888):端口 8888。

htons 和 htonl 是什么? 不同的 CPU 存储多字节整数的顺序不一样。比如 8888 用十六进制表示是 0x22B8,x86 CPU 在内存里存成 B8 22(低位在前,叫小端),而网络协议规定一律用 22 B8(高位在前,叫大端,也叫网络字节序)。

  • htons = host to network short:把 16 位的整数(端口号)从本机顺序转成网络顺序。
  • htonl = host to network long:转 32 位的整数(IPv4 地址)。
  • 反过来是 ntohs、ntohl。

忘了 htons 会怎样? 程序不会报错,但实际监听的端口会变成 0xB822,也就是 47138。你用 nc 127.0.0.1 8888 怎么都连不上。这是新手最常见的 bug 之一。

bind:把插座固定在 8888 端口(第 20 行)

if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }

bind 把刚才的 socket 和地址绑在一起,从此发到“本机 8888 端口”的连接请求都归它管。

  • 第二个参数要求类型是 sockaddr*(通用地址类型),而我们的是 sockaddr_in(IPv4 专用),所以要强制转换。这是 C 语言时代的接口设计,照写就行。
  • 返回值小于 0 表示失败。perror("bind") 会打印 bind: 加上失败原因,比如 bind: Address already in use(端口被占用)或 bind: Permission denied(1024 以下的端口需要 root 权限)。

所有系统调用都要检查返回值。 这份代码为了简短只检查了 bind,阶段 1 写网络库时每一个都要检查。

listen:开始营业(第 23 行)

listen(listen_fd, 128);

把 socket 变成监听状态,从此内核会替它处理三次握手。

第二个参数 128 叫 backlog:已经完成三次握手、但程序还没来得及 accept 的连接,最多排多少个。队列满了,新的连接请求会被丢弃。

从这一刻起,客户端就能连上了。 即使程序还没调用 accept,三次握手也会由内核完成。

accept:接待一个客人(第 28 行)

int conn_fd = accept(listen_fd, nullptr, nullptr);

从“已完成握手”的队列里取出一个连接,返回一个新的 fd,专门用来和这个客户端通信。

这里有两个 fd,一定要分清:

  • listen_fd:监听用的,只负责“接新客人”,从来不用它收发数据。整个程序只有一个。
  • conn_fd:每个客户端一个,用来和这个客户端收发数据。

后两个参数可以用来获取客户端的 IP 和端口,这里传 nullptr 表示不关心。(课后练习会让你把它打印出来。)

accept 是阻塞的: 如果队列里没有连接,程序就停在这一行,什么也不做,直到有客户端连上来。

read 和 write:收发数据(第 31–37 行)

char buf[1024];
while (true) {
    ssize_t n = read(conn_fd, buf, sizeof(buf));
    if (n <= 0) break;
    write(conn_fd, buf, n);
}

read(conn_fd, buf, sizeof(buf)):从连接里读数据,最多读 1024 字节,放进 buf。返回值 n:

返回值 含义 怎么处理
n > 0 读到了 n 个字节 处理数据
n == 0 对方关闭了连接(收到了 FIN) 关闭这个连接
n < 0 出错了 关闭这个连接

read 也是阻塞的: 如果对方还没发数据,程序就停在这一行等着。

read 不保证一次读完对方发的所有数据,也不保证只读到一条消息。 TCP 是字节流,对方发了 2000 字节,你可能第一次读到 1024,第二次读到 976;对方连发两条消息,你可能一次就读到了两条拼在一起的。echo server 原样发回,所以不用关心消息边界;到了阶段 2 解析 Redis 命令时,这就是必须处理的问题。

write(conn_fd, buf, n):把刚读到的 n 个字节写回去。

ssize_t 是“有符号的 size_t”,因为 read 可能返回 -1,必须用有符号类型接收。

close:送客(第 38 行)

close(conn_fd);

关闭这个连接,释放 fd。每个 accept 得到的 fd 都必须 close,否则就是 fd 泄漏,任务 4 里看到的 CLOSE_WAIT 就是这么来的。

然后回到外层 while 循环的开头,accept 下一个客户端。

第 3 步:运行,先用一个客户端试试

开终端 1,运行服务器:

./blocking_echo

输出 listening on 8888,然后停在那里不动了。它正卡在 accept 那一行,等客户端。

开终端 2,用 nc 连上去:

nc 127.0.0.1 8888

终端 1 马上打印:

new client fd=4

为什么是 4?0、1、2 是标准输入输出,3 是 listen_fd,所以第一个连接是 4。

在终端 2 里输入 hello 回车,会立刻看到一行 hello,这就是服务器发回来的。输入什么都会原样回来。

在终端 2 按 Ctrl + C 退出 nc。终端 1 打印:

client fd=4 closed

nc 退出时关闭了连接,服务器的 read 返回 0,跳出内层循环,close 掉连接,又回到 accept 等下一个。

第 4 步:用两个客户端,看到问题

保持终端 1 的服务器在运行。

终端 2:

nc 127.0.0.1 8888

输入 i am A 回车,马上收到回显 i am A。不要退出这个 nc。

再开终端 3:

nc 127.0.0.1 8888

输入 i am B 回车。没有任何回显。 等多久都没有。终端 1 也没有打印 new client。

现在回到终端 2,按 Ctrl + C 退出客户端 A。

奇怪的事情发生了:终端 3 立刻收到了 i am B,终端 1 打印:

client fd=4 closed
new client fd=4

我用脚本做了同样的实验,实际结果:

A got: b'i am A\n'
B got nothing (timeout)
B got after A closed: b'i am B\n'

为什么会这样

按时间顺序看服务器在干什么:

  1. A 连上来,accept 返回 A 的 fd,程序进入内层循环。
  2. 读到 A 的 i am A,写回去,再次调用 read(A)。A 没有再发东西,程序卡在 read(A) 这一行。
  3. B 连上来。三次握手由内核完成,所以 B 的 nc 显示连接成功了。B 的连接进入了“已完成握手”的队列,但程序卡在 read(A) 上,根本没机会执行 accept。
  4. B 发了 i am B,数据被内核收下,放在 B 连接的接收缓冲区里。没有人去读它。
  5. A 退出,read(A) 返回 0,程序跳出内层循环,close(A),回到外层循环。
  6. 执行 accept,取出了一直在队列里排队的 B。
  7. read(B) 立刻读到了一直在缓冲区里的 i am B,写回去。B 终于收到了回显。

结论:阻塞版的问题是,程序在等一个客户端的时候,什么别的事都做不了。 只要有一个客户端连上了却不说话,所有其他客户端都被卡住。

真实世界里,客户端连上来大部分时间都不说话(你打开一个网页,大部分时间在看,不在点)。所以这样的服务器同一时间只能服务一个人,完全没法用。

能不能每个客户端开一个线程?

可以,每 accept 一个连接就开一个线程去 read 它,主线程继续 accept。连接少的时候没问题。但每个线程要占 8 MB 左右的栈空间(虚拟内存),一万个连接就是一万个线程,线程之间切换的开销也会变得很大。这就是著名的 C10K 问题(同时处理一万个连接)。

真正的解法是下一个任务的 epoll:一个线程同时盯着所有连接,谁有数据就处理谁,没数据的不用等。

第 5 步:做一个 SO_REUSEADDR 的实验

  1. 把代码里 setsockopt 那一行注释掉,重新编译,存成另一个名字:
g++ -Wall -Wextra -o noreuse blocking_echo.cpp
  1. 终端 1 运行 ./noreuse,终端 2 用 nc 127.0.0.1 8888 连上(不要退出)。
  2. 在终端 1 按 Ctrl + C 关掉服务器(这样是服务器先关闭连接)。
  3. 马上再运行 ./noreuse:
bind: Address already in use
  1. 用 ss -tan | grep 8888 看,会有一条:
TIME-WAIT 0      0          127.0.0.1:8888      127.0.0.1:41744

服务器是主动关闭方,所以 8888 这一侧留下了 TIME_WAIT,它挡住了新的 bind。(如果终端 2 的 nc 还没退出,你看到的可能是 FIN-WAIT-2,同样会挡住 bind。)等一分钟后它消失,就能启动了。

  1. 把 setsockopt 那一行恢复,重新编译 blocking_echo,等一分钟让旧的 TIME_WAIT 消失后,再重复第 2 到第 4 步:这次能立刻重启。

(注意第 6 步要先等旧的 TIME_WAIT 消失:Linux 要求旧连接和新 socket 都设置了 SO_REUSEADDR 才允许复用,第 3 步留下的那个旧连接没设置。)

交作业

cd ~/code/cpp-practice
git add task5
git commit -m "Add blocking echo server"
git push

检查清单

  • 自己敲出了代码,编译没有警告
  • 能说出 listen_fd 和 conn_fd 的区别
  • 能说出 htons 是干什么的,忘了它会怎样
  • 能说出 read 返回 0、正数、-1 各代表什么
  • 做了两个客户端的实验,能按时间顺序讲清楚 B 为什么要等 A 退出才收到回显
  • 做了 SO_REUSEADDR 的实验,看到了 Address already in use

自测

B 的 nc 在 A 退出前就显示连接成功了,可是服务器还没 accept,这是为什么?

三次握手是内核完成的,和程序有没有调用 accept 无关。listen 之后,内核就会替这个端口处理握手,握手完成的连接放进队列。accept 只是从队列里取出一个。

为什么第二个连接的 fd 也是 4?

第一个连接 close 之后,fd 4 被释放了。Linux 分配 fd 时总是给出最小的空闲号码,所以第二个连接又拿到了 4。这也说明 close 之后绝不能再用旧的 fd 号,它可能已经属于别的连接了。

服务器程序卡在哪几个地方?

两个地方:accept(没有新连接时卡住)和 read(当前连接没有数据时卡住)。阻塞版的问题就是卡在其中一个时,另一个没法处理。

面试常问

说一下 TCP 服务器端的编程流程。

参考回答: socket 创建套接字;setsockopt 设置 SO_REUSEADDR;bind 绑定 IP 和端口;listen 开始监听,内核开始处理三次握手;循环里 accept 取出已建立的连接,得到一个新的 fd;用这个 fd read / write 收发数据;read 返回 0 表示对方关闭,close 释放 fd。客户端是 socket、connect、read / write、close。

listen 的 backlog 参数是什么意思?

参考回答: 内核为监听 socket 维护两个队列:半连接队列存收到 SYN、还没完成握手的连接;全连接队列存已经完成握手、等着被 accept 的连接。现在 Linux 上 backlog 限制的是全连接队列的长度,实际值是它和 net.core.somaxconn 中较小的那个。服务器 accept 太慢导致队列满了,新的连接就会被丢弃,客户端表现为连接超时。

为什么要用网络字节序?

参考回答: 不同 CPU 存多字节整数的顺序不同,x86 是小端(低位字节在前),有些是大端。网络上的两台机器可能架构不同,所以协议规定统一用大端,也就是网络字节序。发送前用 htons / htonl 转换,收到后用 ntohs / ntohl 转回来。端口和 IP 地址写进 sockaddr_in 之前都要转换。