3、多线程基础:线程与进程的区别、POSIX线程(pthreads)基本操作、线程同步入门

多线程是并行计算的敲门砖。你想想看,如果你的程序只能在一个核心上跑,那CPU再多也帮不上忙。把任务拆成多个线程同时执行,才是榨干多核性能的正确姿势。

这一章,我们重点说三件事:

  • 搞明白线程和进程到底有什么区别
  • 学会用 POSIX 线程(pthreads)写最简单的多线程程序
  • 掌握线程同步的基本思想

嗯,这三点搞透了,后面的优化技巧才有落地的可能。

3.1 进程 vs 线程:本质区别

很多新手把进程和线程混着叫,其实它们差得挺远的。我个人习惯用一个比方来理解:进程是工厂车间,线程是车间里的工人。

每个进程都有自己独立的资源——内存空间、文件句柄、信号处理表等等。进程之间互不干扰,一个崩了不会直接影响另一个。但代价是进程切换的成本很高,得保存和恢复一大堆状态。

线程就不一样了。同一进程内的所有线程共享进程的资源,包括地址空间、全局变量、打开的文件。切换成本低,沟通也方便——直接在内存里读写就行。

说白了就是:进程隔离性强但开销大,线程开销小但容易互相干扰。

维度 进程 线程
地址空间 独立 共享进程地址空间
资源开销 高(PCB、页表等) 低(仅栈和寄存器上下文)
切换速度 慢(需要TLB刷写) 快(同一页表)
通信方式 IPC(管道、消息队列、共享内存) 直接共享内存/全局变量
同步需求 低(天然隔离) 高(需要互斥锁等保护)
典型场景 容器隔离、浏览器多标签页 并行计算、高并发服务器

关键结论:共享内存编程(多线程)是所有并行优化的基础。你后面看到的OpenMP、TBB、甚至GPU编程,本质上都是在不同层次上解决"多个执行单元访问共享资源"的问题。

3.2 POSIX 线程(pthreads)基本操作

Linux/C/C++ 环境下,pthreads 是最基础的多线程API。我记得自己刚学的时候被一堆函数搞得晕头转向,其实真正用得频繁的没几个。

核心操作就四个:

  1. 创建线程 — pthread_create
  2. 等待线程结束 — pthread_join
  3. 分离线程 — pthread_detach
  4. 获取当前线程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章 · 多线程基础核心体系 进程与线程的区别 竞态条件与临界区 POSIX 线程(pthreads)基本操作 互斥锁 (Mutex) 条件变量 (Cond) 屏障 (Barrier) 正确理解线程模型 → 熟练使用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 的易用性确实差了点,但它能让你理解底层发生了什么。