learn.ochresign
阶段 0 · 补前置

任务 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) 解决。

提醒: 这道题一定要说你真的遇到过的问题,面试官会顺着细节追问。这也是为什么要自己从零写一遍。