1. 从一个诡异的“卡死”案例说起
那天下午,我正在调试一个后台服务,它运行得好好的,突然就“僵”住了。CPU占用率几乎为零,日志也停止了输出,但进程还在,就像睡着了一样。这感觉太熟悉了,十有八九是死锁。用 gdb 挂上去一看,果然,所有线程都卡在了同一个地方:__lll_lock_wait_private。顺着调用栈往上追,源头指向了 malloc。我当时心里就“咯噔”一下,因为出问题的线程,当时正在执行一个信号处理函数。
你可能觉得奇怪,malloc 不是线程安全的吗?我明明用了 pthread 库,也开启了多线程模式,怎么还会因为内存分配卡死?这正是问题的狡猾之处。线程安全不等于可重入,而信号处理函数这个“不速之客”,恰恰是触发重入问题的经典场景。简单来说,线程安全保证多个线程同时调用 malloc 不会把内存管理的数据结构搞乱,但它不保证同一个线程在执行 malloc 的过程中,被强行打断后再次进入 malloc 还能正常工作。
想象一下这个场景:你的主线程正在厨房(堆内存区)精心分配食材(内存块),它已经拿起了菜刀(获得了内部锁),正在切菜。突然,门铃响了(信号到达),你必须立刻放下手头的活去开门(执行信号处理函数)。结果开门一看,快递员(信号处理函数)说:“我也要进厨房拿个东西(调用 malloc)”。但厨房的门只能从里面锁一次(锁不可重入),钥匙还在你刚才切菜的手上。于是,你和快递员就僵在门口了——这就是死锁。
我遇到的正是这种情况。服务在正常业务逻辑中频繁分配内存,同时,一个外部管理脚本在不断发送 SIGTERM 信号进行“健康检查”。信号处理函数里为了记录日志,间接调用了 malloc。在绝大多数情况下,这两者相安无事,但就在某个微妙的时间点,当主线程的 malloc 正持有着内部锁时,信号抵达了,悲剧就此发生。这个问题的复现率不高,但一旦出现,就是致命的。接下来,我们就一层层剥开 malloc 的内部实现,看看这个“厨房的门锁”到底是怎么工作的,以及我们该如何避免被关在门外。
2. 解剖malloc:线程安全之锁与不可重入之困
要理解为什么 malloc 会死锁,我们得钻进 glibc 的源码里看看。现代 glibc 的 malloc 实现(ptmalloc2)是一个为多线程高度优化的内存分配器,其核心机制是 arena(分配区) 和 锁。
2.1 Arena与锁机制
默认情况下,ptmalloc2 会为每个线程创建一个线程本地缓存(tcache)来加速小内存分配,但对于需要从系统申请内存或管理大型空闲链表等操作,则需要进入更全局的 arena 进行操作。主 arena 只有一个,负责管理进程的初始堆。当多线程竞争激烈时,glibc 会创建多个非主 arena 来减少锁冲突。每个 arena 都有一把独立的锁(通常是 mutex),用来保护该 arena 内部的所有元数据,比如空闲块链表、边界信息等。
当你调用 malloc(size) 时,大致的路径是这样的:
- 首先检查 tcache,如果有合适大小的空闲块且未上锁,直接返回,速度极快。
- 如果 tcache 不满足,则根据大小决定是走
_int_malloc(小/中块)还是mmap(大块)。 - 进入
_int_malloc后,它会根据策略找到对应的 arena,并尝试获取该 arena 的锁:LIBC_LOCK。 - 获取锁成功后,在 arena 的空闲链表(bins)中查找、分割或合并内存块。
- 操作完成,释放锁,返回内存地址。
这个锁,就是 __lll_lock_wait_private 在等待的东西。它是一个底层的、基于 futex 的锁实现,高效但不可重入。“不可重入”意味着,如果持有锁的线程再次尝试获取同一把锁,它就会永久地等待下去,因为锁正在被自己占用,永远不会被释放。
2.2 不可重入性的致命陷阱
malloc 被设计为线程安全函数,这意味着不同线程同时调用 malloc,它们会通过锁机制排队进入不同的 arena,不会互相踩踏数据。但是,“可重入性”是一个更高的、更苛刻的要求。一个可重入函数要求,即使在函数执行中途被中断(比如被信号打断),然后再次进入该函数,也能正确运行。这通常要求函数不使用任何静态或全局数据,或者以原子方式访问它们。
malloc 显然不符合这个要求。它的整个状态——所有 arena、bins、top chunk——都是全局共享数据,并且用锁来保护。这就埋下了我们案例中的陷阱:
- 线程A 执行
malloc,获得了 arena 锁,正在操作空闲链表。 - 此时,一个信号(如 SIGTERM)送达线程A。无论线程A在做什么,操作系统都会强行暂停它,保存当前上下文(包括它正卡在
malloc的某个步骤,且锁未释放),然后跳转到该线程注册的信号处理函数去执行。 - 信号处理函数 中,直接或间接地(比如通过
printf写日志)又调用了malloc。 - 新的
malloc调用尝试获取锁,但它发现所需的锁已经被持有了。然而,持有锁的正是线程A自己(虽然它被信号中断了)。由于锁是不可重入的,这次获取操作会失败,调用线程(还是线程A)就会在__lll_lock_wait_private中无限期地等待。 - 信号处理函数无法返回,被中断的原始
malloc调用也就永远没有机会释放锁。死锁形成。
这就是为什么说 malloc 是线程安全但不可重入的。在多线程+信号的组合拳下,这个特性从优点变成了致命的缺陷。下面的表格清晰地对比了这两个概念:
| 特性 | 线程安全 | 可重入 |
|---|---|---|
| 核心目标 | 防止多线程并发访问导致数据损坏 | 防止同一执行流被中断后重入导致状态错乱 |
| 实现方式 | 使用互斥锁、原子操作等同步机制 | 避免使用静态/全局数据,或使用可重入锁 |


321

被折叠的 条评论
为什么被折叠?



