第二章:网络编程内存模型 — TCP/UDP缓冲区、堆内存分配、连接对象生命周期
好,咱们进入正题。网络编程里的内存泄漏,说白了就是「内存去哪儿了」的问题。你写了个服务器,跑了两天,内存从 200M 涨到 2G,再涨到 20G…… 嗯,这时候你肯定慌了。
要搞懂泄漏,先得搞懂内存是怎么被分配和释放的。这一章,我带你看看 TCP/UDP 的缓冲区、堆内存的分配机制,还有连接对象的生老病死。
2.1 TCP/UDP 缓冲区 — 你看不见的「内存黑洞」
先说说缓冲区。很多人写网络程序,只关注自己 new 出来的对象,却忽略了内核里的缓冲区。我见过一个案例:某网关程序,每接收一个包就分配 64KB 内存,但从来不检查接收缓冲区是否满了。结果呢?内存被内核缓冲区吃光了。
2.1.1 发送缓冲区与接收缓冲区
每个 TCP 连接,内核都会分配两个缓冲区:
- 发送缓冲区:你调用
send()时,数据先拷到这里,内核再慢慢发出去。 - 接收缓冲区:对端发来的数据,内核先收在这里,你调用
recv()时再读走。
这两个缓冲区的大小,默认值通常是 16KB ~ 256KB(不同系统不一样)。你可以用 setsockopt() 调整:
int send_buf = 65536; // 64KB
setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &send_buf, sizeof(send_buf));
int recv_buf = 65536;
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &recv_buf, sizeof(recv_buf));
2.1.2 UDP 的缓冲区陷阱
UDP 和 TCP 不一样。UDP 没有流量控制,发送端可以疯狂发,接收端来不及收,数据就会堆积在接收缓冲区。一旦缓冲区满了,新来的数据包会被直接丢弃。
我个人的习惯是:UDP 接收缓冲区一定要设大一点。尤其是在高并发场景下,默认值往往不够。我曾经调过一个游戏服务器,UDP 丢包率高达 15%,查了半天,发现是接收缓冲区太小,内核直接扔包了。把缓冲区从 64KB 调到 1MB,丢包率降到 0.5%。
2.2 堆内存分配 — 你的程序到底吃了多少?
堆内存是泄漏的重灾区。每个 malloc() 或 new,都对应一块堆内存。如果只分配不释放,内存就泄漏了。
2.2.1 谁在分配堆内存?
网络程序里,堆内存分配主要来自:
- 连接对象本身:每个连接对应一个结构体或类。
- 数据缓冲区:比如你为每个请求分配的
char* buf。 - 协议解析的临时对象:比如 HTTP 解析时生成的字符串、链表。
举个例子,一个简单的 TCP 服务器,每来一个连接就 new 一个 Connection 对象:
class Connection {
public:
char* recv_buf;
int fd;
Connection(int f) : fd(f) {
recv_buf = new char[4096]; // 堆分配
}
~Connection() {
delete[] recv_buf; // 必须释放!
}
};
// 新连接到来
Connection* conn = new Connection(client_fd);
你看,这里有两个堆分配:Connection 对象本身,以及内部的 recv_buf。如果忘记在析构函数里 delete[] recv_buf,或者忘记 delete conn,内存就泄漏了。
new 必须对应一个 delete,每个 malloc 必须对应一个 free。这是铁律。
2.2.2 内存池 — 我为什么推荐你用?
频繁的 malloc/free 不仅慢,还容易产生碎片。我个人习惯用内存池。比如,预分配一大块内存,然后按需从池里取,用完归还。这样既快又不容易泄漏。
一个简单的内存池思路:
class MemoryPool {
char* pool;
size_t pool_size;
size_t offset; // 当前分配位置
public:
MemoryPool(size_t size) : pool_size(size), offset(0) {
pool = new char[size];
}
void* alloc(size_t bytes) {
if (offset + bytes > pool_size) return nullptr;
void* ptr = pool + offset;
offset += bytes;
return ptr;
}
void reset() { offset = 0; } // 一次性重置
~MemoryPool() { delete[] pool; }
};
当然,这只是个简化版。实际项目中,我会用更成熟的库,比如 jemalloc 或 tcmalloc。它们对多线程场景优化得很好。
2.3 连接对象生命周期 — 从生到死,每一步都要管好
一个网络连接,从 accept 到 close,就是它的生命周期。每个阶段都可能泄漏内存。
2.3.1 连接创建
当 accept() 返回一个新的 fd 时,你需要创建一个连接对象来管理它。这时候,堆内存就分配了。
我见过一个坑:有人在 accept() 之后,直接 new 了一个对象,但没把 fd 保存好。结果 fd 泄漏了,连接对象也泄漏了。嗯,这是双重泄漏。
2.3.2 连接使用中
连接存活期间,可能会有多次数据收发。每次收发,都可能分配临时缓冲区。比如,你收到一个 HTTP 请求,解析出 URL、Header,这些都可能分配堆内存。
这里有个常见问题:缓冲区重用。如果你每次 recv 都 new 一个新缓冲区,用完又不释放,内存会持续增长。正确的做法是:复用已有的缓冲区,或者用完后立即释放。
2.3.3 连接关闭
连接关闭时,必须释放所有相关资源。包括:
- 关闭 socket fd
- 释放连接对象(delete)
- 释放所有临时分配的缓冲区
- 从连接管理器中移除该连接
我曾经排查过一个泄漏:服务器运行一周后,内存涨了 10 倍。最后发现,是某个分支逻辑里,连接关闭时忘了 delete 连接对象。那个分支只在特定错误码下触发,平时测试根本走不到。嗯,这就是典型的「隐藏泄漏」。
2.4 知识体系总览
下面这张图,是我自己总结的网络编程内存模型。你看一眼,就能把整个章节串起来:
2.5 小结
这一章,我们聊了三个核心点:
- TCP/UDP 缓冲区:内核分配的内存,容易被忽略。记得检查缓冲区大小,尤其是 UDP。
- 堆内存分配:每个 new 都要有对应的 delete。用内存池可以减少碎片和泄漏风险。
- 连接对象生命周期:从创建到关闭,每一步都要管好资源。关闭连接时,别忘了释放所有东西。
说白了,内存泄漏就是「借了不还」。你借了内存,用完得还回去。内核的缓冲区、堆上的对象、连接的文件描述符,都是借来的。不还?系统会找你算账的。
下一章,我会带你实战排查一个真实的泄漏案例。到时候,咱们用工具说话。