Linux 系统编程 10:线程同步

前言:

承接上一篇线程基础与创建回收,多线程共享进程地址空间的特性带来了极低的通信成本,但也引发了并发访问共享资源的数据竞争问题。本篇讲解线程同步的三大核心工具:互斥锁、条件变量与读写锁,深度拆解死锁的成因与规避方案,掌握这些才能写出正确、稳定、高效的多线程程序,也是笔试面试中并发模块的核心必考内容。


一、线程同步基础概念

1. 什么是数据竞争

当多个线程同时对同一个共享资源进行读写操作时,最终结果依赖于线程的调度执行顺序,出现不可预期的错误,这就是数据竞争(竞态条件)。

典型场景:两个线程同时对一个全局变量执行自增操作。自增在底层分为三步:读取变量值、计算加一、写回内存。两个线程交替执行时,可能出现读取到同一个旧值,最终只加了一次的错误结果。

2. 临界区与同步

  • 临界区:访问共享资源的代码片段,同一时间只能有一个线程进入执行
  • 线程同步:通过特定机制控制多个线程的执行顺序,保证临界区的并发安全,避免数据竞争

线程同步的核心思想不是让线程完全串行,而是只在访问共享资源的临界区进行互斥保护,兼顾安全性与执行效率。


二、互斥锁(Mutex)

互斥锁是最基础、最常用的线程同步工具,本质是一个二元状态变量(锁定 / 解锁),保证同一时间只有一个线程进入临界区。

1. 核心操作函数

复制代码
#include <pthread.h>

// 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

// 加锁(阻塞)
int pthread_mutex_lock(pthread_mutex_t *mutex);

// 尝试加锁(非阻塞)
int pthread_mutex_trylock(pthread_mutex_t *mutex);

// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

// 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • pthread_mutex_lock:锁已被占用时,线程阻塞等待,直到锁被释放
  • pthread_mutex_trylock:锁已被占用时立刻返回错误,不阻塞,适用于不能等待的场景
  • 所有函数成功返回 0,失败返回错误码

2. 基础使用模板

复制代码
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL);

// 进入临界区前加锁
pthread_mutex_lock(&mutex);
// 临界区:访问共享资源
shared_data++;
// 离开临界区解锁
pthread_mutex_unlock(&mutex);

pthread_mutex_destroy(&mutex);

3. 实战:解决多线程计数问题

复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

#define COUNT 10000
int g_count = 0;
pthread_mutex_t g_mutex;

void *task(void *arg) {
    for (int i = 0; i < COUNT; i++) {
        pthread_mutex_lock(&g_mutex);
        g_count++;
        pthread_mutex_unlock(&g_mutex);
    }
    pthread_exit(NULL);
}

int main(void) {
    pthread_t tid1, tid2;
    pthread_mutex_init(&g_mutex, NULL);

    pthread_create(&tid1, NULL, task, NULL);
    pthread_create(&tid2, NULL, task, NULL);

    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("最终计数:%d,预期:%d\n", g_count, COUNT * 2);
    pthread_mutex_destroy(&g_mutex);
    return 0;
}

不加互斥锁时最终结果大概率小于 20000,加锁后结果始终正确。

4. 互斥锁的本质特性

  • 原子性:加锁操作是原子的,不会出现两个线程同时加锁成功的情况
  • 互斥性:同一时间最多一个线程持有锁,保证临界区串行执行
  • 阻塞性:拿不到锁时线程进入阻塞睡眠,不占用 CPU 资源

三、死锁:成因与规避

死锁是多线程编程中最经典的问题,指多个线程互相持有对方需要的锁,同时等待对方释放,导致所有线程永久阻塞。

1. 死锁产生的四个必要条件

  1. 互斥条件:资源同一时间只能被一个线程持有
  2. 持有并等待:线程已经持有至少一个资源,同时等待其他线程持有的资源
  3. 不可剥夺:已持有的锁不能被其他线程强行夺走,只能主动释放
  4. 循环等待:线程之间形成首尾相接的循环等待资源关系

四个条件同时满足才会产生死锁,破坏任意一个即可避免死锁。

2. 典型死锁场景

两个线程,两把锁,加锁顺序相反:

复制代码
// 线程A:先锁1,再锁2
pthread_mutex_lock(&lock1);
pthread_mutex_lock(&lock2);
// 业务逻辑
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);

// 线程B:先锁2,再锁1
pthread_mutex_lock(&lock2);
pthread_mutex_lock(&lock1);
// 业务逻辑
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);

当线程 A 拿到 lock1、线程 B 拿到 lock2 时,双方都会阻塞等待对方的锁,永久死锁。

3. 死锁规避方案

  1. 顺序加锁(最常用):所有线程严格按照相同的顺序加锁,破坏循环等待条件。比如永远先加编号小的锁,再加编号大的锁。
  2. 尝试加锁 + 回退 :使用trylock尝试加第二把锁,失败就释放已持有的锁,重试或执行其他逻辑,破坏持有并等待条件。
  3. 减少锁的数量:尽量用一把锁保护多个资源,避免多锁嵌套,从根源上消除循环等待的可能。
  4. 设置超时时间:使用带超时的加锁函数,超时后自动释放资源,避免永久等待。

四、条件变量(Condition Variable)

互斥锁解决了临界区互斥访问的问题,但无法实现线程间的等待与通知。条件变量用于实现线程间的同步等待:当条件不满足时,线程阻塞睡眠;当条件满足时,其他线程唤醒等待的线程。

条件变量必须和互斥锁配合使用,由互斥锁保护条件的并发安全。

1. 核心操作函数

复制代码
#include <pthread.h>

// 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);

// 等待条件满足(阻塞)
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

// 唤醒一个等待的线程
int pthread_cond_signal(pthread_cond_t *cond);

// 唤醒所有等待的线程
int pthread_cond_broadcast(pthread_cond_t *cond);

// 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);

2. 为什么必须配合互斥锁

pthread_cond_wait内部执行三步原子操作:

  1. 释放持有的互斥锁
  2. 阻塞等待条件变量被唤醒
  3. 被唤醒后,重新获取互斥锁再返回

互斥锁的作用是保护 "条件判断 + 等待" 这个过程的原子性,防止条件在判断和等待之间发生变化,出现唤醒丢失的问题。

3. 标准使用范式

等待线程

复制代码
pthread_mutex_lock(&mutex);
while (条件不满足) {       // 必须用while循环,不能用if
    pthread_cond_wait(&cond, &mutex);
}
// 条件满足,处理业务
pthread_mutex_unlock(&mutex);

为什么必须用 while 而不是 if:因为可能出现虚假唤醒,线程被唤醒但条件其实还不满足,需要再次检查条件。这是 POSIX 标准的规范写法。

唤醒线程

复制代码
pthread_mutex_lock(&mutex);
// 修改共享条件
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond); // 解锁后再发信号,减少无意义的锁竞争

4. 实战:生产者消费者模型

生产者消费者是条件变量最经典的应用场景:生产者生产数据放入队列,消费者从队列取出数据,队列为空时消费者等待,队列满时生产者等待。

复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>

#define MAX_QUEUE 5
int g_queue[MAX_QUEUE];
int g_count = 0;

pthread_mutex_t g_mutex;
pthread_cond_t g_not_empty; // 队列非空条件
pthread_cond_t g_not_full;  // 队列非满条件

// 生产者线程
void *producer(void *arg) {
    int id = *(int *)arg;
    while (1) {
        pthread_mutex_lock(&g_mutex);
        // 队列满了,等待非满条件
        while (g_count == MAX_QUEUE) {
            pthread_cond_wait(&g_not_full, &g_mutex);
        }

        // 生产一个数据
        g_queue[g_count++] = rand() % 100;
        printf("生产者%d生产,当前数量:%d\n", id, g_count);

        pthread_mutex_unlock(&g_mutex);
        pthread_cond_signal(&g_not_empty); // 通知消费者队列有数据了
        sleep(1);
    }
    pthread_exit(NULL);
}

// 消费者线程
void *consumer(void *arg) {
    int id = *(int *)arg;
    while (1) {
        pthread_mutex_lock(&g_mutex);
        // 队列为空,等待非空条件
        while (g_count == 0) {
            pthread_cond_wait(&g_not_empty, &g_mutex);
        }

        // 消费一个数据
        int data = g_queue[--g_count];
        printf("消费者%d消费:%d,剩余:%d\n", id, data, g_count);

        pthread_mutex_unlock(&g_mutex);
        pthread_cond_signal(&g_not_full); // 通知生产者队列有空位了
        sleep(2);
    }
    pthread_exit(NULL);
}

int main(void) {
    pthread_t p1, p2, c1, c2;
    int id1 = 1, id2 = 2;

    pthread_mutex_init(&g_mutex, NULL);
    pthread_cond_init(&g_not_empty, NULL);
    pthread_cond_init(&g_not_full, NULL);

    pthread_create(&p1, NULL, producer, &id1);
    pthread_create(&p2, NULL, producer, &id2);
    pthread_create(&c1, NULL, consumer, &id1);
    pthread_create(&c2, NULL, consumer, &id2);

    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    pthread_join(c1, NULL);
    pthread_join(c2, NULL);

    pthread_mutex_destroy(&g_mutex);
    pthread_cond_destroy(&g_not_empty);
    pthread_cond_destroy(&g_not_full);
    return 0;
}

五、读写锁(Read-Write Lock)

互斥锁不管读还是写都会加锁,完全串行。在多读少写的场景下,读取操作本身不会修改数据,并发读取是安全的,互斥锁会浪费大量性能。读写锁就是为读多写少场景优化的同步工具。

1. 核心规则

  • 读共享:多个线程可以同时持有读锁,并发读取数据
  • 写互斥:写锁是独占的,同一时间只能有一个写线程,且写的时候不能有读线程
  • 写优先:当有写线程等待时,新来的读线程会被阻塞,避免写操作长期饥饿

2. 核心操作函数

复制代码
#include <pthread.h>

// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);

// 加读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

// 加写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

// 解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

3. 适用场景与对比

同步工具 适用场景 并发度 性能
互斥锁 读写都频繁、操作简单 最低,完全串行 开销小,通用场景
读写锁 读多写少,读取远多于写入 中等,读并发写互斥 读场景性能高,写场景开销略大

注意:写操作很少时读写锁优势明显;如果读写频率接近,读写锁的额外开销可能反而不如普通互斥锁。


六、三种同步工具对比与选型

工具 核心作用 典型场景 注意事项
互斥锁 临界区互斥访问 通用共享资源保护 避免嵌套加锁导致死锁
条件变量 线程间等待与通知 生产者消费者、任务队列 必须配合互斥锁,等待用 while 循环
读写锁 读多写少场景优化 配置读取、缓存查询 写频繁时性能不如互斥锁

七、面试高频考点与易错坑点

1. 经典面试问答

Q1:什么是死锁?产生的四个必要条件是什么?怎么避免?

答:

  1. 死锁是多个线程互相持有对方需要的资源,同时等待对方释放,导致所有线程永久阻塞的状态。
  2. 四个必要条件:互斥条件、持有并等待、不可剥夺、循环等待。
  3. 避免方法:破坏任意一个条件即可,最常用的是统一加锁顺序破坏循环等待,或者减少锁的数量避免多锁嵌套。

Q2:条件变量为什么必须和互斥锁配合使用?

答: 因为条件判断和等待操作必须是原子的。如果没有互斥锁保护,线程判断条件不满足后、进入等待前,其他线程可能修改了条件并发出信号,这个信号就会丢失,导致线程永久等待。 pthread_cond_wait 内部会原子地释放锁并进入等待,被唤醒后重新加锁,保证整个过程的安全性。

Q3:条件变量等待为什么要用 while 循环而不是 if?

答: 两个原因:

  • 一是可能出现虚假唤醒,线程被唤醒但条件其实还不满足;
  • 二是可能有多个线程被同时唤醒,第一个线程处理完后条件又不满足了。 用 while 循环可以在唤醒后再次检查条件,不满足就继续等待,保证逻辑正确性,这是 POSIX 标准的规范写法。

Q4:读写锁有什么特点?什么场景下使用?

答:

  1. 读写锁的特点是读共享、写互斥:多个读线程可以同时持有读锁,写锁是独占的,写的时候不能有读。
  2. 适用于读多写少的场景,比如配置信息、缓存数据的读取,能大幅提升并发度;如果写操作频繁,读写锁开销反而更大,适合用普通互斥锁。

Q5:互斥锁和信号量有什么区别?

答:

  1. 作用范围:互斥锁用于线程间互斥;信号量可以用于进程间,也可以用于线程间。
  2. 功能:互斥锁只能实现互斥;信号量既可以实现互斥(二元信号量),也可以实现同步,还能控制并发数量。
  3. 加解锁主体:互斥锁必须由加锁的线程解锁;信号量可以由其他线程释放。

2. 常见易错坑点

  1. 加锁后忘记解锁,尤其是异常分支提前 return 时,导致其他线程永久阻塞
  2. 多把锁加锁顺序不一致,导致死锁
  3. 条件变量等待用 if 而不是 while,出现虚假唤醒导致逻辑错误
  4. 条件变量使用时忘记配合互斥锁,出现唤醒丢失和数据竞争
  5. 写频繁的场景盲目使用读写锁,性能反而不如普通互斥锁
  6. 解锁已经销毁的锁,或者重复销毁锁,导致未定义行为
  7. 临界区范围过大,把不需要保护的耗时操作也放进锁里,降低并发性能

以上就是线程同步三大核心工具的全部内容,掌握互斥、同步、死锁这三大要点,就能应对绝大多数多线程开发场景。下一篇我们将进入网络编程模块,讲解 TCP/IP 基础与 Socket 编程入门,开启网络并发编程的学习。


制作不易,如果对你有用,希望能点赞收藏支持一下。

相关推荐
Vect__1 小时前
Go 数据结构 slice 深度剖析
开发语言·数据结构·golang
想你依然心痛1 小时前
AtomCode在Python数据科学项目中的使用体验:从数据分析到可视化
开发语言·python·数据分析
满天星83035771 小时前
【Qt】控件(二) (geometry及与frameGeometry的区别)
开发语言·qt
河铃旅鹿1 小时前
在Ubuntu系统上为Android交叉编译OpenSSL
android·linux·ubuntu
Esaka_Forever2 小时前
Python 与 JS (V8) 垃圾回收核心区别 + 底层根源分析
开发语言·javascript·jvm
长孙豪翔2 小时前
引发事件的问题
java·linux·数据库
小张成长计划..2 小时前
【Linux】7:第一个系统程序-进度条
linux·运维·服务器
pp起床2 小时前
黑马点评 - 短信验证码登录实现
java·开发语言·tomcat
芒鸽2 小时前
在仓颉语言里造一个没有反射的服务端框架
开发语言·华为·harmonyos