第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);
}
警告: 千万不要在线程退出后,还去访问线程栈上的局部变量。线程一退出,栈空间就释放了,访问野指针会出段错误。我调试过这种bug,查了两天才定位到,血泪教训。

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;
}

注意,锁的粒度要适中。锁得太细,性能差;锁得太粗,并发度低。我在项目中一般遵循“最小临界区”原则——只锁真正需要保护的代码,别把无关操作也包进去。

核心要点: 使用互斥锁时,一定要保证每个分支都能解锁。如果某个分支提前return了,锁没释放,其他线程就会永远等下去。这就是死锁。

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的地方。

多线程编程 线程管理 pthread_create pthread_join pthread_exit pthread_cancel 同步机制 互斥锁 (mutex) 条件变量 (cond) 读写锁 (rwlock) 信号量 (sem) 自旋锁 (spinlock) 常见问题 死锁 · 数据竞争 · 虚假唤醒 优先级反转 · 线程泄漏

4. 避坑指南:我踩过的那些坑

多线程的坑,我踩过不少。挑几个典型的说说:

  • 忘记初始化锁: 静态初始化的锁没问题,但动态分配的锁一定要调pthread_mutex_init。我曾经在堆上分配了一个锁结构体,直接用了,结果锁根本不起作用。查了半天才发现没初始化。
  • 信号处理与线程: 多线程程序里,信号的处理很复杂。我建议只在主线程里处理信号,子线程全部屏蔽信号。否则信号来了,不知道发给哪个线程,程序行为就不可预测了。
  • 线程栈大小: 嵌入式系统里,默认线程栈可能只有1MB。如果你的线程函数里定义了大的局部数组,栈会溢出。我一般用pthread_attr_setstacksize显式设置栈大小,心里踏实。
调试技巧:gdb调试多线程程序时,可以用info threads查看所有线程,用thread N切换到指定线程。配合backtrace看调用栈,定位死锁很方便。

5. 总结

多线程编程,说白了就是三件事:创建线程、同步数据、避免死锁。创建线程的API很简单,但同步问题才是真正的考验。我个人觉得,写多线程代码时,脑子里要时刻想着“这个变量会不会被两个线程同时访问?”——多问自己几遍,能避免很多bug。

嗯,最后说一句:多线程不是银弹。如果你的任务可以拆成独立的子任务,用多进程或者异步IO可能更简单。别为了用多线程而用多线程,合适才是最好的。

专注资料整理