做后端开发这些年,我面试过不少候选人,也带过不少新人,几乎每次聊到 Linux 下的多线程编程,总能看到大家眼神里那种“好像懂一点,又说不清楚”的状态。线程这个东西,说白了就是操作系统里最常用的并发单元,但真要把“它是什么、怎么用、出问题了怎么查”讲明白,还真不是一两句话的事。尤其现在服务器动辄几十核上百核,线程用得好不好,直接决定你的服务能抗住多少并发、出了故障能不能快速定位。这篇文章我打算从头到尾把 Linux 线程这件事梳理一遍,包括进程和线程的区别、Linux 里的线程到底是怎么实现的、互斥锁和条件变量怎么用、线程池参数怎么配、线上线程卡死了怎么排查。内容会偏实战一些,也会给一些可以直接抄作业的命令和代码片段,适合刚接触多线程的初学者,也适合带过一阵子项目、想系统理清细节的工程师。
先说一个我观察到的现象:很多人在 Windows 上写多线程程序,用 CreateThread 或者 C++11 的 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 核数 * 2;CPU 密集型任务(比如视频编码、复杂计算)线程数建议等于 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% 的问题都能定位到方向。我自己处理过很多“服务卡死”的工单,最后发现一半是死锁,一半是线程池配得太小任务堆积,还有少数是下游接口抖动导致线程全堵住。把基础概念吃透,把常用命令练熟,遇到问题才不会慌。