把 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
2
3
13281 socket:
742 /data/cache/
0 pipe:

一万三千个 socket,七百多个缓存文件。

两个泄漏点,互相独立,前者几乎不用猜,去代码里 grep 一下连接函数就出来了,形态非常经典,正常路径记得关,错误路径提前 return。

这一篇是 C++,g++ -O2 -std=c++17 -o x x.cpp && ./x,要 C++17 是因为用到了 std::optional 和 if constexpr。只会 C 的读者不用慌,模板和移动语义这两块可以扫过去,真正要紧的是析构函数里那一行 close() 出现在哪儿,这件事没有任何 C++ 特有的知识。

1
2
3
4
5
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (connect(fd, ...) < 0) {
log_error("connect failed");
return -1; // 这里漏了 close(fd)
}

这段代码我盯了很久,因为它没有一行是明显错的,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
2
3
4
5
6
7
8
9
10
11
class TcpSocket {
public:
TcpSocket() noexcept;
explicit TcpSocket(int fd) noexcept; // 接管已有的 fd
~TcpSocket(); // 关掉
TcpSocket(TcpSocket&&) noexcept; // 移动构造
TcpSocket& operator=(TcpSocket&&) noexcept; // 移动赋值
TcpSocket(const TcpSocket&) = delete; // 禁止复制
TcpSocket& operator=(const TcpSocket&) = delete;
// ...
};

重点是最后四行,禁止复制,允许移动。

如果允许复制,两个对象析构的时候会对同一个 fd 各 close 一次,而第二次关掉的可能是别人刚打开的新 fd,因为 fd 号会被复用,这种 bug 不崩、不打日志、不报错,只在某个连接莫名其妙断掉的时候露一次头,你还不一定在场,事后去看日志只会看到一片正常,于是你开始怀疑网络、怀疑对面、怀疑机房。这类问题最花时间的从来不是修,而是让别人相信它真的存在,我第一次把它讲清楚是在会议室白板上画了两条时间线,一条画 fd 的出生和关闭,一条画连接本身,两条线错开的那一格就是 bug 所在,画完才有人点头。

记住这条就够了,持有资源的东西只能移动。

Rule of Five 的完整写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class TcpSocket {
public:
TcpSocket() = default;

explicit TcpSocket(int fd) : fd_(fd) {}

~TcpSocket() { close(); }

TcpSocket(const TcpSocket&) = delete;
TcpSocket& operator=(const TcpSocket&) = delete;

TcpSocket(TcpSocket&& other) noexcept : fd_(other.release()) {}

TcpSocket& operator=(TcpSocket&& other) noexcept {
if (this != &other) {
close();
fd_ = other.release();
}
return *this;
}

int release() noexcept { // 放弃所有权,返回 fd
int fd = fd_;
fd_ = -1;
return fd;
}

void close() noexcept {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}

bool valid() const noexcept { return fd_ >= 0; }
int get() const noexcept { return fd_; }

private:
int fd_ = -1;
};

六个地方不能省。

  1. fd_ = -1 是无效的哨兵值,不是 0,0 是合法的 fd。
  2. release() 把 fd 交出去但不关,用于转交给别处。
  3. close() 要幂等(判 >= 0),否则析构和手动关闭会撞在一起。
  4. 移动构造直接拿走,不留副本。
  5. 移动赋值先关自己再接管,而且要判 this != &other,不然 a = std::move(a) 会把自己关掉。
  6. 移动操作标 noexcept,抛异常的移动会让容器处于半残状态。

这六条不是背出来的,每一条背后都有一次事故,哨兵值写 0 会在关掉 stdin 之后出问题,close 不幂等,析构和手动关闭就撞在一起,移动赋值不判自赋值,a = std::move(a) 就把自己关掉了。

std::unique_ptr 一字不差也是这么写的,C++ 的 RAII 套路到这儿基本就到底了,再往上走是引用计数那一层,而 fd 这一层通常用不上引用计数,因为连接的所有权是明确的,模糊的时候更该回去改设计,而不是加一层 shared_ptr 把问题盖住。

fd 号会被复用,定时器关错人

1
2
3
TcpSocket s1 = connect_to("10.0.5.9", 8080);   // fd = 5
s1.close(); // fd 5 被回收
TcpSocket s2 = connect_to("10.0.5.10", 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
2
3
4
5
6
7
int n = ::read(fd_, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue; // 被信号打断,重试
if (errno == EAGAIN || errno == EWOULDBLOCK) return kWouldBlock;
return kError;
}
if (n == 0) return kPeerClosed; // 对端关闭

三个陷阱,一个比一个容易忽略。

  1. EINTR 不是错误。被信号打断的系统调用会返回 -1 并置 errno = EINTR,必须重试(除非你用 sigaction 的 SA_RESTART)。
  2. EAGAIN 和 EWOULDBLOCK 在 Linux 上是同一个值(都是 11),但可移植代码要判两个。
  3. errno 可能被覆盖。错误处理路径里调用了别的函数(log_error() 里可能调 write),errno 就变了,要立刻保存。

第三条最阴,它不报错,只让你判错,而且错得很有规律,白天几乎复现不出来。

三十行的 Errc

1
2
3
4
5
6
7
8
9
10
11
12
class Errc {
public:
explicit Errc(int e) : code_(e) {}
int code() const noexcept { return code_; }
std::string str() const { return ::strerror(code_); }
private:
int code_;
};

// 用法
int n = ::read(fd_, buf, n);
if (n < 0) return Errc(errno); // 立刻捕获

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
2
struct timeval tv{5, 0};
setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);

超时之后 read 返回 -1,errno 是 EAGAIN 或者 EWOULDBLOCK,这两个在 Linux 上是同一个值,麻烦在于它跟非阻塞暂时没数据的返回值完全一样,你分不出这一次到底是超时了还是对面没说话,这就是生产代码更爱用非阻塞加 poll 的原因,多写十行,换一个能区分的错误码。

非阻塞 connect 的经典写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
set_nonblocking(fd);
int r = ::connect(fd, ...);
if (r == 0) return TcpSocket(fd); // 立即成功(罕见)
if (errno != EINPROGRESS) { ::close(fd); return ...; }

pollfd pfd{fd, POLLOUT, 0};
if (::poll(&pfd, 1, 5000) <= 0) { // 5 秒超时
::close(fd);
return kTimeout;
}

int err = 0;
socklen_t len = sizeof err;
::getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); // 关键一步!
if (err != 0) { ::close(fd); return Errc(err); }

clear_nonblocking(fd); // 之后想阻塞就恢复
return TcpSocket(fd);

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
2
3
4
// 发送完请求后告诉对方:我不会再发了
::shutdown(fd_, SHUT_WR);
// 但仍然可以从这个 fd 读响应,对方会收到 FIN
while (::read(fd_, buf, sizeof buf) > 0) { ... }

这是请求-响应协议最干净的写法,比用长度前缀或者 Content-Length 更简单,因为结束标记本来就在 TCP 语义里,不用在应用层另外约定一个,HTTP/1.0 的 Connection: close 就是这个意思,很多老协议比如 FTP、SMTP 也用它标记数据发完了;shutdown 关的是一个方向,close 减的是一个引用计数,这两件事被混着讲了很多年,混着用通常也不出错,只在还有别的 fd 指着它的时候才会露馅。(dup 出来的那个 fd 就是最典型的例子,很多人以为 close 一个就彻底没了。)

地址结构体那 128 字节

1
2
3
struct sockaddr_in { ... };    // IPv4
struct sockaddr_in6 { ... }; // IPv6
struct sockaddr_storage { ... }; // 能放任何

sockaddr 只是个名义类型,真正的数据在 sockaddr_in 和 sockaddr_in6 里,而所有函数签名只认那个名义类型,所以实际代码里通常再封一层,把解析地址和算出长度这两件最容易写错的事收进一个地方。

1
2
3
4
5
6
7
8
9
10
class Endpoint {
public:
static std::optional<Endpoint> parse(const std::string& host, uint16_t port);
const sockaddr* addr() const;
socklen_t len() const;
std::string to_string() const; // "1.2.3.4:8080" 或 "[::1]:8080"
private:
sockaddr_storage storage_{};
socklen_t len_ = 0;
};

sockaddr_storage 的存在就是为了保证能装下 IPv6 地址,sockaddr_in6 是 28 字节,sockaddr_storage 是 128 字节,多出来的空间是故意留的,反过来用 sockaddr_in 去装 IPv6 就会溢出,这不是课本上的假设,是一类真实存在过的漏洞,我第一份工作里做代码审计的同事就从一个开源库里翻出过一个。

攒成一个头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// tcp_socket.h
#pragma once
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <cstring>
#include <string>
#include <optional>
#include <cerrno>

class TcpSocket {
public:
TcpSocket() = default;
explicit TcpSocket(int fd) noexcept : fd_(fd) {}
~TcpSocket() { reset(); }

TcpSocket(const TcpSocket&) = delete;
TcpSocket& operator=(const TcpSocket&) = delete;

TcpSocket(TcpSocket&& o) noexcept : fd_(o.release()) {}
TcpSocket& operator=(TcpSocket&& o) noexcept {
if (this != &o) { reset(); fd_ = o.release(); }
return *this;
}

int release() noexcept { int f = fd_; fd_ = -1; return f; }
void reset() noexcept { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } }
bool valid() const noexcept { return fd_ >= 0; }
int get() const noexcept { return fd_; }

// 连接到 host:port,超时 ms
static std::optional<TcpSocket> connect(std::string host, uint16_t port,
int timeout_ms = 5000,
std::string* err = nullptr) {
int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) { if (err) *err = std::strerror(errno); return std::nullopt; }

sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_port = htons(port);
if (::inet_pton(AF_INET, host.c_str(), &a.sin_addr) != 1) {
::close(fd);
if (err) *err = "bad address: " + host;
return std::nullopt;
}

set_nonblock(fd);
int r = ::connect(fd, reinterpret_cast<sockaddr*>(&a), sizeof a);
if (r < 0 && errno != EINPROGRESS) {
if (err) *err = std::strerror(errno);
::close(fd);
return std::nullopt;
}
if (r < 0) {
pollfd p{fd, POLLOUT, 0};
int n = ::poll(&p, 1, timeout_ms);
if (n == 0) { ::close(fd); if (err) *err = "connect timeout"; return std::nullopt; }
if (n < 0) { if (err) *err = std::strerror(errno); ::close(fd); return std::nullopt; }
int soerr = 0;
socklen_t len = sizeof soerr;
::getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &len);
if (soerr != 0) { if (err) *err = std::strerror(soerr); ::close(fd); return std::nullopt; }
}
clear_nonblock(fd);

int one = 1;
::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
::setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one);
return TcpSocket(fd);
}

// 读取最多 max 字节;返回 >0 读到,0 对端关闭,-1 错误,-2 超时
ssize_t read(void* buf, size_t max, int timeout_ms = -1) {
if (timeout_ms >= 0) {
pollfd p{fd_, POLLIN, 0};
int n = ::poll(&p, 1, timeout_ms);
if (n == 0) return -2;
if (n < 0) return -1;
}
for (;;) {
ssize_t n = ::recv(fd_, buf, max, 0);
if (n >= 0) return n;
if (errno == EINTR) continue;
return -1;
}
}

bool write_all(const void* buf, size_t len) {
const char* p = static_cast<const char*>(buf);
while (len > 0) {
ssize_t n = ::send(fd_, p, len, MSG_NOSIGNAL); // 关键:不发 SIGPIPE
if (n < 0) {
if (errno == EINTR) continue;
return false;
}
p += n;
len -= static_cast<size_t>(n);
}
return true;
}

std::string peer() const {
sockaddr_in a{};
socklen_t len = sizeof a;
if (::getpeername(fd_, reinterpret_cast<sockaddr*>(&a), &len) != 0) return "?";
char ip[INET_ADDRSTRLEN] = {0};
::inet_ntop(AF_INET, &a.sin_addr, ip, sizeof ip);
return std::string(ip) + ":" + std::to_string(ntohs(a.sin_port));
}

private:
static void set_nonblock(int fd) {
int f = ::fcntl(fd, F_GETFL, 0);
::fcntl(fd, F_SETFL, f | O_NONBLOCK);
}
static void clear_nonblock(int fd) {
int f = ::fcntl(fd, F_GETFL, 0);
::fcntl(fd, F_SETFL, f & ~O_NONBLOCK);
}

int fd_ = -1;
};

这个文件里最要紧的三行。

  1. ~TcpSocket() { reset(); } — 所有泄漏问题的终点。
  2. SOCK_CLOEXEC — 防止 fork+exec 之后子进程继承这个 fd,这是一个真实的安全问题。
  3. MSG_NOSIGNAL — 对方关闭后 send 会触发 SIGPIPE,默认动作是杀掉整个进程。不想让一个断开的连接把服务带走,就得带上它。

前两行管泄漏和安全,第三行管进程别被打死,三条都不是可选装饰。

用这个类发一次 HTTP 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// get.cpp
#include "tcp_socket.h"
#include <cstdio>

int main(int argc, char* argv[]) {
auto s = TcpSocket::connect("127.0.0.1", 8080);
if (!s) { std::fprintf(stderr, "connect failed\n"); return 1; }

const char* req = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
if (!s->write_all(req, std::strlen(req))) { std::fprintf(stderr, "write failed\n"); return 1; }

char buf[4096];
ssize_t n;
while ((n = s->read(buf, sizeof buf, 5000)) > 0)
std::fwrite(buf, 1, static_cast<size_t>(n), stdout);

return 0;
}
1
2
# 先把前面那个完整的 tcp_socket.h 存成文件,再写 get.cpp(内容就是上面这段 main)
g++ -O2 -std=c++17 get.cpp -o get && ./get

这个读循环是靠服务端 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
2
strace -f -e trace=socket,connect,close -o /tmp/tr.txt ./get
grep -c 'close(' /tmp/tr.txt

每个 socket 都应该对应一个 close,数量对不上就是泄漏,这两个手段查的角度不一样,valgrind 看的是退出时的快照,strace 看的是整个过程中有没有配对,前者能抓忘了关,后者还能抓关得太早,而关得太早比忘了关难查得多。

复制一个对象,让编译器报错

1
2
3
// 错误示范(如果编译得过的话)
TcpSocket a = TcpSocket::connect("127.0.0.1", 8080).value();
TcpSocket b = a; // 编译错误:拷贝构造被 delete 了

这正是 = delete 的价值,它把运行时的双 close 变成了编译错误,错误提前到根本跑不起来的那一步,std::unique_ptr 是同一个道理,只是资源从内存换成了 fd,而 fd 比内存更麻烦,因为内存踩了通常当场崩,fd 关错了要等下一次连接被莫名其妙掐断才会显形。

fd 有没有设 close-on-exec

1
2
ls -l /proc/$(pgrep -o myserver)/fd | grep -c socket
cat /proc/$(pgrep -o myserver)/fdinfo/3 | grep flags

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
2
3
4
int idle = 60, intvl = 10, cnt = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof idle);
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof intvl);
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof cnt);

这样是 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 比注释有用,而且在打开编辑器之前,最好先想清楚你要回答的是哪个具体问题,这一点比手上有什么工具重要得多。