13、并发与线程安全:数据竞争、死锁、线程安全函数、互斥锁的正确使用
多线程编程,说白了就是让程序同时干好几件事。听起来很爽,对吧?但我在项目中见过太多人栽在这上面。数据竞争、死锁、莫名其妙的崩溃……嗯,今天我们就来把这些坑一个个填平。
13.1 数据竞争:最隐蔽的敌人
数据竞争是什么?简单说就是两个线程同时读写同一个变量,而且至少有一个是写操作。结果就是——你永远不知道读出来的是什么鬼东西。
核心问题:多个线程访问共享数据,没有同步机制保护。
举个例子,你想想看:
// 危险!数据竞争
int counter = 0;
void* thread_func(void* arg) {
for (int i = 0; i < 100000; i++) {
counter++; // 这不是原子操作!
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d\n", counter); // 结果不是200000!
return 0;
}
为什么会这样?因为 counter++ 在底层是三步操作:读、加、写。两个线程可能同时读到同一个值,然后各自加1写回去——结果只加了1次。我在项目中遇到过类似问题,排查了整整两天才发现是这里出了问题。
避坑指南:我曾经以为加个 volatile 就能解决,结果发现 volatile 只保证不优化,不保证原子性。该用锁还得用锁。
13.2 死锁:线程们的“互相等待”
死锁就是两个线程互相持有对方需要的资源,谁也不肯放手。就像两个人过独木桥,你让我先过,我让你先过,结果谁都没法过。
经典的死锁场景:
// 死锁示例
pthread_mutex_t lock_a, lock_b;
void* thread1(void* arg) {
pthread_mutex_lock(&lock_a);
sleep(1); // 故意制造时间差
pthread_mutex_lock(&lock_b); // 等lock_b,但被thread2占着
// 干活...
pthread_mutex_unlock(&lock_b);
pthread_mutex_unlock(&lock_a);
return NULL;
}
void* thread2(void* arg) {
pthread_mutex_lock(&lock_b);
sleep(1);
pthread_mutex_lock(&lock_a); // 等lock_a,但被thread1占着
// 干活...
pthread_mutex_unlock(&lock_a);
pthread_mutex_unlock(&lock_b);
return NULL;
}
死锁的四个必要条件,我建议你记一下:
| 条件 | 说明 |
|---|---|
| 互斥 | 资源一次只能被一个线程占用 |
| 持有并等待 | 线程占着资源不放手,同时等别的资源 |
| 不可剥夺 | 资源不能被强制拿走 |
| 循环等待 | 形成等待环路 |
个人习惯:我写代码时,所有线程获取锁的顺序必须一致。比如都先拿 lock_a 再拿 lock_b,这样就不会有循环等待了。
13.3 线程安全函数:哪些能用,哪些不能用
不是所有函数都能在多线程里随便用的。标准库里有不少函数不是线程安全的。
常见的非线程安全函数:
strtok()— 内部有静态缓冲区rand()— 全局状态ctime()/asctime()— 返回静态字符串gethostbyname()— 内部静态数据
对应的线程安全版本:
strtok_r()— 传入自己的上下文rand_r()— 传入种子指针ctime_r()/asctime_r()— 传入缓冲区getaddrinfo()— 可重入设计
判断标准:看函数是不是可重入的(reentrant)。可重入函数不依赖全局或静态数据,所有状态都由调用者传入。
13.4 互斥锁的正确使用
互斥锁(mutex)是最基本的同步工具。但用不对,反而会出问题。
基本用法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* safe_thread_func(void* arg) {
pthread_mutex_lock(&mutex);
// 临界区:只有当前线程能进来
// 操作共享数据...
pthread_mutex_unlock(&mutex);
return NULL;
}
几个关键点:
- 锁的粒度要合适:锁太大,性能差;锁太小,容易出bug。我一般先粗后细,性能不够再优化。
- 避免在锁内调用外部函数:你不知道那个函数会不会也去拿锁,万一形成死锁就麻烦了。
- 检查返回值:
pthread_mutex_lock()可能失败,虽然概率低,但严谨的代码应该检查。
避坑指南:我曾经在锁内调用了 malloc(),结果 malloc 内部也有锁,而且和另一个线程形成了死锁。从那以后,我坚持锁内只做最必要的事。
13.5 知识体系总览
下面这张图,是我梳理的并发与线程安全的核心逻辑。你把它理解了,基本就能应对大部分场景。
13.6 实战建议
说了这么多,最后给你几条我自己的经验:
- 能用局部变量就别用全局变量:减少共享数据,从源头避免问题。
- 锁的持有时间越短越好:只锁必要的代码行,别锁整个函数。
- 优先使用高级同步原语:比如读写锁、条件变量,比裸用互斥锁更安全。
- 测试要覆盖并发场景:单线程跑得欢,多线程就崩,这种案例我见得太多了。
个人习惯:我每次写完多线程代码,都会用 ThreadSanitizer 跑一遍。它能自动检测数据竞争,省了我不少排查时间。
并发编程没有银弹。但只要你理解了数据竞争、死锁的本质,掌握了互斥锁的正确用法,再配合线程安全函数的选择,大部分问题都能迎刃而解。嗯,今天就到这里。