【譯】「結構化併發」簡析,或:有害的go語句

原博文(@vorpalsmith)寫於 2018年4月25日html

每種併發API都有其併發執行代碼的方式。下面是幾個看上去使用了不一樣API的例子:python

go myfunc();                                // Golang

pthread_create(&thread_id, NULL, &myfunc);  /* C with POSIX threads */

spawn(modulename, myfuncname, [])           % Erlang

threading.Thread(target=myfunc).start()     # Python with threads

asyncio.create_task(myfunc())               # Python with asyncio
複製代碼

符號和術語的區別不影響語義的一致:它們都安排myfunc開始與程序的其他部分併發運行,而後當即返回以便父程序執行其餘操做。git

另外一種選擇是使用回調:程序員

QObject::connect(&emitter, SIGNAL(event()),        // C++ with Qt
                 &receiver, SLOT(myfunc()))

g_signal_connect(emitter, "event", myfunc, NULL)   /* C with GObject */

document.getElementById("myid").onclick = myfunc;  // Javascript

promise.then(myfunc, errorhandler)                 // Javascript with Promises

deferred.addCallback(myfunc)                       # Python with Twisted

future.add_done_callback(myfunc)                   # Python with asyncio
複製代碼

狀況依舊,符號不一樣可是效果同樣:從如今起,若是特定事件發生,執行myfunc。一旦設定完畢,就當即返回以便調用者執行其餘操做。(有時候回調被包裝得很漂亮,例如 promise combinators, or Twisted-style protocols/transports,可是核心概念同樣。)github

而後……沒了。隨便找一個實際的例子,你會發現它不是屬於前者就是後者,要麼就是兼而有之,好比asyncio。golang

可是我(原博主@vorpalsmith)的新庫 Trio有點怪,它兩種方法都不用。取而代之的是,若是咱們想並行運行myfuncanotherfunc,這樣寫:算法

async with trio.open_nursery() as nursery:
    nursery.start_soon(myfunc)
    nursery.start_soon(anotherfunc)
複製代碼

當人們首次遇到這種「nursery」(nursery,託兒所)結構時,他們會有點困惑。爲何有一個縮進塊?這個nursery對象是什麼東西,還有爲何派生任務以前還得有它?他們又會發現,別的框架裏駕輕就熟的模式在這無法用了,就很惱火。它看起來古怪又獨特,並且由於抽象層次太高也很難成爲一個基本原語。這些反應均可以理解。但請容忍我。express

在這篇文章裏,我想告訴你nursery模式一點也不古怪,而是一個像for循環或者函數調用同樣基本的新控制流原語。更進一步,咱們以前看到的其餘方法——線程派生,回調註冊——通通都不須要,並且能換成nursery式寫法編程

聽起來不太可能?其實這樣的事情家常便飯:goto語句曾是個王者,如今是個笑話。只有少數語言還有一些類goto語句,但仍然不一樣於且遠弱於本來的goto。大多數語言甚至有都沒有。大多數人甚至都不知道這陳芝麻爛穀子的事。可是彼時彼刻,恰如此時此刻。goto如是,併發API亦如是。小程序

什麼是goto?

讓咱們回顧段歷史:早期計算機用 彙編語言編程,或者別的甚至更基本的原語。這有點糟糕。因此在1950年代, 一些人像IBM的John Backus 還有Remington Rand的 Grace Hopper開發了 FORTRANFLOW-MATIC(更爲知名的是他的直接繼承者 COBOL)等等語言。

FLOW-MATIC當年野心勃勃。能夠將它看做Python的曾曾曾……祖父:第一門「以人爲本"的語言。下面是一些FLOW-MATIC的代碼,你細品:

和現代語言不一樣,它沒有if塊,循環塊,或者函數調用。實際上它根本沒有塊分隔符或縮進,就是一列線性語句。這不是由於這個程序剛好不須要任何花哨的控制語法,而是塊語法根本還沒發明呢!

相反,FLOW-MATIC有兩種用於控制流的選項。一般,代碼是線性的,就像你期待的:從頭開始,一直向下,一次執行一條語句。可是若是你執行了一條特殊語句好比JUMP TO,它就會改變控制流直接跳轉到別的地方。好比,語句(13)跳轉回語句(2)。

就像咱們最初的併發原語同樣,對於如何稱呼這種「單向跳轉」有一些爭議。在這裏它是JUMP TO,可是約定俗成的名字是goto(就像‘go to’,懂吧?),因此我將用goto來稱呼它。

下面是這個小程序裏所有的goto跳轉:

若是你看着心累,你不是一我的!這種基於跳轉的編程風格是FLOW-MATIC從彙編語言直接繼承來的。它功能強大,很是符合計算機硬件的實際工做方式,可是就這麼使用它讓人很是困惑。爲何有「意大利麪式代碼」這種說法,就是由於這些錯綜複雜的箭頭。顯然,咱們得來點更好的。

可是……是什麼致使了這些問題?爲何有些控制結構不錯,有的很差?咱們怎麼選擇那些好的?當時,一切都還很不明朗,並且若是你不理解的話很難解決這個問題。

什麼是go語句?

但讓咱們先停一下,每一個人都知道goto很差。這跟併發有什麼關係?額,想一下Go語言著名的go語句,它被用來派生一個新的」goroutine「(輕量級線程):

// Golang
go myfunc();
複製代碼

畫個圖?額,跟咱們以前看到的都有所不一樣,由於控制流實際上真的分離了。咱們可能會這樣畫:

這裏的顏色用來表示兩條路徑都被採用。從父goroutine(綠線)的角度來看,控制流線性執行:從頭開始,而後當即出如今底部。與此同時,從子goroutine(紫線)的角度看,控制流從頭開始,而後直接跳到myfunc函數。與常規函數調用不一樣,這種跳轉是單向的:當運行myfunc時,咱們切換到一個全新的棧,運行時會當即忘記咱們從哪來。

可是這不只僅適用於Golang。這就是咱們這篇文章開頭列出的全部原語的流程控制圖:

  • 線程庫一般會提供一些相似handle對象的東西讓你以後join該線程——但這是一個獨立的操做,語言自己不會獲取任何相關的信息。實際的線程派生原語具備以上的控制流。
  • 註冊回調在語義上等同於啓動一個後臺線程,該線程(a)阻塞直到某個事件發生,而後(b)運行回調。(儘管實現顯然不一樣)因此就高級控制流而言,註冊回調其實是一個go語句。
  • Future和Promise也是同樣的:當你調用一個函數並返回一個promise時,意味着它將工做安排在後臺進行,而後給你一個handle對象,以便稍後join(若是你想)。就控制流語義而言,這就像派生線程同樣。而後在promise上註冊回調,請參見前面的要點。

這種徹底相同的模式以多種形式出現:關鍵的類似之處在於,在全部狀況下,控制流都會分離,一邊執行單項跳轉,另外一邊返回到調用方。一旦你知道要找什麼,你就會開始處處找——有趣的遊戲![1]

不過使人惱火的是,這類控制流語句沒有標準的名字。就像「goto語句」成爲全部這些類goto語句的總稱同樣,我用「go語句」來稱呼這類語句。爲何是go?一個緣由是Golang給咱們提供了一個特別純粹的例子。另外一個是……額,你可能已經猜到我想的了。看下面兩張圖,有什麼類似之處?

沒錯:go語句是goto語句的一種形式。

衆所周知,併發程序難以編寫和推斷。基於goto的程序也是如此。僅僅是巧合嗎?有沒有多是出於一樣的緣由?在現代語言中,goto引發的問題基本上已經獲得瞭解決。若是咱們研究它們是如何修復goto的,會不會能告訴咱們如何寫出更易用的併發API?讓咱們看看。

goto怎麼了?

因此爲何goto闖了這麼多禍?在1960年代末, Edsger W. Dijkstra寫了兩篇如今頗有名的論文,讓這個問題更加明晰:Go to statement considered harmful, Notes on structured programming (PDF).

goto:抽象破壞者

在這些論文中,Dijkstra擔憂的是如何編寫有意義的軟件並使其正確無誤。我不能簡單地加以評判,有太多迷人的看法了。例如,你可能看到過如下引用:

沒錯,這正是Notes on structured programming裏的話。可是他主要關心的是抽象。他想寫的程序太大了,你腦子裏很難裝得下。要作到這一點,你須要將程序的某些部分視爲一個黑箱,就像你看下面的Python代碼同樣:

print("Hello world!")
複製代碼

你無需知道全部的細節,好比print是怎麼實現的(字符串格式化,緩衝,跨平臺差別……)。只需知道它會以某種方式打印出你給它的文本,而後你就能把你的精力集中在思考你的代碼上,這是否是就是你如今想要的。Dijkstra但願語言支持這種抽象。

至此,塊語法被髮明出來了,並且像ALGOL這樣的語言已經積累了大約5種不一樣類型的控制結構:它們仍然有線性流和goto

![](cdn.lsongzhi.cn/blog/sequen… (1).svg)

還有了if/else、循環和函數調用:

你能夠用goto來實現更高級別的構造,早期人們就是這麼看待它們的:方便的簡寫。可是Dijkstra指出,若是你觀察這些圖,goto不同凡響。對於其他全部構造,流控制來到頂部→[事情發生]→流控制到達底部。咱們能夠稱之爲「黑箱規則」:若是一個控制結構具備此形狀,在不關心內部細節時,可忽略[事情發生]部分,將整個程序視爲常規的順序流。更好的是,這也適用於由這些片斷組成的任何代碼。當我看到這段代碼時:

print('Hello world!')
複製代碼

我沒必要去閱讀print的定義和它全部的可傳遞依賴項來搞清楚控制流是怎麼工做的。可能在print裏面有一個循環,在循環中有一個if/else,在if/else中有另外一個函數調用……或者是別的緣由。這其實不重要:我知道控制流將流入print,該函數將執行它的操做,而後控制流最終將返回到我正在閱讀的代碼。

這彷佛顯而易見,可是若是你有一種帶有goto的語言——一種函數和其餘一切都創建在goto之上的語言,並且goto能夠在任什麼時候間跳轉到任何地方——那麼這些控制結構就根本不是黑箱。若是你有一個函數,函數裏有一個循環,循環裏面有一個if/elseif/else裏面有一個goto……而後,goto能夠將控制流跳轉到任何它想去的地方。也許控制流會忽然從另外一個函數返回,一個你甚至尚未調用的函數。你什麼都不會知道!

這打破了抽象:這意味每個函數調用都有多是假裝的goto語句,惟一知道的方法是當即將系統的所有源代碼保存在大腦中。一旦goto在你的語言中出現,你就不能對流控制進行原地推斷。這解釋了爲何goto會產生意大利麪式代碼。

如今Dijkstra明白了這個問題,他能夠解決。他的革命性建議是:咱們應該中止把if/循環/函數調用看做是goto的簡寫,而應該將他們視爲基本原語,並且咱們應該將goto從咱們的語言中徹底刪除。

在2018年的當下,這彷佛已經足夠明顯了。可是你有沒有看到過程序員在你以他們的愚笨會致使安全問題爲由試圖拿走他們的玩具時的反應?是的,大人,時代沒變。1969年,這項提議引發了極大的爭議。Donald Knuth爲goto辯護。那些已經成爲goto專家的人必須從新學習如何編程才能使用更新的、更有約束性的結構來表達他們的想法,而他們對此很是反感。固然,這須要一套全新的語言。

最後,現代語言對這一點的要求比Dijkstra最初的公式要低一些。它們將容許你使用breakcontinuereturn等語句一次從多個嵌套結構中分離出來。但從根本上說,它們都是圍繞Dijkstra的思想設計的;即便是這些突破邊界的語句,也只能以嚴格限制的方式來使用。特別是函數——這是將控制流包裝在黑盒中的基本工具——被認爲是不可侵犯的。不能從一個函數break到另外一個函數,return可讓你從當前函數中斷,但到此爲止。無論一個函數內部的控制流多麼花裏胡哨,其餘函數都沒必要在乎。

左:傳統`goto`。右:馴化過的`goto`,見於C,C#,Golang等語言。
無法跨越函數邊界意味着它仍然能夠在你鞋子上撒尿,就是不能把你的臉撕破而已。

這甚至延伸到goto自己。你會發現一些語言仍然有它們稱之爲goto的東西,好比C,C++,Golang……可是它們添加了嚴格的限制。起碼,它們不會容許你從一個函數體跳轉到另外一個函數體。除非你在執行彙編代碼[2],經典的不受限的goto已經沒了。Dijkstra贏了。

意外收穫:移除goto開啓了新特性

一旦goto消失,有趣的事來了:語言設計者能夠開始添加依賴於結構化控制流的特性。

例如,Python有個很好的資源清理語法:with語句。你能夠寫出這樣的代碼:

# Python
with open("my-file") as file_handle:
    ...
複製代碼

它保證文件會在…代碼中打開,隨後當即關閉。大多數現代語言都有一些等價物(RAII,using,try-with-resource,defer,……)。他們都假設控制流有序,結構化。若是咱們用goto來跳轉到with塊的中間……會發生什麼?文件是否會打開?若是咱們再次跳出而不是正常退出呢?文件會關閉嗎?若是你的語言中有goto,這個特性就不能正常工做。

錯誤處理也有一個相似的問題:當出現錯誤時,代碼應該怎麼處理?一般的答案是將錯誤沿堆棧傳給代碼調用者,讓他們去處理它。現代語言有專門的構造來簡化這一過程,好比異常或者其餘形式的自動錯誤傳播。但你的語言只有在有一個堆棧和可稱之爲「調用者」的概念存在的狀況下才能提供這種幫助。再看看咱們的FLOW-MATIC程序中的意大利麪式控制流,想象一下在代碼當中引起一個異常。它會跳到哪?

goto語句:一行也不要寫

因此goto——忽略函數邊界的傳統類型——不只是通常的很難正確使用的糟糕特性。若是是的話,它可能會倖存至今——就像許多壞特性同樣。但它甚至更糟。

即便你本身不使用goto,在你的語言中僅把它做爲一個選項,也會使一切都變糟。不管什麼時候開始使用第三方庫,都不能將其視爲一個黑箱——必須通讀全部函數,才能找出哪些函數是常規函數,哪些是假裝的特殊控制流構造。這嚴重阻礙了原地推斷。並且,你將失去諸如可靠的資源清理和自動錯誤傳播等強大的語言功能。最好徹底刪除goto,以支持遵循「黑箱」原則的控制流構造。

有害的go語句

goto的歷史講完了。如今,有多少能用在go語句上?額,基本上,所有!這個類比結果很是準確。

go語句破壞了抽象 還記得咱們說過若是咱們的語言容許goto,那麼任何函數都有多是假裝的goto嗎?在大多數併發框架中,go語句會致使徹底相同的問題:每當調用函數時,它可能會也可能不會生成一些後臺任務。函數彷佛返回了,但它是否是仍然在後臺運行?若是不通讀全部源代碼,就沒辦法知道。何時結束?很難說。若是有go語句,那麼函數就再也不是控制流的黑箱。在個人第一篇併發API文章中,我稱之爲「破壞了因果律」,並發現這是使用了asyncio和Twisted的程序中許多常見的實際問題的根源所在,好比backpressure問題,正常關閉時出現的問題等等。

go語句破壞了自動資源清理。 讓咱們再回顧一下with語句的例子:

# Python
with open("my-file") as file_handle:
    ...
複製代碼

之前,咱們說過咱們「保證」文件在…代碼中運行,而後關閉。可是若是…代碼派生了一個後臺任務呢?而後咱們的保證就沒了:看起來像在with塊中的操做實際上可能會在with塊結束後繼續運行,而後崩潰,由於文件在它們仍在使用時被關閉。並且,你不能在原地將錯誤檢查出來;要知道是否發生了這種狀況,你必須通讀全部在…代碼中被調用的函數的源代碼。

若是咱們想讓這段代碼正常工做,咱們須要以某種方式跟蹤任何後臺任務,並手動安排文件在完成後關閉。這是可行的——除非咱們使用的庫在任務完成時不提供任何得到通知的方法,這是使人不安的常見現象(例如,由於它沒有暴露任何能夠join的handle)。但即便在最好狀況下,非結構化的控制流也意味着語言沒法幫助咱們。咱們又得手工執行資源清理,一朝回到解放前。

go語句破壞了錯誤處理。 正如咱們上面討論的,現代語言提供了諸如異常之類的強大工具,以幫助咱們確保錯誤被檢測到並傳播到正確的位置。但這些工具依賴於「當前代碼的調用者」的可靠概念。一旦派生任務或者註冊回調,這種概念就被破壞了。所以,我所知道的每個主流併發框架都簡單地放棄了。若是在後臺任務中發生錯誤,而且你沒有手動處理它,那麼運行時只是……把它扔在地上,交叉手指,說它不過重要。若是你幸運的話,它可能會在控制檯上打印一些東西(我使用過的惟一一個認爲「打印並繼續運行」是一個很好的錯誤處理策略的其餘軟件是糟糕的舊Fortran庫,但咱們已經到這了)甚至Rust——這門被高中班級選爲最熱衷於線程正確性的語言——也爲此感到羞愧。若是後臺線程panic,Rust將丟棄錯誤並但願得到最佳結果。

固然你能夠在這些系統中正確地處理錯誤,方法是當心地確保join每一個線程,或者構建本身的錯誤傳播機制,好比errbacks in Twisted或者Promise.catch in Javascript。可是如今你在寫一個自定義的,脆弱的,你的語言已經擁有的特性的重實現。你已經失去了一些有用的東西,好比「回溯」和「調試器」。只要有一次忘記了調用Promise.catch,而後就會忽然間產生了巨大的錯誤,而你甚至都意識不到。即便你以某種方式解決了這些問題,你仍然獲得兩個冗餘的作着一樣事情的系統。

go語句:一行也不要寫

就像goto是第一種使用高級語言的明顯原語同樣,go也是第一種實用併發框架的明顯原語:它與底層調度程序的實際工做方式相匹配,而且它足夠強大,能夠實現任何其餘併發流模式。但一樣像goto同樣,它破壞了控制流抽象,所以在你的語言中僅僅將它做爲一個可選項就使得全部東西都很難使用。

不過,好消息是,這些問題均可以解決:Dijkstra向咱們展現瞭如何解決!咱們須要:

  • 找到一個具備相似能力但遵循「黑箱原則」的go語句的替代項。
  • 將這個新構造做爲原語構建到咱們的併發框架中,而且不包含任何形式的go語句。

Trio就是這麼幹的。

「nursery」:一個go語句的結構化替代項

核心思想是:每次咱們的控件拆分紅多個併發路徑時,咱們都但願確保它們再次鏈接起來。例如,若是咱們想同時作三件事,咱們的控制流應該是這樣的:

注意上面只有一個箭頭,下面也有一個箭頭,因此它遵循Dijkstra的黑箱原則。如今,咱們怎樣才能把這個草圖變成一個具體的語言結構呢?有一些現成的結構能夠知足這個約束,可是(a)個人建議與我所知道的全部的結構都有所不一樣,並且比它們更有優點(特別是在想要使它成爲獨立的原語的狀況下),(b)併發文獻龐大而複雜,試圖把全部的歷史和取捨分開會完全打亂這場爭論,因此我將其推遲到另外一篇文章。在這裏,我將集中精力解釋個人解決方案。但請注意,我並非聲稱本身發明了併發之類的概念,這篇文章從不少方面獲取靈感,我站在巨人的肩膀上。[3]

不管如何,咱們要作的是:首先,咱們聲明一個父任務在它首先爲子任務建立了一個居住的地方:nursery以前不能啓動任何子任務。它經過打開一個nursery塊來實現這一點,在Trio中,咱們使用Python的async with語法來實現這一點:

打開一個nursery塊會自動建立一個表示此nursery的對象,而且as nursery語法將此對象分配給名爲nursery的變量。而後咱們可使用nursery對象的start_soon方法來啓動併發任務:在本例中,一個任務調用函數myfunc,另外一個任務調用函數anotherfunc。從概念上講,這些任務在nursery塊內執行。實際上,將在nursery塊中編寫的代碼看做是在建立塊時自動啓動的初始任務一般是很方便的。

最重要的是,在全部的任務都退出以前,nursery塊不會退出——若是在全部的子任務完成以前,父任務到達塊的結束,那麼它停在那裏等待它們。nursery自動擴大以容納孩子們。

下面是控制流:你能夠看到它是如何與咱們在本節開頭顯示的基本模式相匹配的:

這種設計有許多後果,並不是全部後果都顯而易見。讓咱們仔細想一想。

「nursery」保全了函數抽象

go語句的基本問題是,當你調用一個函數時,你不知道它是否會派生一些後臺任務,這些任務在完成後仍在運行。使用「nursery」,你就沒必要擔憂這個問題:任何函數均可以打開一個nursery並運行多個併發任務,但在它們所有完成以前,函數不能返回。因此當一個函數真的返回時,你就知道它真的完成了。

「nursery」支持動態任務派生

這裏有一個更簡單的原語,也能夠知足上面的流程控制圖。它獲取一個函數的列表而後併發地執行它們。

run_concurrently([myfunc, anotherfunc])
複製代碼

但問題是你必須事先知道你要運行的任務的完整列表,並不老是可以如此。例如,服務器程序一般有accept循環,接受傳入的鏈接並啓動一個新任務來處理每一個鏈接。如下是Trio中最小的accept循環:

async with trio.open_nursery() as nursery:
    while True:
        incoming_connection = await server_socket.accept()
        nursery.start_soon(connection_handler, incoming_connection)
複製代碼

對於「nursery」來講,這很簡單,可是用run_concurrently來實現將很是困難。若是你想的話,很容易就能夠在「nursery」的基礎上實現run_concurrently,可是實際上不必。由於run_concurrently能夠處理的簡單狀況,「nursery」一樣也能夠處理,還更易讀。

有一個出口

「nursery」對象還爲咱們提供了一個逃生艙口。若是你真的須要編寫一個函數來生成一個後臺任務,然後臺任務比函數自己還長,該怎麼辦?簡單:向函數傳遞一個nursery對象。沒有規則規定只有直接位於async with open_nursery()塊內部的代碼才能調用nursery.start_soon——只要該「nursery」塊保持打開狀態[4],那麼任何獲取對該「nursery」對象的引用的人均可以得到將任務生成到該nursery的能力。你能夠將其做爲函數參數傳入,或經過隊列發送。

實際上,這意味着你能夠編寫「違反規則」的函數,可是得在必定限制範圍內:

  • 因爲必須顯式地傳遞"nursery"對象,你能夠經過查看它們的調用位置當即肯定哪些函數違反了正常的流控制。所以仍然能夠進行原地推理。
  • 函數生成的任何任務仍受傳入的「nursery」生存期的約束。
  • 調用的代碼只能傳入它本身能夠訪問的「nursery」對象。

所以,這與那種任何代碼均可以在任什麼時候刻派生具備無限生存期的後臺任務的傳統模型有很大不一樣。

有一點頗有用,那就是證實「nursery」有着和go語句同樣的表達力,可是這篇文章已經夠長了,因此我改天再說。

你能夠定義跟「nursery」同樣「嘎嘎叫」的新類型

標準的「nursery」語義提供了堅實的基礎,但有時你想要不一樣的東西。也許你羨慕Erlang還有它的supervisors,並但願定義一個相似於「nursery」的類,該類經過從新啓動子任務來處理異常。(譯者注:有一個典型的例子,Bastion,一個從Erlang中汲取了靈感用Rust編寫的高可用分佈式容錯運行時)這是徹底可能的,對你的用戶來講,它看起來就像一個普通的「nursery」:

async with my_supervisor_library.open_supervisor() as nursery_alike:
    nursery_alike.start_soon(...)
複製代碼

若是有一個函數以一個「nursery」爲參數,則能夠傳遞其中一個參數來控制它派生的任務的錯誤處理策略。很漂亮。可是,這裏有一個微妙之處,將Trio推向了不一樣於asyncio或其餘一些庫的不一樣約定:這意味着start_soon必須獲取一個函數,而不是協程對象或者一個Future。(你能夠屢次調用函數,可是沒法重啓一個協程對象或者Future。)我認爲這是更好的約定,無論怎麼說,有不少緣由(特別是由於Trio甚至沒有Future!),可是仍然值得一提。

真的,「nursery」老是等着其中的任務退出

另外一件值得討論的事情是,任務取消和任務join是如何相互做用的,這裏有一些微妙之處,若是處理不當,可能會破壞nursery不變量。

在Trio中,代碼能夠隨時接受取消請求。請求取消後,下次代碼執行「檢查點」操做(詳細信息)時,將引起取消的異常。這意味着,請求取消和實際發生取消之間存在差距——任務執行檢查點以前可能須要一段時間,以後一場必須解除堆棧、運行清理處理程序等。發生這種狀況時,nursery老是等到徹底清理完畢。咱們從不在不給任務運行清理處理程序的機會的狀況下終止任務,也從不讓任務脫離nursery的監管,即便它正在被取消。

自動資源清理

由於nursery遵循黑箱原則,with塊又能派上用場。比方說,在with塊的末尾關閉一個文件不會意外中斷仍在使用該文件的後臺任務。

自動錯誤傳播

如上所述,在大多數併發系統中,後臺任務中未處理的錯誤只是被丟棄。而後就實在沒什麼事情能夠作了。

在Trio中,因爲每一個任務都位於nursery內,而且每一個nursery都是父任務的一部分,所以父任務須要等待nursery內的任務……咱們確實能夠處理未處理的錯誤。若是後臺任務因異常而終止,咱們能夠在父任務中從新運行它。這裏的直覺是,nursery相似於「併發調用」原語:咱們能夠將上面的示例看做同時調用myfuncanotherfunc,所以咱們的調用堆棧已成爲一棵樹。異常向上傳播這個調用樹到根,就像它們向上傳播一個常規調用堆棧同樣。

不過,在此有一個微妙之處:當咱們在父任務中引起異常時,它將開始在父任務中傳播。通常來講,這意味着父任務將退出nursery塊。可是咱們已經說過,當仍有子任務在運行時,父任務不能離開nursery塊。那咱們該怎麼辦?

答案是,當一個未處理的異常發生在一個子任務身上時,Trio會當即取消同一個nursery中的全部其餘任務,而後等待它們完成,而後再從新引起異常。這裏的直覺是,異常會致使堆棧展開,若是咱們想展開堆棧樹中的某個分支點,則須要經過取消這些分支來展開其餘分支。

這確實意味着若是你想用你的語言實現nursery,你可能須要在nursery代碼和你的取消系統中進行某種集成。若是您使用的是像C#或Go這樣的語言,其中一般經過手動對象傳遞和約定來管理取消,或者(更糟的是)沒有通用取消機制的語言,那麼這可能會很棘手。

意外之喜:移除go語句開啓新的特性

消除goto容許之前的語言設計人員對程序的結構做出更有力的假設,從而啓用了新的功能:如塊和異常;消除go語句也有相似的效果。例如:

  • Trio的取消機制比競爭對手更易用,也更可靠,由於它能夠假設任務嵌套在一個規則的樹結構中,有關完整的討論,請參考Timeouts and cancellation for humans
  • Trio是惟一一個其中control-C的工做方式與Python開發者指望的(細節)相同的併發庫。若是沒有nursery提供傳播異常的可靠機制,這是不可能的。

實踐中的nursery

上面的全是理論,實踐中怎麼樣?

額……這是一個經驗性問題:你應該試試看,而後找出答案!但說真的,得不少人用過它以後咱們才能知道。在這一點上,我頗有信心,基礎很牢靠,但也許咱們會意識到咱們須要調整一下,好比早期結構化編程倡導者最終中止擺脫breakcontinue

若是你是一個有經驗的併發程序員,正在學習Trio,那麼你應該會發現它有時會有點不穩定。你將不得不學習新的作事方法——就像70年代的程序員發如今沒goto的狀況下學習如何編寫代碼頗有挑戰性同樣。

固然,這就是重點。正如Knuth所寫,(Knuth, 1974, p. 275):

Probably the worst mistake any one can make with respect to the subject of go to statements is to assume that "structured programming" is achieved by writing programs as we always have and then eliminating the go to's. Most* go to's shouldn't be there in the first place! What we really want is to conceive of our program in such a way that we rarely even* think about go to statements, because the real need for them hardly ever arises. The language in which we express our ideas has a strong influence on our thought processes. Therefore, Dijkstra asks for more new language features – structures which encourage clear thinking – in order to avoid the go to's temptations towards complications.*

到此爲止,這是我使用nursery的經驗:它們鼓勵清晰的思考。它們帶來了更健壯、更易於使用和更全面的設計。這些限制實際上使解決問題變得更容易,由於你花在沒必要要的複雜問題上的時間更少。從一個很是真實的意義上說,使用Trio教會了我成爲一個更好的程序員。

例如,考慮Happy eybells算法 (RFC 8305),這是一個簡單的併發算法,用於加快TCP鏈接的創建。從概念上講,這個算法並不複雜——你能夠相互競爭多個鏈接嘗試,交錯開始以免網絡過載。但若是你看看Twisted的最佳實現,他差很少有600行代碼,並且至少還有一個邏輯錯誤。Trio中的等效實現至可能是其十五分之一。更重要的是,使用Trio,我能夠在幾分鐘內而不是幾個月內寫出它,並且我在第一次嘗試時邏輯就正確了。我不可能在任何其餘框架中作到這一點,即便是那些我有更多經驗的框架。你能夠看我上個月在Pyninsula的演講以瞭解更多細節。這是典型的嗎?時間會證實一切,但這確定頗有但願。

結論

流行的併發原語——go語句,線程派生函數、回調、FuturePromise……在理論和實踐上它們都goto的變體。甚至不是現代的馴化goto,而是老式的火燒石的goto,能夠跨越函數邊界。即便咱們不直接使用它們,這些原語也是危險的,由於它們破壞了咱們對控制流的推理能力,破壞了從抽象的模塊部分中構造出複雜系統的能力,並且它們干擾了有用的語言特性,好比自動資源清理和錯誤傳播。所以,像goto同樣,它們在現代高級語言中沒有立錐之地。

Nursery提供了一個安全而方便的替代方案,它保留了語言的所有功能,並實現了強大的新功能(正如Trio的做用域級別任務取消和Ctrl-C處理所證實的那樣),而且能夠在可讀性、效率和正確性方面有顯著的提升。

不幸的是,爲了徹底擁有這些好處,咱們確實須要徹底刪除的舊的原語,這可能須要從頭開始構建新的併發框架——就像消除goto須要設計新的語言同樣。可是,儘管FLOW-MATIC在當時給人留下了深入的印象,但咱們大多數人對升級到更好的東西都樂見其成。我想咱們也不會後悔改用nursery,Trio證實了這是一種實用的、通用的併發框架的可行設計。

鳴謝

很是感謝Graydon Hoare、Quentin Pradet和Hynek Schlawack對這篇文章的草稿提出的意見。固然,剩下的任何錯誤都是個人錯。

FLOW-MATIC樣本代碼來自於本手冊(PDF),由計算機歷史博物館保存。Wolves in Action,做者:i:am. photography / Martin Pannier, 以 CC-BY-SA 2.0協議發佈, 有所裁剪. French Bulldog Pet Dog by Daniel Borker, 以CC0 public domain dedication協議發佈 .

腳註


  1. 至少對某一類人來講是這樣的. ↩︎

  2. 而WebAssembly甚至證實了沒有 "goto "的低級彙編語言是可能的,至少在某種程度上是可取的: reference, rationale ↩︎

  3. 對於那些在不知道我是否知道他們最喜歡的論文的狀況下,不會關注這篇文章的人,我目前已經閱讀過的主題包括: the "parallel composition" operator in Cooperating/Communicating Sequential Processes and Occam, the fork/join model, Erlang supervisors, Martin Sústrik's article on Structured concurrency and work on libdill, and crossbeam::scope / rayon::scope in Rust. [Edit: I've also been pointed to the highly relevant golang.org/x/sync/errg… and github.com/oklog/run in Golang.] If I'm missing anything important, let me know. ↩︎

  4. 若是你在nursery塊退出後調用 start_soon,那麼start_soon會產生一個錯誤,反之,若是它沒有產生錯誤,那麼nursery塊將被保證保持開放,直到任務結束。若是你正在實現你本身的nursery系統,那麼你會但願在這裏當心地處理同步。 ↩︎

相關文章
相關標籤/搜索