任务 8:不看代码,从零重写 epoll echo server
阶段 0 的毕业考试。给你一个只有注释的骨架、分 6 关逐步验证的路线,以及一张“症状 → 原因”的排错表。写出来、测通过,阶段 0 就过关了。
前提: 完成了任务 1 到任务 7。
规则: 写的过程中不能打开任务 6、任务 7 的代码,也不能问 AI 要代码。可以查
man手册(比如man 2 accept)和 cppreference,这些是面试时也被允许的“工具书”。预计用时: 第一次可能要 3 到 5 小时。一周后再重写一次,争取 1 小时以内。
为什么要重写
看懂和会写是两回事。你现在大概能看懂 epoll echo 的每一行,但面试官让你在白板上写,或者阶段 1 要你在它的基础上拆类的时候,“好像在哪见过”是不够的。
从零写一遍,你会发现很多“我以为我懂了”的地方:accept 的参数顺序、epoll_event 怎么填、errno 什么时候判断……每卡住一次,就是一次真正的学习。
准备
mkdir -p ~/code/cpp-practice/task8
cd ~/code/cpp-practice/task8
code .
新建 server.cpp,把下面这个只有注释的骨架抄进去。你的任务是把每个 TODO 换成代码:
// 从零重写的 epoll echo server
// TODO: 需要哪些头文件?(提示:一共 9 个左右,写到哪个函数报“未声明”再查它在哪个头文件里)
// TODO: 写一个函数,把 fd 设成非阻塞
int main() {
// TODO: 忽略 SIGPIPE
// TODO: 创建监听 socket
// TODO: 设置 SO_REUSEADDR
// TODO: 填好 sockaddr_in:IPv4、所有网卡、端口 8888(别忘了字节序)
// TODO: bind,失败就打印原因并退出
// TODO: listen
// TODO: 把监听 fd 设成非阻塞
// TODO: 创建 epoll 实例
// TODO: 把监听 fd 注册进 epoll,关心可读事件
// TODO: 准备输出缓冲区(每个连接一个)
while (true) {
// TODO: epoll_wait 等事件
// TODO: 遍历每个就绪的 fd
// 如果是监听 fd:循环 accept 直到 EAGAIN,每个新连接设非阻塞、注册进 epoll
// 如果是普通连接:
// 可读:read,按四种返回值分别处理;读到的数据放进输出缓冲区并尝试写出去
// 可写:继续写输出缓冲区,写完就不再关心可写事件
}
}
分 6 关写,每关都要验证通过再往下
一次写完再编译,大概率会得到一屏幕的错误,而且不知道从哪改起。所以分关来写,每一关都编译、运行、验证。
第 1 关:能启动,能监听
写完从开头到 listen 的部分,在 listen 后面加一行 printf("listening\n"); 和一个 while (true) pause();(pause 在 <unistd.h> 里,让程序一直睡着)。
验证:
g++ -Wall -Wextra -o server server.cpp && ./server
另一个终端:
ss -tlnp | grep 8888
能看到 LISTEN 并且进程名是 server,就过关了。再试试 nc 127.0.0.1 8888,能连上(虽然没有回显,因为还没 accept)。
第 2 关:能接受连接
删掉 pause 那行。写 epoll 的创建、注册监听 fd、epoll_wait,以及“监听 fd 就绪时循环 accept”的部分。每 accept 一个就 printf("new client fd=%d\n", conn_fd);。先不用把新连接注册进 epoll。
验证: 用 nc 连三次,服务器应该打印三行 new client,fd 分别是 5、6、7(为什么不是 4?想想 epfd)。
第 3 关:能回显
把新连接注册进 epoll(关心 EPOLLIN),写“普通连接可读”的处理:读到就直接 write 回去,读到 0 或出错就从 epoll 删除并 close。
验证:
python3 ../task6/test_clients.py rewrite 500
输出 rewrite echo ok: 500 / 500。
第 4 关:断开不泄漏
连接断开时打印 client fd=%d closed。
验证: 用 nc 连上再 Ctrl + C 断开,重复几次。每次新连接拿到的 fd 应该都是同一个号码(比如一直是 5)。如果号码越来越大,说明 close 没执行,fd 泄漏了。
再用 ss -tan | grep 8888 确认没有残留的 CLOSE-WAIT。
第 5 关:写不完也不丢
加上输出缓冲区、flush 函数、EPOLLOUT 的开关。
验证:
python3 ../task7/big_test.py
输出 OK。然后再跑一次第 3 关的测试,确认没有改坏。
第 6 关:打开 ASan 跑一遍
g++ -Wall -Wextra -g -fsanitize=address -o server_asan server.cpp
./server_asan
另一个终端把第 3 关和第 5 关的测试都跑一遍,然后在服务器终端按 Ctrl + C。ASan 没有打印任何 ERROR 就过关了。
排错表:症状 → 原因
卡住的时候先查这张表:
| 症状 | 最可能的原因 |
|---|---|
bind: Address already in use |
上一个服务器还在运行(ss -tlnp 看看),或者忘了 SO_REUSEADDR |
启动正常,但 nc 连 8888 显示 Connection refused |
端口忘了 htons,实际监听在别的端口。用 ss -tlnp 看真正监听的端口 |
编译报 'xxx' was not declared in this scope |
少了头文件。man 2 xxx 第一部分 SYNOPSIS 会写需要哪个头文件 |
| 第一个客户端能回显,第二个卡住 | 某个 fd 忘了设非阻塞,程序卡在 accept 或 read 上。用 gdb -p 进程号 挂上去,bt 看卡在哪 |
| 连了很多个,只有一部分被 accept | accept 没放在循环里,一次通知只接了一个 |
| CPU 一直 100% | 一直关心着 EPOLLOUT 没有取消;或者 epoll_wait 的超时传了 0 |
| 每次新连接的 fd 越来越大 | 断开时没有 close |
| 服务器莫名其妙退出,没有任何报错 | 没有忽略 SIGPIPE。用 echo $? 看退出码,141 就是被 SIGPIPE 杀的 |
big_test.py 显示 DATA LOST |
没有处理 write 返回值小于要写的长度,或者没有关心 EPOLLOUT |
Segmentation fault |
用 gdb 跑,bt 看崩在哪一行;或者用 ASan 编译再跑 |
测试脚本报 Too many open files |
默认最多 1024 个 fd,在两个终端里都执行 ulimit -n 20000 |
写完之后,对照检查
现在可以打开任务 7 的 echo_buffered.cpp,和你写的逐段对比。重点看:
- 所有系统调用的返回值,你都处理了吗?
errno的判断,是在系统调用返回 -1 之后马上判断的吗?(中间如果调用了别的函数,比如printf,errno可能被改掉。)- 你的代码哪里比参考答案写得更好?哪里更差?
把你的版本提交上去:
cd ~/code/cpp-practice
git add task8
git commit -m "Rewrite epoll echo server from scratch"
git push
阶段 0 过关标准
全部做到,就可以进入阶段 1:
- 不看任何资料,独立写出了 epoll echo server(6 关全部通过)
-
python3 test_clients.py epoll 500输出500 / 500 -
big_test.py输出OK - ASan 下跑完测试没有报错
- 能回答路线页“阶段 0”右边的 6 个面试题,以及面试题页里阶段 0 的题目
自测
为什么要分关写,而不是一口气写完再编译?
一口气写完,错误会混在一起:编译错误、逻辑错误、漏掉的步骤同时出现,不知道从哪里改起。分关写的话,每一关只多了一小段代码,出了问题一定在这一小段里,而且之前的部分已经验证过是对的。这也是阶段 1 写网络库时的方法:每加一个类,都要有一个能跑的例子验证它。
服务器退出码是 141,说明什么?
退出码大于 128 表示进程是被信号杀死的,减去 128 就是信号编号。141 - 128 = 13,13 号信号是 SIGPIPE:往一个已经关闭的连接写数据了。解决办法是 signal(SIGPIPE, SIG_IGN)。
“errno 要在系统调用返回 -1 之后马上判断”,为什么?
errno 是一个全局(准确说是每个线程一个)变量,任何系统调用或库函数失败时都可能改写它。如果在 read 返回 -1 之后先调用了 printf 再判断 errno,printf 内部的操作可能已经把它改成了别的值,你看到的就不是 read 的错误原因了。
面试常问
现场手写一个 epoll 服务器的主循环。
参考回答思路: 面试时一般不要求写完整代码,而是写主干并说清楚关键点。按顺序写:创建监听 socket、SO_REUSEADDR、bind、listen、设非阻塞;epoll_create1;把监听 fd 用 EPOLL_CTL_ADD 注册 EPOLLIN;while 循环里 epoll_wait,遍历就绪事件:监听 fd 就循环 accept 到 EAGAIN,新连接设非阻塞并注册;普通 fd 就 read,处理返回值大于 0、等于 0、EAGAIN 和其他错误四种情况。写完主动补充:write 可能写不完,需要输出缓冲区和 EPOLLOUT;要忽略 SIGPIPE。主动说出这些坑,比代码写得一字不差更加分。
你说你写过 epoll 服务器,遇到过什么问题?怎么解决的?
参考回答(按你的真实经历改): 举两三个具体的。比如:用测试脚本发 20 MB 数据时发现只收回来 4 MB,原因是非阻塞 write 只写进去一部分,剩下的被丢了,后来给每个连接加了输出缓冲区,写不完就注册 EPOLLOUT,写完再取消;还有一次服务器莫名其妙退出,退出码是 141,查到是对端关闭后继续写触发了 SIGPIPE,加了 signal(SIGPIPE, SIG_IGN) 解决。
提醒: 这道题一定要说你真的遇到过的问题,面试官会顺着细节追问。这也是为什么要自己从零写一遍。