什么是线程安全?
线程安全 指的是当多个线程并发地访问和操作同一个共享数据时,程序依然能够表现出正确、一致的行为,不会出现数据损坏或不可预期的结果。

无论多少个线程同时跑,结果都是对的”。
反例: 假设你有一个银行账户,余额为 1000,两个线程同时尝试从这个账户取款 500,如果代码不是线程安全的,可能会发生以下情况:
- 线程 A 读取余额,发现是 1000。
- 线程 B 读取余额,发现也是 1000。
- 线程 A 计算
1000 - 500 = 500,并将余额写回 500。 - 线程 B 计算
1000 - 500 = 500,并将余额写回 500。
账户余额变成了 500,而不是期望的 0,这就是线程不安全导致的数据不一致。
为什么 C 语言天生不线程安全?
C 语言在设计之初,主要关注的是性能和底层控制,对并发和同步的支持非常有限,其“不线程安全”主要源于以下几点:

a. 共享可变状态
这是问题的根源,多个线程默认可以访问进程中的全局变量、静态变量以及堆上分配的内存,如果一个线程正在修改这些数据,而另一个线程同时读取或修改,就会发生竞争。
b. 非原子操作
很多看似简单的操作,在底层都是由多条 CPU 指令组成的。i++ 操作:
- 从内存中读取
i的值到寄存器。 - 在寄存器中将
i的值加 1。 - 将寄存器中的新值写回内存。
如果一个线程在执行完步骤 1 后,被操作系统切换出去,另一个线程也执行了这三个步骤,那么当第一个线程回来执行步骤 3 时,它会覆盖掉第二个线程的修改,导致 i 只增加了 1 而不是 2。
c. 缺乏内置的同步机制
与 Java 或 C# 等语言不同,C 语言本身没有内置的 synchronized 关键字或高级的并发工具类,开发者必须依赖操作系统的 API(如 POSIX Threads 的 pthread 库)或编译器提供的原子操作来实现同步。

导致线程不安全的常见原因
- 竞争条件:多个线程的执行顺序和调度方式导致程序结果不可预测,上面银行账户的例子就是典型的竞争条件。
- 死锁:两个或多个线程互相等待对方释放资源,导致所有线程都阻塞,无法继续执行。
- 内存可见性问题:一个线程修改了共享变量,但这个修改对其他线程是不可见的,因为现代 CPU 为了优化性能,会将变量的值缓存在每个核心的私有缓存中,如果没有适当的同步机制,其他核心可能永远看不到这个更新。
如何实现线程安全?(核心解决方案)
实现线程安全的核心思想是同步,即控制多个线程对共享资源的访问顺序,以下是 C 语言中最常用的几种同步机制:
a. 互斥锁
这是最常用、最基本的同步工具,它就像一个“门卫”,确保同一时间只有一个线程可以进入临界区(访问共享资源的代码段)。
工作原理:
pthread_mutex_lock():尝试获取锁,如果锁已经被其他线程持有,则当前线程会阻塞,直到锁被释放。pthread_mutex_unlock():释放锁,唤醒一个正在等待该锁的线程。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
int global_counter = 0;
pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
// 加锁,确保同一时间只有一个线程能执行下面的代码
pthread_mutex_lock(&counter_mutex);
global_counter++; // 临界区
// 解锁,允许其他线程进入
pthread_mutex_unlock(&counter_mutex);
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, increment, NULL);
pthread_create(&thread2, NULL, increment, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Final counter value: %d\n", global_counter); // 应该输出 200000
return 0;
}
重要原则:
- 锁定时间要短:尽量减少临界区的代码量,避免锁竞争成为性能瓶颈。
- 配对使用:
lock和unlock必须成对出现,否则会导致死锁。 - 避免死锁:不要在持有锁 1 的时候再去尝试获取锁 2,而持有锁 2 的线程又在尝试获取锁 1。
b. 信号量
信号量比互斥锁更通用,它是一个计数器,用于控制对多个相同资源的访问。
sem_wait():如果信号量值大于 0,则将其减 1 并继续;如果值为 0,则阻塞。sem_post():将信号量值加 1,并唤醒一个等待的线程。
互斥锁可以看作是值为 0 或 1 的信号量。
示例:模拟一个有 3 个座位的休息室
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
sem_t seats;
void* person_enters(void* arg) {
int id = *(int*)arg;
// 尝试获取一个座位
sem_wait(&seats);
printf("Person %d has taken a seat. Seats left: %d\n", id, sem_getvalue(&seats, NULL));
// 模拟在休息室待一段时间
sleep(1);
// 离开,释放一个座位
sem_post(&seats);
printf("Person %d has left. Seats left: %d\n", id, sem_getvalue(&seats, NULL));
free(arg);
return NULL;
}
int main() {
const int TOTAL_SEATS = 3;
sem_init(&seats, 0, TOTAL_SEATS);
pthread_t threads[10];
for (int i = 0; i < 10; i++) {
int* id = malloc(sizeof(int));
*id = i + 1;
pthread_create(&threads[i], NULL, person_enters, id);
}
for (int i = 0; i < 10; i++) {
pthread_join(threads[i], NULL);
}
sem_destroy(&seats);
return 0;
}
c. 条件变量
条件变量通常与互斥锁配合使用,允许线程在某个条件未满足时挂起(等待),直到其他线程满足该条件并通知它。
pthread_cond_wait():原子性地解锁互斥锁并阻塞当前线程,当被唤醒时,它会重新获取互斥锁。pthread_cond_signal()/pthread_cond_broadcast():唤醒一个或所有等待的线程。
关键点: pthread_cond_wait 必须在一个 while 循环中检查条件,而不是 if,因为可能存在“伪唤醒”(spurious wakeup),线程被唤醒时条件可能仍然不满足。
示例:生产者-消费者模型
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t can_produce = PTHREAD_COND_INITIALIZER;
pthread_cond_t can_consume = PTHREAD_COND_INITIALIZER;
void* producer(void* arg) {
for (int i = 0; i < 20; i++) {
pthread_mutex_lock(&mutex);
// 如果缓冲区已满,等待消费者通知
while (count == BUFFER_SIZE) {
printf("Producer: Buffer is full. Waiting...\n");
pthread_cond_wait(&can_produce, &mutex);
}
buffer[count++] = i;
printf("Producer: Produced item %d. Buffer count: %d\n", i, count);
// 通知消费者可以消费了
pthread_cond_signal(&can_consume);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
void* consumer(void* arg) {
for (int i = 0; i < 20; i++) {
pthread_mutex_lock(&mutex);
// 如果缓冲区为空,等待生产者通知
while (count == 0) {
printf("Consumer: Buffer is empty. Waiting...\n");
pthread_cond_wait(&can_consume, &mutex);
}
int item = buffer[--count];
printf("Consumer: Consumed item %d. Buffer count: %d\n", item, count);
// 通知生产者可以生产了
pthread_cond_signal(&can_produce);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t prod, cons;
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&can_produce);
pthread_cond_destroy(&can_consume);
return 0;
}
d. 原子操作
对于简单的共享变量(如计数器),使用原子操作比互斥锁性能更好,因为它避免了线程的上下文切换和阻塞。
C11 标准引入了 <stdatomic.h> 头文件,提供了原子类型和操作,在 C11 之前,GCC 等编译器也通过内置函数提供了支持。
示例(GCC 内置函数):
#include <stdio.h>
#include <pthread.h>
int global_counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
// 使用原子操作进行自增
__sync_fetch_and_add(&global_counter, 1);
}
return NULL;
}
int main() {
// ... (线程创建和等待代码同上) ...
printf("Final counter value: %d\n", global_counter); // 应该输出 200000
return 0;
}
线程安全的最佳实践
- 尽量减少共享数据:这是最重要的一条原则,如果数据不需要被多个线程共享,就不要共享,尽量让每个线程操作自己的数据。
- 使用不可变数据:如果数据可以被设计成不可变的(即创建后不再修改),那么它天然就是线程安全的。
- 同步访问共享数据:当必须共享数据时,使用互斥锁、信号量等机制进行保护。
- 避免在持有锁时调用可能阻塞的函数:在持有锁时进行 I/O 操作或调用另一个可能加锁的函数,这极易导致死锁。
- 注意库函数的线程安全性:C 标准库中很多函数(如
strtok,rand,qsort)都不是线程安全的,在多线程环境中使用它们时,要么使用它们的线程安全版本(如strtok_r),要么在调用时加锁。 - 注意栈变量的共享:不要将局部变量的地址传递给其他线程,因为当函数返回时,该局部变量所在的栈帧会被销毁,其他线程通过这个地址访问就是非法的。
| 机制 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 互斥锁 | 保护临界区,确保独占访问。 | 简单直观,使用广泛。 | 可能导致死锁,竞争激烈时性能下降。 |
| 信号量 | 控制对多个资源的访问,实现复杂的同步。 | 功能强大,比互斥锁更灵活。 | 使用复杂,容易出错。 |
| 条件变量 | 线程间需要等待/通知某个条件。 | 避免忙等待,效率高。 | 必须与互斥锁配合使用,逻辑复杂。 |
| 原子操作 | 对单个简单变量(如计数器)进行原子更新。 | 性能极高,无阻塞。 | 功能有限,无法保护复杂的临界区。 |
在 C 语言中进行多线程编程,线程安全是一个必须严肃对待的课题,理解这些同步机制,并根据场景选择合适的工具,是编写稳定、高效并发程序的关键。
