cswork / 系统设计

第 21 章 / 共 57 章 · 约 6 分钟

并发题 1:有界阻塞队列

本页目录
  1. 题目
  2. 先问清楚
  3. 方案一:一把锁 + 两个条件变量
  4. 方案二:两个信号量
  5. 方案三:两把锁
  6. 常见扩展
  7. 怎么测
  8. 去做题
  9. 章节测验

题目

实现一个容量为 N 的队列:

  • put(x):队列满时阻塞,直到有空位。
  • take():队列空时阻塞,直到有元素。
  • size():返回当前元素数。

这就是 LeetCode 1188,也是生产者-消费者模型的核心组件。线程池的任务队列、异步写日志、流水线式数据处理都建立在它之上。

先问清楚

  • 容量固定吗?元素允许为 null 吗?
  • 需要带超时的 offer(x, timeout) / poll(timeout) 吗?
  • 需要关闭语义吗(关闭后 take 不再阻塞)?
  • 对公平性(先等的线程先被服务)有要求吗?

先写最基础的阻塞版本,再按追问扩展。

方案一:一把锁 + 两个条件变量

存储用环形数组:head 指向下一个要取的位置,tail 指向下一个要放的位置,count 记录元素数。比链表少了每个元素的对象分配,内存也是固定的。

capacity = 5, count = 3
index:  0    1    2    3    4
       [ ]  [a]  [b]  [c]  [ ]
             ^head          ^tail
class BoundedBlockingQueue<T> {
    private final Object[] items;
    private int head, tail, count;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    BoundedBlockingQueue(int capacity) { items = new Object[capacity]; }

    public void put(T x) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == items.length) notFull.await();
            items[tail] = x;
            tail = (tail + 1) % items.length;
            count++;
            notEmpty.signal();
        } finally { lock.unlock(); }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == 0) notEmpty.await();
            T x = (T) items[head];
            items[head] = null;               // 让被取出的对象可以被 GC
            head = (head + 1) % items.length;
            count--;
            notFull.signal();
            return x;
        } finally { lock.unlock(); }
    }

    public int size() {
        lock.lock();
        try { return count; } finally { lock.unlock(); }
    }
}

逐条解释设计决策

为什么是两个条件变量? 生产者等“不满”,消费者等“不空”,这是两个不同的条件。如果共用一个,put 之后的 signal 可能唤醒另一个生产者——它醒来发现还是满的,又睡回去——而真正能干活的消费者没被叫醒,系统可能卡住。共用一个条件就只能用 signalAll,代价是每次惊醒一大群线程。

为什么 signal 而不是 signalAll? 每次 put 只多出一个元素,只够一个消费者用,唤醒一个就够了。

为什么用 while? 虚假唤醒,以及从被唤醒到重新拿锁之间,状态可能已经被别的线程改掉。

为什么用 try/finally? await 可能抛出 InterruptedException,无论如何锁都要释放。

中断:方法签名声明抛出 InterruptedException,把中断交给调用方处理,不要在内部吞掉。

为什么用 lockInterruptibly() 而不是 lock()? 既然 put / take 声明了可中断,排队抢锁的阶段也应该能响应中断。lock() 在等锁时会无视中断,线程可能卡在抢锁上取消不掉。JDK 的 ArrayBlockingQueue 也是这么写的。size() 不会长时间阻塞,用普通的 lock() 即可。

方案二:两个信号量

把“空位数”和“元素数”看成两种资源:

slots = Semaphore(N)   // 空位数
items = Semaphore(0)   // 元素数
mutex = Lock           // 保护数组本身

put:  slots.acquire(); 持 mutex 入队; items.release()
take: items.acquire(); 持 mutex 出队; slots.release()

结构非常对称,也很好讲。注意先 acquire 信号量,再拿 mutex。如果顺序反过来,持有 mutex 时阻塞在信号量上,另一方就拿不到 mutex 去释放信号量,直接死锁。

方案三:两把锁

方案一里生产者和消费者竞争同一把锁。如果改用链表,put 只碰尾部,take 只碰头部,就可以用 putLock 和 takeLock 两把锁,加上一个原子计数器 count,让入队和出队真正并行。Java 的 LinkedBlockingQueue 就是这个思路,而 ArrayBlockingQueue 用的是方案一的单锁。

代价是代码复杂得多:比如 put 之后要唤醒消费者,就得去拿 takeLock 才能 signal notEmpty。面试中能讲清思路和取舍就够了,通常不要求写完。

方案并行度复杂度面试中的定位
单锁 + 双条件生产、消费互斥低主答案
双信号量 + mutex同上低备选,展示对信号量的理解
双锁 + 原子计数生产、消费可并行高口述优化

常见扩展

带超时:用 awaitNanos(remaining),它返回剩余可等待的时间。循环里每次用返回值更新 remaining,降到 0 或以下就返回 false 或 null。不要每次都用原始超时值重新等,否则多次唤醒会让总等待时间远超预期。

关闭语义:加一个 closed 标志。close() 设置标志后对两个条件都 signalAll;关闭后 put 抛异常;take 在“已关闭且为空”时返回结束标记,让消费者退出循环。

怎么测

  • 多个生产者各放入 K 个不同的数,多个消费者一直取,最后检查取出的集合和放入的集合完全一致,没有丢失也没有重复。
  • 把容量设为 1,制造最激烈的竞争。
  • 给测试加超时:阻塞类 bug 的表现往往是测试永远跑不完。

去做题

有界阻塞队列是 Core Skills 里的一道判题题,可以用 Python、Java、Go 或 C++ 把本章的单锁双条件写法实现一遍。驱动会同时跑多个生产者和消费者,检查队列有没有超出容量、取出的值有没有丢失或重复、同一个生产者的元素是否按顺序出队。同一分组里还有读写锁、哲学家就餐等并发题。

章节测验

6 道选择题
  1. 为什么用两个条件变量,而不是一个条件变量加 signal?
  2. 信号量方案里,如果先拿 mutex 再 acquire slots,会发生什么?
  3. 这里为什么用环形数组存储?
  4. LinkedBlockingQueue 使用两把锁的主要好处是?
  5. 如何正确实现带超时的 poll?
  6. take 之后把 items[head] 置为 null 的作用是?

章节讨论

正在加载讨论…