3、多线程基础:线程与进程的区别、POSIX线程(pthreads)基本操作、线程同步入门
多线程是并行计算的敲门砖。你想想看,如果你的程序只能在一个核心上跑,那CPU再多也帮不上忙。把任务拆成多个线程同时执行,才是榨干多核性能的正确姿势。
这一章,我们重点说三件事:
- 搞明白线程和进程到底有什么区别
- 学会用 POSIX 线程(pthreads)写最简单的多线程程序
- 掌握线程同步的基本思想
嗯,这三点搞透了,后面的优化技巧才有落地的可能。
3.1 进程 vs 线程:本质区别
很多新手把进程和线程混着叫,其实它们差得挺远的。我个人习惯用一个比方来理解:进程是工厂车间,线程是车间里的工人。
每个进程都有自己独立的资源——内存空间、文件句柄、信号处理表等等。进程之间互不干扰,一个崩了不会直接影响另一个。但代价是进程切换的成本很高,得保存和恢复一大堆状态。
线程就不一样了。同一进程内的所有线程共享进程的资源,包括地址空间、全局变量、打开的文件。切换成本低,沟通也方便——直接在内存里读写就行。
说白了就是:进程隔离性强但开销大,线程开销小但容易互相干扰。
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享进程地址空间 |
| 资源开销 | 高(PCB、页表等) | 低(仅栈和寄存器上下文) |
| 切换速度 | 慢(需要TLB刷写) | 快(同一页表) |
| 通信方式 | IPC(管道、消息队列、共享内存) | 直接共享内存/全局变量 |
| 同步需求 | 低(天然隔离) | 高(需要互斥锁等保护) |
| 典型场景 | 容器隔离、浏览器多标签页 | 并行计算、高并发服务器 |
关键结论:共享内存编程(多线程)是所有并行优化的基础。你后面看到的OpenMP、TBB、甚至GPU编程,本质上都是在不同层次上解决"多个执行单元访问共享资源"的问题。
3.2 POSIX 线程(pthreads)基本操作
Linux/C/C++ 环境下,pthreads 是最基础的多线程API。我记得自己刚学的时候被一堆函数搞得晕头转向,其实真正用得频繁的没几个。
核心操作就四个:
- 创建线程 —
pthread_create - 等待线程结束 —
pthread_join - 分离线程 —
pthread_detach - 获取当前线程ID —
pthread_self
3.2.1 创建线程
接口长这样:
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
thread:返回的线程ID指针attr:线程属性(栈大小、调度策略等),传NULL用默认值start_routine:线程入口函数,必须返回void*,接受void*参数arg:传给入口函数的参数
举个例子:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("线程 %d 开始工作\n", id);
sleep(1);
printf("线程 %d 工作结束\n", id);
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("所有线程完成\n");
return 0;
}
需要注意:传给 pthread_create 的 arg 指针所指向的内存,必须在线程读取完毕之前保持有效。我在项目中遇到过传局部变量的地址,结果变量在线程启动前就销毁了,导致线程读到垃圾数据。解决方法很简单:动态分配或者用全局数组。
3.2.2 等待与分离
pthread_join 会阻塞调用线程,直到目标线程结束。这有点像wait(),用来回收线程资源和获取返回值。
// 等待t1线程结束,并获取返回值
void* retval;
pthread_join(t1, &retval);
如果你不想等待一个线程结束,可以把它 分离:
pthread_detach(t1);
分离后的线程结束时会自动释放资源,不需要也不应该再 pthread_join。我曾经调试过一个内存泄漏问题,查了半天才发现是一堆分离线程没有被回收——嗯,如果线程不分离也不join,它的资源会一直保留着(线程处于"僵尸"状态),直到进程退出。
3.2.3 编译链接
pthreads 不是默认链接的库,编译时加 -lpthread 或 -pthread:
gcc -o example example.c -pthread
3.3 线程同步入门
线程之间共享内存确实方便,但麻烦也出在这里。多个线程同时读写同一个变量时,结果往往是乱套的。
为什么会这样?因为看上去是一条C语句,在CPU层面可能是多条指令:读、改、写。如果线程A读到一半,线程B把值改了,那A最后写回去的时候,B的更新就丢了。
这就是竞态条件(Race Condition)。
一句话总结同步的意义:把"不确定"变成"确定"。多个线程对共享数据的操作,必须串行化——一次只有一个人能碰。
3.3.1 互斥锁(Mutex)
互斥锁是最基础的同步工具。用法很简单:进入临界区前上锁,离开时解锁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 上锁
pthread_mutex_lock(&mutex);
// 临界区:操作共享资源
shared_counter++;
// 解锁
pthread_mutex_unlock(&mutex);
如果一个线程已经持有锁,其他线程调用 pthread_mutex_lock 时会被阻塞,直到锁被释放。
实际项目中,锁的粒度挺重要的。锁太大,并发度低;锁太小,频繁加解锁的开销也不小。我个人习惯先按最大数据粒度锁上跑通,再逐步细化锁的粒度做优化——保证正确性是第一位的。
死锁风险:如果线程A持有锁L1,等着锁L2,而线程B持有锁L2,等着锁L1,那就僵住了。我曾经调过一个死锁,折腾了两天才发现是加锁顺序不一致。修起来很简单:所有线程按固定的全局顺序加锁(比如先锁L1再锁L2)。
3.3.2 条件变量(Condition Variable)
互斥锁解决的是"互斥"问题,但有时候我们还需要等待某个条件成立。比如生产者-消费者场景:消费者发现队列为空,不能一直空转轮询吧?
条件变量就是干这个的。它配合互斥锁使用,让线程在条件不满足时休眠,条件满足时被唤醒。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;
// 等待线程
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex); // 自动解锁并等待
}
// ready == 1,继续执行
pthread_mutex_unlock(&mutex);
// 通知线程
pthread_mutex_lock(&mutex);
ready = 1;
pthread_cond_signal(&cond); // 唤醒一个等待线程
pthread_mutex_unlock(&mutex);
pthread_cond_wait 执行时会原子性地:解锁mutex + 进入等待队列。等到唤醒后,又会自动重新持有锁。这里面有个经典陷阱——虚假唤醒:线程可能在没有收到 signal 的情况下被唤醒。所以标准做法是把条件判断放在 while 循环里,而不是 if。
3.3.3 屏障(Barrier)
屏障的作用是让多个线程"集合":所有线程都到达同一位置后,才能继续往下走。并行计算里特别常见,比如每个线程算完自己那部分数据后,需要等所有人算完再做汇总。
pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, num_threads);
// 每个线程里:
pthread_barrier_wait(&barrier);
pthread_barrier_destroy(&barrier);
pthread_barrier_wait 会使调用线程阻塞,直到有 num_threads 个线程都调用了这个函数。最后一个到达的线程会唤醒所有人,然后一起往下走。
嗯,屏障在复杂并行流水线中特别管用。但注意:屏障的计数必须与实际线程数一致,多一个或少一个都会导致死锁。
3.4 知识结构总览
这一章的核心逻辑其实是一条线:从资源模型(进程vs线程)到编程接口(pthreads)再到协同机制(同步原语)。下面这张图帮你把这三个层次串起来。
3.5 避坑总结
最后,我把这几个容易踩的坑集中说一下——都是我用时间换来的教训:
- 传参生命周期:传递给线程的指针所指向的数据,必须在线程读完前保持有效。尽量传堆内存或全局变量的地址。
- 必须分离或join:线程创建后要么
pthread_detach,要么pthread_join,否则线程退出后资源不会自动释放,直到进程结束。 - 死锁:多把锁的时候统一加锁顺序。如果必须在持有锁A的情况下去请求锁B,那所有线程都必须先锁A再锁B。
- 条件变量的while循环:
pthread_cond_wait返回后,条件不一定就真的满足了(虚假唤醒),一定要用while重新判断,不要用if。 - 尽量用更高级的抽象:如果真的要在生产项目中写多线程代码,可以考虑C++的
std::thread和std::mutex,或者OpenMP。pthreads 的易用性确实差了点,但它能让你理解底层发生了什么。