4、Socket通信中的内存泄漏:TCP发送缓冲区与接收缓冲区溢出、UDP数据报丢失导致的内存黑洞、Unix Domain Socket的SOCK_DGRAM与SOCK_STREAM内存差异、epoll事件驱动下的内存管理

Socket通信,说白了就是进程间传数据。但传着传着,内存就丢了。

我见过太多项目,上线跑几天,内存蹭蹭往上涨。一查,全是Socket缓冲区惹的祸。今天咱们就把这块硬骨头啃下来。

4.1 TCP发送缓冲区与接收缓冲区溢出

TCP有俩缓冲区:一个发送,一个接收。内核帮你管着,但管得不彻底。

发送缓冲区溢出,怎么回事?

你调用send(),数据先拷到内核的发送缓冲区。如果对端收得慢,缓冲区就满了。send()会阻塞,或者返回EAGAIN。但很多人不管返回值,继续发。数据没发出去,内存却已经分配了。

⚠ 避坑指南

我曾经接手过一个网关项目,日志里全是send()返回-1,但代码里没处理。结果发送缓冲区堆积了上万个未发送的数据包,每个包几KB,内存直接爆了。

接收缓冲区溢出,更隐蔽。

你调用recv(),但读得不及时。内核缓冲区满了,新数据来了怎么办?

  • TCP有流量控制,会通知对端别发了。但如果你程序卡死,对端一直发,缓冲区会持续增长。
  • 默认缓冲区大小一般是几十KB到几百KB。但可以调大,调得越大,风险越高。

我建议:

  • 每次send()/recv()后,检查返回值。没发完的,要缓存起来,下次再发。
  • 设置合理的缓冲区上限。别让内核无限分配。
  • setsockopt()设置SO_SNDBUFSO_RCVBUF,但注意,内核会翻倍。
// 设置发送缓冲区大小
int sendbuf = 1024 * 1024; // 1MB
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &sendbuf, sizeof(sendbuf));

// 但实际大小可能是2MB,内核会翻倍
// 所以别设太大,否则内存扛不住

4.2 UDP数据报丢失导致的内存黑洞

UDP无连接,无确认,无重传。数据报丢了就丢了。

但内存黑洞是怎么回事?

你想想看:

  • 发送端:调用sendto(),数据拷到内核缓冲区,然后就不管了。如果对端没收到,这数据在内核里待多久?
  • 接收端:调用recvfrom(),如果缓冲区满了,新来的数据报直接丢弃。但旧数据还在缓冲区里占着内存。

说白了,UDP没有流控。发送端可以疯狂发,接收端来不及收,缓冲区就满了。满了之后,新数据丢弃,旧数据占着内存不放。

🔍 个人经验

我记得有个视频流项目,用UDP传实时画面。发送端每秒30帧,接收端处理不过来。结果接收缓冲区里积压了上千帧数据,每帧几百KB,内存直接飙到几百MB。最后发现,是接收线程的recvfrom()循环里加了个sleep(1),导致读得太慢。

怎么防?

  • 接收端:用非阻塞模式,及时清空缓冲区。
  • 设置SO_RCVBUF上限,别让内核无限分配。
  • 应用层自己做流控,比如丢帧策略。
// 非阻塞接收
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

// 循环读取,直到EAGAIN
while (1) {
    n = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL);
    if (n < 0) {
        if (errno == EAGAIN) break; // 缓冲区空了
        // 其他错误处理
    }
    // 处理数据
}

4.3 Unix Domain Socket的SOCK_DGRAM与SOCK_STREAM内存差异

Unix Domain Socket,本地进程通信用的。比TCP快,不走网络栈。

但SOCK_DGRAM和SOCK_STREAM,内存管理完全不同。

类型 特点 内存风险
SOCK_STREAM 面向连接,可靠,有序 缓冲区溢出,类似TCP
SOCK_DGRAM 无连接,有消息边界 数据报丢失,类似UDP

SOCK_STREAM

  • 有发送和接收缓冲区,大小可调。
  • 如果对端读得慢,缓冲区会堆积。和TCP一样,要检查返回值。

SOCK_DGRAM

  • 每个数据报独立存储。如果接收端不读,数据报会堆积在缓冲区。
  • 缓冲区满了,新数据报丢弃。但旧数据报占着内存不放。
  • 而且,数据报有大小限制。默认可能只有几KB,但可以调大。

💡 小技巧

我个人习惯:用SOCK_STREAM做控制通道,用SOCK_DGRAM做数据通道。控制通道要可靠,数据通道可以丢。但不管哪种,都要设置缓冲区上限。

// 创建Unix Domain Socket
int sockfd = socket(AF_UNIX, SOCK_DGRAM, 0);

// 设置接收缓冲区大小
int rcvbuf = 1024 * 64; // 64KB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));

4.4 epoll事件驱动下的内存管理

epoll是Linux下高性能IO多路复用机制。但用不好,内存泄漏比谁都狠。

epoll的内存泄漏常见场景:

  • 事件注册后不注销:用epoll_ctl()注册了fd,但fd关闭时没调用EPOLL_CTL_DEL。epoll内部还保留着这个fd的引用,内存泄漏。
  • 边缘触发(ET)模式下的数据残留:ET模式下,数据没读完,epoll不会再次通知。如果程序没循环读,数据留在缓冲区里,内存泄漏。
  • 事件处理函数中分配的内存没释放:比如epoll_wait()返回后,你分配了内存处理数据,但处理完忘了free。

⚠ 避坑指南

我曾经排查过一个epoll内存泄漏,查了三天。最后发现,是某个fd在关闭前没调用EPOLL_CTL_DEL。epoll内部维护了一个红黑树,每个节点对应一个fd。fd关了,但节点还在树上,内存就漏了。

我建议:

  • 关闭fd前,一定要调用epoll_ctl(fd, EPOLL_CTL_DEL, ...)
  • ET模式下,用循环读,直到返回EAGAIN
  • 事件处理函数中,用RAII或智能指针管理内存。
// 正确的epoll使用方式
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 边缘触发
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

// 关闭前注销
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
close(sockfd);

// ET模式下循环读
while (1) {
    n = read(sockfd, buf, sizeof(buf));
    if (n <= 0) {
        if (errno == EAGAIN) break; // 读完了
        // 错误处理
        break;
    }
    // 处理数据
}

🔍 总结一下

Socket通信的内存泄漏,核心就三点:

  1. 缓冲区管理:设上限,检查返回值,及时清空。
  2. 事件驱动:注册要注销,ET模式要循环读。
  3. 资源释放:分配的内存、注册的事件,都要成对释放。

嗯,做到这三点,大部分内存泄漏都能防住。