刚接触Linux线程时,我犯过一个特别低级的错误:在线程入口函数里直接操作了一个全局变量,两个线程同时跑,结果那个计数器忽大忽小,跟抽风一样。后来慢慢啃完概念、踩过死锁的坑,才算摸清这套东西的脾气。今天这篇就从Linux系统出发,把线程的概念与控制从头到尾捋一遍——线程到底是什么,怎么创建和销毁,同步互斥怎么做,死锁怎么排查,哪些坑是新手必踩的,全放这儿了。如果你是刚开始写并发程序的C/C++开发者,或者正在复习Linux系统编程准备面试,这篇内容应该能帮你省不少事。
1. 线程是什么:从进程到线程的一次认知升级
想象你经营一家餐厅。进程就像一间独立的餐厅门店,有自己的厨房、仓库、收银台和门面;线程则是店里的员工,大家共享同一间厨房、同一个仓库和同一套设备,各自负责洗菜、炒菜、上菜。进程之间老死不相往来,各有各的地盘;线程之间天生就是来协作的,共享所在进程的地址空间、文件描述符、全局变量等资源。
Linux下的线程并不是什么全新物种。从内核视角看,线程本质上是一个轻量级进程(Lightweight Process, LWP),创建时通过clone()系统调用指定资源共享的粒度。这带来一个直接结论:在多核处理器上,多个线程确实可以同时运行在不同核心上,调度平等性与进程一致。区别在于开销——进程上下文切换要更换页表、刷新TLB,代价高昂;线程切换因为共享地址空间,代价小得多。
我经常用一个比喻帮朋友理清思路:“进程是切地皮,线程是分人。”新建进程要重新划分一块独立地址空间;新建线程只是从已有进程里拉出一支队伍,地址空间还是同一个。所以线程也常被称为“进程内的执行流”。正因如此,线程之间通信非常方便,直接访问共享变量就行,不需要像进程间通信那样搞消息队列、共享内存、信号之类的花活。但方便背后藏着一个大坑:多个线程同时读写同一个变量,如果不加保护,竞态条件(race condition)就会出现,结果完全不可预测。
还有一个关键点容易混淆:Linux的调度单位是线程,不是进程。你在ps命令里看到的一个“进程”,其实是一个线程组,组里有若干线程。用gettid()或者ps -eLf能看到每个线程独立的线程ID,而用getpid()拿到的是线程组ID,也就是平时说的进程号。这一点排查问题尤其重要——很多人以为“一个进程卡死了”,其实是进程里某个线程出了问题,把整个进程拖住了。
1.1 什么时候该用线程
- IO密集型任务:比如网络服务端要同时处理成千上万个连接,每个连接阻塞在read/write上,线程可以一边等IO一边互不干扰。
- 多核计算密集型任务:把一个大任务拆成若干子任务,并发跑满CPU核。图像处理、矩阵运算就是典型场景。
- 需要共享大量数据:多个模块要频繁访问同一个缓存、同一个状态机,用线程可以避免IPC的序列化和拷贝开销。
反过来,如果任务是独立且短命的,或者必须强隔离(一个崩了不影响另一个),那进程更合适。线程有一个广为人知的缺点:线程崩溃会导致整个进程退出,其他线程跟着陪葬。线上服务里,线程的数量和职责都要精心设计,而不是无脑创建个万个。
1.2 线程与进程的账本对比
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 系统开销 | 创建/切换开销大 | 创建/切换开销小 |
| 通信方式 | IPC(管道、消息队列、共享内存) | 共享全局变量 |
| 稳定性 | 一个崩溃不影响其他进程 | 一个崩溃全进程遭殃 |
| 调试难度 | 相对简单 | 竞态、死锁问题隐蔽 |
这张表不是让你背,而是帮你建立选择依据。做项目时先问自己:并发实体之间,是资源共享多还是隔离需求强?资源共享多,选线程;隔离需求强,选进程。多线程和多进程也可以混合用,比如Nginx采用多进程加异步IO的模式,而MySQL用多线程处理连接。
2. 线程的创建与生命周期管理
2.1 pthread_create:创建线程的唯一入口
Linux下操作线程的接口是POSIX线程库pthread,使用前需要包含<pthread.h>,编译时加上-lpthread。创建线程的入口是pthread_create:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);四个参数分别是:返回线程ID的指针、线程属性(传NULL用默认属性)、线程入口函数、传给入口函数的参数。注意入口函数的签名是void()(void *),返回值和参数都是void *,这样设计是为了能传递任意类型的数据。
这里有一个新手必踩的坑:如果传给线程的实参是局部变量的地址,而主线程在子线程真正读取之前就修改了这个变量(比如循环里传同一个变量i的地址),那子线程拿到的值往往不是你预期的。我见过有人创建10个线程,每个线程拿到的工作ID全是10。正确的做法是为每个线程分配独立的内存(malloc一个int),线程结束后再free。如果只是传整数值,也可以直接强转成void *传进去,但这种写法可读性差,不推荐。
编译命令也容易踩坑。很多人只写gcc test.c,结果报错找不到pthread_create,其实是忘了加-lpthread。正确的编译命令是:
gcc -o thread_demo thread_demo.c -lpthread2.2 线程的退出:return、pthread_exit 与 pthread_join
线程退出有三种方式:
- 在线程函数里执行return,线程正常结束,返回值可以被其他线程获取。
- 调用pthread_exit(void *retval),效果类似return,但可以在线程函数任意位置退出。
- 被其他线程调用pthread_cancel取消,属于异常退出。
主线程如果需要等待某个线程结束并拿到它的返回值,就调用pthread_join:
int pthread_join(pthread_t thread, void **retval);pthread_join会阻塞调用线程,直到目标线程终止。这里有两个细节容易被忽略。第一,pthread_join只能等待“可结合的(joinable)”线程,如果目标线程已经分离(detached),join会返回错误。第二,线程结束后如果不join,又不detach,那么线程所占的资源(栈、内核线程结构等)不会自动释放,程序里会出现僵尸线程,长时间运行可能把内存耗干,这一点和进程的僵尸态非常像。
我自己的习惯是:如果一个线程需要主线程等待并获取结果,创建时保持joinable;如果只是后台跑个任务,不关心结束时间,果断detach。凡是和网络连接、定时任务相关的线程,基本都是detach,因为它们在服务退出前本来就要一直运行;需要请求/响应模型的线程,则join,因为要拿结果。
2.3 detach:让线程自生自灭
如果完全不关心子线程的返回值和结束时间,可以在创建后调用pthread_detach,或者创建时就通过线程属性把它设置为分离状态:
pthread_detach(tid);或者创建时设置:
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_create(&tid, &attr, worker, NULL); pthread_attr_destroy(&attr);注意join和detach是互斥的,一个线程不可能既分离又可结合。如果两个线程同时对同一个线程调用join,行为是未定义的,所以join之前要确认不会有其他线程抢着做这件事。
2.4 线程取消与清理回调
pthread_cancel(tid)可以向指定线程发送取消请求,但是否立即取消取决于目标线程的取消状态。默认情况下,线程会在取消点(比如read、usleep、pthread_cond_wait这类可能阻塞的系统调用)响应取消。如果线程函数里有需要释放的资源(malloc的内存、持有的锁),可以借助pthread_cleanup_push / pthread_cleanup_pop注册清理函数,确保线程被取消或退出时能够回收资源。
这里有个隐藏陷阱:pthread_cleanup_push和pthread_cleanup_pop必须成对出现。而且在某些实现里,pthread_cleanup_pop(0)只是把清理函数从队列里移除但不执行,pthread_cleanup_pop(1)会立即执行并移除。很多人把这一对宏写在不同分支里,导致编译或运行时报错,排查半天才发现是括号不匹配。
3. 线程同步与互斥:让协作变得可控
3.1 竞态条件到底是怎么发生的
先看一个最简单的例子。全局counter初始为0,两个线程各自执行counter++循环10万次。理论上最终应该是200000,但实际跑出来经常是十几万,而且每次运行结果还不一样。原因在于counter++不是原子操作,它在底层被拆成了取内存值、加一、写回内存三步。线程A和线程B可能同时取到相同的旧值,各自加一后写回,结果只增加了一次,丢了一次更新。
这就引出了线程同步的两大目标:互斥(同一时刻只能有一个线程进入临界区)和同步(合理安排线程执行的先后顺序)。Linux下的经典武器包括互斥锁、条件变量、读写锁和自旋锁。
3.2 互斥锁:临界区的守门员
互斥锁(mutex)是所有同步工具里最基础的一种,使用流程是:初始化、加锁、访问共享资源、解锁、销毁。静态初始化用PTHREAD_MUTEX_INITIALIZER,动态初始化用pthread_mutex_init。常用接口:
- pthread_mutex_lock:阻塞加锁,拿不到锁就挂起等待。
- pthread_mutex_trylock:非阻塞尝试,拿不到锁立即返回EBUSY。
- pthread_mutex_timedlock:带超时的尝试,超时返回ETIMEDOUT。
一个完整的计数器保护示例:
#include <stdio.h> #include <pthread.h> static int counter = 0; static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; void *add_func(void *arg) { for (int i = 0; i < 100000; i++) { pthread_mutex_lock(&lock); counter++; pthread_mutex_unlock(&lock); } return NULL; } int main() { pthread_t t1, t2; pthread_create(&t1, NULL, add_func, NULL); pthread_create(&t2, NULL, add_func, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("counter = %d\n", counter); return 0; }使用互斥锁最常见的错误是忘记在某个分支解锁,比如中途return了,导致死锁。所以我在写代码时给自己定了一条规矩:临界区里不要多做事,只访问共享资源,所有计算都放到锁外;解锁之后不要再碰临界区变量。另外锁的粒度要合理——锁太粗,并发度低,多个线程互相排队;锁太细,加锁解锁本身的额外开销上来,还可能引入复杂的嵌套锁顺序问题。
3.3 条件变量:让线程学会等待
互斥锁解决的是“互斥”,但很多场景是“协作”:生产者生成了数据,要通知消费者来取;消费者没数据可消费,就得等待。这些场景单靠mutex是不够的,需要条件变量(condition variable)。
条件变量的核心API是:
- pthread_cond_wait(&cond, &mutex):原子地释放mutex并等待被唤醒,被唤醒后重新获取mutex。
- pthread_cond_signal(&cond):唤醒一个等待线程。
- pthread_cond_broadcast(&cond):唤醒所有等待线程。
pthread_cond_wait的“原子地释放并等待”这个设计很精妙。如果它不是原子的,在释放mutex到进入等待之间会插入一个窗口,就可能丢失唤醒信号——消费者还没开始等,生产者就已经signal了,然后消费者永远等不到。为了防止这种“通知丢失”,更严谨的写法是使用while循环来检查条件,而不是用if判断一次就完事。因为即使被唤醒了,也可能有其他线程抢先消耗了条件资源。
一个简单的生产者消费者模型:
#include <stdio.h> #include <pthread.h> #include <unistd.h> #define BUFFER_SIZE 10 pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int buffer[BUFFER_SIZE]; int count = 0; void *producer(void *arg) { for (int i = 0; i < 20; i++) { pthread_mutex_lock(&mutex); while (count == BUFFER_SIZE) { pthread_cond_wait(&cond, &mutex); } buffer[count++] = i; printf("生产: %d, 当前库存: %d\n", i, count); pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); usleep(50000); } return NULL; } void *consumer(void *arg) { for (int i = 0; i < 20; i++) { pthread_mutex_lock(&mutex); while (count == 0) { pthread_cond_wait(&cond, &mutex); } int val = buffer[--count]; printf("消费: %d, 当前库存: %d\n", val, count); pthread_cond_signal(&cond); pthread_mutex_unlock(&mutex); usleep(100000); } return NULL; } int main() { pthread_t p, c; pthread_create(&p, NULL, producer, NULL); pthread_create(&c, NULL, consumer, NULL); pthread_join(p, NULL); pthread_join(c, NULL); return 0; }我写生产者消费者模型的固定模板是:加锁、while(条件不满足) cond_wait、处理共享数据、解锁。唤醒端在修改共享数据后signal或broadcast。有个小经验:signal之前不用持有锁,先释放锁再signal可以更快唤醒等待线程,减少锁竞争。
3.4 读写锁与自旋锁:不同场景的取舍
读写锁(pthread_rwlock_t)适合读多写少的场景。它可以被多个读者同时持有,但写者独占。接口和mutex类似,只是区分了读锁定和写锁定。需要注意“写者饥饿”问题:如果读者持续不断抢占读锁,写者可能一直等不到机会。有些实现提供了写优先策略,但使用前要确认你用的库是哪种行为。
自旋锁(spinlock)则适合临界区极短的场景。它不会让线程睡眠,而是原地自旋忙等,避免了线程切换开销,但会一直占用CPU。在单核机器上,自旋锁如果临界区里有IO操作,基本上等于自掘坟墓——一个线程在临界区里被调度出去,另一个自旋的线程占满CPU,谁也进不去。所以自旋锁只在多核且临界区极短时使用,比如内核里的很多场景。用户态使用自旋锁的场景其实不多,我一般会用mutex代替,因为mutex在锁冲突时会睡眠,对系统更友好。
4. 线程死锁:概念、复现与排查实录
4.1 死锁的四个必要条件
死锁是线程同步里最让人头疼的问题。它的产生需要同时满足四个条件:
- 互斥条件:资源只能同时被一个线程持有。
- 持有并等待:线程持有至少一个资源,又在等待其他资源。
- 不可剥夺:资源只能被持有者主动释放,不能被其他线程抢走。
- 循环等待:存在一个线程等待环,A等B持有的资源,B等C持有的资源,C又等A持有的资源。
只要破坏其中一个条件,死锁就不会发生。但现实中,互斥和不可剥夺往往是业务强约束,改不了;能做的通常是打破“持有并等待”(一次性申请所有资源),或者打破“循环等待”(给锁编号,强制按顺序申请)。
4.2 现场复现:两个线程互相等待
下面的代码是一个典型的死锁复现,两个线程分别持有锁A和锁B,然后都去抢对方手里的锁:
#include <stdio.h> #include <pthread.h> #include <unistd.h> pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER; void *thread_1(void *arg) { pthread_mutex_lock(&lock_a); printf("线程1 持有锁A,等待锁B...\n"); fflush(stdout); usleep(100000); pthread_mutex_lock(&lock_b); printf("线程1 获取锁B成功\n"); pthread_mutex_unlock(&lock_b); pthread_mutex_unlock(&lock_a); return NULL; } void *thread_2(void *arg) { pthread_mutex_lock(&lock_b); printf("线程2 持有锁B,等待锁A...\n"); fflush(stdout); usleep(100000); pthread_mutex_lock(&lock_a); printf("线程2 获取锁A成功\n"); pthread_mutex_unlock(&lock_a); pthread_mutex_unlock(&lock_b); return NULL; } int main() { pthread_t t1, t2; pthread_create(&t1, NULL, thread_1, NULL); pthread_create(&t2, NULL, thread_2, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf("程序正常结束\n"); return 0; }运行后程序会卡住,两个printf打完之后就再没有输出。这就是死锁的典型症状:程序既不退出,也不报错,就像被按了暂停键。usleep故意让两个线程在持有锁之后暂停一下,增大死锁概率,实际代码里即使没有usleep,只要两个线程在特定时刻交错执行,照样可能死锁。
4.3 排查死锁的工具链
遇到卡住的程序,第一反应不是去翻代码,而是先看现场。我常用的排查顺序:
- 用ps -eLf找到目标进程,记住它的主线程PID。
- 用top -H -p 看每个线程的CPU占用,判断是哪个线程卡住。死锁时通常所有相关线程CPU占用都接近0。
- 用gdb -p 附加到进程,执行thread apply all bt,看每个线程的调用栈。这一步基本能直接定位死锁现场。
# 查看进程中的线程列表 ps -eLf | grep deadlock_demo | grep -v grep # 查看线程CPU占用 top -H -p <pid> # gdb附加后打印所有线程堆栈 gdb -p <pid> (gdb) thread apply all bt假设输出里看到Thread 1卡在pthread_mutex_lock的调用栈,另一个Thread 2也卡在pthread_mutex_lock,两个栈分别指向对方持有的锁,那循环等待的证据就实锤了。gdb堆栈里通常会有源码文件名和行号,直接对照就能找到问题代码。
如果程序是长期运行的守护进程,不方便用gdb附加(怕打断业务),可以用pstack 快速打印堆栈,或者用valgrind --tool=helgrind做运行时检测。helgrind能自动识别锁顺序错误和竞态,虽然跑起来比较慢,但在测试环境里很实用。我自己排查死锁最常走的路径就是gdb加thread apply all bt,十次里有九次能直接看穿。
4.4 避免死锁的工程实践
- 给锁定义全局编号,所有线程按照编号从小到大申请,破坏循环等待。
- 尽量避免锁嵌套;如果必须嵌套,把最内层的锁尽量短,甚至用trylock,拿不到就释放外层锁重试。
- 使用pthread_mutex_timedlock设置超时,超时就回滚、释放已有锁,然后重新尝试,而不是无限等。
- 写代码时画一张锁序图,评审时重点看有没有环。
我个人体会最深的是:死锁往往不是逻辑复杂导致的,而是“后来加需求”导致的。两个模块原本各用各的锁,某天联调时需要跨模块调用,锁顺序就不一致了。所以接口设计阶段就要约定好锁的层级和顺序,并写成文档,而不是靠大家自觉。
5. 线程实战进阶:线程池、线程安全与并发新思路
5.1 线程池:减少创建销毁开销的经典解法
频繁创建线程的代价不容小觑。每个线程创建时都要分配栈空间(默认8MB的虚拟内存),还要初始化内核线程结构,销毁时也有缓存失效、内存回收等开销。处理高并发请求时,如果请求来了才建线程,请求结束就销毁,性能会被大幅拖垮。
线程池的基本模型是:启动时预先创建N个线程,它们都阻塞在任务队列上;外部只需要往队列里塞任务,并唤醒一个空闲线程去执行。这样线程的创建销毁只发生在启动时,运行期间线程的复用成本极低。队列通常用互斥锁加条件变量实现,线程空闲时cond_wait,有任务时signal。任务结构里可以保存函数指针和参数,用链表或数组做队列。
设计线程池时,有几个问题必须想清楚:
- 线程数怎么定?经验法则是结合CPU核心数和IO等待比例。纯计算型任务,线程数等于核心数即可;IO密集型,线程数可以多一些。
- 队列要不要有上限?如果无限塞任务,内存会被撑爆,所以要设置队满策略(阻塞还是丢弃)。
- 线程池退出时怎么优雅停机?先停止接收新任务,再通知线程退出并join回收。
线程池是面试高频考点,也是实战高发坑区。我曾经见过一个线程池,任务队列加了锁,但线程退出时没做join,导致服务重启时旧线程还在跑,新线程又起来,两个线程同时操作同一批数据,问题相当隐蔽。
5.2 线程安全与可重入:容易被忽略的细节
很多C函数是线程不安全的,比如strtok、localtime、rand。原因是它们内部使用了静态或全局缓冲区。strtok用静态指针保存剩余字符串的位置,两个线程交替调用就互相覆盖,导致解析结果错乱。解决办法有两个:一是使用线程安全版本,比如strtok_r、localtime_r、rand_r;二是用互斥锁保护调用,但锁的粒度要覆盖整个解析过程,否则还是会竞态。
还有一个容易忽略的点:errno。errno在Linux线程里是线程局部存储(Thread Local Storage, TLS)的,每个线程有自己的errno,不用担心互踩。这其实是个好消息,意味着你可以在每个线程里安全地检查错误码,而不需要全局加锁。
线程安全函数和可重入函数严格来说不是一回事。可重入函数要求函数内部不依赖任何共享可变状态,即便被中断或嵌套调用也没问题;线程安全函数只保证在并发环境下行为正确,可能内部用了锁来实现。一个函数可以线程安全但不可重入(比如用了static变量且加锁保护),也可以可重入因此天然线程安全。写库代码时,尽量让函数可重入,这是更彻底的设计。
5.3 延伸思考:从原生线程到自由线程、虚拟线程与协程
Linux pthread是系统级线程,由内核调度。但近年来并发模型越来越丰富。比如Python 3.13引入了“自由线程”(free-threaded)模式,尝试移除全局解释器锁(GIL),让Python的多线程真正能在多核上并行;Java的虚拟线程(virtual thread)则把海量轻量级任务映射到少量平台线程上,解决线程创建开销和每次请求一线程的模型瓶颈。再比如Go的goroutine和Rust的async/await,本质上都是在用户态做任务调度。
作为Linux从业者,我的观点是:无论上层模型怎么包装,底层依然离不开操作系统线程这层地基。理解pthread的概念、生命周期、同步原语和死锁,会让你在用虚拟线程或协程时更明白调度器在做什么,出了问题也更容易定位。比如调度器把N个协程扔到1个内核线程上,如果某个协程阻塞在内核调用上,整个线程都会阻塞——这就是为什么协程库反复强调“不能做阻塞调用”的原因。
最后聊两句踩坑心得
遇到并发问题,先看现象,再猜原因,永远不如直接抓现场:ps -eLf看线程全貌,top -H看CPU分布,gdb thread apply all bt看栈。三步走完,绝大多数死锁和竞态都能对上号。写代码的时候,锁的顺序、临界区的长短、线程的join/detach,这三件事只要有一件拎不清,后期维护起来就是灾难。线程不是越多越好,资源也不是抢得越快越好,把控制做扎实了,并发这块才算真正入门。最后再分享一个小技巧:线程入口函数里尽量用断言记录入口参数,如果传参错了,断言能立刻暴露问题,而不是等程序跑到一半才出现诡异数据。