内存泄露就让它漏,可行吗?

可行,重启解千愁,我曾经就这么干过,当时做了个定时重启。

但那一年,在流量爆发时,我直接被虐的体无完肤。

那一年,我深刻理解了,高并发场景下,内存泄漏的速率被放大,原本能撑一天的内存,在几小时内迅速打满并触发 OOM,导致服务在业务高峰期彻底宕机。

一、重启理论上确实成立

每个进程有独立的虚拟地址空间,页表把虚拟地址映射到物理页框。进程exit() 也罢,被 SIGKILL 也罢,内核二话不说,遍历 mm_struct,回收该进程名下的所有物理页。

你 leak 了多少,OS 全能兜得住。

int main() {
    for (int i = 0; i < 1000000; ++i)
        malloc(1024);   // 一个都不 free
    return 0;           // OS 一把回收,干净利落
}

lsgrep 这些命令行工具,生命周期几十毫秒,退出前逐块 free 纯属浪费 CPU。Cloudflare Workers 的 CPU 限制 30ms,分配了就不释放,让 OS 兜底,这是一项真实的工程策略。

但服务器不同于 ls,它要连轴转几个月。

二、四个致命死穴,个个能单杀你的服务

内存泄漏速率与流量是绑死的

假设一天漏 100MB,16GB 内存能撑 160 天。

但对于一个调度服务来讲,正常 QPS 3000,泄漏速率每小时 5MB。

秒杀时,流量冲到八倍,泄漏速率直接飙到了 300MB/h。三小时内,容器内存从 4GB 飙到 14GB,cgroup 16GB 限制直接触发 OOM Killer。

[1638734.234567] oom-kill:constraint=CONSTRAINT_MEMCG
[1638734.234568] Memory cgroup out of memory: Killed process 31829

泄漏速率 = 单次泄漏量 × QPS。流量一爆,内存直接炸。你假设的那条线性泄漏曲线,在真实业务里根本不存在。

碎片化导致,即使空闲 1GB,也分配不到 50MB

glibc 的 ptmalloc2 把堆分成 arena 管理。频繁分配不同大小的对象,其中一些永久泄漏,这些小泄漏块像钉子一样钉在堆里,把原本连续的空闲空间切成碎块。

int main() {
    std::vector<void*> blocks;
    for (int i = 0; i < 1000; ++i) {
        blocks.push_back(malloc(1024));  // 稍后释放
        malloc(64);                      // 永久泄漏
    }
    for (auto p : blocks) free(p);
    // 释放了 1000KB,但每 1024 字节之间卡着 64 字节的泄漏块
    void* p = malloc(512 * 1024);  // 找不到连续 512KB,返回 nullptr
    printf("%s\n", p ? "OK" : "FAILED");  // FAILED
}

STL 容器加剧了这个问题。std::vector 扩容、std::unordered_map,每次重新分配都在堆上换位置,正常情况 allocator 能把相邻的空闲块合并。一旦有块被永久泄漏卡死,合并就堵了。

# 看进程的内存碎片状态,用 smaps
$ cat /proc/$(pidof my_service)/smaps | grep -E "^([0-9a-f]+|Pss:)"

new 不 delete,漏的远不止那几字节

C++ 对象的分配远比 malloc 一个 sizeof(T) 复杂。构造函数里可能打开文件、建 TCP 连接、锁 mutex。当你跳过 delete,你跳过的是整个析构链。

class DatabaseConnection {
    MYSQL* conn_;
public:
    DatabaseConnection(const std::string& host, int port) {
        conn_ = mysql_init(nullptr);
        mysql_real_connect(conn_, host.c_str(), ...);
    }
    ~DatabaseConnection() { mysql_close(conn_); }
};

void handleRequest() {
    auto* db = new DatabaseConnection("10.0.1.5", 3306);
    // 忘了 delete ⇒ mysql_close() 永不执行
}

你以为每次请求只泄漏 sizeof(DatabaseConnection) 那几十个字节?实际漏的是:

  • 一个 TCP 连接,内核 socket 缓冲区收发各至少 4KB
  • 一个文件描述符,进程上限默认 1024,满了之后 accept() 直接报 EMFILE
  • MySQL 一个连接槽,wait_timeout 八小时内死占着
$ ls /proc/$(pidof my_service)/fd | wc -l
1020  # 还差四个,服务就该挂了

std::thread 不 join/detach 就析构 → std::terminatestd::ofstream 不关 → 数据没刷盘。重启解决不了这些,因为在你重启之前,错误已经造成了。

每日重启不是免费的

服务定时重启,要有四个默认前提:切流无中断、状态不丢失、数据不损坏、重启不出事。

微服务+负载均衡,摘流→等存量→重启→挂流,至少有几秒不可用。你是链路关键节点的话,上游看到连接拒绝,触发重试风暴。

内存缓存,SIGTERM 一声响全没了。重启后前几分钟所有请求穿透到数据库,瞬时压力是平时的十倍。

kill 卡在 write() 中间,文件直接损坏。

三、不用重启的写法

智能指针和 RAII 都是让析构函数替你擦屁股。

// 这种代码不该出现在生产环境
void legacy() {
    Widget* w = new Widget();
    // 中间如果抛异常?w 永不释放
    delete w;
}

// 正确写法
void modern() {
    auto w = std::make_unique<Widget>();
}  // 正常 return、抛异常、goto,不管怎么退,~unique_ptr() 保证 delete

unique_ptr 是默认选项:sizeof(unique_ptr<T>) == sizeof(T*),一个指针的大小,零额外开销。

shared_ptr 只在确需共享所有权时用,且始终 make_shared 控制块和对象一次分配,比 new shared_ptr<T>(new T) 少一次 malloc。

weak_ptr 用来打断循环引用:

class Node {
    std::vector<std::shared_ptr<Node>> children_;
    std::weak_ptr<Node> parent_;  // 不参与引用计数,父子互指不会泄漏
};

智能指针的自定义删除器能接管包括内存在内的任何资源:

auto file = std::unique_ptr<FILE, decltype(&fclose)>(
    fopen("data.log", "w"), fclose);
// 离开作用域,fclose 自动调用,不需要写 close

对于 POSIX 的 fd 也一样:

class ScopedSocket {
    int fd_;
public:
    explicit ScopedSocket(int fd) : fd_(fd) {}
    ~ScopedSocket() { if (fd_ >= 0) close(fd_); }
    ScopedSocket(const ScopedSocket&) = delete;
    ScopedSocket& operator=(const ScopedSocket&) = delete;
    ScopedSocket(ScopedSocket&& other) noexcept : fd_(other.fd_) {
        other.fd_ = -1;
    }
};

每一个 new 对应一个 delete,每一个打开的资源对应一个析构函数。做到这一点全凭纪律。

检测别靠肉眼。

# ASan + LSan,泄漏精确到行号
$ clang++ -g -fsanitize=address -fno-omit-frame-pointer my_service.cpp
$ ASAN_OPTIONS=detect_leaks=1 ./a.out
# Direct leak of 1024 byte(s) ... my_service.cpp:42

Clang 3.1+ / GCC 4.8+ 内置,开发阶段跑一遍就能把泄漏抓在 QA 之前。Valgrind 不用重编译也能检,开销 10-20 倍,CI 上做补充。Heaptrack 用火焰图看哪些调用栈一直在分配不释放:

$ heaptrack ./my_service --run-for 300
$ heaptrack_gui heaptrack.my_service.XXXXX.gz

现代新写法:

std::pmr::monotonic_buffer_resource(C++17)。每个 HTTP 请求分配一个独立的内存池,请求处理完整个池一次性析构,所有临时对象一把清,不碰全局堆,不出碎片:

char buf[4096];
std::pmr::monotonic_buffer_resource pool(buf, sizeof(buf));
std::pmr::vector<int> v(&pool);  // 所有分配走池,作用域结束整池释放

std::span(C++20)替代裸指针+长度。编译器知道边界,ASan 能抓 use-after-free:

void process(std::span<int> data) {
    for (auto& x : data) x *= 2;
}
int arr[100];
process(arr);  // 自动推导大小,零拷贝

std::expected(C++23)消除异常路径的资源泄漏:

auto result = connect("10.0.1.5");
if (result) {
    auto& db = *result;  // 正常使用
} else {
    log_error(result.error());  // 失败路径,资源已自动释放
}
// 不需要在 catch 块里补 delete

四、这事说到底是个态度问题

入行前几年,觉得「重启一下就行」是聪明的捷径。现在回头看,那是懒。懒得去理解自己写的代码到底持有了哪些资源。

你有没有打开过 /proc/<pid>/fd 看过你服务的文件描述符数量?

有没有用 ASan 跑过一次完整的测试?

有没有在凌晨三点被报警叫醒、盯着 OOM 日志发愣?

我都经历过。

从每天定时重启改成三个月不重启之后,半夜被叫起来的次数从每周两次降到了零。

其实就是终于停止给自己挖坑了。

std::unique_ptr、RAII、ASan、std::pmr,都是标准库和编译器自带的,免费的。

别再骗自己重启就行了。

编辑于 2026-06-04 · 著作权归作者所有
相关文章
如何长时间保存重要数据?因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?用 A4 纸记录一个 G 的数据需要多少钱?回收旧手机前,如何彻底清除个人数据以防隐私泄露?某手机芯片漏洞致『秒解 BL 锁』风险增高,用户如何平衡刷机自由与个人信息安全?工信部紧急提醒苹果用户 iOS 13 至 17.2.1 有高危漏洞,或导致用户信息被窃取,该怎样应对?作为普通用户,我们应该如何有效保护自己的网络隐私和数据安全?苹果的个人隐私安全性确实是真的的比安卓高吗?因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?手机如果发原图会泄露隐私么?回收旧手机前,如何彻底清除个人数据以确保隐私安全不泄露?个人重要数据,你们都用什么方式进行备份和存储,才感觉最安心?因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?手机“偷听”你说话?这份防监听指南,手把手教你如何关闭这些烦恼因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?如何评价用户接到汽车试驾邀约电话后质疑信息泄露来源,乾昆启境员工称可能是小米给的这一说法?如何将个人隐私与第三方数据平台隔离?因印度供应商遭网络攻击,iPhone18 Pro Max银灰色曝光,有哪些亮点?此泄露暴露出哪些问题?对个人用户而言,如何制定一套高效、安全的长期数据备份策略?