上一课我们制造了一个"翻车现场":100 个线程同时 i++,结果随机丢数据。病确诊了,这一课开药——怎么让这些线程排好队,一个一个安全地进临界区?答案是一整套"排队工具":互斥锁、信号量、条件变量。它们各自解决一类问题,我们一个个来。

从"洗手间"说起

想象一间只有一个坑位的洗手间,外面排着一群人。规则很简单:

  1. 进去的人锁门
  2. 别人看到门锁着,就排队等
  3. 里面的人用完,开锁出来;
  4. 下一个进去,锁门……

这套"锁门—排队—开锁"的规则,就是并发同步的核心思想。计算机里的锁,和洗手间的门锁,是一回事。


13.1 互斥锁(mutex)——"一次一人进"

上一课的病灶是"同时进临界区"。最直接的药,就是互斥锁(mutex,mutual exclusion)

一句话理解:锁是一把"门禁",同一时刻只有一个线程能持有它进临界区,其余排队等。

用起来就三步:

pthread_mutex_lock(&m);    // 加锁:拿到才进,拿不到就排队等
counter++;                 // 临界区:现在安全了,同一时刻只有我在里面
pthread_mutex_unlock(&m);  // 解锁:出来,让下一个进去

把上一课的 counter++ 用锁包起来,竞态就消失了——因为"读-改-写"三步期间,别的线程被挡在锁外,插不了队。

一句话记死:mutex 保证"同一时刻只有一个线程在临界区里"。它是最基本、最常用的同步原语。

13.2 信号量(semaphore)——"计数器门禁"

mutex 只能表示"一个人进"或"没人进"——本质是个二值的开关。但很多场景不是"0 或 1",而是"有 N 个资源"。比如停车场有 5 个车位。这时候,就该信号量上场了。

一句话理解:信号量维护一个计数器,wait(P)尝试减一(不够就等),signal(V)加一并唤醒等待者。可用来表示"有 N 个资源"或"发信号"。

信号量就是一个整数计数器,配合两个操作:

  • P(sem_wait,也叫 wait/down):尝试把计数器减 1。如果减完是负数,说明资源不够,就阻塞等待
  • V(sem_post,也叫 signal/up):把计数器加 1,并唤醒一个等待者。

停车场类比:

  • 5 个车位 → 信号量初始值 = 5
  • 进一辆车 → P 一次(计数 5→4);
  • 出一辆车 → V 一次(计数 4→5);
  • 车满了 → 计数变成 0,再来车 P 一下发现不够,就排队
关键区别:mutex 是"一次一人"的二值门禁;semaphore 是"最多 N 个"的计数器门禁。 信号量初始为 1 时,就退化成了一个 mutex。

13.3 条件变量(cond)——"等某个条件成立"

锁解决"互斥",信号量解决"计数"。但还有一种需求它们都办不好:"等某个条件成立"

比如生产者要往一个满的缓冲区放数据,它不能一直占着锁空转问"有空位了吗有空位了吗"(这叫忙等 busy wait,白白烧 CPU)。它需要的是:先睡觉,等有人通知"空位来了"再醒。

这个"睡觉+被叫醒"的机制,就是条件变量

一句话理解:条件变量让线程"睡到某个条件满足",通常和互斥锁配合,避免忙等。

典型用法:

pthread_mutex_lock(&m);
while (缓冲区是满的) {
    pthread_cond_wait(&cond, &m);  // 释放锁 + 睡觉,等通知
}
// 这里缓冲区有空位了,继续放数据
pthread_mutex_unlock(&m);

注意 pthread_cond_wait 的巧妙:它一边睡觉、一边自动把锁释放掉(否则别人拿不到锁,永远没法来通知你);等被唤醒时,它自动重新拿回锁再继续。

一句话:mutex 管"互斥",semaphore 管"计数",cond 管"等待条件"。三者常配合使用。

13.4 生产者-消费者(经典问题)

把上面的工具串起来,就是并发编程最经典的一道题:生产者-消费者

一句话理解:一个/多个生产者往缓冲放数据,一个/多个消费者取数据;用信号量控制"有空间才能放""有数据才能取"。

设想一个容量 5 的缓冲区,生产者往里放,消费者从里取。要保证:

  1. 缓冲区满了,生产者不能放(要等有空位);
  2. 缓冲区空了,消费者不能取(要等有数据);
  3. 同一时刻,只能一个人动缓冲区(防竞态)。

于是需要三样东西:

  • empty 信号量(初始 = 缓冲区大小 5):表示"还有几个空位";
  • full 信号量(初始 = 0):表示"有几个数据可拿";
  • 一个 mutex:保护缓冲区本身,防止并发读写。

生产者要放数据前,先 P(empty)(有空位才放);放完 V(full)(数据 +1)。消费者对称:先 P(full)(有数据才取),取完 V(empty)(空位 +1)。

这个"用两个信号量互相制约"的设计,是同步问题的经典范式,值得反复琢磨。

13.5 自旋锁 vs 睡眠锁 vs 读写锁

锁也有"性格"之分,等不到锁时的行为不同:

等不到时适用场景
自旋锁 spinlock原地打转(占着 CPU 一直试)临界区极短
睡眠锁 mutex让出 CPU 去睡觉临界区较长
读写锁 rwlock读可并发,写独占读多写少
  • 自旋锁:等锁时不睡觉,而是"原地转圈"反复试。好处是避免了"睡觉—唤醒"的开销;坏处是白白烧 CPU。所以只适合临界区极短的场景(比如就改一个变量,几纳秒的事)。
  • 睡眠锁(mutex):等锁时让出 CPU 去睡觉,等被唤醒。适合临界区较长、可能要等一会的场景。
  • 读写锁(rwlock):更精细——读操作可以并发(大家同时读不冲突),但写操作独占(写的时候谁都不能进)。适合"读多写少"的场景,比如配置、缓存。
一句话选择标准:临界区极短用自旋,较长用睡眠,读多写少用读写锁。

动手实验:C 语言实现生产者-消费者

// lesson13.c —— 生产者-消费者(信号量 + 互斥锁)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define BUFSIZE 5

int buf[BUFSIZE];
int in = 0, out = 0;
sem_t empty, full;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void* producer(void* arg) {
    for (int i = 1; i <= 10; i++) {
        sem_wait(&empty);            // 有空格才放
        pthread_mutex_lock(&m);
        buf[in] = i;
        in = (in + 1) % BUFSIZE;
        printf("生产了 %d\n", i);
        pthread_mutex_unlock(&m);
        sem_post(&full);             // 数据 +1
    }
    return NULL;
}

void* consumer(void* arg) {
    for (int i = 1; i <= 10; i++) {
        sem_wait(&full);             // 有数据才取
        pthread_mutex_lock(&m);
        int item = buf[out];
        out = (out + 1) % BUFSIZE;
        printf("        消费了 %d\n", item);
        pthread_mutex_unlock(&m);
        sem_post(&empty);            // 空位 +1
    }
    return NULL;
}

int main(void) {
    sem_init(&empty, 0, BUFSIZE);   // 初始有 BUFSIZE 个空位
    sem_init(&full, 0, 0);          // 初始 0 个数据
    pthread_t p, c;
    pthread_create(&p, NULL, producer, NULL);
    pthread_create(&c, NULL, consumer, NULL);
    pthread_join(p, NULL);
    pthread_join(c, NULL);
    return 0;
}

编译运行:

gcc lesson13.c -o lesson13 -lpthread && ./lesson13

观察输出:生产和消费交替进行,缓冲区大小始终受控——永远不会出现"满了还放"或"空了还取"。

注意 (in + 1) % BUFSIZE 这个取模操作:它把缓冲区变成一个环形队列,放满了就绕回开头,是缓冲区的经典实现技巧。

深入点:两个进阶问题

① 锁的粒度

锁不是越多越好,也不是越少越好:

  • 太粗(一把大锁锁住所有东西)→ 大家都得排队等这一把锁 → 并发度低
  • 太细(每个变量一把锁)→ 并发度高,但逻辑复杂、容易漏锁或锁错 → 易出错。
关键是在"简单"和"并发"之间找平衡。这是工程经验,不是死公式。

② 优先级反转

一个经典的坑:低优先级线程先拿到锁,高优先级线程来抢锁时只能等着——结果高优先级的反被低优先级的拖累。更糟的是,如果又有个中等优先级的线程插进来,低优先级线程抢不到 CPU、迟迟不释放锁,高优先级线程就一直卡住。

这在实时系统里是致命的(还记得第 11 课的"必须按时完成"吗?)。解法叫优先级继承:让持锁的低优先级线程,临时"继承"等待者的高优先级,尽快跑完释放锁。

小结与思考题

这一课,我们给并发开出了三味药:

  • mutex:一次一人进临界区,最基础的同步原语;
  • semaphore:计数器门禁,表示"N 个资源"(如停车位);
  • 条件变量 cond:等某个条件成立,配合锁避免忙等;
  • 三者配合,实现了经典的生产者-消费者问题;
  • 自旋/睡眠/读写锁:按临界区长短、读写比例选型。

留三个问题:

  1. mutex 和 semaphore 的本质区别是什么?
  2. 生产者-消费者里,emptyfull 两个信号量各起什么作用?
  3. 什么场景该用自旋锁,什么场景该用睡眠锁?
有了锁,竞态解决了。但别高兴太早——锁本身也会闯祸。当你手里的锁 A 在等锁 B,而对方手里的锁 B 又在等你的锁 A,会发生什么?答案是:大家都卡死,谁也动不了。这就是下一课的主角——死锁,并发世界里最"优雅"的僵局。

标签: 计算机基础, 操作系统, Linux

添加新评论