第30章 多线程编程入门:pthread_create()、pthread_join()、pthread_exit()、线程同步(互斥锁pthread_mutex_t)
终于到了多线程这一章。说实话,很多嵌入式工程师对多线程又爱又怕。爱的是它能榨干CPU每一滴性能,怕的是那些莫名其妙的数据错乱和死锁。我刚开始接触pthread时,也踩过不少坑。今天咱们就把这几个核心API和互斥锁彻底讲透。
30.1 线程是什么?为什么需要它?
一个进程就像一家工厂,线程就是工厂里的工人。单线程程序,相当于只有一个工人在干活。多线程,就是多个工人同时工作。你想想看,如果只有一个工人,他既要搬砖又要记账,效率肯定上不去。
在嵌入式系统里,我经常用多线程来处理这样的场景:一个线程负责从传感器读取数据,另一个线程负责把数据通过串口发出去,第三个线程负责响应用户按键。各干各的,互不干扰。
核心要点:线程是CPU调度的最小单位。同一进程内的线程共享地址空间、文件描述符等资源,但各自拥有独立的栈和寄存器上下文。
30.2 三个核心API:创建、等待、退出
30.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:指向pthread_t变量的指针,用来接收线程ID
- attr:线程属性,传NULL就用默认属性
- start_routine:线程要执行的函数,返回void*,参数也是void*
- arg:传给线程函数的参数
返回值:成功返回0,失败返回错误码。
我的习惯:每次调用pthread_create后,一定检查返回值。别偷懒,否则线程创建失败了你都不知道,程序静悄悄地挂掉。
看个简单例子:
#include <stdio.h>
#include <pthread.h>
void* thread_func(void* arg) {
int* num = (int*)arg;
printf("子线程收到参数: %d\n", *num);
return NULL;
}
int main() {
pthread_t tid;
int value = 42;
if (pthread_create(&tid, NULL, thread_func, &value) != 0) {
perror("pthread_create failed");
return 1;
}
printf("主线程继续执行...\n");
pthread_join(tid, NULL);
return 0;
}
30.2.2 pthread_join() —— 等待线程结束
主线程创建子线程后,如果不做任何处理,主线程可能先跑完退出,子线程还没跑完就被强制终止了。pthread_join就是用来解决这个问题的——它会让调用者阻塞,直到目标线程退出。
int pthread_join(pthread_t thread, void **retval);
第二个参数retval用来接收线程的返回值。如果不需要,传NULL即可。
我曾经踩过的坑:对同一个线程调用两次pthread_join,会导致未定义行为,程序可能直接崩溃。记住,一个线程只能被join一次。
30.2.3 pthread_exit() —— 线程主动退出
线程可以在函数末尾return,也可以调用pthread_exit主动退出。两者效果类似,但pthread_exit可以在函数任意位置调用,而且它不会导致整个进程退出(exit()会)。
void pthread_exit(void *retval);
看个综合例子:
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("线程 %d 开始工作\n", id);
// 模拟一些计算
int result = id * 100;
printf("线程 %d 结束,结果: %d\n", id, result);
pthread_exit((void*)(long)result);
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
void* ret1, *ret2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
pthread_join(t1, &ret1);
pthread_join(t2, &ret2);
printf("主线程: t1返回 %ld, t2返回 %ld\n",
(long)ret1, (long)ret2);
return 0;
}
30.3 线程同步:为什么需要互斥锁?
多个线程共享全局变量时,问题就来了。假设两个线程同时对一个全局计数器做自增操作:
int counter = 0;
// 线程A
counter++; // 实际是三步:读-改-写
// 线程B
counter++; // 同样三步
如果线程A刚读完counter的值(比如0),还没来得及写回,线程B也读到了0,然后各自加1写回,结果counter变成1而不是2。这就是经典的竞争条件。
我在项目中遇到过类似问题:一个多线程数据采集系统,两个线程同时往环形缓冲区写数据,结果数据覆盖了,采集到的波形全是乱的。查了两天才定位到是缺少互斥保护。
30.4 互斥锁 pthread_mutex_t
互斥锁,说白了就是一把锁。线程在访问共享资源前先上锁,用完再解锁。其他线程想上锁时,如果锁已被占用,就会阻塞等待。
30.4.1 基本用法
#include <pthread.h>
// 定义互斥锁变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 或者动态初始化
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);
// 上锁
pthread_mutex_lock(&mutex);
// 访问共享资源...
// 解锁
pthread_mutex_unlock(&mutex);
// 销毁锁
pthread_mutex_destroy(&mutex);
重要原则:锁的粒度要适中。锁得太细,保护不全面;锁得太粗,性能下降严重。我一般遵循「保护最小必要范围」的原则。
30.4.2 完整示例:用互斥锁保护计数器
#include <stdio.h>
#include <pthread.h>
#define NUM_THREADS 5
#define LOOP_COUNT 1000000
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* increment(void* arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t threads[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, increment, NULL);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
printf("最终计数: %d (期望: %d)\n",
counter, NUM_THREADS * LOOP_COUNT);
pthread_mutex_destroy(&mutex);
return 0;
}
运行结果:
最终计数: 5000000 (期望: 5000000)
如果不加锁,结果大概率小于5000000,而且每次运行都不一样。
30.5 死锁:最头疼的问题
死锁就是两个线程互相等待对方释放锁,结果谁也动不了。典型的场景:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。
我曾经遇到过一次:一个多线程日志系统,写日志线程和刷盘线程互相等待,整个系统卡死。最后发现是锁的获取顺序不一致导致的。从那以后,我强制团队规定:所有线程获取多个锁时,必须按照相同的顺序。
避免死锁的几个原则:
- 固定锁的获取顺序(比如总是先锁A再锁B)
- 使用pthread_mutex_trylock()尝试加锁,失败就释放已持有的锁
- 尽量减小锁的持有时间
30.6 本章知识体系
下面这张图帮你理清多线程编程的核心脉络:
30.7 避坑指南与个人经验
最后,分享几个我多年积累的经验:
- 检查返回值:pthread系列函数返回错误码,不是设置errno。别用perror,用strerror打印错误信息。
- 线程栈大小:嵌入式系统内存有限,默认线程栈可能太大。用pthread_attr_setstacksize()调整。
- 不要用锁保护太小的操作:比如counter++这种,用原子操作__sync_fetch_and_add()更高效。
- 调试多线程程序:gdb支持多线程调试,用info threads查看所有线程,thread n切换线程。
我的建议:刚开始学多线程,先写简单的生产者-消费者模型。一个线程生产数据,一个线程消费数据,用互斥锁保护缓冲区。这个模型搞懂了,多线程就算入门了。
多线程编程就像走钢丝,平衡好了性能飞升,平衡不好bug丛生。但别怕,多写多练,慢慢就能掌握其中的门道。
公众号:蓝海资料掘金营,微信deep3321