任务 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'
为什么会这样
按时间顺序看服务器在干什么:
- A 连上来,
accept返回 A 的 fd,程序进入内层循环。 - 读到 A 的
i am A,写回去,再次调用read(A)。A 没有再发东西,程序卡在read(A)这一行。 - B 连上来。三次握手由内核完成,所以 B 的
nc显示连接成功了。B 的连接进入了“已完成握手”的队列,但程序卡在read(A)上,根本没机会执行accept。 - B 发了
i am B,数据被内核收下,放在 B 连接的接收缓冲区里。没有人去读它。 - A 退出,
read(A)返回 0,程序跳出内层循环,close(A),回到外层循环。 - 执行
accept,取出了一直在队列里排队的 B。 read(B)立刻读到了一直在缓冲区里的i am B,写回去。B 终于收到了回显。
结论:阻塞版的问题是,程序在等一个客户端的时候,什么别的事都做不了。 只要有一个客户端连上了却不说话,所有其他客户端都被卡住。
真实世界里,客户端连上来大部分时间都不说话(你打开一个网页,大部分时间在看,不在点)。所以这样的服务器同一时间只能服务一个人,完全没法用。
能不能每个客户端开一个线程?
可以,每 accept 一个连接就开一个线程去 read 它,主线程继续 accept。连接少的时候没问题。但每个线程要占 8 MB 左右的栈空间(虚拟内存),一万个连接就是一万个线程,线程之间切换的开销也会变得很大。这就是著名的 C10K 问题(同时处理一万个连接)。
真正的解法是下一个任务的 epoll:一个线程同时盯着所有连接,谁有数据就处理谁,没数据的不用等。
第 5 步:做一个 SO_REUSEADDR 的实验
- 把代码里
setsockopt那一行注释掉,重新编译,存成另一个名字:
g++ -Wall -Wextra -o noreuse blocking_echo.cpp
- 终端 1 运行
./noreuse,终端 2 用nc 127.0.0.1 8888连上(不要退出)。 - 在终端 1 按
Ctrl + C关掉服务器(这样是服务器先关闭连接)。 - 马上再运行
./noreuse:
bind: Address already in use
- 用
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。)等一分钟后它消失,就能启动了。
- 把
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 之前都要转换。