具代表性的面試主題

程式面試:如何實作帶相依關係與循環回報的任務排程器?

程式題困難
Offer.cc 編輯團隊發佈 更新

題幹

請實作一個相依感知的 TaskScheduler:任務由 taskId、相依列表與可執行函式組成。只有所有相依成功後任務才能執行;相依失敗時下游如何結束由你定義。請說明如何回傳可執行任務、限制並行、回報循環相依、處理重複提交與取消。

題幹與適用場景

請實作一個記憶體內的 TaskScheduler。呼叫方提交任務 ID、相依 ID 列表與函式;所有相依成功後任務才可被領取執行。排程器需要提供 submitreadycompletefailcancel 等操作,並能回報無法執行的循環相依。請說明並行上限、重複提交、失敗傳播、關閉與重啟邊界。

這題把圖遍歷與可執行介面放在同一組限制中。候選人要先釐清任務狀態與失敗契約,再把入度維護、就緒佇列與狀態轉移寫進程式。Python 的 TopologicalSorter 將「沒有未完成前驅的節點」視為可處理節點,並把偵測到的循環保留為可診斷結果;這些語意可協助定義狀態機,但不能只呼叫現成排序函式,因為任務會動態完成、失敗與取消。

面試官考察點

  • 能否區分 pendingreadyrunningsucceededfailedblockedcancelled
  • 能否維護入度與反向鄰接表不變量,而不是每次掃描所有任務。
  • 能否在相依完成時只釋放受影響的下游節點,讓每條邊只被處理有限次。
  • 能否先定義循環、失敗與取消傳播規則,再選擇回傳值與錯誤型別。
  • 能否限制 worker 並行、保證同一任務只被領取一次,並處理重複提交的版本語意。
  • 能否給出時間與空間複雜度,以及覆蓋交錯時序的測試。

回答前需要釐清的問題

  • 任務提交是一次性靜態 DAG,還是允許動態加入?預設任務必須在開始排程前提交,執行期間只允許提交尚未被引用的新版本。
  • 相依失敗後,下游是 blocked、自動取消,還是允許人工重試?本題預設下游進入 blocked,重試必須明確建立新版本。
  • 取消一個任務是否級聯到所有後代?預設只取消該任務;後代觀察到必要相依取消後進入 blocked,不隱式刪除無關分支。
  • 函式失敗是否自動重試?預設不自動重試;重試策略由呼叫方提交新版本並保證副作用具冪等性。
  • ready() 回傳一個任務還是一批任務?預設回傳最多 maxConcurrency - running 個、順序穩定的任務。

30 秒回答框架

我會為每個任務保存狀態、未完成相依數與依賴它的反向鄰接表。提交完成後先做一次三色 DFS 或 Kahn 偵測,發現循環就回傳包含節點路徑的診斷。入度為零的任務進入就緒佇列;領取時原子地從 ready 改成 running,成功完成後遍歷反向鄰接表,讓下游未完成相依數減一,降到零就入隊。失敗或取消依照預先宣告的規則把後代標成 blocked。所有狀態轉移由同一把鎖或單執行緒事件迴圈保護,並用版本號拒絕舊提交。

分步驟深入解答

第一步:定義狀態與邊界

任務狀態只能沿有限方向前進:pendingready,再到 running,最後到 succeededfailed;取消可發生在 pendingready,執行中的取消只記錄合作式停止請求。blocked 表示任務沒有執行,且至少一個必要相依不可能成功。終態不能回到 ready,否則同一函式可能被執行兩次。

每個任務記錄 generation。同一 ID 的重複提交要明確拒絕,或建立新版本並使舊版本失效;本題選擇後者,但只有尚未執行的舊版本可被替換。已經 running 的版本不能靜默覆蓋,應回傳衝突或等待它進入終態。

第二步:建立入度與反向鄰接表

任務表保存 remainingDeps,反向表保存 dependents[dependencyId]。提交任務時,先確認相依存在,或依契約建立佔位節點,再登記每條邊一次。初始化階段把入度為零的任務放入就緒佇列,之後只在相依狀態變化時更新計數。

核心資料結構如下:

text
Task:
  id, generation, dependencies, dependents
  remainingDeps, state, fn, error

submit(task):
  validateUniqueDependencies(task)
  registerEdges(task)
  if task.remainingDeps == 0:
      task.state = READY
      readyQueue.push(task.id)

若允許缺少相依,不能把它視為已完成。應保存 waiting 狀態,直到相依提交;否則入度會被錯誤減到零。若不允許缺少相依,提交時回傳可定位的 UnknownDependency

第三步:在開始前回報循環

靜態圖可以使用 Kahn 演算法:複製入度,把零入度節點加入佇列,移除它們的出邊;若處理數量小於節點總數,剩餘節點至少屬於一個環。為讓答案可操作,應同時回傳一條具體路徑,例如 A → B → C → A,而不是只回傳「存在循環」。

若選擇 DFS,使用白、灰、黑三色標記:從灰節點再次遇到灰節點時,沿父指標截取循環。偵測必須在任務進入 running 前完成;執行期間動態加邊會讓已領取任務的語意含糊,最簡單契約是禁止修改已開始的圖。

第四步:領取任務並限制並行

ready() 計算可用槽位,按穩定提交序號從佇列取出任務,並在同一臨界區把狀態改為 running。回傳後即使 worker 尚未開始執行,其他呼叫也不能再次領取。complete(id, generation) 必須驗證版本與狀態,舊 worker 的遲到回呼只能回傳衝突,不得再次釋放下游。

並行上限可由固定 worker 數或 semaphore 表達。佇列長度不等於活動任務數;只有 running 計入上限。若一次請求批次大於剩餘槽位,應只回傳可用數量或明確回傳 CapacityExceeded,不能偷偷增加並行。

第五步:傳播成功、失敗與取消

成功完成後,遍歷直接下游,把仍為 pendingready 的節點之 remainingDeps 減一;降到零就入隊。失敗時,本題將直接下游及其後代標成 blocked,並保存第一個阻塞原因;也可以選擇允許替代相依,但必須寫入契約,不能在程式中隱式判斷。

取消只影響尚未執行的版本。若執行中的函式支援 AbortSignal,排程器可以發出取消請求,但只有函式確認退出後才把狀態設為 cancelled。後代看到必要相依為 failedcancelled 時進入 blocked,不能假裝相依成功。

第六步:處理重複提交與冪等回呼

(taskId, generation) 作為外部操作的冪等鍵。重複的 completefailcancel 請求回傳目前終態,不重複減少下游入度。新提交若替換舊的 pending 版本,應先從反向表撤銷舊邊,再登記新邊並重新計算可達狀態;直接覆蓋物件會留下舊邊,讓下游永遠等待。

若系統不需要更新語意,拒絕重複 ID 會更簡單。面試中應說明選擇:靜態建構器可以拒絕重複,長期執行的工作流則通常需要 generation、稽核與重試版本。

第七步:處理關閉、重試與恢復

close() 先拒絕新提交,再停止 ready() 領取,最後等待 running 任務回呼或達到明確逾時。佇列中的任務應依契約取消或保留,不能只清空記憶體結構而遺失原因。重試建立新 generation,重新檢查相依快照;直接把 failed 改回 ready 會繞過失敗原因與並行回呼。

純記憶體實作不能在程序崩潰後恢復。若要持久化,需要保存任務、版本、狀態、相依與租約;恢復 worker 透過條件更新搶佔任務,並讓函式具備冪等性。恢復只能提供至少一次執行,不能承諾恰好一次副作用。

第八步:複雜度與測試

初始化圖的時間複雜度是節點數加邊數,記作 O(V + E)。每次完成任務只遍歷它的出邊,因此整批傳播總成本仍為 O(V + E);就緒佇列若使用堆,領取為 O(log V)。空間複雜度為 O(V + E)

測試至少覆蓋空圖、多個獨立分支、長鏈、環、缺少相依、同一節點被兩個相依同時完成、失敗傳播、取消後代、重複回呼、重複提交、並行槽位為零、關閉期間領取及舊 generation 的遲到回呼。可用小型狀態模型對拍每次可執行集合,並記錄每個任務最多一次 pending → running 轉換。

高品質示範回答

我會先凍結任務圖,為每個任務保存狀態、generation、未完成相依數與反向鄰接表。建構階段用 Kahn 加父指標偵測循環並回傳一條環路;沒有相依的任務進入帶穩定序號的就緒佇列。ready() 在鎖內按剩餘並行槽位領取,立即把任務改成 running。成功回呼只接受匹配 generation 的一次性終態,沿反向邊遞減下游計數,降到零才入隊。失敗與取消不會冒充成功,而是把受影響後代設為 blocked。重複回呼具冪等性,重試建立新版本;關閉先拒絕新任務,再停止領取並等待執行中的回呼。整批是 O(V + E),並行與副作用邊界都由測試驗證。

常見錯誤

  • 只做一次拓撲排序,沒有說明動態完成與失敗後的狀態變化。
  • 每次尋找就緒任務都掃描全部節點,忽略反向鄰接表與入度不變量。
  • 發現環時只回傳布林值,無法指出哪組任務阻塞了發佈。
  • 任務完成回呼不帶版本號,舊 worker 會重複釋放下游。
  • 把相依失敗當成相依成功,讓下游在前置條件不滿足時執行。
  • 把取消執行中函式描述成強制中止,卻沒有合作式訊號或資源回收契約。
  • 透過增加 worker 數解決佇列積壓,結果同時突破並行上限與下游容量。
  • 重試直接重用舊狀態,遺漏冪等、副作用與租約恢復邊界。

追問及應對

如果任務圖很大,如何避免一次把所有節點放進記憶體?

把任務中繼資料與邊存入持久化儲存,按租戶或分區載入可執行視窗;記憶體只保留活動節點與游標。領取使用條件更新或短租約,完成時仍驗證 generation。要說明跨分區相依、分頁一致性與租約過期後的重複執行。

如何允許失敗分支繼續執行,但阻止相依失敗的節點?

把邊標註為必要或可選;任務只有所有必要相依成功且可選相依已終態時才進入 ready。可選相依失敗應寫入輸入摘要與指標,不能靜默丟棄,這會擴大狀態機與測試矩陣。

如何支援動態加入相依?

只允許任務仍為 pending 時追加邊,並在同一臨界區增加入度;已經 readyrunning 的任務拒絕修改。若業務必須支援執行中變更,應建立新 generation,等待舊版本終態後再按新圖執行。

取消共享相依時,怎樣避免誤傷其他分支?

取消只改變該相依節點終態,傳播器沿反向邊逐一判斷必要關係。沒有該相依的分支繼續執行;共享相依的所有必要下游進入 blocked。稽核紀錄要保留取消者、時間與傳播路徑。

多個 worker 程序如何保證同一任務只執行一次?

用持久化儲存的原子領取或租約欄位搶佔任務,並把 generation 放入條件。租約過期後允許再次領取,因此函式必須具冪等性或提供補償。記憶體鎖只能保護單一程序,不能作為跨程序唯一保證。

面試官要求可觀測指標,你會選什麼?

記錄循環任務數、阻塞任務數、就緒等待時間、執行時長、領取衝突、租約過期、重複回呼,以及每條邊的傳播延遲。按任務類型與租戶分層,避免只看平均值掩蓋尾部積壓;指標還要區分圖設定錯誤與執行函式失敗。

如何證明不會把任務領取兩次?

把「檢查狀態、減少槽位、寫入 running」放在同一臨界區或原子條件更新中,並讓回呼攜帶 generation。模型測試列舉兩個並發 ready() 呼叫,斷言每個版本最多一次 pending → running;重複完成只回傳已知終態。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

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

查看工具