題目與適用情境
實作一個固定容量佇列:只有一個執行緒寫入,只有一個執行緒讀取;push 滿時失敗,pop 空時失敗。面試官希望看到你先利用 SPSC 限制,再說明索引、可見性、記憶體回收與測試邊界,而不是直接搬用多生產者佇列。
面試官考察什麼
核心是並發不變量與實作取捨。你要保證生產者只寫自己的 tail,消費者只寫自己的 head;跨執行緒只讀取對方索引,並用原子操作建立「先寫資料、後發布索引」的 happens-before。cppreference 明確說明 release store 與 acquire load 的同步關係;Java VarHandle 也區分 acquire、release 與 volatile 存取模式。
回答前要釐清的問題
先確認元素是否可複製或需要移動、佇列是否允許覆蓋舊資料、容量是否執行時指定、是否要求阻塞介面、是否只有一個生產者與一個消費者,以及元素解構或例外如何處理。若限制放寬為 MPSC 或 MPMC,當前演算法不能直接重用。
30 秒回答架構
可以這樣說:「我維護單調遞增的 head 與 tail,陣列位置用索引取模。生產者先讀取本地 tail 和消費者已發布的 head,確認未滿後寫入槽位,再用 release 發布新 tail;消費者 acquire 讀取 tail,確認非空後移動元素,再用 release 發布新 head。容量不要求是 2 的冪時使用取模,測試重點放在環繞、滿空與並發可見性。」
分步深入分析
第一步:寫出不變量
使用 tail - head 表示已占用數量,前提是索引使用足夠寬的無號整數並允許自然回繞。生產者不能讓差值超過容量;消費者不能讀取 head == tail 以外的槽位。每個槽位在被消費前只由生產者寫入一次。
第二步:區分本地索引與共享索引
生產者頻繁修改 tail,消費者頻繁修改 head;各自的本地副本可用普通變數保存。跨執行緒讀取對方索引時使用 acquire,發布自己的新索引時使用 release,避免兩個執行緒對同一索引產生寫競爭。
第三步:實作 push 的發布順序
生產者先讀取消費者的 head,判斷 tail - head < capacity。接著把元素寫入 buffer[tail % capacity],最後以 release store 寫入新 tail。消費者只有在 acquire 讀到新 tail 後才能讀取該槽位。
第四步:實作 pop 的回收順序
消費者先讀取生產者發布的 tail,判斷 head != tail。讀取並移動槽位元素後,再以 release store 發布新 head。生產者 acquire 讀到新 head 後,才能重用剛釋放的槽位。
第五步:處理容量與回繞
容量為 2 的冪可以用位元遮罩取代取模,但實作必須明確檢查溢位與型別寬度。普通容量直接使用 % capacity 較易驗證。若長時間執行,選擇足夠寬的無號索引並用差值判斷,不要把索引截斷成小整數。
第六步:定義失敗、生命週期與測試
滿時 push 回傳 false,空時 pop 回傳 false,不自旋等待。元素寫入失敗時不能發布 tail;元素移動或解構例外需要明確型別限制或恢復策略。測試單元素容量、容量加一、反覆環繞、生產消費速度不同、滿空邊界與執行緒結束前的剩餘元素。
高品質示範回答
push(x):
t = tail.load(relaxed)
h = head.load(acquire)
if t - h == capacity: return false
buffer[t % capacity] = x
tail.store(t + 1, release)
return true
pop():
h = head.load(relaxed)
t = tail.load(acquire)
if h == t: return empty
x = move(buffer[h % capacity])
head.store(h + 1, release)
return xhead 與 tail 都是原子計數器,生產者只寫 tail,消費者只寫 head。buffer 的普通寫入發生在 release 發布 tail 之前,消費者 acquire 讀到該值後才能讀取元素;反向的 release head 讓生產者安全重用槽位。容量不是二的冪時使用取模,容量為二的冪才可在額外溢位限制下使用遮罩。這個版本只適用單一生產者、單一消費者,不聲稱支援多寫者或多讀者。
常見錯誤與改進
- 兩個執行緒都寫同一索引: 明確 SPSC 所有權,擴展到 MPSC/MPMC 時改用專門演算法。
- 先發布索引再寫元素: 先寫槽位,最後 release 發布索引。
- 所有操作都用 relaxed: relaxed 只保證原子性,不能發布普通元素寫入;跨執行緒索引使用 acquire/release。
- 把容量當成二的冪: 普通容量用取模,不要使用未驗證的位元遮罩。
追問及應對
為什麼本地索引可以用 relaxed?
生產者只修改自己的 tail,消費者只修改自己的 head,本地讀取不需要與另一執行緒同步。讀取對方索引時仍需 acquire,因為它同時負責可見性。
如果元素是參照,什麼時候可以重用槽位?
消費者完成移動或解構後才 release 發布新的 head。生產者 acquire 讀到該值,才可以覆蓋對應槽位;不能只看到消費者開始讀取就重用。
如何擴展為多生產者?
不能讓多個生產者直接寫同一個 tail。需要 CAS 預留序號、每槽位序號或帶鎖方案,並重新證明預留、發布與回收順序;目前 SPSC 程式碼不應偽裝成通用佇列。
如何判斷效能是否真的更好?
在相同元素大小、執行緒親和性、批量大小與負載下比較吞吐、p99 延遲、上下文切換與快取未命中。若生產者經常追上消費者,容量、批量提交或背壓策略比更弱的記憶體序更值得先驗證。