记录一下这个经典的多线程同步模型

一、这是个什么问题?

说白了就是两个角色在共享一个固定大小的缓冲区

  • 生产者:往缓冲区里放数据
  • 消费者:从缓冲区里拿数据

听起来简单,但实际运行起来有两个核心矛盾

  1. 缓冲区满了,生产者还往里放 → 数据丢失
  2. 缓冲区空了,消费者还来拿 → 拿不到数据

二、一个具体场景

想象一个仓库(缓冲区),容量是10件货物:

┌─────────────────────────────────────────┐
│  [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] │  ← 仓库已满
└─────────────────────────────────────────┘
         ↑                          ↑
      生产者想放第11件             消费者来取
         ✗                          ✓
      阻塞等待                     正常取走

正常运转的情况:

  • 仓库没满 → 生产者随便放
  • 仓库没空 → 消费者随便取

出问题的情况:

  • 仓库满了 → 生产者必须等待消费者取走一些
  • 仓库空了 → 消费者必须等待生产者放进新的

三、需要解决什么问题?

多线程环境下,主要有三个坑:

坑1:竞态条件

两个生产者同时往同一个空位放数据,会相互覆盖。

生产者A:读取count=5,准备往位置5放
生产者B:读取count=5,也准备往位置5放  ← 同时发生
生产者A:放入数据A
生产者B:放入数据B  ← 把A覆盖了

坑2:忙等待(浪费CPU)

// 消费者代码(错误示范)
while (count == 0) {
    // 空转,疯狂检查,CPU占用100%
    continue;
}
// 取数据

坑3:死锁

拿锁的顺序不对,两个线程互相等对方释放资源,永远卡住。


四、操作系统怎么解决?

4.1 用信号量(Semaphore)

信号量就是一个计数器,两个关键操作:

  • P(S):资源减1,如果不够就阻塞等待
  • V(S):资源加1,唤醒等待的线程

具体实现:

// 初始化
semaphore mutex = 1;   // 互斥锁,保护缓冲区
semaphore empty = n;   // 空位数量,初始=n
semaphore full = 0;    // 已占用的数量,初始=0

// 生产者
void producer() {
    while(1) {
        item = produce_item();      // 生产数据
        P(&empty);                  // 等一个空位
        P(&mutex);                  // 加锁,进入临界区
        insert_item(item);          // 放数据
        V(&mutex);                  // 解锁
        V(&full);                   // 通知消费者:多了一个货
    }
}

// 消费者
void consumer() {
    while(1) {
        P(&full);                   // 等有货
        P(&mutex);                  // 加锁
        item = remove_item();       // 取数据
        V(&mutex);                  // 解锁
        V(&empty);                  // 通知生产者:空出一个位置
        consume_item(item);         // 消费数据
    }
}

4.2 图示流程

初始状态:empty=3, full=0, mutex=1

生产者P1执行:
        P(empty) → empty=2
        P(mutex) → mutex=0
        放入数据
        V(mutex) → mutex=1
        V(full)  → full=1

此时缓冲区:[A] [_] [_]

消费者C1执行:
        P(full)  → full=0
        P(mutex) → mutex=0
        取出A
        V(mutex) → mutex=1
        V(empty) → empty=3

缓冲区:[_] [_] [_]

4.3 用管程(Monitor)

信号量写法容易出错,管程是更高级的封装,把共享资源和操作方法包在一起。

伪代码:

monitor ProducerConsumer {
    condition full, empty;
    int count = 0;
    Item buffer[N];
    
    void put(Item item) {
        if (count == N) 
            wait(full);           // 满了就等
        buffer[in] = item;
        count++;
        signal(empty);            // 通知消费者
    }
    
    Item get() {
        if (count == 0) 
            wait(empty);          // 空了就等
        Item item = buffer[out];
        count--;
        signal(full);             // 通知生产者
        return item;
    }
}

Java 里用 synchronized + wait/notify 就是管程的实现。


五、几种变体

单生产者-单消费者(最简单)

  • 只需要两个信号量:empty、full
  • 不需要互斥锁,因为只有一个生产者和一个消费者,不会冲突

多生产者-多消费者(常见)

  • 必须加互斥锁 mutex
  • 多个生产者可能同时写同一个位置

有界缓冲区 vs 无界缓冲区

类型 区别 风险
有界 缓冲区大小固定 满了必须阻塞生产者
无界 理论上无限大 消费者跟不上时,内存会爆

实际生产中,一定有界,防止内存被撑爆。


六、和业务开发的区别

对比项 操作系统层面 业务开发(消息队列)
缓冲区 共享内存数组 Redis、Kafka、RabbitMQ
同步机制 信号量、管程、互斥锁 ACK确认、offset、持久化
阻塞方式 线程挂起/唤醒 长轮询、回调
容错 程序崩了就没了 消息持久化、重试机制

一句话概括:

OS的模型解决的是多线程访问共享内存的问题
消息队列解决的是分布式系统里服务间通信的问题

本质逻辑一样,但工程实现差很多。


七、手绘一个完整流程图

        ┌─────────────┐
        │  生产者线程1  │
        └──────┬──────┘
        ┌──────┴──────┐
        │  生产者线程2  │
        └──────┬──────┘
               │
               ▼ P(empty) 等待空位
        ┌─────────────┐
        │  P(mutex)   │  ← 互斥锁,一次只能一个进
        └──────┬──────┘
               │
               ▼
        ╔═══════════════════╗
        ║   缓冲区(有限)    ║
        ║  [ ][A][B][ ][ ]  ║
        ╚═══════════════════╝
               │
               ▼ V(mutex) + V(full)
        ┌─────────────┐
        │  唤醒消费者   │
        └─────────────┘
               │
               ▼
        ┌─────────────┐  ┌─────────────┐
        │  消费者线程1  │  │  消费者线程2  │
        └─────────────┘  └─────────────┘

八、思考题(面试常见)

  1. 为什么需要三个信号量?

    • mutex:保护缓冲区的互斥访问
    • empty:生产者等空位
    • full:消费者等数据
  2. 交换P(mutex)和P(empty)的顺序会怎样?
    先拿锁再等空位,如果缓冲区满了,生产者会拿着锁等消费者,而消费者拿不到锁 → 死锁

  3. 单生产单消费需要mutex吗?
    不需要,但前提是生产者和消费者各自只有一个线程。多个就要。


九、总结

一句话总结
这个模型的本质是:

用阻塞替代忙等,用计数器控制流量,用互斥锁防止冲突

核心代码就是那两段 P/V 操作,弄明白这个,再去理解 Kafka、RabbitMQ 的设计思路会轻松很多。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐