並行處理模式(Java)
範圍 — L4 以上會出現的少數幾題 Java 並行題:依序列印、生產者/消費者、讀寫協調與死結避免,以及它們會用到的同步原語。 另見:python_gotchas.md — GIL 與 Python 的並行故事;design.md — 執行緒安全的結構設計;java_trick.md — Java 容器慣用手法。
LeetCode 題目清單
總覽
Google 在 L4 以上偶爾會考並行/多執行緒題。這類題目在檢驗你對同步機制、執行緒安全,以及並行資料結構設計的理解。
什麼時候用
- 題目明講了執行緒、平行執行,或生產者/消費者
- 要求設計執行緒安全資料結構的設計題
模式 1:依序列印/順序控制
用 CountDownLatch — LC 1114
// LC 1114 — Print in Order
class Foo {
private CountDownLatch latch1 = new CountDownLatch(1);
private CountDownLatch latch2 = new CountDownLatch(1);
public void first(Runnable printFirst) throws InterruptedException {
printFirst.run();
latch1.countDown();
}
public void second(Runnable printSecond) throws InterruptedException {
latch1.await();
printSecond.run();
latch2.countDown();
}
public void third(Runnable printThird) throws InterruptedException {
latch2.await();
printThird.run();
}
}
用 Semaphore — LC 1115
// LC 1115 — Print FooBar Alternately
class FooBar {
private int n;
private Semaphore fooSem = new Semaphore(1);
private Semaphore barSem = new Semaphore(0);
public FooBar(int n) { this.n = n; }
public void foo(Runnable printFoo) throws InterruptedException {
for (int i = 0; i < n; i++) {
fooSem.acquire();
printFoo.run();
barSem.release();
}
}
public void bar(Runnable printBar) throws InterruptedException {
for (int i = 0; i < n; i++) {
barSem.acquire();
printBar.run();
fooSem.release();
}
}
}
模式 2:生產者-消費者/有界緩衝區
// LC 1188 — Design Bounded Blocking Queue
class BoundedBlockingQueue {
private Queue<Integer> queue = new LinkedList<>();
private int capacity;
private ReentrantLock lock = new ReentrantLock();
private Condition notFull = lock.newCondition();
private Condition notEmpty = lock.newCondition();
public BoundedBlockingQueue(int capacity) { this.capacity = capacity; }
public void enqueue(int element) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) notFull.await();
queue.offer(element);
notEmpty.signal();
} finally { lock.unlock(); }
}
public int dequeue() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) notEmpty.await();
int val = queue.poll();
notFull.signal();
return val;
} finally { lock.unlock(); }
}
public int size() { lock.lock(); try { return queue.size(); } finally { lock.unlock(); } }
}
模式 3:讀寫鎖/H2O 問題
// LC 1117 — Building H2O
class H2O {
private Semaphore hSem = new Semaphore(2);
private Semaphore oSem = new Semaphore(0);
private CyclicBarrier barrier = new CyclicBarrier(3, () -> {
hSem.release(2);
});
public void hydrogen(Runnable releaseHydrogen) throws InterruptedException {
hSem.acquire();
releaseHydrogen.run();
try { barrier.await(); } catch (BrokenBarrierException e) {}
}
public void oxygen(Runnable releaseOxygen) throws InterruptedException {
// NOTE: sketch only — oSem is initialized to 0 permits and never released,
// so this acquire() blocks forever. A correct H2O gates O with its own
// Semaphore(1) (limit one O in flight) and lets the CyclicBarrier(3)
// rendezvous 2 H + 1 O; the barrier action then release(2)s hSem.
oSem.acquire();
releaseOxygen.run();
try { barrier.await(); } catch (BrokenBarrierException e) {}
}
}
模式 4:死結 — 打破四個條件之一
死結需要四個條件同時成立,所以只要阻止其中任何一個就夠了:
| 條件 | 意義 | 怎麼消除 |
|---|---|---|
| 互斥(Mutual exclusion) | 一把鎖同一時間只被一個執行緒持有 | 幾乎無法消除 —— 這正是鎖的用途 |
| 持有並等待(Hold and wait) | 執行緒握著一把鎖,同時又要求另一把 | 一開始就取得全部,或在要求前先釋放 |
| 不可搶占(No preemption) | 鎖無法被奪走 | tryLock(timeout) —— 取得失敗時先釋放已持有的鎖,退讓後重試,而不是一直等 |
| 循環等待(Circular wait) | 「誰在等誰」形成一個環 | 全域的加鎖順序,或限制競爭者的數量 |
面試時要攻擊的是循環等待:它最便宜就能強制執行,也最容易論證正確性。
標準解法 — 全域加鎖順序
給每把鎖一個固定的等級(id、位址,任何全序且穩定的東西),並讓每個執行緒都依等級遞增的順序取得。 要形成環,必須有人握著高等級的鎖去等低等級的鎖,而這條規則禁止這麼做 —— 所以環不可能形成。
// java
// Transfer between two accounts without deadlocking against the reverse transfer
// IDEA: order the two locks by a stable id, so t1 (A->B) and t2 (B->A) both take
// the lower id first and one of them simply waits
void transfer(Account from, Account to, long amount) {
Account first = from.id < to.id ? from : to; // total order over the locks
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
CtCI 15.4 問的是預防版本:讓執行緒事先宣告它會需要哪些鎖,建立「先取得於」(acquires-before)圖, 並拒絕任何會讓圖閉合成環的宣告。同樣的想法,只是在註冊時檢查,而不是靠慣例強制。
哲學家就餐 — LC 1226
五位哲學家、五支叉子,每人需要身旁的兩支。每個人都先拿左手邊的叉子,正好就是循環等待。 兩個一行就能搞定的解法:
// java
// LC 1226 — The Dining Philosophers
// IDEA: a Semaphore(4) means at most 4 philosophers ever hold a fork, so at least one
// always finds both free — the cycle can never close
// time = O(1) per meal, space = O(n)
class DiningPhilosophers {
private final ReentrantLock[] forks = new ReentrantLock[5];
private final Semaphore room = new Semaphore(4); // n - 1 diners
public DiningPhilosophers() {
for (int i = 0; i < 5; i++) forks[i] = new ReentrantLock();
}
public void wantsToEat(int philosopher,
Runnable pickLeftFork, Runnable pickRightFork,
Runnable eat,
Runnable putLeftFork, Runnable putRightFork)
throws InterruptedException {
int left = philosopher, right = (philosopher + 1) % 5;
room.acquire();
forks[left].lock();
forks[right].lock();
pickLeftFork.run(); pickRightFork.run(); eat.run();
putLeftFork.run(); putRightFork.run();
forks[right].unlock();
forks[left].unlock();
room.release();
}
}
另一種效果相同的做法:先拿 forks[min(left, right)] —— 也就是上面的全域順序解法。
第三個選項是讓奇數號的哲學家先拿右邊,打破造成環的對稱性。
模式 5:接力棒傳遞 — 誰做完就喚醒正確的下一位
當超過兩個角色交錯執行時,不要讓每個執行緒去輪詢共享計數器。給每個角色各自的 semaphore, 讓剛印完的執行緒只釋放擁有下一個值的那一個 —— 沒有任何執行緒會去檢查別人的狀態。
// java
// LC 1195 — Fizz Buzz Multithreaded
// IDEA: one semaphore per role, all starting at 0 except number(). After printing i,
// hand the permit to whoever owns i+1, so the order is enforced by construction.
// time = O(n) total, space = O(1)
class FizzBuzz {
private final int n;
private final Semaphore num = new Semaphore(1); // 1 owns the first turn
private final Semaphore fizz = new Semaphore(0);
private final Semaphore buzz = new Semaphore(0);
private final Semaphore fizzbuzz = new Semaphore(0);
public FizzBuzz(int n) { this.n = n; }
public void fizz(Runnable printFizz) throws InterruptedException {
for (int i = 3; i <= n; i += 3) {
if (i % 5 == 0) continue; // 15s belong to fizzbuzz()
fizz.acquire();
printFizz.run();
handOff(i + 1);
}
}
public void buzz(Runnable printBuzz) throws InterruptedException {
for (int i = 5; i <= n; i += 5) {
if (i % 3 == 0) continue;
buzz.acquire();
printBuzz.run();
handOff(i + 1);
}
}
public void fizzbuzz(Runnable printFizzBuzz) throws InterruptedException {
for (int i = 15; i <= n; i += 15) {
fizzbuzz.acquire();
printFizzBuzz.run();
handOff(i + 1);
}
}
public void number(Runnable printNumber) throws InterruptedException {
for (int i = 1; i <= n; i++) {
if (i % 3 == 0 || i % 5 == 0) continue;
num.acquire();
printNumber.run();
handOff(i + 1);
}
}
/** Wake the single thread that owns value i. */
private void handOff(int i) {
if (i > n) { // past the end: wake everyone
num.release(); fizz.release(); // so nobody is left blocked
buzz.release(); fizzbuzz.release();
} else if (i % 15 == 0) fizzbuzz.release();
else if (i % 3 == 0) fizz.release();
else if (i % 5 == 0) buzz.release();
else num.release();
}
}
每個迴圈只走訪自己角色擁有的值,而任一時刻恰好只有一張許可在流通 —— 所以列印順序在結構上就是正確的, 沒有需要保護的共享可變計數器。LC 1114 和 LC 1116 是同一根接力棒,只是角色比較少。
重要並行原語
| 原語 | 用途 | 主要方法 |
|---|---|---|
synchronized |
互斥 | wait()、notify()、notifyAll() |
ReentrantLock |
可搭配條件變數的顯式鎖 | lock()、unlock()、newCondition() |
Semaphore |
計數式許可 | acquire()、release() |
CountDownLatch |
一次性閘門(數到 0 就開) | await()、countDown() |
CyclicBarrier |
可重複使用的會合點 | await() |
volatile |
可見性保證 | — |
AtomicInteger |
免鎖計數器 | incrementAndGet()、compareAndSet() |
LC 範例
| # | 題目 | 關鍵概念 |
|---|---|---|
| 1114 | Print in Order | CountDownLatch/Semaphore |
| 1115 | Print FooBar Alternately | 一對 Semaphore |
| 1116 | Print Zero Even Odd | Semaphore 協調 |
| 1117 | Building H2O | CyclicBarrier + Semaphore |
| 1188 | Bounded Blocking Queue | ReentrantLock + Condition |
| 1195 | Fizz Buzz Multithreaded | Semaphore/CyclicBarrier |
| 1226 | The Dining Philosophers | 避免死鎖 |