ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux线程控制实战:从创建线程到线程池与死锁排查

Linux线程控制实战:从创建线程到线程池与死锁排查 做Linux后台开发这些年线程控制是绕不开的基本功。面试的时候会被问写业务代码的时候会碰到程序莫名其妙卡死、数据偶发错乱、线程数一天比一天高——这些问题的根源大半都在线程控制这个环节。接下来我不打算照本宣科而是把自己从创建线程到设计线程池、再到排查死锁的一整套经验完整梳理一遍适合正在写多线程服务的C/C开发者也适合准备Linux高级岗位面试的同行参考。1. 线程本质先搞清楚你在控制什么1.1 进程和线程到底差在哪大学课本里的定义已经说得很清楚了进程是资源分配的最小单位线程是CPU调度的最小单位。但只有真正跑过并发代码的人才会深刻理解这句话的分量。进程拥有独立的地址空间每个进程都被操作系统隔离在不同“房间”里进程之间的通信要经过管道、共享内存这些“中介”。而同一个进程内的多个线程住在同一个“房间”里——它们共享代码段、数据段、堆、文件描述符表、信号处理函数、当前工作目录几乎一切都共享。这个“共享”是线程存在的最大理由。你想想如果开10个进程去并行处理一批任务每个进程都要复制一遍环境、维护独立的内存页表光建立和回收的代价就够喝一壶了。换成线程创建开销小一个数量级切换也快得多数据天然共享不需要走IPC。代价就是共享空间意味着大家都能改同一块数据改乱了怎么办这就是后面同步机制要解决的事。1.2 线程私有与共享的边界共享的部分上面列了再看看私有的部分线程栈、寄存器集合、errno、线程局部存储TLS。这个边界一定要刻在脑子里因为大部分线上bug都是从这条边界上冒出来的。先说线程栈。Linux下pthread线程栈默认8MB主线程的栈通常更大一些但每个线程的栈都是预先分配好的一块虚拟地址空间真正使用时会按需提交物理页。你在线程函数里写一个很大的局部数组或者递归没有控制深度随时栈溢出崩溃。我见过一个线上服务某个业务线程里递归解析JSON数据异常时递归深度直接爆掉整个进程段错误业务全挂——这就是不理解线程栈边界的代价。再说errno。errno在单线程程序里就是个普通全局变量但在多线程里它必须变成线程私有的否则两个线程同时做系统调用错误码互相覆盖排查的时候会得出完全错误的结论。glibc在实现上把它做成了TLS线程局部存储所以我们写代码时不用关心errno的线程安全问题但心里要清楚错误码是按线程隔离的。TLS是个好东西。当你发现某些全局变量频繁被多个线程读写、造成缓存竞争的时候把它改成__thread修饰的TLS变量性能往往能立竿见影。这个技巧在后面的性能优化部分我再展开。2. 生命周期控制创建、终止与回收里的坑2.1 pthread_create创建线程之前要明白的事先看清楚这个函数int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);第一个参数是线程id输出第二个是线程属性传NULL就是全部默认第三个是你写的线程入口函数第四个是传给入口函数的参数。返回值0表示成功非0是一个错误码注意它不设置errno。这个函数有三个容易出问题的地方。第一个就是参数传递。写线程函数时参数不要指向局部变量void *worker(void *arg) { int *v (int *)arg; // 危险args可能在main栈上已经被销毁 } void create() { int args 42; pthread_t tid; pthread_create(tid, NULL, worker, args); // 不推荐 }这个代码运气好能跑运气不好就是悬垂指针。参数应该指向堆内存或者指向一个在整个线程生命周期内都有效的区域——通常直接malloc一块在线程内用完再释放。第二个是attr的用途。默认线程栈大小8MB默认是joinable的。如果你知道某个线程只需要很小的栈可以用pthread_attr_setstacksize显式调小如果线程是长生命周期且不需要join可以直接在attr里设置PTHREAD_CREATE_DETACHED比创建后手动detach更干净。第三个是EAGAIN错误。当系统线程数达到上限或者内存不足以分配栈空间时pthread_create会返回EAGAIN。反复创建线程又没有正确回收是最常见的触发条件。这也是线程池存在的重要理由之一。2.2 join与detach线程资源一定要有人收一句话先讲清楚一个joinable的线程在结束时不会自动释放资源必须由另一个线程调用pthread_join来回收而detach的线程结束时自动把资源还给系统。那“资源”具体是什么主要是线程栈和内核里的线程控制块。可以类比为创建一个线程就像租了一个摊位租金已经付过了内存已分配你走了摊位还在要有人来拆。没人来拆那就是泄漏。对应的两种管理方式// 方式一join pthread_t tid; pthread_create(tid, NULL, worker, NULL); void *ret; int err pthread_join(tid, ret); if (err 0) { // ret是线程返回值 } // 方式二detach pthread_t tid; pthread_create(tid, NULL, worker, NULL); pthread_detach(tid); // 线程结束自动回收不能再join我的建议凡是你能确定线程会很快结束、又关心退出结果就用join凡是长生命周期或者压根不关心退出状态的直接detach。最忌讳的是创建一个joinable线程既不管它也不join它这等于白白泄漏一份线程资源。线上排查线程泄漏时先检查所有pthread_create有没有对应的join或detach。还有一个细节已经detach的线程绝对不能再join返回的是EINVAL。反过来先join后detach也是在自找麻烦。这两个操作是互斥的一个线程的生命周期内只能选一条路走到底。2.3 终止线程的几种姿势只有一部分是对的线程函数里return就是正常退出返回值会作为线程退出状态传给join的ret指针。这是最常见的姿势资源也会正常走清理逻辑。第二个姿势是pthread_exitvoid *ret_val malloc(sizeof(int)); *(int *)ret_val 100; pthread_exit(ret_val);它跟return在效果上差不多都是结束当前线程。但有一个坑如果线程在持有互斥锁的时候调用pthread_exit锁不会自动释放因为pthread_exit不会像C的异常栈回退那样调用析构函数。结果就是另一个线程永远等不到这把锁。这种问题不好复现但一旦发生就是死锁。所以在使用pthread_exit之前一定要把资源清理干净。第三个是pthread_cancel这个最容易被误解。它只是“请求”取消目标线程不是立刻把它干掉。默认情况下目标线程要运行到某个取消点才会响应。取消点通常是一些可能会阻塞的系统调用比如read、write、pthread_cond_wait等。如果你的线程里全是纯计算没有取消点那pthread_cancel可能永远等不到生效。想改变行为需要用pthread_setcanceltype设置异步取消但是异步取消极其危险线程可能在任何一条指令处被终止栈上的资源、锁全都来不及清理我强烈不建议在生产代码里用异步取消。第四个姿势是直接在某个线程里调用exit()。注意exit是终止整个进程不是终止当前线程。很多人排查问题的时候误用了这个程序直接没了。生命周期控制的基本盘到这里就清楚了创建时要管好参数和资源结束时要想清楚谁来回收取消要理解取消点的语义。接下来进入真正的重头戏——同步。3. 同步互斥体系让多线程安全地协作3.1 互斥锁保护临界区的基本功当两个线程同时执行i这样的操作表面上是一条语句在CPU层面却是“读、加、写”三个步骤。线程A读到100线程B也读到100各自加1写回结果还是101——正确的应该是102。这就是竞态条件。要解决竞态条件就得引入互斥锁。Linux下互斥锁pthread_mutex_t的标准用法pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; void *worker(void *arg) { pthread_mutex_lock(lock); // 临界区只操作共享数据 shared_counter; pthread_mutex_unlock(lock); return NULL; }两个关键点第一加锁和解锁务必配对。特别是函数里有多个return路径时解锁要覆盖每一条路径。我见过太多同事在返回前忘记释放锁等到线上偶发死锁才追悔莫及。第二临界区要尽量短不要在锁内放耗时的IO操作——这会拖住所有等待这把锁的线程。如果不用默认属性可以用pthread_mutex_init动态初始化结束时pthread_mutex_destroy销毁。还有更高级的递归锁PTHREAD_MUTEX_RECURSIVE允许同一个线程重复加锁可以在递归函数里用。但递归锁不能滥用它往往是设计有问题的信号——如果锁的逻辑本身是短临界区出现递归场景说明你该重构临界区了。3.2 死锁四个条件与工程上的应对死锁有个经典的四必要条件互斥、持有并等待、不可剥夺、循环等待。工程上预防的思路主要集中在打破“循环等待”上。最常见的死锁场景是加锁顺序颠倒。线程A先锁X再锁Y线程B先锁Y再锁X当两者交错时就死锁了。解决方案很简单所有线程都按同一个全局顺序加锁。比如在涉及多把锁的业务里约定一律先锁编号小的再锁编号大的。这虽然不解决所有死锁场景但能解决大多数。另一个容易忽略的死锁来源是“自己锁自己”——pthread_mutex_lock在同一个线程对同一把非递归锁重复加锁也会阻塞等待自己释放直接卡死。排查的时候看到某个互斥锁同时被同一个上下文拿着先检查有没有重入。工程上除了规范加锁顺序还可以用pthread_mutex_timedlock做超时加锁struct timespec ts; clock_gettime(CLOCK_REALTIME, ts); ts.tv_sec 2; int err pthread_mutex_timedlock(lock, ts); if (err ETIMEDOUT) { // 已经等了两秒说明有地方不对劲 }这不能防止死锁但可以在死锁发生时让线程不至于无限阻塞为诊断留出窗口。3.3 条件变量从“轮询等待”进化为“通知唤醒”互斥锁已经解决了“同时访问”的问题但生产者和消费者的场景还缺一个关键机制消费者需要等任务到来而不能在循环里一遍遍空转。条件变量pthread_cond_t专门解决这个问题。它让一个线程在某个条件不满足时主动睡眠等另一个线程“唤醒”它。核心接口是pthread_cond_wait和pthread_cond_signal/broadcast。这里有个很绕但必须理解的点pthread_cond_wait必须在持有互斥锁的前提下调用并且它会原子完成三件事——释放锁、把自己挂到等待队列、睡眠。被唤醒后它会在返回前重新获取锁。所以使用框架永远是pthread_mutex_lock(lock); while (任务队列为空 !shutdown) { pthread_cond_wait(cond, lock); } // 现在可以安全地取任务了 pthread_mutex_unlock(lock);生产者那边pthread_mutex_lock(lock); // 往队列里塞任务 pthread_cond_signal(cond); pthread_mutex_unlock(lock);为什么判断条件要用while而不是if必须说清楚一是虚假唤醒spurious wakeup在POSIX规范下是允许发生的二是有多个消费者时signal唤醒的线程醒来后条件可能已经被别人消费了。所以每次被唤醒后都必须重新检查条件不成立就继续睡。这是条件变量使用中最常见的错误一旦用if就是偶发bug的根源。signal和broadcast的选择也很关键。broadcast唤醒所有等待的线程代价是惊群signal只唤醒一个唤醒哪个由调度决定。如果只有一个消费者signal够用如果消费者不止一个且唤醒后竞争激烈要结合具体情况权衡。我在图省事的情况下倾向于broadcast——虽然多一点惊群开销但不会因为signal唤醒了一个暂时不需要处理任务的线程而丢事件。严格要求性能的场景还是要仔细设计。3.4 读写锁和自旋锁按场景挑合适的工具互斥锁是“一刀切”读和写都互斥。但很多业务是典型的读多写少比如配置文件缓存、路由表用互斥锁会严重浪费并发能力。读写锁pthread_rwlock_t允许任意多个读者同时持有读锁写者独占写锁但读与写、写与写之间互斥。pthread_rwlock_t rwlock PTHREAD_RWLOCK_INITIALIZER; // 读者 pthread_rwlock_rdlock(rwlock); config_value load_config(); pthread_rwlock_unlock(rwlock); // 写者 pthread_rwlock_wrlock(rwlock); update_config(new_value); pthread_rwlock_unlock(rwlock);自旋锁则是另一个方向的优化。它不像互斥锁那样让线程睡眠而是让线程原地循环“自旋”等待锁释放。对临界区极短、临界区内没有任何系统调用的场景自旋锁能省掉线程睡眠和唤醒的上下文切换开销性能显著提升。但如果临界区稍长自旋就等于空耗CPU其他线程也得不到调度。经验上临界区只在几十条简单指令内才值得用自旋锁否则老老实实用互斥锁。这几个锁选型给个速查表场景推荐工具理由一般互斥、临界区可能阻塞互斥锁成熟可靠线程会在等待时睡眠读多写少、读并发要求高读写锁读者不互斥并发度更高临界区极短、不阻塞自旋锁省去上下文切换开销条件等待场景条件变量互斥锁让线程等待事件而非忙等并发计数、标志位原子操作比锁更轻量避免锁开销4. 线程池实战从零搭一个能用的线程池4.1 为什么非要线程池线上服务里如果每个请求都创建线程、用完后销毁高并发下会有三个问题第一创建和销毁线程本身有开销线程栈的分配、内核线程的建立与回收这些成本在大流量下会被放大第二频繁创建销毁会导致上下文切换频繁cache也一直被打乱第三线程数量不可控系统负载一高就可能触发EAGAIN直接服务失败。线程池的思路就是把线程先建好、复用起来。池子里维持一批常驻工作线程任务来了就放到队列里工作线程自己去取任务处理完了线程不退出继续等下一个任务。线程数量可控创建成本摊薄到大量任务上系统的稳定性会好很多。4.2 线程池大小怎么定公式和一次实测线程池的线程数不是凭空拍脑袋的数字主要分两类场景。CPU密集型任务线程数 核心数 1。多出的1个线程是为了应对某些线程由于缺页中断、短暂调度等待等原因阻塞时还能多一个线程占满CPU。设成2倍核心数往往效果并不好因为线程切换反而让CPU时间被管理开销吃掉。IO密集型任务常见经验值 核心数 * 2 1。更精确的做法是估算任务中等待时间和计算时间的比例线程数 核心数 * (1 等待时间 / 计算时间)。如果一次IO等待1ms计算才0.1ms那么每个核心大概需要11个线程才能把计算时间塞满。算法上说得过去但实际最好配合压测观察吞吐量和延迟曲线来微调。我自己的实测体会单靠公式会翻车。一次线上压测按公式算了32个线程但服务延迟还是高。后来发现每个任务里有一段锁竞争线程一多全部堵在锁上。这种情况应该先优化锁的粒度再调整线程数。线程池参数必须配合系统实测表现反复迭代公式只提供起点。4.3 线程池核心实现拆解一个最小可用的线程池需要几个部件任务结构体、任务队列、工作线程数组、一把互斥锁、一个条件变量、一个关闭标志。typedef struct task_s { void (*func)(void *arg); void *arg; struct task_s *next; } task_t; typedef struct threadpool_s { pthread_mutex_t lock; pthread_cond_t notify; task_t *queue_head; // 任务队列头 int queue_num; // 当前任务数 pthread_t *threads; // 工作线程数组 int max_threads; // 线程上限 int shutdown; // 1正在关闭 } threadpool_t;提交任务的核心逻辑int threadpool_add(threadpool_t *pool, void (*func)(void *), void *arg) { pthread_mutex_lock(pool-lock); if (pool-shutdown) { pthread_mutex_unlock(pool-lock); return -1; } task_t *t malloc(sizeof(task_t)); t-func func; t-arg arg; t-next pool-queue_head; pool-queue_head t; pool-queue_num; pthread_cond_signal(pool-notify); pthread_mutex_unlock(pool-lock); return 0; }工作线程的执行逻辑void *worker(void *arg) { threadpool_t *pool (threadpool_t *)arg; while (1) { pthread_mutex_lock(pool-lock); while (pool-queue_num 0 !pool-shutdown) { pthread_cond_wait(pool-notify, pool-lock); } if (pool-shutdown pool-queue_num 0) { pthread_mutex_unlock(pool-lock); break; } task_t *t pool-queue_head; pool-queue_head t-next; pool-queue_num--; pthread_mutex_unlock(pool-lock); // 取完任务立刻解锁 t-func(t-arg); free(t); } return NULL; }有一个细节值得重点说worker线程在取出任务后要立刻释放锁再去执行函数。如果把执行函数放在临界区里那所有线程取任务时都得排队等别人执行完池子就退化成单线程了。这个错误非常典型一度以为自己代码写得没问题结果压测吞吐始终上不去最后才发现是锁把整个任务执行过程串行化了。4.4 线程池的关闭与异常处理线程池的关闭是一个容易被忽视的细节。关闭分两种不等队列清空直接杀和优雅关闭等队列里的任务都执行完再退出。优雅关闭的正确顺序是先置shutdown标志再broadcast唤醒所有阻塞在wait的worker最后逐个join。注意一定不要先join再置标志那样worker永远阻塞在cond_wait上不会退出join就永远等不到结束。顺序反了就是经典的线程池关闭死锁。void threadpool_destroy(threadpool_t *pool) { pthread_mutex_lock(pool-lock); pool-shutdown 1; pthread_cond_broadcast(pool-notify); pthread_mutex_unlock(pool-lock); for (int i 0; i pool-max_threads; i) { pthread_join(pool-threads[i], NULL); } pthread_mutex_destroy(pool-lock); pthread_cond_destroy(pool-notify); free(pool-threads); free(pool); }另一个坑是任务数量不均衡。队列如果无界增长任务生产速度大于消费速度时会内存膨胀。生产项目里建议给线程池加一个“最大队列长度”限制队列满了可以采取阻塞提交或直接拒绝两种策略。或者让提交方自己承担背压比如队列满时让提交线程主动等待一点时间再重试这比重试逻辑写满整个代码库要干净得多。5. 调试排错与性能优化这些坑我替你踩过了5.1 常见问题速查表多线程问题难排查因为它往往不是稳定复现的。我整理了一个自己的速查表碰到问题先对号入座能节省大量时间。症状大概率原因第一步排查手段进程卡住、CPU占用极低死锁或条件变量漏唤醒gdb attach后用thread apply all bt看所有线程栈偶发数据错误、时对时错竞态条件临界区未正确加锁valgrind --toolhelgrind或开TSan跑一遍线程数只增不减线程泄漏join/detach缺失连续观察top -H -p PID线程数查pthread_create配对突然段错误、core dump栈溢出或悬垂指针先看backtrace再检查线程栈上是否有超大数组生产者不喂、消费者饿死signal唤醒丢失或条件判断没循环确认条件变量用while循环信号是否在线程函数外部发送死锁排查最实用的工具就是gdb。先gdb attach到卡住的进程然后(gdb) thread apply all bt这会打印所有线程的调用栈。如果看到多个线程都停在pthread_mutex_lock上马上看它们分别等的是哪把锁再对比加锁顺序通常当场就能定位。还有个技巧编译时加-fsanitizethread或者用valgrind的helgrind工具能自动报告数据竞争的位置比人肉看代码高效得多。5.2 条件变量丢失唤醒与虚假唤醒案例很多时候问题代码看起来完全没有毛病但就是偶发卡顿。我碰到的一个典型案例是这样的一个业务线程在循环里写if (queue_empty) { pthread_cond_wait(cond, lock); }条件判断写成了if而不是while。这个代码在低负载时跑得极其正常因为唤醒总是精准的。但某次系统负载一高两个线程同时被唤醒来消费同一个任务一个拿走了另一个醒来看见队列空就退出循环去做空操作逻辑便开始错乱。改成while之后问题彻底消失。所以我的经验就是条件变量判断一律用while养成肌肉记忆不要靠分析“这次应该没问题”来侥幸。5.3 性能优化锁粒度、亲和性、无锁当功能稳定后性能优化有几个方向按投入产出比排序。第一个方向是缩小锁临界区。能放到锁外计算的内容尽量放出去锁里只保留对共享数据本身的更新。这个方向改动最小、收益最明显。我做过一个缓存系统把读锁内的一个序列化操作移出去QPS直接翻了近一倍——就是加锁范围缩小这么简单。第二个方向是降低锁竞争频率。不同线程如果操作的是不同数据就不需要用同一把锁。用更细粒度的锁比如分片锁或者读写锁替代互斥锁都能把竞争降下来。还有一类场景可以用TLS只在线程内部使用的数据声明成__thread完全绕开锁。第三个方向是线程亲和性。pthread_setaffinity_np可以把线程绑定到某个具体核上减少跨核迁移带来的cache miss。在NUMA架构下让线程在本地内存节点上运行也能减少远端内存访问耗时。这个优化对计算密集型的线程池效果比较明显。第四个方向才是无锁数据结构。无锁队列比如基于CAS的MPSC队列能在极高竞争下获得优势但实现复杂度显著上升正确性验证成本也高。我的建议是先用前面三步压测结果不满足再说。无锁不是银弹它只是把锁竞争换成了更精细的原子操作竞争。这些年我做过不少多线程相关的系统从最初为每个请求裸建线程到后来封装线程池、压测调参中间踩过的坑无一例外都是对线程本质理解不深造成的。如果要说最重要的一条那就是创建线程之前先想清楚资源和生命周期管理写同步代码之前先想清楚锁的粒度和加锁顺序出问题的时候先用工具把线程栈拉出来看而不是靠猜。把这几点做扎实了Linux多线程编程不敢说精通至少能把线上服务的稳定性稳稳兜住。
返回列表