拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux多线程编程实战:从原理到线程池配置与故障排查

做后端开发这些年我面试过不少候选人也带过不少新人几乎每次聊到 Linux 下的多线程编程总能看到大家眼神里那种“好像懂一点又说不清楚”的状态。线程这个东西说白了就是操作系统里最常用的并发单元但真要把“它是什么、怎么用、出问题了怎么查”讲明白还真不是一两句话的事。尤其现在服务器动辄几十核上百核线程用得好不好直接决定你的服务能抗住多少并发、出了故障能不能快速定位。这篇文章我打算从头到尾把 Linux 线程这件事梳理一遍包括进程和线程的区别、Linux 里的线程到底是怎么实现的、互斥锁和条件变量怎么用、线程池参数怎么配、线上线程卡死了怎么排查。内容会偏实战一些也会给一些可以直接抄作业的命令和代码片段适合刚接触多线程的初学者也适合带过一阵子项目、想系统理清细节的工程师。先说一个我观察到的现象很多人在 Windows 上写多线程程序用 CreateThread 或者 C11 的 std::thread觉得挺顺手的。但一到 Linux 服务器上就发现“线程”这个概念的呈现方式很不一样。你在 Linux 下用 ps 命令看进程列表会发现线程和进程一样都有一个独立的 PID准确说是 TID这也是很多人第一次查资料时被搞懵的地方。所以我觉得与其急着写代码不如先把 Linux 线程的底子摸清楚后面一切才顺理成章。1. 线程的本质先弄懂它在Linux里到底是个什么角色1.1 进程和线程的关系别看教科书说得玄乎教科书上会说进程是资源分配的最小单位线程是 CPU 调度的最小单位。这句话本身没错但太抽象。我习惯用一个工厂来做类比进程就是一家工厂它有自己的厂房内存空间、设备文件描述符、水电账单系统资源线程就是工厂里的工人大家共享同一间厂房和设备但各自有自己干活的状态比如手里拿着什么工具寄存器、干到哪一步了程序计数器。多线程最大的价值在于工人之间不需要重新申请厂房、搬运设备直接在一个空间里协作干活所以创建线程比创建进程轻量得多。同一个进程下的线程共享进程的代码段、数据段、堆、打开的文件等资源唯一不共享的主要是线程自己的栈空间和寄存器现场。这也意味着一个线程崩了如果是因为野指针乱写内存整个进程都遭殃其他线程也跑不了——这就是共享的代价。1.2 Linux里没有“纯粹”的线程只有轻量级进程这句话我建议每个 Linux 开发者都记住。Linux 内核里其实没有单独为线程设计一套调度实体它把线程统一当成“轻量级进程”Light Weight Process来管理。你用 pthread_create 创建一个线程本质上是调用 clone 系统调用只不过 clone 的时候指定了很多共享的 flag比如共享地址空间、共享文件系统信息、共享打开的文件列表。所以你在 Linux 上就会看到这样的现象用 ps -eLf 查看一个多线程程序会显示多行每一行是一个线程有一个“LWP”列这个 LWP 就是线程ID。如果用 top 的 -H 选项也能看到线程级别的 CPU 占用。这一点和 Windows 差别很大Windows 的线程在任务管理器里默认是折叠在进程里的而 Linux 直接把线程摊开给你看刚开始会觉得乱但排查问题的时候这种“摊开”反而非常有用。提示面试里常问“进程和线程的区别”最核心的考点不是谁大谁小而是“共享了什么”和“独享了什么”。能清晰说出线程共享地址空间、文件描述符等资源但拥有独立栈和寄存器上下文基本就能过这一关。1.3 什么时候该用线程什么时候该用进程这个问题看着简单但实际项目里经常有人选错。我总结一个简单原则如果你需要多个执行流之间频繁共享数据、协同干活比如同一个服务里同时处理多个请求、共享一份缓存那就用线程通信成本低如果你需要强隔离比如某个子任务崩了不能影响主服务或者不同模块由不同团队维护、接口已经定好走 IPC那就用进程比如 Nginx 的 worker 进程模型、Chrome 的多进程模型。另一个实际考虑是语言生态。C/C、Java、Go 这类语言多线程是标配思路线上服务基本都是多线程模型但 Python 因为有 GIL 锁多线程在 CPU 密集型场景下反而吃亏更多人会用多进程。所以选型不能只看理论还得看语言运行时的特性。2. 线程同步与互斥并发最容易翻车的三个坑2.1 互斥锁保护共享资源的第一道门只要有两个线程同时读写同一个变量哪怕只是一个简单的 i都有可能出现数据错乱。原因在于 i 在 CPU 指令层面不是原子的它分“读取-修改-写回”三步线程 A 刚读完、还没写回去线程 B 也读了同一个值两个线程都加 1结果只加了 1。这种问题被称为竞态条件。解决竞态最常用的手段就是互斥锁在 Linux 下对应 pthread_mutex_t。使用模型很简单访问共享资源前加锁访问完了解锁。但这里有个特别容易犯的错加锁的粒度太大把不相关的代码也包进去结果多线程退化成串行执行性能还不如单线程加锁的粒度太小又可能保护不完整数据还是会被并发篡改。我见过一个典型的案例一个 C 服务里两个线程分别读写一个大数组写线程每秒更新数组的一部分读线程需要周期性读取整个数组做统计。一开始开发者在数组里每个元素旁边都加一个锁结果代码复杂到没法维护后来改成“双缓冲区”方案写线程写副本读线程读主区靠一个原子指针切换既避免了锁竞争又保证了数据一致性。所以遇到复杂的共享结构别只盯着锁换个思路可能更优雅。2.2 条件变量让线程学会“等通知”而不是“傻等”互斥锁解决了互斥但解决不了“等待”。最常见的场景是生产者-消费者模型消费者线程得等队列里有数据才能继续处理如果用一个 while 循环不停检查队列大小CPU 会被白白空转吃掉。这时候就需要条件变量pthread_cond_t。条件变量的标准用法是配合互斥锁使用调用 pthread_cond_wait 之前必须先持有互斥锁wait 内部会原子性地释放锁、挂起线程等收到唤醒信号后再重新抢锁、继续执行。这里我最想提醒的一点是pthread_cond_wait 被唤醒后并不代表条件一定成立了可能出现“虚假唤醒”或者别的线程抢先消费了数据。所以稳妥的写法是用 while 循环重新检查条件而不是用 if。pthread_mutex_lock(mutex); while (queue_empty()) { pthread_cond_wait(cond, mutex); } process_data(); pthread_mutex_unlock(mutex);这段代码里的 while 是保命用的我见过有人改成 if 之后线上偶发处理到空数据的问题排查了一整天才找到原因。2.3 死锁是怎么发生的以及怎么避免死锁的经典场景是两个线程各持有一把锁同时又在等对方手里的锁。比如线程 A 持有锁 1、等待锁 2线程 B 持有锁 2、等待锁 1两个线程就这么永远僵住了。用 jstack 或者 gdb 看到的结果就是线程状态 R 或者 D 卡住不动但 CPU 不高服务也没有响应。避免死锁有三个常用招数。第一锁的顺序要一致所有线程都以同样的顺序去加锁比如都先锁 1 再锁 2第二加锁尽量用带超时的接口比如 pthread_mutex_timedlock、Java 里 ReentrantLock 的 tryLock获取不到就放弃并做补偿逻辑第三缩小锁范围避免锁嵌套。死锁问题在面试里是必考题但在实际项目中很容易在代码 review 时被漏掉尤其是多模块联合持锁的时候最好在文档里明确锁的层级关系。3. 线程实操从创建到查看命令与代码一起上手3.1 创建线程的最简例子先跑起来再说Linux 下最基础的线程库是 POSIX 线程库也就是 pthread。写代码时引入 pthread.h编译时记得加 -lpthread 链接。一个最简的例子是这样的#include stdio.h #include pthread.h void* worker(void* arg) { printf(worker thread, arg%ld\n, (long)arg); return NULL; } int main() { pthread_t tid; long arg 100; pthread_create(tid, NULL, worker, (void*)arg); pthread_join(tid, NULL); return 0; }编译命令gcc -o demo demo.c -lpthread这里值得注意的有三点。一是 pthread_create 的返回值只有返回 0 才表示创建成功否则要用 strerror 看看具体错误常见的是 EAGAIN 资源不够或者 EPERM 权限不足二是线程函数必须是 void* ()(void) 的形式参数只能靠一个指针传递传多个参数就得自己包一个结构体千万注意参数的生命周期别传局部变量的地址给线程线程还没跑到那就已经出栈了三是 pthread_join 会阻塞等待线程结束作用相当于回收线程资源如果你既不 join 也不 detach线程结束后资源得不到释放长此以往会内存泄漏。3.2 查看线程数量这些命令手册里不一定写全线上排查问题第一步往往就是看这个进程到底起了多少线程、线程在干什么。我常用的命令有这么几个# 查看进程下所有线程LWP 列就是线程ID ps -eLf | grep java # top 里按 H 键切换到线程视图看哪个线程吃 CPU top -H -p pid # 查看进程下线程数量 ls /proc/pid/task | wc -l # 线程实时状态看是否存在大量阻塞 cat /proc/pid/statuscat /proc/ /status 里有一项 Threads直接给线程总数。通常我会把 ps -eLf 作为第一轮排查找到 PID 之后再用 top -H -p 盯 CPU 占用最高的线程最后把线程 ID 转成十六进制去 jstack 或者 gdb 里定位对应线程栈。这套流程处理过不少“服务突然变慢”的线上问题非常顺手。经验Linux 下每个线程还有一个独立的 TID从应用层获取可以用 syscall(SYS_gettid)。很多监控系统报警只能看到进程 PID定位不到具体线程在代码日志里把线程 ID 打出来线上排查会省很多力气。3.3 线程的调度策略和优先级决定了实时性Linux 默认的调度策略是 SCHED_OTHER也就是完全公平调度器CFS它根据线程的 nice 值来分配 CPU 时间nice 值越低优先级越高。默认 nice 是 0普通程序基本不用改。但如果做的是音视频处理、工业控制这类对实时性有要求的应用就可以考虑 SCHED_FIFO 或 SCHED_RR 这类的实时调度策略。设置实时调度策略需要用到 pthread_setschedparam并且通常需要 root 权限。SCHED_FIFO 是先进先出高优先级线程只要不主动让出 CPU低优先级线程就得不到运行SCHED_RR 是时间片轮转同样优先级下大家轮流用 CPU。这俩策略用起来要非常小心一个无限循环的 SCHED_FIFO 线程可以把整个系统卡死连 SSH 都连不进去。我在测试机上就干过这种事最后只能重启虚拟机。大部分后端服务其实用默认策略就够了。如果发现某些关键线程经常被其他线程抢占导致延迟抖动比较温和的做法是调整 nice 值或者把线程绑定到指定 CPU 核上pthread_setaffinity_np而不是一上来就上实时调度。4. 线程池与工程实践为什么生产环境不用裸线程4.1 线程池的价值省去反复创建的代价线程不是免费的午餐。每创建一个线程内核都要分配栈空间默认 8MB 虚拟内存、建立各种内核数据结构线程切来切去也有上下文切换的开销。如果服务每来一个请求就创建一个线程等请求处理完再销毁线程高并发下光线程创建销毁的开销就够喝一壶的。线程池的思路是预先把一批线程创建好放在池子里任务来了直接提交给池子里的线程去处理处理完线程不销毁继续等下一个任务。这样既省了频繁创建销毁的成本又能通过池子的上限限制同时执行的线程数量避免系统资源被耗尽。线程池在 Java 里有现成的 ThreadPoolExecutor在 C 里虽然标准库没有直接提供但网上开源实现很多自己封装一个也不麻烦。4.2 Java 线程池参数怎么配置才合理Java 的 ThreadPoolExecutor 是面试高频考点也是线上最容易配错的组件。它的核心参数有七个核心线程数、最大线程数、空闲存活时间、时间单位、阻塞队列、线程工厂、拒绝策略。每次面试我都会问“你线上线程池怎么配的”能答明白的人真的不多。先说核心线程数和最大线程数。IO 密集型任务比如调远程接口、读数据库CPU 大部分时间在等待可以多配些线程一般公式是 CPU 核数 * 2CPU 密集型任务比如视频编码、复杂计算线程数建议等于 CPU 核数或核数 1配多了反而因为频繁上下文切换变慢。这个公式不是银弹但作为起点是够用的。阻塞队列的选择也大有讲究。如果队列是无界的比如 LinkedBlockingQueue 不设容量最大线程数形同虚设因为任务永远在排队核心线程处理不过来只会无限堆积内存被吃爆。常用的做法是用有界队列比如 ArrayBlockingQueue(capacity)配合拒绝策略 AbortPolicy 或 CallerRunsPolicy。CallerRunsPolicy 挺有意思队列满了之后它把任务回退给提交任务的线程执行相当于一个天然背压机制提交方慢下来系统不至于直接崩掉。4.3 C 场景下的线程池还有两个线程读写大数组的问题C 里没有 Java 那么便捷的线程池库很多项目直接用开源组件比如 folly 的 ThreadPoolExecutor或者自己实现一个简单版本。自己实现的时候要注意三件事线程安全的任务队列、线程的启动和停止流程、线程异常的处理。停止流程很容易被忽略有些实现直接销毁线程池但池里的线程还在跑任务结果就是 crash 或者内存泄漏。稳妥做法是设置一个关闭标志等已接收的任务都完成之后再真正销毁线程。关于 C 两个线程分别读写一个大数组这类问题我的建议是优先考虑能否避免真共享。如果写线程和读线程之间不需要实时同步双缓冲区或内存屏障就够了如果必须同步那就用互斥锁锁住数组的片段或者用原子变量做标识位。大数组的并发访问瓶颈往往不在 CPU而在缓存一致性——两个线程频繁写同一个缓存行会导致互相使对方的缓存失效也就是常说的伪共享。解决办法是对数组元素做内存填充对齐让每个线程操作的数据落在不同的缓存行上。4.4 中间件里的线程模型Tomcat、Nginx 和系统服务不只是业务代码Linux 上大量中间件也依赖线程池。Tomcat 默认用线程池处理 HTTP 请求配置里 maxThreads 和 minSpareThreads 决定了它能扛多大并发。如果你在线上用 jstack 看到一堆名为“tomcat-http-xx”的线程说明请求量上来了线程池在排队处理连接。Nginx 则有点不同它的 worker 进程里用事件驱动 少量线程或者单线程处理大量连接这是典型的 IO 多路复用思路。而有些网管系统、监控代理会通过 JMX 等接口拿 JVM 线程数实际上就是读取 Threading MXBean 里的线程数据。所以理解线程模型是排查中间件故障的基础——你得知道这个组件用的是线程池模型、事件模型还是两者混合。注意开发自研中间件时线程池的线程名一定要有意义。Java 里可以在 ThreadFactory 里统一命名C 里也可以用 pthread_setname_np 给线程起名。默认的“pool-1-thread-1”在线上排查看多了真的会崩溃。5. 常见问题与排查技巧实录5.1 线程卡死或死锁的快速定位线上服务突然无响应首先要确认是死锁还是某个线程被阻塞。Java 服务我喜欢先执行 jstack 把线程栈 dump 下来。搜“Found one Java-level deadlock”如果能搜到死锁线程和锁的持有关系一目了然。C/C 程序则可以用 gdb attach 到进程上执行 thread apply all bt 打印所有线程的调用栈。如果是“线程卡在 IO 上”比如读数据库迟迟没有返回jstack 里会看到线程卡在 socketRead 之类的调用上。这类问题一般不是代码死锁而是下游服务变慢你的线程全都在等下游响应。此时配合 jstack 里线程的堆积数量以及下游服务的监控指标基本能判断是连接池不够用还是下游超时设置太长了。5.2 线程数量过多导致 CPU 飙升和内存不足有一种线上事故是这样的代码里每收到一个任务就 new Thread任务堆积的时候线程数飞速上涨CPU 被上下文切换吃光内存也爆了。排查时先用 ps -eLf 看线程总数再用 top -H -p 看是哪些线程吃 CPU。绝大多数情况都是因为用了无界队列或者没有限制最大线程数修复方式就是改造成线程池并给线程池设置合理的核心线程数、最大线程数、队列容量和拒绝策略。另外还要注意线程栈大小。Linux 下默认的线程栈是 8MB如果线程数上千虚拟内存占用会非常可观。栈空间与实际使用不一致很多测试环境跑得好好的程序线上线程一多就内存不足往往就是这个原因。可以用 ulimit -s 查看或者在创建线程时通过 pthread_attr_setstacksize 指定更小的栈比如 512KB对大多数业务逻辑已经够用了。5.3 线程安全的数据结构选型说到线程安全第一反应是加锁。但不同语言、不同场景其实有更细的选择。C 里std::mutex std::condition_variable 是基础组合如果只是读多写少可以用 std::shared_mutex 让多个读线程共享锁如果是简单计数直接用 std::atomic 就够。Java 这边ConcurrentHashMap、CopyOnWriteArrayList 在特定场景下可以避免加锁的麻烦而 C# 里 ConcurrentBag、ConcurrentQueue 等并发集合也很成熟。我见过一个比较典型的误用有人用处处加锁的 List 来存在线用户列表结果用户量上去后性能直线下降。后来换成读写锁再后来改成 ConcurrentHashMap 分段管理性能才恢复正常。这里想表达的观点是锁不是问题问题是选错了锁的粒度或者数据结构。能用原子操作就不用锁能用读写锁就不用互斥锁能用并发容器就不用自己维护同步逻辑。5.4 其他容易踩的坑线程退出、信号处理与守护线程线程函数里如果调用了 exit()整个进程都会退出而不是只退出当前线程这一点新手经常栽跟头。正确的结束方式是让线程函数 return或者调用 pthread_exit。线程的资源回收也要注意joinable 的线程必须 join 才能释放资源detach 的线程则由系统自动回收。判断一个线程该不该 detach核心问题是“你还有没有兴趣等它结束”等不到了就 detach。守护线程也是个常见概念。Java 里 setDaemon(true) 创建守护线程进程主线程结束后守护线程也会跟着退出C 里没有直接等价物但可以通过设置线程属性、或者在主程序退出时主动通知线程退出实现。守护线程适合做后台日志、监控上报等非关键任务不适合做必须保证完成的核心业务逻辑。最后再分享一个小技巧排查线程问题时不要只盯着业务代码。先用 top 看整体负载再用 ps 看线程数量再 dump 线程栈看具体状态这三步走下来80% 的问题都能定位到方向。我自己处理过很多“服务卡死”的工单最后发现一半是死锁一半是线程池配得太小任务堆积还有少数是下游接口抖动导致线程全堵住。把基础概念吃透把常用命令练熟遇到问题才不会慌。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门