1. 线程到底是什么:从进程说起
1.1 进程的老概念和它的痛点
很多人第一次学Linux编程,都会背概念:进程是资源分配的最小单位,进程有独立的地址空间、独立的PCB、独立的文件描述符表……这话没错,但放到实际写程序的时候,你会发现进程有一个非常尴尬的地方——太“重”了。
我最早写多进程网络服务时,一个客户端连上来就fork一个子进程去处理。跑起来倒是也正常,但再仔细一观察,内存占用蹭蹭往上涨,进程切换的CPU开销也肉眼可见地变大。尤其当业务里有大量需要共享的数据(比如一个全局计数器、一份配置信息)时,多进程之间的通信就得靠管道、消息队列、共享内存。每次同步还要考虑加锁,写起来又繁又容易出错。更头疼的是,fork出来的子进程虽然继承了父进程的内存映像,但它们是独立的副本,父子之间改一个变量互相看不见。你想让好几个并发执行流共享同一份数据,用多进程纯粹是给自己找麻烦。
于是线程就出现了。Linux下线程其实可以理解成一个“轻量级进程”,它自己维护一个栈和执行上下文,但和同一进程里的其他线程共享地址空间、全局变量、文件描述符、信号处理器等一大堆资源。这样一来,多个执行流之间共享数据变得极其简单,代价是——你得操心同步,否则分分钟被数据竞争教做人。
1.2 线程在Linux内核里的真实身份
这里要澄清一个很多人困惑的点:Linux内核里其实没有独立的“线程”数据结构。内核认为线程本质上是“共享了地址空间的进程”,所以底层仍旧用task_struct来表示。我们常说的“线程”是用户态pthread库(POSIX线程,通常就是glibc里的libpthread)提供的抽象。
也就是说,你在用户态调用pthread_create,最终会执行一个clone系统调用,往里传一堆CLONE_*标志位,告诉内核“我要创建一个新task,但下面这些资源我要和父task共享”。比如CLONE_VM表示共享地址空间,CLONE_FILES表示共享文件描述符表,CLONE_SIGHAND表示共享信号处理函数。从内核调度器角度看,线程和进程都是task,调度平权。
理解这一点对你很有用。你会发现“线程切换成本低”并不是凭空来的,而是因为它们共享了大部分资源,切换时不需要切换MMU映射、不需要刷新TLB(当然现代CPU有PCID优化,但大方向上还是比进程切换轻)。下次如果面试官问你“Linux中线程是什么”,你可以回这个层次的理解,比单纯的“轻量级进程”要深入一截。
1.3 线程之间到底共享什么、独享什么
用一个生活类比:进程是一个公司,线程是这个公司里的员工。公司有独立的办公地址、独立账本、独立门禁。员工们在同一个办公室里办公,都看同一块白板(全局变量),都能用同一个打印机(文件描述符),但每个人有自己的座位、自己手里的笔记本(栈),也记得自己当前在干什么活(寄存器上下文、程序计数器)。
具体到Linux的pthread语义:
| 线程之间共享 | 线程各自独享 |
|---|---|
| 地址空间(代码段、数据段、堆、动态库映射) | 线程栈(局部变量、函数调用栈帧) |
| 全局变量和静态变量 | 线程ID(tid) |
| 打开的文件描述符列表 | 寄存器上下文(程序计数器、栈指针等) |
| 信号处理函数 | errno 变量(线程局部存储) |
| 当前工作目录、用户ID、用户组ID | 线程调度优先级(可独立设置) |
| 进程PID和父进程PID | 信号掩码(可用pthread_sigmask单独设置) |
注意最后两行。很多人写代码时默认“线程和进程一样”或者“线程啥都是共享的”,这俩都会出问题。比如errno,明明是全局的,但线程库里把errno做成了线程局部存储,这样两个线程同时出错时不会互相覆盖,你才能分别拿到自己的错误码。如果你误以为errno是共享的,在多线程里看到奇怪的值,排查方向就错了。
再比如线程栈,默认线程栈大小通常是8MB(可以通过pthread_attr_setstacksize修改)。这个栈是独立分配的,所以线程里的局部变量在逻辑上互不相干。但堆上的东西(malloc出来的)是共享的,因为你用的是同一个进程堆。这也就引出了多线程编程的第一定律:缺省情况下,栈上自享,堆上共享,全局共享,所以堆和全局变量统统要注意同步。
2. 线程控制的核心API:创建、等待、终止、分离
2.1 pthread_create:创建线程的细节与参数
创建线程的函数长这样:
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);四个参数,逐个拆解:
thread:输出参数,线程创建成功后,这里会写入一个不透明的线程ID。注意这个ID只在进程内有效,不要和内核tid混淆,更不要和getpid()搞混。attr:线程属性,传NULL表示默认属性。需要定制栈大小、调度策略、分离状态时就传一个初始化好的pthread_attr_t。start_routine:线程入口函数,一个接收void返回void的函数。函数返回就代表线程正常结束。arg:传给入口函数的参数。这是一个void*,所以可以传整数(得强转),也可以传结构体指针。
我踩过最大的坑是给线程传栈上局部变量的地址。比如:
void start_threads() { int id = 1; pthread_t t; pthread_create(&t, NULL, worker, &id); }这里的id是start_threads栈上的局部变量,线程worker还没来得及读它,start_threads就返回了,这块栈空间可能被复用,线程拿到的id就是不可预测的垃圾值。正确做法是传堆上的内存,或者干脆把整数直接塞进void*参数里,比如:
pthread_create(&t, NULL, worker, (void *)(long)1);在线程函数里再转回来:
void *worker(void *arg) { long id = (long)arg; // ... }这种写法在64位Linux下是完全安全的,因为long和void*等宽。能用简单方式就别搞复杂结构,这算多线程入门第一课。
attr参数在大多数场景下都可以置空,但有一个场景我建议显式设置:你想控制线程栈大小时。默认栈8MB对某些深度递归算法不够用,可以用如下方式设置:
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 16 * 1024 * 1024); // 16MB pthread_create(&t, &attr, worker, NULL); pthread_attr_destroy(&attr);注意attr在pthread_create后就不需要了,destroy即可。另外千万别在创建多个线程时复用同一个attr并同时修改,属性设置最好集中一次性完成。
2.2 线程结束的三种姿势,别搞混
一个线程可以通过三种方式终止:
- 入口函数调用
pthread_exit(void *retval),指定返回值,线程立即终止。 - 入口函数执行return,返回void*作为返回值,效果和pthread_exit等价。
- 被其他线程调用
pthread_cancel强制取消。
特别容易出错的是,在线程入口函数里调用exit()。exit()是进程级的,它会结束整个进程,所有线程全被带走。我见过一个线上服务,某个工作线程在处理请求时遇到错误条件直接exit(1),结果整个服务崩溃重启,排查了半天才找到罪魁祸首。记住:线程里要退出当前线程,用pthread_exit或return;要终止进程,那是主线程或专人干的事。
再补充一个冷门但实用的点:如果main函数里调用了pthread_exit,那么进程并不会立刻退出,所有其他线程会继续运行(因为main所在的线程退出了,但进程还存活)。如果main直接用return或exit退出,进程会终止,所有还在运行的线程都会被立刻杀死。很多跑多线程程序的人发现主线程一旦结束,工作线程还没跑完就消失了,原因就是这个。
2.3 线程资源回收与pthread_join
默认情况下,线程退出后其资源不会自动释放,需要另一个线程调用pthread_join来“收尸”。如果不join,线程退出后它会变成一个僵尸(类似进程的僵尸态),累积多了会导致ID耗尽,pthread_create失败,报EAGAIN。
pthread_join的典型用法:
ret = pthread_join(tid, &retval);tid:要等待的线程ID。retval:输出参数,用来接收被等待线程的返回值。如果线程是被pthread_cancel取消的,返回PTHREAD_CANCELED。- 返回值:0表示成功,非0表示出错(ESRCH表示找不到线程ID;EDEADLK表示死锁,比如自己join自己)。
join相当于“阻塞等待线程结束并回收资源”,有点类似进程的wait/waitpid。使用join还有一个隐含的同步效果:只有join返回后,你才能确定线程已经跑完了,才好去读它写好的数据。所以不要把join只当成资源回收,它也是线程间同步的一种原始方式。
有个细节:join只能对“可join”的线程使用。如果一个线程已被设为detached(分离状态),它的资源退出后由系统自动回收,你去join会返回EINVAL。别把分离线程的ID还留着去join,那是白费力气。
2.4 pthread_detach:什么时候需要让线程自生自灭
如果一个线程不需要被别人等待,那就在创建后立刻把它设置成分离状态:
pthread_detach(tid);或者创建时通过attr设置PTHREAD_CREATE_DETACHED:
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_create(&t, &attr, worker, NULL);两种方式等价。你用第二种,可以避免线程已经退出后再detach导致的竞争问题。注意pthread_detach只决定资源回收方式,并不影响线程是否继续运行。分离线程退出后,资源由系统自动回收,你再也不能join它了,它的返回值也无从获取。
实务中,像线程池里的工作线程、定时器线程、后台监控线程,基本都会设置为分离状态。而主流程需要结果并行计算,通常要走join。
需要特别提醒的是:就算分离线程,也不代表“不用管它”。它依然共享进程的地址空间,如果它访问了非法内存,照样把整个进程崩掉。分离只解决资源回收,不解决错误隔离。
3. 线程同步:概念之外的第一个大坑
3.1 为什么必须同步:一段代码引发的血案
聊线程控制,绕不开同步。如果你写一个多线程程序,所有线程只读不写共享数据,那倒还好,一旦发生写,马上就会出乱子。原因很简单:现代CPU和编译器为了性能,会对指令重排,而且内存操作不是原子性的。比如下面这段代码:
// 全局变量 long counter = 0; void *inc(void *arg) { for (int i = 0; i < 1000000; ++i) { counter++; } return NULL; }创建两个线程同时跑,正常预期counter最终是2000000。但实际运行你可能会得到180万,甚至更离谱的值。因为counter++在机器层面是“读-改-写”三个操作,两个线程可能同时读到相同旧值,各自加1再写回,把一个增量覆盖了。这种问题用完全公平的线程调度也躲不开,因为它本质上是并发写共享变量的数据竞争问题。
3.2 互斥锁:保护临界区的正确姿势
最基础的工具是互斥锁pthread_mutex_t。用起来四步走:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 或者动态初始化 pthread_mutex_init(&mutex, NULL); // 加锁 pthread_mutex_lock(&mutex); // 临界区:只做必要操作 counter++; // 解锁 pthread_mutex_unlock(&mutex);互斥锁的核心思想是:同一时刻最多只有一个线程能持有锁,其他想拿锁的线程会阻塞等待。这相当于给临界区加了一扇门,门里一次只能进一个人。
但要注意加锁粒度。锁的区域太大,所有线程都挤在门口排队,性能退化成串行;锁的区域太小,保护不住数据。我常用的经验是:在共享变量周围尽可能只锁必要的读写,不锁那些耗时的IO或计算。比如把一个复杂的业务逻辑整个锁住,看起来安全了,实际上性能惨不忍睹,而且后面调试死锁会让你崩溃。
还有几个容易翻车的点:
- 忘记解锁,尤其是函数中间有return或goto跳出的分支。建议检查所有路径。
- 同一线程对同一非递归互斥锁重复加锁,会死锁。标准pthread_mutex_t是快速锁,不是可重入锁,自己锁自己就是死锁。需要重入就改用pthread_mutexattr_settype的PTHREAD_MUTEX_RECURSIVE。
- 加锁顺序不一致,多把锁时很容易相互等待产生死锁。后面会细说。
3.3 条件变量:等待某个条件成立的正确姿态
互斥锁只解决了“互斥”,没解决“等待”。经典场景:生产者-消费者。消费者要等队列里有数据才去取,没数据时如果循环轮询,白白烧CPU。条件变量就是为此设计的。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 消费者线程 pthread_mutex_lock(&mutex); while (queue_empty()) { pthread_cond_wait(&cond, &mutex); } item = pop_item(); pthread_mutex_unlock(&mutex); // 生产者线程 pthread_mutex_lock(&mutex); push_item(); pthread_cond_signal(&cond); // 唤醒一个等待线程 pthread_mutex_unlock(&mutex);这里有个必须刻进骨子里的细节:pthread_cond_wait不是一个纯“睡眠”调用,它在阻塞之前会先自动释放mutex,等被唤醒后再尝试重新获得mutex然后返回。所以调用wait时的前提必须是“已经持有mutex”,这个顺序错了程序就彻底乱套。
为什么要用while循环而不是if?因为pthread_cond_signal可能唤醒多个线程(broadcast),而且信号还可能“虚假唤醒”,甚至等待者被唤醒后锁又被别人抢走。用while重新判断条件,才保险。这个理由光靠记不牢,写进代码里才是正道。
条件变量和互斥锁配合使用的实质是:互斥锁保护条件本身(判断队列非空必须加锁),条件变量让线程在条件不满足时沉睡等待,而不是忙轮询。理解了这层关系,再写生产者-消费者就会顺畅很多。
4. 常见问题与排查技巧实录
4.1 编译链接报错:undefined reference to pthread_create
新手最容易撞上的问题,编译命令少写了-lpthread。在较新的glibc上,pthread函数已经合并到libc里,单线程编译可能也能过,但严谨起见还是要显式链接。我一般这样写:
gcc -Wall -Wextra -o app app.c -lpthread注意链接库的位置,要放在源文件后面。如果你用的CMake,记得在target_link_libraries里加上pthread。敲错大小写也会报错——是pthread不是Pthread。
还有一种情况,你用了链接顺序在没有把库放在引用它的.o目标文件之后,也会出现undefined reference。把-lpthread挪到最后,一切灰飞烟灭。
4.2 线程访问了已释放的内存
多线程程序里最常见的隐性崩溃就是悬垂引用。比如在线程A里malloc了一块缓冲区,把这个指针传给线程B,之后线程A完成了任务,人家调用了free,线程B还在用这个指针,轻则读到垃圾数据,重则直接段错误。排查这种问题,最容易的办法不是盯着代码看,而是用AddressSanitizer:
gcc -fsanitize=address -g -o app app.c -lpthread ./app如果存在use-after-free,ASan会非常明确地指出是哪个地址、哪一行释放的、哪一行继续访问的。比你自己猜效率高一倍不止。日常开发多线程程序,我几乎默认开着大象、ASan、ThreadSanitizer测一轮再上线。ThreadSanitizer专门检测数据竞争:
gcc -fsanitize=thread -g -o app app.c -lpthread跑一下测试,凡是它有告警的地方,基本就是你的共享变量没加锁。
4.3 主线程提前退出,子线程跟着团灭
我刚入门时写过一段代码:main里创建了一个线程去后台写日志,然后main继续干别的,干完直接return。结果日志文件里内容断断续续,很多条没写进去。原因前面讲过:main返回等于整个进程退出,所有线程立刻被终止,后台线程根本来不及把缓冲区的数据落盘。
解决思路分两种。一是main里在退出前join那个后台线程,确保它干完活:
pthread_create(&log_tid, NULL, log_writer, NULL); // ...干别的... pthread_join(log_tid, NULL); return 0;二是如果你不想阻塞等待,可以给后台线程设置分离状态,但必须在main函数最后调用pthread_exit,让main线程自己退出而进程继续存活:
pthread_detach(log_tid); // ...干别的... pthread_exit(NULL); // 注意这里永远执行不到 return 0后面这个方式常用于服务型程序,main线程本身就是用来监控各个线程的,不参与业务计算。它通过pthread_exit退出自己这层“老板线程”,但公司(进程)还在。
4.4 死锁:现象、定位与预防
死锁太常见了。代码逻辑看起来没毛病,跑起来偶尔卡死。最典型的场景是两把锁交叉等待:
线程1持锁A,等待锁B;线程2持锁B,等待锁A。于是两边谁都等不到,程序挂起。排查死锁,可以试试gdb:
gdb -p <pid>在gdb里执行info threads,查看每个线程的栈:
(gdb) thread apply all bt你能看到每个线程卡在哪个锁的lock调用上。再用frame切换线程栈,看它已经持有哪把锁。找到“两把锁在互相等”的证据,基本就锁定问题了。
预防死锁有几种工程实践。最有效也最无脑的是“锁顺序一致”:所有线程获取多把锁时都按完全相同的顺序拿。比如约定必须先拿锁A再拿锁B,线程2也按这个顺序,就不会交叉等待。其次是使用trylock,拿不到就释放已持有的锁,退避重试。不过trylock在多线程复杂场景下容易产生活锁,务实一点,优先做锁顺序约定。
另一个根治思路是缩小锁粒度。如果两个共享数据可以在逻辑上合并成一把锁保护,就不存在锁顺序问题了,死锁概率大大降低。毕竟“没有多把锁,就没有交叉等待”。
5. 我的一点实操体会
最开始接触线程,我老想把线程想成一堆互不相干的小进程,结果总在共享变量上翻车。后来我把思考方式改成了“同一进程内的多个执行单元,共享是常态,隔离是意外”,再去写同步逻辑就顺多了。尤其那句“能共享的都共享了,不能共享的只有栈和上下文”,基本解释了线程绝大多数行为模式。
给新入坑的同学几个建议。
第一,多线程入门阶段,优先掌握线程创建、join、detach、互斥锁、条件变量这几样,够覆盖90%场景。信号量、读写锁、自旋锁等可以后续按需深入,别一上来贪多。
第二,能用消息传递或线程池封装调度,就别到处裸写pthread。裸写pthread意味着大量lock/unlock散落在业务代码里,出bug概率随代码量线性上升。我在模拟项目X里就是先把线程池封装好,业务线程只管提交任务,彻底把创建、销毁、同步的细节关进了笼子。
第三,调试线程问题时,多依赖工具而不是肉眼。TSan、ASan的顺序是:先跑TSan查数据竞争,再跑ASan查内存错误,最后实在没办法才上gdb慢慢看锁。有一次线上服务间歇性阻塞,我用gdb attach看线程栈,发现两个线程分别卡在read和mutex_lock上,再结合当时的业务日志,很快定位到是某个持锁路径里做了一次长时间阻塞IO。这把锁的范围改成不覆盖IO操作后,问题彻底消失。这个经历给我的启发是:线程问题的根源,往往不在你“找不到共享数据在哪”,而在你“不小心把非必要资源也框进了临界区”。
写多线程没有银弹。概念背熟只是起点,真正的分水岭是你对“共享”的敬畏:共享变量要加锁,锁的粒度要小,锁的顺序要统一,线程的资源要回收。把这四条刻在脑子里,你的多线程程序大概率能跑得又稳又顺。