C语言线程安全如何实现?关键点有哪些?

99ANYc3cd6
预计阅读时长 25 分钟
位置: 首页 C语言 正文

什么是线程安全?

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

thread safety c语言
(图片来源网络,侵删)

无论多少个线程同时跑,结果都是对的”。

反例: 假设你有一个银行账户,余额为 1000,两个线程同时尝试从这个账户取款 500,如果代码不是线程安全的,可能会发生以下情况:

  1. 线程 A 读取余额,发现是 1000。
  2. 线程 B 读取余额,发现也是 1000。
  3. 线程 A 计算 1000 - 500 = 500,并将余额写回 500。
  4. 线程 B 计算 1000 - 500 = 500,并将余额写回 500。

账户余额变成了 500,而不是期望的 0,这就是线程不安全导致的数据不一致。


为什么 C 语言天生不线程安全?

C 语言在设计之初,主要关注的是性能和底层控制,对并发和同步的支持非常有限,其“不线程安全”主要源于以下几点:

thread safety c语言
(图片来源网络,侵删)

a. 共享可变状态

这是问题的根源,多个线程默认可以访问进程中的全局变量静态变量以及堆上分配的内存,如果一个线程正在修改这些数据,而另一个线程同时读取或修改,就会发生竞争。

b. 非原子操作

很多看似简单的操作,在底层都是由多条 CPU 指令组成的。i++ 操作:

  1. 从内存中读取 i 的值到寄存器。
  2. 在寄存器中将 i 的值加 1。
  3. 将寄存器中的新值写回内存。

如果一个线程在执行完步骤 1 后,被操作系统切换出去,另一个线程也执行了这三个步骤,那么当第一个线程回来执行步骤 3 时,它会覆盖掉第二个线程的修改,导致 i 只增加了 1 而不是 2。

c. 缺乏内置的同步机制

与 Java 或 C# 等语言不同,C 语言本身没有内置的 synchronized 关键字或高级的并发工具类,开发者必须依赖操作系统的 API(如 POSIX Threads 的 pthread 库)或编译器提供的原子操作来实现同步。

thread safety c语言
(图片来源网络,侵删)

导致线程不安全的常见原因

  1. 竞争条件:多个线程的执行顺序和调度方式导致程序结果不可预测,上面银行账户的例子就是典型的竞争条件。
  2. 死锁:两个或多个线程互相等待对方释放资源,导致所有线程都阻塞,无法继续执行。
  3. 内存可见性问题:一个线程修改了共享变量,但这个修改对其他线程是不可见的,因为现代 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;
}

重要原则:

  • 锁定时间要短:尽量减少临界区的代码量,避免锁竞争成为性能瓶颈。
  • 配对使用lockunlock 必须成对出现,否则会导致死锁。
  • 避免死锁:不要在持有锁 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;
}

线程安全的最佳实践

  1. 尽量减少共享数据:这是最重要的一条原则,如果数据不需要被多个线程共享,就不要共享,尽量让每个线程操作自己的数据。
  2. 使用不可变数据:如果数据可以被设计成不可变的(即创建后不再修改),那么它天然就是线程安全的。
  3. 同步访问共享数据:当必须共享数据时,使用互斥锁、信号量等机制进行保护。
  4. 避免在持有锁时调用可能阻塞的函数:在持有锁时进行 I/O 操作或调用另一个可能加锁的函数,这极易导致死锁。
  5. 注意库函数的线程安全性:C 标准库中很多函数(如 strtok, rand, qsort)都不是线程安全的,在多线程环境中使用它们时,要么使用它们的线程安全版本(如 strtok_r),要么在调用时加锁。
  6. 注意栈变量的共享:不要将局部变量的地址传递给其他线程,因为当函数返回时,该局部变量所在的栈帧会被销毁,其他线程通过这个地址访问就是非法的。

机制 适用场景 优点 缺点
互斥锁 保护临界区,确保独占访问。 简单直观,使用广泛。 可能导致死锁,竞争激烈时性能下降。
信号量 控制对多个资源的访问,实现复杂的同步。 功能强大,比互斥锁更灵活。 使用复杂,容易出错。
条件变量 线程间需要等待/通知某个条件。 避免忙等待,效率高。 必须与互斥锁配合使用,逻辑复杂。
原子操作 对单个简单变量(如计数器)进行原子更新。 性能极高,无阻塞。 功能有限,无法保护复杂的临界区。

在 C 语言中进行多线程编程,线程安全是一个必须严肃对待的课题,理解这些同步机制,并根据场景选择合适的工具,是编写稳定、高效并发程序的关键。

-- 展开阅读全文 --
头像
a5dedecms织梦建站初级培训
« 上一篇 12-07
C语言如何实现优先队列?
下一篇 » 12-07

相关文章

取消
微信二维码
支付宝二维码

目录[+]