把 socket 封成类:一开始觉得多余,后来知道它能省两千行

把 socket 封成类:一开始觉得多余,后来知道它能省两千行
隔壁老康难度 ★★★★★ · 前置:UDP、广播与组播:发出去就完事,到底能省多少事 | 一句话结论:socket 封装的唯一目标是 RAII——文件描述符在自己的析构里
close()。所有「连接泄漏」「fd 耗尽」「异常路径忘了关」的问题,都源自这个目标没达成。其余的设计(错误码、超时、缓冲)都是副产品。
服务跑到第三周,fd 从 200 涨到 14000
我接的那个内部服务上线头两周安安静静,第三周周一早上监控开始报打开文件数过高,我登上去敲的第一条命令不是去看代码,而是先数一数它到底开了多少个,因为在我当时的判断里,fd 这个数一旦上了四位数,问题就不在业务逻辑上了,而应该先去问操作系统。这个判断后来被证明只对了一半,方向是对的,但我对操作系统那部分的理解错得挺离谱,真正的原因在我眼前晃了半个多小时我才认出来。(那会儿我刚接这个服务不到一个月,前任写的注释比代码还少,我连它到底有没有优雅退出都不确定,数 fd 是我唯一有把握的切入点。)
1 | ls /proc/$(pgrep -o myserver)/fd | wc -l |
1 | 14023 |
一万四千多。这个数我盯着看了好一会儿。
我当时的第一个念头很朴素,也觉得挺有道理,就是限制配错了,因为一万四这个量级在我脑子里一直属于系统应该拦下来的那一档,一个正常跑着的服务怎么会自己涨到那儿去,而我对自己那句“肯定是 ulimit 没设”还挺有信心,以至于接下来半小时都在往这个方向查,现在回头看,这个念头的问题不在于它错,而在于它太好验证了,敲一条命令就能拿到一个看着像答案的数字,人一旦拿到一个像答案的东西,就不太愿意再往下问第二层。(那天我还跟旁边的同事说了一句这肯定是配置问题,说得挺笃定。)
1 | ulimit -n |
1 | 1024 |
一千零二十四。它凭什么没崩。
这句疑问把我带偏了半个多小时,我先把每个线程是不是有自己的 fd 上限这个念头排除掉,RLIMIT_NOFILE 是进程级的,不是线程级的,这一点我在文档里反复确认过,然后才想起来去看进程实际生效的那份限制,而不是我自己 shell 里的这份,这两者经常不是一回事。
1 | grep -i 'open files' /proc/$(pgrep -o myserver)/limits |
1 | Max open files 65535 65535 files |
有人在 limits.conf 里把它调到过 65535,只是进程一直没重启,新限制一直没生效,所以它没崩,只是离那条线不远了,而我那条 ulimit -n 看到的是自己的 shell,跟这个进程没有任何关系。
问题于是从它为什么没崩,变成了这一万四千个 fd 到底是谁的。
1 | ls -l /proc/$(pgrep -o myserver)/fd | awk '{print $NF}' | sed 's/[0-9]\+//g' | sort | uniq -c |
1 | 13281 socket: |
一万三千个 socket,七百多个缓存文件。
两个泄漏点,互相独立,前者几乎不用猜,去代码里 grep 一下连接函数就出来了,形态非常经典,正常路径记得关,错误路径提前 return。
这一篇是 C++,g++ -O2 -std=c++17 -o x x.cpp && ./x,要 C++17 是因为用到了 std::optional 和 if constexpr。只会 C 的读者不用慌,模板和移动语义这两块可以扫过去,真正要紧的是析构函数里那一行 close() 出现在哪儿,这件事没有任何 C++ 特有的知识。
1 | int fd = socket(AF_INET, SOCK_STREAM, 0); |
这段代码我盯了很久,因为它没有一行是明显错的,socket 成功了,connect 失败也处理了,日志也老老实实打了,问题在于 return -1 这条路提前走了,close 被留在函数后半段,而那个后半段在那个时刻永远不会执行,这类 bug 最讨厌的地方是 review 的时候看着很顺,因为你的眼睛会自动把那个没写的 close 补上去,我们组后来做了一次很土但有效的规定,凡是函数里出现 socket( 的地方,review 必须把从这一行到函数结尾的每一条 return 都数一遍,数不出来就不给过。(这条规定是一个老同事提的,他自己也漏过一次。)
我第一个补救方案是加个 goto cleanup,后来发现那只是换一种方式治标,因为下一段代码还会有人写成这样,如果 fd 从出生的那一刻就是一个对象、析构里自动 close,这个 bug 在类型层面根本写不出来,后面几节说的都是这句话怎么落地。
一个 socket 类真正要管的四件事
不多不少,就四件。
| 要解决的问题 | 不封装时的样子 |
|---|---|
| 生命周期 | 每条 -1 退出路径都要记得 close,漏一条就泄漏 |
| 错误处理 | 每个系统调用都要判 -1 并转 errno,容易漏 |
| 超时 | connect、read、write 的阻塞没有上限 |
| 半关闭 | shutdown 与 close 的语义差别 |
九成的 socket 泄漏来自第一条。
两种风格,这篇只讲第一种
| 风格 | 例子 | 特点 |
|---|---|---|
| 持有 fd 的对象(RAII) | C++ 的 TcpSocket、Rust 的 TcpStream |
析构即关闭,最安全 |
| 回调式框架 | libuv、asio、Netty | 把状态机也包了,学习成本高 |
第二种当然更强,libuv 那一套把状态机也一起包了,但它是另一个话题,学它的成本跟学一个框架差不多,这篇要的是第一种里最小的那一档,不是最小可用,是最小到不泄漏,这两个标准差得挺远,后者要难得多,因为它要求你把每一条退出路径都数一遍。
形状这件事不用自己发明,抄 std::unique_ptr 就行。
1 | class TcpSocket { |
重点是最后四行,禁止复制,允许移动。
如果允许复制,两个对象析构的时候会对同一个 fd 各 close 一次,而第二次关掉的可能是别人刚打开的新 fd,因为 fd 号会被复用,这种 bug 不崩、不打日志、不报错,只在某个连接莫名其妙断掉的时候露一次头,你还不一定在场,事后去看日志只会看到一片正常,于是你开始怀疑网络、怀疑对面、怀疑机房。这类问题最花时间的从来不是修,而是让别人相信它真的存在,我第一次把它讲清楚是在会议室白板上画了两条时间线,一条画 fd 的出生和关闭,一条画连接本身,两条线错开的那一格就是 bug 所在,画完才有人点头。
记住这条就够了,持有资源的东西只能移动。
Rule of Five 的完整写法
1 | class TcpSocket { |
六个地方不能省。
fd_ = -1是无效的哨兵值,不是0,0是合法的 fd。release()把 fd 交出去但不关,用于转交给别处。close()要幂等(判>= 0),否则析构和手动关闭会撞在一起。- 移动构造直接拿走,不留副本。
- 移动赋值先关自己再接管,而且要判
this != &other,不然a = std::move(a)会把自己关掉。 - 移动操作标
noexcept,抛异常的移动会让容器处于半残状态。
这六条不是背出来的,每一条背后都有一次事故,哨兵值写 0 会在关掉 stdin 之后出问题,close 不幂等,析构和手动关闭就撞在一起,移动赋值不判自赋值,a = std::move(a) 就把自己关掉了。
std::unique_ptr 一字不差也是这么写的,C++ 的 RAII 套路到这儿基本就到底了,再往上走是引用计数那一层,而 fd 这一层通常用不上引用计数,因为连接的所有权是明确的,模糊的时候更该回去改设计,而不是加一层 shared_ptr 把问题盖住。
fd 号会被复用,定时器关错人
1 | TcpSocket s1 = connect_to("10.0.5.9", 8080); // fd = 5 |
新连接拿到了一模一样的 fd 号,这就是复用的全部意思。
如果还有旧的 epoll 里注册着这个号,情况会比你想的复杂一点,不过这部分还好,epoll 是按 fd 号加文件表项跟踪的,fd 号复用时内核会把它当成已经关闭的旧 fd 处理,没先 EPOLL_CTL_DEL 就 close 的话,内核会自动清理。
真正的问题在应用层,而且这类问题在内核文档里找不到,假设有个定时器还存着“fd 5 到点要清理”这条记录,等它到点的时候,fd 5 已经属于一条全新的连接了,它会把那条新连接关掉,而新连接跟它毫无关系,中间没有任何因果关系,你从日志里也看不出这两件事有联系,我第一次碰上这种情况是在一个连接池里,关不掉的连接和关错人的连接同时出现,查了整整两天,最后是靠给每个连接加了一个递增的 ID 才定位到的。
办法只有一条,不在别的地方存裸 fd 号。
存一个连接 ID,或者用 shared_ptr 把生命周期一起持有,两种都行,核心是让“谁的 fd”这个问题在任何地方都能回答,而不是靠一个整数去猜。(连接池这类东西尤其要注意,很多池子内部就存着裸 fd,池子一扩容这种 bug 就冒出来。)
errno 的三个陷阱
1 | int n = ::read(fd_, buf, sizeof buf); |
三个陷阱,一个比一个容易忽略。
EINTR不是错误。被信号打断的系统调用会返回-1并置errno = EINTR,必须重试(除非你用sigaction的SA_RESTART)。EAGAIN和EWOULDBLOCK在 Linux 上是同一个值(都是 11),但可移植代码要判两个。errno可能被覆盖。错误处理路径里调用了别的函数(log_error()里可能调write),errno就变了,要立刻保存。
第三条最阴,它不报错,只让你判错,而且错得很有规律,白天几乎复现不出来。
三十行的 Errc
1 | class Errc { |
Go 的 error 返回值本质就是这个思路,把 errno 变成一个带类型的一等公民,让它不能被忽略,C++ 里手写一个三十行的 Errc 也能达到差不多的效果,代价是每个调用点都要改,而这个代价比想象中大,所以我只在读写的边界上用,中间层还是老老实实判返回值和 errno,而这中间的差别在于你要花多少时间说服别人,一个 Errc 类型的返回值会逼着每个调用点写一行错处理,这一行在多线程里还能保住,而在一个只返回 int 的接口上,你永远不知道下一个人会不会只判一个 < 0。(我见过最省事的写法是让函数返回 bool,连错误码都丢了,后来没人敢动那块代码。)
读和写都没有上限,只能自己给
connect、read、write 的阻塞都可能无限长,内核不会替你兜底,它保证的是没数据就等,不保证等多久算太久。
三种办法,只有一种值得用。
| 方法 | 做法 | 适用 |
|---|---|---|
SO_RCVTIMEO / SO_SNDTIMEO |
设 socket 选项,内核返回时判 EAGAIN |
简单场景,只管读写不管 connect |
非阻塞 + poll/epoll |
自己判超时 | 推荐,能精确控制每阶段 |
alarm + siglongjmp |
信号跳转 | 别用(不可重入,极易出错) |
SO_RCVTIMEO 最省事,也最容易误解。
1 | struct timeval tv{5, 0}; |
超时之后 read 返回 -1,errno 是 EAGAIN 或者 EWOULDBLOCK,这两个在 Linux 上是同一个值,麻烦在于它跟非阻塞暂时没数据的返回值完全一样,你分不出这一次到底是超时了还是对面没说话,这就是生产代码更爱用非阻塞加 poll 的原因,多写十行,换一个能区分的错误码。
非阻塞 connect 的经典写法
1 | int fd = ::socket(AF_INET, SOCK_STREAM, 0); |
SO_ERROR 那一步必不可少,我第一次写的时候就是漏了它,代价是线上一个服务连不上任何下游却报成功,poll 返回可写不代表连接成功,连接失败时 fd 的状态也变了,poll 一样返回 POLLOUT,只有 SO_ERROR 能告诉你真实结果,我当时把 poll 可写直接当成连上了,然后往上写数据,写了几秒才反应过来自己在往一个不存在的连接里灌。
shutdown 关方向,close 关引用
close |
shutdown |
|
|---|---|---|
| 作用对象 | fd(引用计数减一) | 连接的方向(读/写/双向) |
| 影响其他 fd | 不影响(dup 出来的还在) |
影响所有指向它的 fd |
| 何时真正关闭 | 最后一个引用 | 方向立刻关闭 |
| 参数 | 无 | SHUT_RD / SHUT_WR / SHUT_RDWR |
关方向这件事本身就是整篇最反直觉的一处。
最有用的是 SHUT_WR,半关闭。
1 | // 发送完请求后告诉对方:我不会再发了 |
这是请求-响应协议最干净的写法,比用长度前缀或者 Content-Length 更简单,因为结束标记本来就在 TCP 语义里,不用在应用层另外约定一个,HTTP/1.0 的 Connection: close 就是这个意思,很多老协议比如 FTP、SMTP 也用它标记数据发完了;shutdown 关的是一个方向,close 减的是一个引用计数,这两件事被混着讲了很多年,混着用通常也不出错,只在还有别的 fd 指着它的时候才会露馅。(dup 出来的那个 fd 就是最典型的例子,很多人以为 close 一个就彻底没了。)
地址结构体那 128 字节
1 | struct sockaddr_in { ... }; // IPv4 |
sockaddr 只是个名义类型,真正的数据在 sockaddr_in 和 sockaddr_in6 里,而所有函数签名只认那个名义类型,所以实际代码里通常再封一层,把解析地址和算出长度这两件最容易写错的事收进一个地方。
1 | class Endpoint { |
sockaddr_storage 的存在就是为了保证能装下 IPv6 地址,sockaddr_in6 是 28 字节,sockaddr_storage 是 128 字节,多出来的空间是故意留的,反过来用 sockaddr_in 去装 IPv6 就会溢出,这不是课本上的假设,是一类真实存在过的漏洞,我第一份工作里做代码审计的同事就从一个开源库里翻出过一个。
攒成一个头文件
1 | // tcp_socket.h |
这个文件里最要紧的三行。
~TcpSocket() { reset(); }— 所有泄漏问题的终点。SOCK_CLOEXEC— 防止fork+exec之后子进程继承这个 fd,这是一个真实的安全问题。MSG_NOSIGNAL— 对方关闭后send会触发SIGPIPE,默认动作是杀掉整个进程。不想让一个断开的连接把服务带走,就得带上它。
前两行管泄漏和安全,第三行管进程别被打死,三条都不是可选装饰。
用这个类发一次 HTTP 请求
1 | // get.cpp |
1 | # 先把前面那个完整的 tcp_socket.h 存成文件,再写 get.cpp(内容就是上面这段 main) |
这个读循环是靠服务端 Connection: close 来结束的,所以它读到一个 0 就退出了,如果服务端用 Content-Length,你就得自己按长度解析,那就是前两篇讲过的粘包问题,得在应用层维护一个缓冲区,读和写不再是一一对应的关系,这也是为什么 HTTP 库都做得那么厚。
验证:valgrind 数 fd,strace 数 close
验证要分两个角度,两个都得做,因为它们查的不是同一件事。
1 | valgrind --leak-check=full --track-fds=yes ./get |
--track-fds=yes 会把退出时还开着的 fd 报出来,我们只看一个数:
1 | FILE DESCRIPTORS: 3 open (3 std) at exit. |
3 是标准输入输出错误三个,这个数比 3 大就说明有东西没关,这一条应该进 CI,因为它是本地看不出、上线三周才炸这类问题里最便宜的一道防线,跑一次几秒钟,比事后数 fd 便宜太多,而且不需要任何外部依赖。
1 | strace -f -e trace=socket,connect,close -o /tmp/tr.txt ./get |
每个 socket 都应该对应一个 close,数量对不上就是泄漏,这两个手段查的角度不一样,valgrind 看的是退出时的快照,strace 看的是整个过程中有没有配对,前者能抓忘了关,后者还能抓关得太早,而关得太早比忘了关难查得多。
复制一个对象,让编译器报错
1 | // 错误示范(如果编译得过的话) |
这正是 = delete 的价值,它把运行时的双 close 变成了编译错误,错误提前到根本跑不起来的那一步,std::unique_ptr 是同一个道理,只是资源从内存换成了 fd,而 fd 比内存更麻烦,因为内存踩了通常当场崩,fd 关错了要等下一次连接被莫名其妙掐断才会显形。
fd 有没有设 close-on-exec
1 | ls -l /proc/$(pgrep -o myserver)/fd | grep -c socket |
flags 里的 02000000 是 O_CLOEXEC,八进制,等于 0x80000,别和 FD_CLOEXEC 搞混,后者的值是 1,是 fd 表那一层的标志,跟这个位不是一回事;fs/proc/fd.c 打印时是拿 file->f_flags 打底,再靠 if (close_on_exec(fd, fdt)) f_flags |= O_CLOEXEC; 把这一位补上去的,这段我是从内核源码里翻出来的,文档里没写,翻它的起因也很偶然,我先是在 ls -l /proc/self/fd 的输出里看到那个八进制数字对不上自己的预期,才顺着 fs/proc/fd.c 一路找过去;没置位的话,fork 加 exec 出来的子进程会把 socket 一起继承走,子进程莫名其妙占着端口多半就是这么来的,而这类问题的排查顺序永远是先怀疑业务代码,最后才有人想到是继承来的。
这些坑我一个没躲过
上面那一整节的代码,我是分三次才写对的。
下面这几条,每一条我都栽过。
封装是为了代码好看?不是,封装是为了让关闭只发生在一个地方,从而不可能漏,这个区别在写的时候看不出来,在读别人代码的时候特别明显,因为你能一眼看出哪个类保证了这件事,哪个只是把函数包了一层。
用智能指针包一下 close 就够了?可以先这么写。
1 | auto fd = std::unique_ptr<int, void(*)(int*)>(new int(raw), [](int *p) { ::close(*p); delete p; }); |
这么写得留神,::close 的签名是 int(int),而 unique_ptr 的删除器拿到的是指针,所以中间要一层 lambda 把指针拆开,我第一次照着别处的写法直接传 &::close,编译报了一屏类型不匹配,才明白是这一层没对上。
但它不支持移动所有权、不好扩展,而且多一次 new,手写一个三十行的类更清楚,new 出来的那个 int 除了存 fd 什么用都没有,纯粹是为了让 unique_ptr 有个东西可以删。
close() 失败要处理?几乎不用,close() 返回 -1 通常意味着数据已提交但缓冲写失败,NFS 上常见,此时 fd 已经释放,重试也没有意义,有些系统上重试甚至会关掉另一个进程的 fd,这个修一下比不修危险得多,我第一次读到这条的时候不太信,后来在源码注释里看到才接受。
SO_KEEPALIVE 能检测死连接?默认要 2 小时才发现。
1 | int idle = 60, intvl = 10, cnt = 3; |
这样是 1 分 30 秒,比 2 小时实用得多,不过应用层心跳通常更可控,因为它不依赖内核实现,也不受版本差异影响,出事的时候还能自己打印日志。
read 返回 0 是错误?不是,那是对端正常关闭,read 返回 -1 才是错误。这两个返回值在代码里长得太像,我见过不止一个人把它们反着判,结果是客户端一断开,服务端就开始刷错误日志。
send 成功就代表对方收到了?只代表拷贝进了本地发送缓冲区,真正的送达要对方 ACK,而 send 不等这个,它甚至不等数据离开网卡。
shutdown(SHUT_WR) 之后就不能用这个 fd 了?可以,只是写方向关了,读方向还开着。
一个对象对应一个连接是天然成立的?在移动语义下要小心,对象地址变了但 fd 没变,如果别处缓存了这个对象的地址,移动之后那个指针就悬空了,而且悬空的指针看起来还是合法的,因为它指向的那块内存还在,只是里面的内容已经换了,这类问题在 debug 构建里也未必会崩。
这个类最后省下的是什么
- socket 封装的唯一目标是 RAII:fd 在析构里
close(),其余都是副产品。 - 持有资源的类必须禁止复制、只允许移动(Rule of Five),否则会出现双
close,它比泄漏更危险,因为泄漏能数出来,双close数不出来。 fd_ = -1是无效哨兵(0是合法 fd);close()要幂等;移动赋值要判this != &other。EINTR必须重试,EAGAIN是正常状态,errno要立刻捕获。- 非阻塞
connect的SO_ERROR检查不能省,poll可写不代表连接成功。 SO_RCVTIMEO的超时和非阻塞的返回值都是EAGAIN,两者无法区分,要区分就自己用poll算。shutdown(SHUT_WR)是请求-响应协议最干净的结束标记。SOCK_CLOEXEC防继承,MSG_NOSIGNAL防SIGPIPE,这两个都是容易忘的稳定性细节。valgrind --track-fds=yes和strace -e trace=socket,close能把泄漏暴露出来,应该进 CI。
这个头文件一百二十多行,它替掉的是每个调用点重复的 close 和错误判断。
而在那个服务里,同样的活最后省下两千行上下,我不觉得这是面向对象赢了,因为省下来的那两千行本来就不该写,它们只是被复制粘贴了四十多遍;真正有价值的部分是那次 fd 泄漏之后再也没出现过,而这件事跟面向对象三个字没关系,只跟关闭只发生在一个地方有关系。这两千行是我一行行数出来的,用的是最笨的办法,把新文件和老文件放在两个窗口里按函数对,对完再回头看一眼 valgrind 那个 3。
再往下走就是另一件事了,读内核源码。真到了那一步,Documentation/ 比代码有用,git blame 比注释有用,而且在打开编辑器之前,最好先想清楚你要回答的是哪个具体问题,这一点比手上有什么工具重要得多。










