第37章 多线程基础:pthread创建、等待、退出,线程同步问题
多线程编程,说白了就是让程序同时干几件事。你想想看,一个单线程的程序,就像只有一个工人的流水线,活儿再多也得一件一件来。而多线程呢,就是多招了几个工人,大家一起干,效率自然就上去了。
不过,多招工人也有麻烦——他们可能会抢同一把螺丝刀。这就是线程同步要解决的问题。我个人习惯把多线程编程比作“带规矩的群架”,规矩定好了,大家才能打得漂亮。
1. 线程的创建、等待与退出
在Linux环境下,我们用的最多的就是POSIX线程库,也就是pthread。这套接口很成熟,我做了这么多年嵌入式,几乎每个带操作系统的项目都离不开它。
1.1 创建线程:pthread_create
创建线程的接口长这样:
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
参数看着多,其实核心就三个:线程ID、线程函数、传给函数的参数。attr一般传NULL,用默认属性就行。
我在项目中遇到过一个问题:线程函数如果写成int func()而不是void *func(void *),编译会报警告,运行时还可能栈溢出。嗯,这里要注意,pthread_create要求线程函数的签名必须是void *(*)(void *),别图省事写错了。
1.2 等待线程:pthread_join
主线程创建了子线程,有时候需要等子线程干完活再继续。这时候就用pthread_join:
int pthread_join(pthread_t thread, void **retval);
这个函数会阻塞调用线程,直到指定的线程退出。第二个参数可以拿到线程的返回值。如果你不关心返回值,传NULL就行。
我曾经犯过一个低级错误:在子线程里调用了pthread_join等自己,结果死锁了。你想想看,自己等自己,就像一个人揪着自己头发想飞起来,怎么可能?
1.3 线程退出:pthread_exit
线程退出有三种方式:
- 线程函数执行完,自然返回
- 调用
pthread_exit主动退出 - 被其他线程调用
pthread_cancel取消
我个人习惯在线程函数末尾显式调用pthread_exit,这样代码意图更明确。尤其是当线程函数里有复杂的嵌套逻辑时,一眼就能看出退出点在哪。
void *worker(void *arg) {
// 干活...
pthread_exit((void *)0);
}
2. 线程同步:别让工人抢工具
多线程最大的坑就是数据竞争。两个线程同时写一个全局变量,结果可能不是你想要的。为什么会这样?因为CPU的指令不是原子的,一个i++在底层可能是三条指令:读、加、写。两个线程交错执行,数据就乱了。
解决同步问题,常用的工具有三种:互斥锁、条件变量、读写锁。我一个个说。
2.1 互斥锁:pthread_mutex
互斥锁就像厕所的门锁——一个人进去,把门锁上,其他人只能在外面等。用起来很简单:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *counter(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&mutex);
global_count++; // 临界区
pthread_mutex_unlock(&mutex);
}
return NULL;
}
注意,锁的粒度要适中。锁得太细,性能差;锁得太粗,并发度低。我在项目中一般遵循“最小临界区”原则——只锁真正需要保护的代码,别把无关操作也包进去。
2.2 条件变量:pthread_cond
条件变量用于线程间的“通知”。比如生产者-消费者模型:生产者生产了数据,通知消费者来取。消费者没数据时,就等着。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
// 生产者
void *producer(void *arg) {
// 生产数据...
pthread_mutex_lock(&mutex);
data_ready = 1;
pthread_cond_signal(&cond); // 通知消费者
pthread_mutex_unlock(&mutex);
return NULL;
}
// 消费者
void *consumer(void *arg) {
pthread_mutex_lock(&mutex);
while (!data_ready) {
pthread_cond_wait(&cond, &mutex); // 等待通知
}
// 消费数据...
pthread_mutex_unlock(&mutex);
return NULL;
}
这里有个细节:pthread_cond_wait必须在循环里调用。为什么?因为可能发生“虚假唤醒”——线程被唤醒了,但条件并不满足。我在项目中遇到过这种情况,加了while循环后就稳了。
2.3 读写锁:pthread_rwlock
读写锁适合“读多写少”的场景。多个线程可以同时读,但写的时候只能有一个线程,且不能有读操作。
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
// 读操作
pthread_rwlock_rdlock(&rwlock);
// 读取共享数据...
pthread_rwlock_unlock(&rwlock);
// 写操作
pthread_rwlock_wrlock(&rwlock);
// 修改共享数据...
pthread_rwlock_unlock(&rwlock);
我一般在缓存系统或配置表更新时用读写锁。比如一个全局配置表,99%的时间是读,只有1%的时间是写。用互斥锁的话,读操作也要排队,太浪费了。读写锁就完美解决了这个问题。
3. 知识体系总览
下面这张图总结了多线程编程的核心知识点,我画的时候特意把同步问题放在中心,因为这是最容易出bug的地方。
4. 避坑指南:我踩过的那些坑
多线程的坑,我踩过不少。挑几个典型的说说:
- 忘记初始化锁: 静态初始化的锁没问题,但动态分配的锁一定要调
pthread_mutex_init。我曾经在堆上分配了一个锁结构体,直接用了,结果锁根本不起作用。查了半天才发现没初始化。 - 信号处理与线程: 多线程程序里,信号的处理很复杂。我建议只在主线程里处理信号,子线程全部屏蔽信号。否则信号来了,不知道发给哪个线程,程序行为就不可预测了。
- 线程栈大小: 嵌入式系统里,默认线程栈可能只有1MB。如果你的线程函数里定义了大的局部数组,栈会溢出。我一般用
pthread_attr_setstacksize显式设置栈大小,心里踏实。
gdb调试多线程程序时,可以用info threads查看所有线程,用thread N切换到指定线程。配合backtrace看调用栈,定位死锁很方便。
5. 总结
多线程编程,说白了就是三件事:创建线程、同步数据、避免死锁。创建线程的API很简单,但同步问题才是真正的考验。我个人觉得,写多线程代码时,脑子里要时刻想着“这个变量会不会被两个线程同时访问?”——多问自己几遍,能避免很多bug。
嗯,最后说一句:多线程不是银弹。如果你的任务可以拆成独立的子任务,用多进程或者异步IO可能更简单。别为了用多线程而用多线程,合适才是最好的。