具代表性的面試主題

程式設計面試:實作一個有界 SPSC 無鎖環形佇列

程式題困難
OfferCC 編輯團隊發佈 更新

題幹

請實作一個固定容量的單一生產者單一消費者(SPSC)環形佇列,支援無鎖 push 與 pop。說明如何判斷滿與空、為什麼需要 acquire/release,以及如何處理容量不是 2 的冪。

題目與適用情境

實作一個固定容量佇列:只有一個執行緒寫入,只有一個執行緒讀取;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;元素移動或解構例外需要明確型別限制或恢復策略。測試單元素容量、容量加一、反覆環繞、生產消費速度不同、滿空邊界與執行緒結束前的剩餘元素。

高品質示範回答

text
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 x

head 與 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 延遲、上下文切換與快取未命中。若生產者經常追上消費者,容量、批量提交或背壓策略比更弱的記憶體序更值得先驗證。

公開來源

同類題目

資源

產品比較

每份比較均依官方資料整理,並標示複核日期。

查看完整比較

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具