從錯誤處理到結構化並行
原文由 Nelson Elhage 于 發布,訂閱此部落格
在並行程式中,我們該如何思考錯誤處理?
在單執行緒程式中,我們大致已經收斂到一種標準模式,儘管實作與具體寫法五花八門。當錯誤發生時,它會沿著呼叫堆疊往上傳遞,直到找到準備好處理它的堆疊框架。在這個過程中,我們依序展開各個堆疊框架,讓每個框架都有機會適當地清理或釋放資源。
這個模式清楚地描述了許多現代語言中明確的例外處理機制(C++、Python、Java),這些語言也都有在框架展開時進行清理的機制(RAII、finally 區塊、Python 的 context manager)。但它同樣也描述了 Rust 的標準模式(Result 回傳值、? 運算子,以及在展開時呼叫 drop)、Go 的經典寫法(if err != nil { return err } 模式與用 defer 做清理),甚至大多數現代 C 程式的寫法,透過像 goto error 這樣的模式(參見 Linux 核心中的範例)。
以今天的眼光來看,這樣的描述或許顯得過於籠統、近乎空洞,但事實並非一直如此。其他一些(大多已被淘汰的)錯誤處理方式包括 Lisp 的「restarts」機制、C 語言的 longjmp1、像 UNIX signal 這類的「trap」機制,以及 Visual Basic 中惡名昭彰的on error 子句。「展開」這個概念本身也預設了我們擁有一個結構化的呼叫堆疊,而這個概念本身也是經過被發明與普及才廣泛流傳的。
今天我想問的是:對於沒有單一堆疊的並行程式,我們該如何更新這個模式?在存在多個並行任務2的情況下,我們該如何組織程式碼來處理錯誤狀況?
未處理的錯誤
或許最單純的錯誤處理情境,就是當錯誤發生卻沒有任何程式碼明確處理它的時候。在單執行緒程式中,我們預期錯誤會一路「冒泡」到進入點,然後終止程式,並盡可能留下有用的錯誤訊息與/或堆疊追蹤。
在擁有多個任務的並行程式中,接下來該發生什麼事就沒那麼明確了。我們可以讓錯誤往上冒泡並終止拋出錯誤的那個任務,但接下來呢?為了具體說明,我們可以考慮這個玩具範例程式3:
import threading
import time
def background_thread():
# This was supposed to be running some background work, but it
# encountered an error!
raise ValueError("oops")
def main():
threading.Thread(target=background_thread).start()
# do the main work
time.sleep(5)
print("All done, exiting!")
main()這個程式試圖同時執行兩個執行緒,一個執行某種「主要」工作函式,另一個在「背景」執行某種工作。然而,背景執行緒立刻就拋出了例外。接下來應該發生什麼事?
如果我們在各種語言實作上執行這類程式的變體,會發現大致上有兩種常見的處理方式,我認為這也正是兩種最直觀的選項:
- 印出錯誤、終止該執行緒,然後繼續執行,直到所有執行緒(或某些變體:也許只是主執行緒,也許是所有「非 daemon」執行緒)都結束為止。(Java、Python)
- 印出錯誤,並立即終止整個程式(Go、Rust、C++)
在 Python 中,程式會立刻記錄下例外,但接著繼續執行:它會等待 time.sleep 結束,然後離開,甚至是以成功的結束碼離開!
$ time python exception_thread.py
Exception in thread Thread-1 (background_thread):
Traceback (most recent call last):
File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
self.run()
File "/Users/nelhage/.pyenv/versions/3.11.0/lib/python3.11/threading.py", line 975, in run
self._target(*self._args, **self._kwargs)
File "/Users/nelhage/Sync/code/structured-concurrency/exception_thread.py", line 7, in background_thread
raise ValueError("oops")
ValueError: oops
All done, exiting!
python exception_thread.py 0.03s user 0.02s system 0% cpu 5.124 total
$ echo $?
0兩種選項都不太令人滿意。
直接殺掉整個程式是一記重鎚。我曾親自協助處理過高嚴重等級的事故,其近因就是某個背景監控 goroutine 中未處理的 panic 導致關鍵的 daemon 崩潰。在那個特定案例中,我們寧可讓「真正」的工作繼續不受干擾地執行。
另一方面,讓程式繼續執行意味著我們處於一個幾乎可以肯定從未被測試或預期過的狀態,如果其他任務預期已死去的任務會推進進度或執行某些動作,我們就很可能面臨死結甚至更糟的情況。
讓程式繼續跑也會在開發期間帶來痛苦。在開發過程中,許多錯誤都是「微不足道」的開發者失誤——打字錯誤、簡單的邏輯錯誤、其他細微、局部的、大多無關緊要的疏忽。當我遇到這類錯誤時,通常希望能盡快修正並重新啟動程式。如果程式還在跑,我就得手動重啟它,而且如果其他任務持續產生輸出,例外訊息還可能被埋沒在大量的捲動紀錄中。
錯誤該送往何處?
某種意義上,我們想要的是一個更好的地方來「轉送」錯誤。在單執行緒程式中,那個地方就是「呼叫者」。在有並行的情況下,任務並沒有最終會返回的呼叫者,那麼我們該怎麼做?
我們可以從 Python 的 asyncio 框架獲得一些靈感,它採取了與上述兩者都不同的「第三條路」。在 asyncio 中,任務由一個Task 物件來表示,這個物件可以被等待,就像 event、lock 或 socket 之類的東西一樣。等待一個 Task 會阻塞直到它完成;如果該任務拋出例外,該例外會被重新投遞給所有等待該 Task 的對象。
這種做法不預設立場;它不對誰應該處理從任務中逃逸的例外做出定見,而是把做決定的工具交給程式設計師自己。
然而,它也有一個嚴重的缺點。如果沒有人等待某個 Task,而該任務又拋出例外,這個例外實際上會被吞掉,直到程式結束時才會伴隨警告被印出來。如果有人正等待該任務透過某個佇列產生輸出,那麼程式就會永遠靜悄悄、難以捉摸又神祕地卡住。
事實上,情況糟到我有時會把它總結為「預設情況下,asyncio 會完全吞掉主任務之外的例外」。這句話字面上並不完全正確,但在我的經驗中,它是一個不錯的初始心智模型,也相當貼切地描述了許多開發者在使用 asyncio 時的實際體驗。
如果我們總是等待任務呢?
只要你記得等待每一個 Task,asyncio 的做法其實有許多可取之處。我們該如何把這個不變條件強制落實為一條規則呢?最簡單的規則或許是:如果你產生了一個任務,你也要負責等待它。我們可以把 asyncio.create_task(coro) 做成一個非同步的 context manager,讓它在離開區塊前等待任務完成,以此來編碼這個模式。
# (n.b. this is not real API in any version of Python)
async with asyncio.create_task(background_task()) as task:
# …
# `task` will be waited for on exit from this block, and any exception raised當然,我們常常想要建立許多任務——或是數量動態決定的任務——所以我們可以借用contextlib.ExitStack 的慣用法,讓單一個 context manager 物件可以建立任意數量的任務;像是這樣:
async with TaskLauncher() as tasks:
tasks.create_task(background_task())
# do the main work via a second task
tasks.create_task(asyncio.sleep(5))
# All tasks will be waited for on exit from the region只要我們只透過 TaskLauncher 來產生新任務,我們現在就擁有了一個清晰的父子關係,每個任務都「隸屬於」一個明確的父任務,而父任務負責等待從子任務逃逸的例外。如果任何任務拋出未處理的例外,它會透過這個階層結構往上冒泡;如果沒有人捕捉它,最終會在根任務與 asyncio.run 的呼叫中觸底。我們已經在很大程度上把並行的例外處理問題轉換成了熟悉的單執行緒版本!
兩個難題
不幸的是,我們還遠遠沒有完成。上述的草圖有兩個嚴重且相關的挑戰,沒有一個有簡單的解法。
死結
首先是死結。我們在上面說過,父任務「最終」會等待它。但這只有在父任務真的離開 context manager 的情況下才成立。但如果父任務正在等待永遠不會完成的工作,而原因正是某個本該處理它的子任務遇到了錯誤,那該怎麼辦?
這裡有個簡短的範例,展示了這類問題的一種樣貌:
from task_launcher import TaskLauncher
import asyncio
async def do_work(job_id, done_event):
if job_id == 1:
raise ValueError("Oops, job 1 failed!")
done_event.set()
async def main():
async with TaskLauncher() as tasks:
events = []
for i in range(4):
done = asyncio.Event()
tasks.create_task(do_work(i, done))
events.append(done)
for ev in events:
await ev.wait()
print("All done!")
if __name__ == '__main__':
asyncio.run(main())這個範例是常見模式的一種風格化版本:許多「扇出」或「扇出/扇入」並行模式的基本樣貌就是「啟動一些任務;那些任務執行某些工作;父任務等待工作完成」。
對於這個特定的 bug,當然有許多簡單的解法4。然而,我們希望找到一種能「自動」或以某種通用方式修復它的模式,至少避免讓這個容易踩到的陷阱就這樣擺在那裡等著我們。在我的經驗中,這類死結非常容易遇到,尤其是在剛開始開發一個並行系統時。
資源洩漏
在單執行緒程式中,錯誤復原的挑戰不僅在於展開控制流程,還在於確保我們「復原」或「清理」與失敗操作相關、且正在進行中的任何工作。我們可能需要釋放記憶體、關閉檔案控制代碼,或將某些資料結構恢復到一致的狀態。
在並行程式中,與失敗操作相關的資源可能包含了多個不同的執行任務!如果某個與 TaskLauncher 相關的任務失敗了,我們需要確保所有已產生的任務都能以某種方式被停止,或至少有機會清理被遺棄的狀態。
從某種意義上來說,最簡單的修正就是讓 TaskLauncher 在離開前等待所有已產生的任務。然而,實際上,單單做出這個改變會讓我們的死結問題變得嚴重得多。
不得不談取消……
如果我們想要同時:
- 在返回前等待所有子任務,以確保我們知道任何額外的錯誤,並清理相關的資源,而且
- 迅速回應任何子任務中的錯誤,而不需要等待一段可能無止盡的時間,
那麼我想我們基本上需要一種方式,能要求任意任務提早且迅速地離開,以回應在另一個任務中發生的事件。換句話說,我們需要一種取消機制。
我們是在我們正在發展的這個特定典範脈絡下得出這個結論的,但我認為它適用得更廣泛,仔細想想也相當直觀。在任何並行典範中,你都會有某種「多個協作的並行任務」的概念,而這意味著你需要回答「如果其中一個意外死亡會發生什麼事」。而反過來說,我很難想像一個完全通用的答案不是「我們要求其他任務取消並提早終止」。
當然,要透過一些特設機制(ad-hoc)的組合,加上謹慎的推理與建構,來實作不需要通用取消機制的特定並行程式或模式,是完全可能的。但我很難想像一個通用的、可組合的並行典範不需要它。
我覺得這個結論令人不太愉快,因為實作與支援取消機制是很困難的。它幾乎為每一塊程式碼都引入了一條額外的錯誤路徑,而這條路徑本質上是非同步的,很難推理或測試。歷史上對於取消機制的嘗試,像是 C 的 pthread_cancel、Java 的 Thread.stop 和 Ruby 的 Thread.terminate,充其量是極其微妙且容易出錯,最糟的情況下則是根本無法使用。
至少在並行的脈絡下,我們確實有一些優勢。如果我們已經在撰寫並行程式碼,取消機制只是再多增加一個「可能非同步發生的事情」,但至少我們本來就已經在面對某種程度的這類問題。在像 asyncio 這樣的協作式並行系統中,我們還可以把取消限制在只發生於 await 點,這減少了潛在混亂的範圍。另一方面,Go 則把取消編碼到 Context 物件中,要求程式碼明確地檢查是否被取消,這帶來了另一組取捨。
更廣泛地說,作為一個領域,我們在過去幾十年中已經學到了很多,而其中一些較新的系統似乎確實擁有在實務上或多或少可行的通用取消機制。不過,這篇文章並不打算深入探討取消機制的挑戰與設計空間,所以現在我會先假設我們確實擁有某種取消 API5,然後繼續往下談。
帶有取消機制的任務樹
如果我們確實有能力取消任務,我們就可以將它與我們「任務樹」的想法結合,產生一個相當通用的並行錯誤處理解法:
- 如果由
TaskLauncher啟動的任何任務拋出例外(包括父任務本身——也就是執行該 context manager 的任務),我們就取消其他所有任務(包括子任務與父任務本身)。 - 我們提供子任務一種偵測取消並清理自身資源的機制。通常這意味著以某種形式重用我們正常的錯誤處理機制;例如,取消可能會拋出一個
CancelledError例外,任務可以捕捉並重新拋出,或是使用finally區塊或 context manager。 - 在離開
TaskLauncher的脈絡時,我們等待所有子任務離開,無論是成功結束、帶著未處理的例外,還是回應取消而結束。 - 然後,如果任何子任務拋出了錯誤,我們就將它重新拋入父任務中。
有了這個機制,並行錯誤現在的行為就與單執行緒的錯誤相當類似了。如果未被處理,它們會被捕捉並往上傳播。可以使用我們常見的錯誤處理機制來捕捉與處理它們(包括源自子任務的錯誤)。只要我們撰寫的程式碼在常見的單執行緒情境下能正確地在錯誤後進行清理,那麼在多任務的情境下,(至少在很大程度上)也應該能得到適當的清理。
作為交換,我們確實對並行程式碼施加了額外的結構要求:我們必須將任務嵌套成父子階層,並確保它們的生命週期也適當地嵌套。
結構化並行
到這裡我得承認,以上這些都不是什麼新點子,也不是我的發明(雖然我還沒看過其他以這種形式切入這個概念的文章)。這種任務樹、生命週期嵌套、並在整棵樹中自動上下傳遞取消的典範,近年來在「結構化並行」這個名稱下,正緩慢但穩定地獲得普及與採用。
許多想法與組成部分都有著悠久的歷史,但據我所知,這個概念最早是在 2016 年被首次命名的,而最廣為人知的推廣或許是 trio 框架,以及由 trio 的創造者與主要維護者 njs 所撰寫的一篇深入探討此概念的文章。
從 Python 3.11 開始,Python 自身的 asyncio 就包含了TaskGroup 類別,它本質上就是我在上面描繪的 TaskLauncher 的一個(可直接用於正式環境的)版本;trio 則把他們自己的版本稱為「nursery」。在 Go 中,errgroup 套件提供了本質上相同的語意,它建構在提供取消支援的 context 套件之上。
結構化並行有許多優點,我與許多人都發現,以這種風格撰寫程式會讓撰寫正確且安全的並行程式碼變得容易許多(當然,其他挑戰依然存在!)。我強烈推薦 njs 的那篇經典文章,我稍早也已連結過,它對這個典範及其優點做了更徹底的探討。
尾聲:為何要談錯誤處理?
我想以對錯誤處理的一點反思作結,並說明我最初為何會從這個角度切入、寫下這篇文章。
我認為當程式設計師想到錯誤處理時,往往會把它歸類為「強健性」方面的考量,或是「正式產品」或「嚴肅軟體」才需要關心的議題——這是一個當你需要「大規模」運作、或需要「可靠」、或需要在許多不同環境中執行並處理來自網路的非預期輸入時,才必須在意的主題。
這些都沒錯,對於那類系統,確實需要仔細思考可能會出什麼錯、如何出錯,以及如何謹慎地處理。
話雖如此,我開始思考這條脈絡的起點,並非來自「成熟、強健的程式」的方向,而是完全相反的一端:思考從零開始撰寫全新程式碼時的開發體驗。如同我稍早簡要提到的,當撰寫一個新程式時——無論是否為並行——往往有一個早期階段充滿了大量「愚蠢的錯誤」,需要盡快逐一解決。
我在結構化並行框架之外撰寫並行程式的經驗是,最終往往會讓人非常沮喪,很難僅僅執行那個基本的開發循環:「執行程式、看到愚蠢的錯誤、修正愚蠢的錯誤」,正是因為那些在單執行緒程式中只會印出漂亮的堆疊追蹤並離開的愚蠢錯誤,在並行程式中很容易就變成死結、被吞掉,或是出現更離奇的狀況。而且,我發現為了加入錯誤處理而做的臨時嘗試有時反而讓情況更糟!例如,我有時會發現「自然」的做法是透過某條管線來「轉送」錯誤,這樣我們就可以在一個大型並行操作的最後收集所有錯誤,並集中記錄在同一個地方。這種做法可以奏效,但有時也意味著你要等到整個程式執行完畢才會發現任何錯誤,這在開發過程中真的非常令人沮喪!
因此,我發現採用結構化並行的方法,或至少把它當作一種基本的心態與典範——即使在我的環境中沒有「真正」的結構化並行函式庫——實際上會讓並行程式從一開始就變得大幅更容易撰寫與除錯,即使只是拋棄式原型也是如此——它幾乎能立即帶來回報,而不只是「最終」或「在正式環境中」才有好處。
longjmp可以作為幫助實作例外與展開的原語。但就其本身而言,它是一個更低階的原語,並允許各種不同的替代模式。 ↩我感興趣的是邏輯上的並行,而非硬體層面的平行處理,所以我會用「任務(task)」一詞來指稱各自線性的執行序列,這些序列可能會與任意數量其他此類序列在時間上交錯執行。 ↩
這篇文章中的大部分討論旨在廣泛適用於許多語言與並行框架,但為了具體說明,我會使用 Python 範例。除了其他好處之外,Python 同時支援執行緒與協作式非同步,讓我能探索多種典範。 ↩
或許最簡單的修正是完全移除
asyncio.Event,改為由我們自己去asyncio.gather那些Task物件。在這裡這很容易,但在「工作單元」與「子任務」之間不一定有一對一對應時,就不一定那麼簡單了。 ↩
隨機一篇部落格
留言
登入後參與討論