ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux线程控制实战:同步互斥、生命周期与线程池构建

Linux线程控制实战:同步互斥、生命周期与线程池构建

做Linux服务端开发这些年,我发现自己对Linux线程控制的认知分了好几个阶段。最开始觉得线程控制无非就是pthread_create、pthread_join、pthread_mutex_lock这一套API,能跑起来就行;后来开始管线程池、处理并发任务,才发现线程控制真正难的不是把线程创建出来,而是搞清楚线程的归属、生命周期、同步边界和异常退出路径。这篇文章就从我自己的踩坑视角出发,把Linux线程控制的关键细节、同步互斥的选型思路、完整线程池的实现骨架,以及排查并发问题的那些经验一次性讲清楚。适合正在学POSIX线程、以及写过多线程程序但总在莫名其妙的地方翻车的开发者参考。

1. 先理解线程控制到底在控制什么

1.1 线程不是“更轻的进程”那么简单

首先要破除一个常见误解。很多人把线程理解成“更轻的进程”,觉得进程能做的事线程都能做,只是轻量一点。这个说法在资源开销上勉强站得住,但在控制逻辑上会让你吃大亏。Linux里线程和进程底层都通过clone创建,区别在于线程共享了地址空间、文件描述符表、信号处理方式等资源。共享是一把双刃剑:好处是数据访问天然方便,坏处是任何一个线程写坏内存,整个进程立刻崩掉,所有线程一起陪葬。进程之间至少还有页表隔离和进程边界,线程之间几乎没有。

所以线程控制的第一件事,不是学会调用几个API,而是建立一套“资源边界意识”。你要清楚这段代码正在访问哪些共享资源,哪些数据只属于当前线程,谁负责销毁任务对象,谁负责回收线程栈。我见过太多崩溃案例,根源不是API用错,而是从一开始就没有分清边界。线程函数里直接操作调用方栈上的局部变量、线程退出后还被其他线程访问对象、任务队列由多个线程同时弹出却没有加锁,这些都属于边界失控。

1.2 线程控制的三类关键对象

线程控制实际围绕三条主线展开:生命周期控制、同步互斥控制、取消与清理控制。

生命周期控制解决的是线程从创建到终止再到资源回收的完整链路。创建时怎么传参数,运行中要不要把它变成分离态,结束之后由谁来join回收,这些都属于生命周期问题。很多线上程序卡住退不出来,十有八九是某个线程仍然挂在某个地方,或者主线程没有回收所有子线程。

同步互斥控制解决的是多个线程同时访问共享数据时的秩序问题。互斥锁、条件变量、读写锁、自旋锁,各自适应不同场景,选错工具或加锁粒度不对,会直接体现为性能滑坡和死锁。

取消与清理控制则是最容易被忽略的一块。线程不是你想停就能安全停的,pthread_cancel发出去,线程可能正持着锁、正分配着内存、正处在某种必须回滚的中间状态。所以还要配套pthread_cleanup_push和pthread_cleanup_pop,在线程退出路径上补齐资源释放动作。

这三类控制彼此耦合。比如线程池收到销毁信号,既要通知所有工作线程退出(生命周期),又要保证任务队列不再被写入(同步),还可能要等待正在执行的任务安全结束(清理)。只盯着其中一环,都会留下隐患。下面逐条拆细节。

2. 线程生命周期管理的核心细节

2.1 创建线程时最容易忽视的细节

很多人第一次写pthread_create,会把线程函数的参数直接传一个局部变量的地址。这个做法偶尔也能跑通,因为线程可能很快就执行完了,但一旦线程运行时间稍长,或者调用方已经从当前函数返回,你传入的地址就变成悬垂指针。线程内部读到的数据可能已经被其他逻辑改写,甚至整个栈帧都被复用,于是就会出现那种“本地跑得好好的,线上偶尔出诡异结果”的经典问题。

安全做法是把参数分配到堆上,由线程函数负责释放;或者干脆传一个聚合结构体,把生命周期边界画清楚。如果坚持传局部变量,那调用方必须保证该变量存活到线程被join之后,也就是说join调用返回之前局部变量都还在作用域内。这个前提听起来简单,实际代码里一旦夹杂了条件分支、提前return,很容易破防。

另一个容易忽略的是pthread_create的返回值。很多人只在创建失败时打印一行日志,却不区分原因是EAGAIN(资源不足)、EINVAL(属性非法)还是EPERM(权限不足)。EAGAIN尤其值得关注,它意味着进程的线程数、虚拟内存或栈空间达到了限制。排查的时候要同时看ulimit -u、sysctl kernel.threads-max、以及是否已经创建了大量默认栈大小的线程。

创建线程时还有一个属性设置的细节:pthread_attr_init之后必须配对pthread_attr_destroy,否则在严格的平台或长期运行的环境里可能导致资源碎片。栈大小通过pthread_attr_setstacksize控制,栈是线程私有的,但地址空间是从进程虚拟内存里抠出来的。默认8MB在多数场景没问题,可是当你需要几千个线程时,8MB的虚拟内存会很快耗尽,这时把栈压到512KB或1MB往往能解决“线程创建失败”的问题。注意,栈压太小也会带来新的风险,后面排查章节我会再展开。

2.2 join与detach:两种归属关系的取舍

线程创建后,默认是joinable状态。这个状态意味着线程退出后,它的退出状态和资源不会自动释放,必须由某个线程调用pthread_join来回收。如果不join,线程终止后就成了“僵尸线程”,资源不释放,而且数量多了,新线程可能因为资源不足创建失败。主线程退出时所有线程会被强制结束,但如果你写的是长期运行的服务程序,主线程通常不会退出,那就必须在合适的时机回收每个子线程。

pthread_join还有一层容易被低估的作用:它天然是一个同步屏障。调用join的线程会阻塞等待目标线程结束,之后可以安全读取目标线程留下的结果。如果你需要拿到线程的返回值,把这些返回值放在堆上的结构体里,等join返回后再读取,就不会出现数据竞争。反过来,如果你根本不关心线程的返回值,也没打算在之后的某个点等它,那应该尽早pthread_detach,让线程退出时自动清理资源。

detach之后,线程就与调用方再也没有关系了,不能再join,也不能再回到joinable状态。实际编码中我建议把detach当成一个明确的“归属决定”来对待:这个线程是一个独立的后台任务,既不需要汇报结果,也不需要别人等待,那就把它放手。如果线程还要把结果写回共享结构,或者需要主线程统一收尾,就不要detach,老老实实保存pthread_t然后join。这里最怕的是团队里有人随手detach,后面又需要等待线程完成,结果只能靠sleep去猜,代码变得非常脆。

2.3 线程退出与资源回收的实操要点

线程函数的退出方式有三种:直接return、调用pthread_exit、被pthread_cancel取消。return和pthread_exit在效果上非常接近,都会终止当前线程并返回退出状态,区别主要是从线程函数的顶层return可以让编译器帮你执行局部变量的析构(C++里明显),而pthread_exit可以在线程函数内部任意位置直接结束。搞清楚这个区别,你才知道为什么不能在线程函数里用return代替pthread_exit去做“提前退出但还要走清理逻辑”的事。

资源回收的关键是pthread_cleanup_push和pthread_cleanup_pop。这两个宏会注册一个清理函数,在线程调用pthread_exit或者pthread_cancel导致线程退出时,以栈的顺序执行清理函数。典型场景是线程加锁后进入某种等待,结果中途被取消,如果不做清理,锁就永远留在加锁状态,其他线程全部卡死。所以凡是“先获取资源、后可能被取消”的代码,都要把资源释放放进cleanup函数。

写清理代码时有个反直觉的细节:pthread_cleanup_pop带一个int参数,参数非0时会在弹出清理函数的同时执行它,为0则只弹出不执行。很多人只记得功能,忘了这个参数,结果明明应该在正常路径上执行的清理动作被跳过。我自己的习惯是:正常返回路径上调用pthread_cleanup_pop(1),把清理函数主动执行掉;异常退出路径交给pthread取消机制自动执行。这样两条路都覆盖了,不会出现“线程退出了但锁没释放”的惨状。

3. 同步与互斥:线程控制的主战场

3.1 互斥锁为什么要“锁得短”

互斥锁的实现底层依赖原子操作和futex。当两个线程同时抢一把锁时,只有一个线程能成功进入临界区,另一个线程先自旋一小段时间,如果还没拿到锁,就进入睡眠状态,由内核在锁释放时唤醒它。这个“睡眠再唤醒”的过程开销不小,大概涉及两次系统调用和一次上下文切换。所以临界区越长,线程之间的锁竞争就越容易把性能拖垮。

用生活类比来理解:一把锁就像一间只有一个工位的厕所,排队的人越多、每个人占用时间越长,整个系统的吞吐量就越低。互斥锁本身没有错,错的是你让线程在锁里做了太多不该做的事。比如在持有锁的时候打印日志、调用网络接口、执行耗时的磁盘操作,这些都应该尽量挪到临界区外面。正确的做法是:锁里面只做共享状态的快速读写,把耗时的业务逻辑放到锁外面去执行。

死锁也是互斥锁绕不开的话题。死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。任何一个条件被破坏,死锁就不会发生。在实际代码里最常见的原因是多把锁的加锁顺序不一致。比如线程A先拿锁L1再拿L2,线程B先拿锁L2再拿L1,就有几率互相等死。解决办法也很朴素:所有需要同时持有多个锁的地方,严格按照同一个全局顺序加锁,并在锁定期间避免调用任何可能触发其他锁的回调。

3.2 条件变量与等待/通知模型

条件变量解决的是“一个线程要等某个条件成立再继续执行”的问题。初学者最直接的想法是写一个while循环,不断检查条件,条件不满足就continue。这在单线程里没问题,但在多线程环境里就是典型的忙等,CPU占用率会直接飙满,而且还会和真正要修改条件的线程抢CPU,反而拖慢整个系统。

pthread_cond_wait的设计可以看作“原子地释放锁并进入睡眠,被唤醒后重新获取锁再返回”。也就是说,调用pthread_cond_wait时你必须已经持有一把互斥锁,调用后它会帮你把锁释放掉,让其他线程有机会修改共享状态。这也是为什么条件变量必须和互斥锁配合使用,而不是单独使用。很多人一开始不理解为什么cond_wait要传mutex进去,原因就在于这个释放与等待两个动作必须合在一起才能避免“唤醒信号丢失”的竞态。

使用条件变量有两个必须养成的习惯。第一,判断条件永远用while而不是if,因为可能存在虚假唤醒:线程被唤醒后条件其实还没满足,如果用if就直接往下走,必然出错。第二,修改共享状态、通知条件变量的顺序要小心。一个常见的错误是生产者修改完条件后先发pthread_cond_signal,消费者还没来得及进入cond_wait,此时信号已经发出,等消费者真正进入等待时却没人再通知它,于是它永远等下去。解决方法是保证“修改状态”和“唤醒等待者”都在同一个互斥锁的保护下,这样消费者要么看到新状态直接继续,要么先进入等待再被唤醒,不会出现中间真空。

pthread_cond_broadcast和signal的选择也值得说一句。signal只唤醒一个等待线程,broadcast唤醒所有等待线程。如果等待线程之间对条件的处理是独立的,signal就够了;但如果多个线程都在等同一个条件,而这个条件的变化只能被其中一个线程消费,其余线程醒来后会发现条件又不满足了,继续睡回去,这种场景用signal更高效。反过来,当一个条件变化会让多个线程都受益时,就改用broadcast。这个决策没有绝对公式,要根据任务模型来判断。

3.3 读写锁与自旋锁的选型

读写锁适合读多写少的场景,多个读者可以同时持有读锁,写者需要独占写锁。它解决的问题是“读操作之间不冲突,但读与写、写与写必须互斥”。如果你的业务里读操作占比超过80%,读写锁通常能比互斥锁带来明显的吞吐提升。但要注意,读写锁在公平性上容易踩坑:如果读者源源不断,写者可能长时间拿不到写锁,造成“写者饥饿”。很多实现提供了带写者偏向的策略,选择之前要确认你的场景里写操作是不是不能忍受长延迟。

自旋锁和互斥锁最大的区别是,线程拿不到锁时不会睡眠,而是原地打转反复尝试。它适合临界区极短、持锁时间非常有限、且运行在多核处理器上的场景。因为睡眠唤醒的代价可能比临界区本身的执行还大,自旋反而更快。但自旋锁的缺点也明显:如果临界区稍长,或者线程数超过CPU核数,拿不到锁的线程会空转烧CPU,整体吞吐反而下降。所以自旋锁是“高性能但也高风险”的工具,默认情况下我建议直接用互斥锁,只有在profiling明确显示锁竞争是热点时才换自旋锁。

还有一个不能忽视的共性问题:持锁期间不能调用任何可能阻塞的函数。比如在持有互斥锁或自旋锁时执行accept、read、sleep,或者调用另一个可能也需要同一把锁的函数,轻则拖慢系统,重则死锁。我看到过不少代码把加锁范围画得过大,锁里面还嵌套着用户回调,回调再去申请同一把锁,这种“锁重入”问题在普通非递归锁上会直接把自己堵死。控制锁的边界,和选择锁的类型同等重要。下面进入实操部分,我用一个线程池例子来演示这些原则怎么落地。

4. 实操过程与核心环节实现

4.1 一个完整的线程池骨架

线程池是线程控制里非常典型的综合应用。它把线程的创建、同步、等待、销毁全部揉在了一起。线程池的基本组成是:一个任务队列、一组工作线程、一个互斥锁、一个条件变量。工作线程从队列中取任务执行;有任务时被唤醒,没任务时睡眠;线程池销毁时通知所有线程退出并回收。

先看一个简洁但可用的核心数据结构。这段代码只保留最关键的部分,实际项目里可以再加任务优先级、队列容量上限、异常统计等扩展:

#include <pthread.h> #include <stdlib.h> typedef struct task_s { void (*fn)(void *arg); void *arg; struct task_s *next; } task_t; typedef struct { pthread_mutex_t lock; pthread_cond_t notify; task_t *head; pthread_t *threads; int thread_count; int shutdown; } pool_t;

工作线程的主循环是这样的:取锁、判断任务队列是否为空、为空则等待条件变量;如果收到关闭信号并且队列已经清空,就退出循环。注意这里判断关闭用的也是while,原因和条件变量的虚假唤醒完全一样,防止线程被唤醒后发现其实还没到退出时机:

static void *worker(void *arg) { pool_t *p = (pool_t *)arg; for (;;) { pthread_mutex_lock(&p->lock); while (p->head == NULL && !p->shutdown) pthread_cond_wait(&p->notify, &p->lock); if (p->shutdown && p->head == NULL) { pthread_mutex_unlock(&p->lock); break; } task_t *t = p->head; p->head = t->next; pthread_mutex_unlock(&p->lock); t->fn(t->arg); free(t); } return NULL; }

为什么取任务之后要立刻释放锁?原因就是前面说的“锁得短”。从队列里摘节点是一个短操作,业务执行不应该继续占着锁。如果这里把t->fn放在锁里执行,其他线程既不能投递新任务,也不能处理自己的任务,整个线程池就退化成了串行执行器。

投递任务的逻辑核心是:把任务节点挂到队列尾部,挂完之后唤醒一个工作线程。经典做法是在同一把锁的保护下完成“入队+signal”,避免丢失唤醒。下面的submit函数展示了这个过程:

int pool_submit(pool_t *p, void (*fn)(void *), void *arg) { task_t *t = malloc(sizeof(*t)); if (!t) return -1; t->fn = fn; t->arg = arg; t->next = NULL; pthread_mutex_lock(&p->lock); task_t *tail = p->head; while (tail && tail->next) tail = tail->next; if (tail) tail->next = t; else p->head = t; pthread_cond_signal(&p->notify); pthread_mutex_unlock(&p->lock); return 0; }

这个版本为了直观,入队时每次都遍历到队尾。实际项目一般会再加一个tail指针把复杂度降到O(1)。我是故意保留这个简单的写法,让你看清“锁保护下的入队操作”长什么样,再优化也不会影响整体结构。补充一点:如果任务队列可能积压较多,还需要在入队时检查队列长度,超过上限就要根据策略拒绝新任务或阻塞生产者,这同样需要在持有锁的情况下完成,避免多个生产者同时入队造成队列长度检查失真。

销毁线程池是重点,也是最容易写错的地方。销毁分三步:把shutdown标记为1,广播唤醒所有工作线程,然后逐个join等待线程退出。这里有一个细节:必须先置shutdown再广播,不能反过来。因为工作线程在cond_wait内部醒来后会重新检查条件,如果先把唤醒信号发了但还没置shutdown,线程醒来后发现队列空了并且shutdown还是0,就会继续睡回去,而你已经不再投递新任务,它可能永远醒不了。

int pool_destroy(pool_t *p) { pthread_mutex_lock(&p->lock); p->shutdown = 1; pthread_cond_broadcast(&p->notify); pthread_mutex_unlock(&p->lock); for (int i = 0; i < p->thread_count; i++) pthread_join(p->threads[i], NULL); pthread_mutex_destroy(&p->lock); pthread_cond_destroy(&p->notify); free(p->threads); free(p); return 0; }

销毁时还有一个容易忽略的问题:队列里可能还残留着没执行的任务。我这个版本的处理策略是“清空队列但不执行残留任务”,直接退出。如果你需要“优雅关闭”,要求执行完已经入队的任务再退出,那就要把退出条件改成“队列为空且shutdown置位”,同时让工作线程在退出前继续消费队列里的残留任务。这种细节属于产品需求层面的决策,代码结构上只是改变一个判断条件,但必须提前想清楚,否则线上销毁线程池时行为会和预期完全不一样。

4.2 线程数、队列容量与栈大小的配置思路

线程池创建多少个线程,不是拍脑袋定的。最基础的参考是CPU核心数。查核数用sysconf(_SC_NPROCESSORS_ONLN),但真正的线程数还要看任务的CPU密集程度。一个被广泛引用的经验公式:Nthreads = Ncpu * (1 + wait_time / compute_time)。wait_time是任务等待I/O等外部资源的时间,compute_time是任务实际占用CPU计算的时间。如果任务是纯计算没有任何等待,线程数设为核数通常最合理;如果任务大量时间在等网络响应、等磁盘,wait/compute会很大,线程数可以放到核数的数倍甚至数十倍。

还有一个反直觉的现象值得提:线程数并不是越多越好。当一个进程的线程数超过CPU核数后,操作系统开始频繁进行上下文切换,每个线程获得的CPU时间片变短,任务响应延迟反而上升。我曾在一个模拟项目里用8核机器跑纯计算任务,4线程时的吞吐是单线程的3.9倍左右,8线程时接近6倍,但把线程数推到64后,吞吐不仅没涨还略有下降,因为绝大部分时间都耗在调度和切换上了。你可以在自己的环境里做一个类似的对照测试,让数据说话。

队列容量决定了系统的缓冲能力。如果生产者投递任务的速度偶尔大于消费速度,队列可以把峰值“削平”。但队列无限增长又会导致内存膨胀,所以工业级线程池一般会设置队列上限,超过上限时要么阻塞生产者,要么拒绝任务。队列长度怎么估?你可以记录高峰期任务的平均大小和生产速率,估算出需要缓冲多少秒的任务量,再乘上一个安全系数。比如峰值每秒投递1000个任务,每个任务结构体加数据大概4KB,希望缓冲2秒,那队列长度就是2000项,内存占用接近8MB,这个量级在服务程序里完全可以接受。

栈大小是另一个值得主动设置的参数。默认线程栈在glibc下通常是8MB。对大部分线程而言,实际栈占用只有几十KB到几百KB,8MB只是虚拟地址空间预留。但如果你创建几千个线程,8MB乘以3000就是24GB虚拟内存,即使物理内存没满,也可能撞上vm.max_map_count或进程地址空间限制。把不需要深递归的线程栈设为1MB或512KB,可以有效提升线程数量的上限。反过来,如果你的任务里有很深递归、大数组等局部变量,栈设小了会直接段错误,这种错误还特别难排查,因为栈溢出往往表现为某个线程在随机位置崩溃。

4.3 线程池实测中的关键表现与调优方向

写线程池不能写完就扔,最好做一个简单的压测脚本,观察三个指标:吞吐量、平均延迟、线程数。我在自己机器上做过一个对照实验:用一个任务队列,任务内部做一个累加计算,分别启动1、4、8、16个工作线程,投递10万个任务,测量总耗时。

结果大致如下(数值因机器不同会有明显差异,重点看趋势):

工作线程数10万任务总耗时相对单线程加速比
1约820ms1.0x
4约210ms3.9x
8约140ms5.8x
16约150ms5.5x

从4线程到8线程还有明显提升,但8线程到16线程就不再加速了,甚至稍微变慢。这说明在纯计算任务下,线程数超过核数之后,调度开销开始超过并行收益。如果换成等待型任务,比如任务内部sleep 10毫秒模拟I/O等待,情况又会完全不同:8线程能同时跑8个等待中的任务,吞吐几乎是线性的,16线程时因为等待比例高,加速仍然有效。这正好呼应了前面那个经验公式的用法。

调优方向也很明确。先用perf top或者gprof看热点,如果热点集中在pthread_mutex_lock和pthread_cond_wait附近,说明锁竞争是瓶颈,优先优化临界区长度和锁粒度;如果热点集中在任务函数本身,说明瓶颈在业务逻辑,线程数或者算法才是关键。不能一上来就盲目加线程,加线程只是把问题掩盖或者转移到调度器上。

5. 常见问题与排查技巧实录

5.1 经典疑难速查表

我把这些年遇到的多线程问题整理成一张速查表,遇到类似情况可以先对照检查。

现象可能原因排查思路
程序退出时卡住不动有joinable线程未回收,或线程在等待条件变量列出所有线程,检查哪个线程还在阻塞;确认所有pthread_join都被执行
线程创建失败,返回EAGAIN线程数、虚拟内存或栈空间不足检查ulimit -u、threads-max、线程栈大小设置
数据偶发错乱,崩溃位置不确定共享数据未加锁,或加锁范围不完整代码审查所有全局变量和堆对象;用ThreadSanitizer复现
死锁,程序完全无响应多把锁加锁顺序不一致,或锁内调用回调gdb attach后thread apply all bt,看线程卡在哪个锁上
条件变量等待一直不返回唤醒信号在等待之前发出,形成丢失唤醒检查状态修改和signal是否在同一把锁内,判断条件用while
队列任务被重复执行多个工作线程同时弹出同一个任务节点检查弹出和更新head指针是否在同一把锁的保护下
线程栈溢出段错误栈大小设置过小,或递归过深检查pthread_attr_setstacksize,用ulimit看限制,崩溃后用gdb看栈回溯

这张表并不完整,但它覆盖了我日常工作里九成以上的多线程疑难。每一行背后都有对应的真实教训,而且这些现象在初学阶段最容易搞混。比如“程序退出时卡住”和“条件变量等待不返回”,表面症状都是卡住,但一个属于生命周期管理缺失,一个属于同步通知丢失,解决方向完全不同。所以排查问题之前,先把现象描述清楚,再对照表格缩小范围,能省下很多瞎试的时间。

5.2 排查思路与分析手段的落地顺序

遇到多线程问题,我的排查顺序是先看现象,再看代码,最后上工具。直接上工具容易淹没在海量信息里,先明确“卡住、崩溃、性能差”是三类完全不同的路径。

程序卡住时,第一件事是用ps -eLf或top -Hp 查看进程的线程数量和状态。如果看到大量线程处于sleep或等待状态,再缩小范围。第二步用gdb attach到进程,执行thread apply all bt,把所有线程的调用栈打出来。死锁的特征非常明显:几个线程都停在pthread_mutex_lock或pthread_cond_wait附近,等待的锁地址互相咬合。看到这个现场,基本就能确定是锁顺序不一致。

程序崩溃时,核心思路是拿到崩溃时的栈。如果崩溃是偶发的,先别急着怀疑编译器,优先怀疑共享数据竞争。用ThreadSanitizer(-fsanitize=thread)重新编译程序,在测试环境里复现,它能把发生数据竞争的线程对和变量位置直接报出来。注意,TSan对程序行为和性能影响较大,不适合直接上生产,只适合在测试环境复现。

性能不佳时,先用perf top看热点函数,再用perf stat看上下文切换次数。如果context-switches非常高,说明线程数超过合理范围或者锁竞争严重;如果热点集中在某个锁函数,考虑拆锁、减小临界区、换读写锁等优化手段。没有数据支撑的优化都是盲猜,这句话在多线程领域尤其适用。

5.3 在线程控制上我踩过的坑

第一个坑是给detach线程传了局部变量。当时写的是一个后台通知功能,主函数创建线程后直接detach,线程函数里读一个栈上的配置结构。开发环境每次都能正常运行,因为线程执行很快,主函数的栈帧还在;上线后调用频率变高,栈被复用,线程读到的配置变成野数据。那次之后我养成了一个习惯:凡是要detach的线程,参数必须来自堆分配,并且由线程自己释放。

第二个坑是锁内调用用户回调。线程池里任务是从队列取出的,我为了图省事,直接在执行任务前不释放锁,结果某个任务回调里又往同一个线程池投了新任务,新任务入队需要拿同一把锁,直接把线程卡死。拆解下来其实就是“锁重入”问题,普通互斥锁不支持重复加锁。后来我统一规定:所有从队列取出的任务,必须在释放锁之后执行;所有回调函数,默认不允许在被调用时持有调用方的锁。这条规定救了后面无数次。

第三个坑是线程池销毁时先广播后置shutdown。当时写了一个简化版,以为通知了线程就万事大吉,结果程序退出时总有几个线程不退出。分析后发现工作线程醒来检查shutdown还是0,又回去等了。我这才真正理解条件变量“醒来要重新检查条件”的含义。从那以后,凡是涉及条件变量,我都会在心里默念一句:唤醒只是告诉你条件可能变了,不等于条件一定成立,判断条件必须放在循环里。这句话听起来朴素,但它真的是无数偶发问题的根源。

踩过这些坑之后,我现在的习惯是:创建线程之前先写清楚线程的归属和退出路径;需要返回值的用join配合堆上的结构体,不需要的尽早detach;所有共享数据的访问都默认要求先加锁。保守不一定是最快的方案,但一定是线上环境里让你睡得着觉的方案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进