第二章:网络编程内存模型 — 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));
⚠️ 注意: 内核实际分配的大小可能是你设置值的两倍。为什么?因为内核要预留一部分空间做管理开销。我曾经在调优一个视频流服务器时,被这个「双倍分配」坑过——明明设了 64KB,结果内存涨了 128KB。

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 知识体系总览

下面这张图,是我自己总结的网络编程内存模型。你看一眼,就能把整个章节串起来:

网络编程内存模型 TCP/UDP 缓冲区 堆内存分配 连接对象生命周期 发送缓冲区 接收缓冲区 UDP 丢包陷阱 连接对象 数据缓冲区 协议解析对象 内存池优化 连接创建 (accept) 数据收发 连接关闭 资源释放 泄漏根源:分配了没释放 / 释放了不彻底 / 忘记关闭连接 排查工具:valgrind / address sanitizer / 自定义内存追踪

2.5 小结

这一章,我们聊了三个核心点:

  • TCP/UDP 缓冲区:内核分配的内存,容易被忽略。记得检查缓冲区大小,尤其是 UDP。
  • 堆内存分配:每个 new 都要有对应的 delete。用内存池可以减少碎片和泄漏风险。
  • 连接对象生命周期:从创建到关闭,每一步都要管好资源。关闭连接时,别忘了释放所有东西。

说白了,内存泄漏就是「借了不还」。你借了内存,用完得还回去。内核的缓冲区、堆上的对象、连接的文件描述符,都是借来的。不还?系统会找你算账的。

下一章,我会带你实战排查一个真实的泄漏案例。到时候,咱们用工具说话。

📌 记住: 写网络程序,脑子里要时刻有一张「内存地图」。哪里分配了,哪里释放了,心里要有数。否则,泄漏就在你看不见的角落里悄悄发生。

可可精品资料库