热门问题
时间线
聊天
视角
協程
来自维基百科,自由的百科全书
Remove ads
協程(英語:coroutine)是計算機程序的一類組件,推廣了協作式多任務的子例程,允許執行被掛起與被恢復。相對子例程而言,協程更為一般和靈活,但在實踐中使用沒有子例程那樣廣泛。協程更適合於用來實現彼此熟悉的程序組件,如協作式多任務、異常處理、事件循環、迭代器、無限列表和管道。
根據高德納的說法,馬爾文·康威於1958年發明了術語「coroutine」並用於構建匯編程序[1] ,關於協程的最初解說在1963年發表[2]。
同子例程的比較
協程可以通過yield
(取其「退讓」之義而非「產生」)來調用其它協程,接下來的每次協程被調用時,從協程上次yield
返回的位置接着執行,通過yield
方式轉移執行權的協程之間不是調用者與被調用者的關係,而是彼此對稱、平等的。由於協程不如子例程那樣被普遍所知,下面對它們作簡要比較:
- 子例程可以調用其他子例程,調用者等待被調用者結束後繼續執行,故而子例程的生命期遵循後進先出,即最後一個被調用的子例程最先結束返回。協程的生命期完全由對它們的使用需要來決定。
- 子例程的起始處是惟一的入口點,每當子例程被調用時,執行都從被調用子例程的起始處開始。協程可以有多個入口點,協程的起始處是第一個入口點,每個
yield
返回出口點都是再次被調用執行時的入口點。 - 子例程只在結束時一次性的返回全部結果值。協程可以在
yield
時不調用其他協程,而是每次返回一部份的結果值,這種協程常稱為生成器或迭代器。 - 現代的指令集架構通常提供對調用棧的指令支持,便於實現可遞歸調用的子例程。在以Scheme為代表的提供續體的語言環境下[3],恰好可用此控制狀態抽象表示來實現協程。
Remove ads
偽碼示意
這裡是一個簡單的例子證明協程的實用性。假設這樣一種生產者-消費者的關係,一個協程生產產品並將它們加入隊列,另一個協程從隊列中取出產品並消費它們。偽碼表示如下:
var q := new 队列 coroutine 生产者 loop while q 不满载 建立某些新产品 向 q 增加这些产品 yield 消费者 coroutine 消费者 loop while q 不空载 从 q 移除某些产品 使用这些产品 yield 生产者
隊列用來存放產品的空間有限,同時制約生產者和消費者:為了提高效率,生產者協程要在一次執行中儘量向隊列多增加產品,然後再放棄控制使得消費者協程開始運行;同樣消費者協程也要在一次執行中儘量從隊列多取出產品,從而倒出更多的存放產品空間,然後再放棄控制使得生產者協程開始運行。儘管這個例子常用來介紹多線程,實際上簡單明了的使用協程的yield
即可實現這種協作關係。
Remove ads
同線程的比較
協程非常類似於線程。但是協程是協作式多任務的,而典型的線程是內核級搶占式多任務的。這意味着協程提供並發性而非並行性。協程超過線程的好處是它們可以用於硬性實時的語境(在協程之間的切換不需要涉及任何系統調用或任何阻塞調用),這裡不需要用來守衛關鍵區段的同步性原語(primitive)比如互斥鎖、信號量等,並且不需要來自操作系統的支持。有可能以一種對調用代碼透明的方式,使用搶占式調度的線程實現協程,但是會失去某些利益(特別是對硬性實時操作的適合性和相對廉價的相互之間切換)。
用戶級線程是協作式多任務的輕量級線程,本質上描述了同協程一樣的概念。其區別,如果一定要說有的話,是協程是語言層級的構造,可看作一種形式的控制流程,而線程是系統層級的構造。
生成器
生成器,也叫作「半協程」[6],是協程的子集。儘管二者都可以yield
多次,暫停(suspend
)自身的執行,並允許在多個入口點重新進入,但它們特別差異在於,協程有能力控制在它讓位之後哪個協程立即接續它來執行,而生成器不能,它只能把控制權轉交給調用生成器的調用者[7]。在生成器中的yield
語句不指定要跳轉到的協程,而是向父例程傳遞返回值。
儘管如此,仍可以在生成器設施之上實現協程,這需要通過頂層的分派器(dispatcher)例程(實質上是彈跳床)的援助,它顯式的把控制權傳遞給由生成器傳回的記號/令牌(token)所標定的另一個生成器:
var q := new 队列 generator 生产者 loop while q 不满载 建立某些新产品 向 q 增加这些产品 yield 消费者 generator 消费者 loop while q 不空载 从 q 移除某些产品 使用这些产品 yield 生产者 subroutine 分派器 var d := new 字典(生成器 → 迭代器) d[生产者] := start 生产者 d[消费者] := start 消费者 var 当前 := 生产者 loop call 当前 当前 := next d[当前] call 分派器
在不同作者和語言之間,術語「生成器」和「迭代器」的用法有着微妙的差異[8]。有人說所有生成器都是迭代器[9],生成器看起來像函數而表現得像迭代器。在Python中,生成器是迭代器構造子:它是返回迭代器的函數。
Remove ads
同尾調用互遞歸的比較
使用協程用於狀態機或並發運行類似於使用經由尾調用的互遞歸,在二者情況下控制權都變更給一組例程中的另一個不同例程。但是,協程更靈活並且一般而言更有效率。因為協程是yield
而非return
返回,接着恢復執行而非在起點重新開始,它們有能力保持狀態,包括變量(同於閉包)和執行點二者,並且yield
不限於位於尾部位置;互遞歸子例程必須要麼使用共享變量,要麼把狀態作為參數傳遞。進一步的說,每一次子例程的互遞歸調用都需要一個新的棧幀(除非實現了尾調用消去),而在協程之間傳遞控制權使用現存上下文並可簡單地通過跳轉來實現。
協程之常見用例
協程有助於實現:
- 狀態機:在單一子例程里實現狀態機,這裡狀態由該過程當前的出口/入口點確定;這可以產生可讀性更高的代碼。
- 演員模型:並發的演員模型,例如計算機遊戲。每個演員有自己的過程(這又在邏輯上分離了代碼),但他們自願地向順序執行各演員過程的中央調度器交出控制(這是合作式多任務的一種形式)。
- 生成器:可用於串流,特別是輸入/輸出串流,和對數據結構的通用遍歷。
- 通信順序進程:這裡每個子進程都是協程。通道輸入/輸出和阻塞操作會
yield
協程,並由調度器在有完成事件時對其解除阻塞(unblock)。可作為替代的方式是,每個子進程可以是在數據管道中位於其後的子進程的父進程(或是位於其前者之父,這種情況下此模式可以表達為嵌套的生成器)。
支持協程的編程語言
協程起源於一種匯編語言方法,但有一些高級編程語言支持它。早期的例子包括Simula[6]、Smalltalk和Modula-2。更新近的例子是Ruby、Lua、Julia和Go。
- Aikido
- AngelScript
- Ballerina
- BCPL
- Pascal(Borland Turbo Pascal 7.0帶有uThreads模塊)
- BETA
- BLISS
- C++(自從C++20)
- C#(自從2.0)
- ChucK
- CLU
- D
- Dynamic C
- Erlang
- F#
- Factor
- GameMonkey Script
- GDScript(Godot的腳本語言)
- Go
- Haskell[10][11]
- 高級匯編語言[12]
- Icon
- Io
- JavaScript(自從1.7,標準化於ECMAScript 6[13],ECMAScript 2017還包括async/await支持)
- Julia[14]
- Kotlin(自從1.1)[15]
- Limbo
- Lua[16]
- Lucid
- µC++
- Modula-2
- Nemerle
- Perl 5(使用Coro模塊[17])
- PHP(帶有hiphop-php[18],原生支持自從PHP 5.5)
- Picolisp
- Prolog
- Python(自從2.5[19],帶有改進支持自從3.3[20],帶有顯式語法自從3.5[21])
- Raku[22]
- Ruby
- Sather
- Scheme
- Self
- Simula 67
- Smalltalk
- Squirrel
- Stackless Python
- SuperCollider[23]
- Tcl(自從8.6)
- urbiscript
由於續體可被用來實現協程,支持續體的編程語言也非常容易就支持協程。
Remove ads
實現
直到2003年,很多最流行的編程語言,包括C語言和它的後繼者,都未在語言內或其標準庫中直接支持協程。這在很大程度上是受基於堆棧的子例程實現的限制。C++的Boost.Context[24]庫是個例外,它是Boost C++庫的一部分,它在POSIX、Mac OS X和Windows上支持多種CPU架構的上下文切換。可以在Boost.Context之上建造協程。
在協程是某種機制的最自然的實現方式,卻不能獲得可用協程的情況下,典型的解決方法是使用閉包,它是具有狀態變量(靜態變量,常為布爾標誌值)的子例程,基於狀態變量來在調用之間維持內部狀態,並轉移控制權至正確地點。基於這些狀態變量的值,在代碼中的條件語句導致在後續調用時有着不同代碼路徑的執行。另一種典型的解決方法實現一個顯式狀態機,採用某種形式的龐大而複雜的switch語句或goto語句特別是「計算goto」。這種實現被認為難於理解和維護,更是想要有協程支持的動機。
在當今的主流編程環境裡,協程的合適的替代者是線程和適用範圍較小的纖程。線程提供了用來管理「同時」執行的代碼段實時協作交互的功能,在支持C語言的環境中,線程是廣泛有效的,POSIX.1c(IEEE Std 1003.1c-1995)規定了被稱為pthreads的一個標準線程API,它在類Unix系統中被普遍實現。線程被很好地實現、文檔化和支持,很多程序員對其也比較熟悉。但是,線程包括了許多強大和複雜的功能用以解決大量困難的問題,這導致了困難的學習曲線,當任務僅需要協程就可完成時,使用線程似乎就是用力過猛了。GNU Pth可被視為類Unix系統上用戶級線程的代表。
Remove ads
C標準庫里有「非局部跳轉」函數setjmp/longjmp,它們分別保存和恢復:棧指針、程序計數器、被調用者保存的寄存器和ABI要求的任何其他內部狀態。在C99標準中,跳轉到已經用return
或longjmp
終止的函數是未定義的[25],但是大多數longjmp
實現在跳轉時不專門銷毀調用棧中的局部變量,在被後續的函數調用等覆寫之前跳轉回來恢復時仍是原樣,這允許在實現協程時謹慎的用到它們。
POSIX.1-2001/SUSv3進一步提供了操縱上下文的強力設施:makecontext、setcontext、getcontext和swapcontext,可方便地用來實現協程,但是由於makecontext
的參數定義利用了具有空圓括號的函數聲明,不符合C99標準要求,這些函數在POSIX.1-2004中被廢棄,並在POSIX.1-2008/SUSv4中被刪除[26]。POSIX.1-2001/SUSv3定義了sigaltstack
,可用來在不能獲得makecontext
的情況下稍微迂迴的實現協程[27]。極簡實現不採用有關的標準API函數進行上下文交換,而是寫一小塊內聯匯編只對換棧指針和程序計數器故而速度明顯的要更快。
由於缺乏直接的語言支持,很多作者寫了自己的含藏上述技術細節的協程庫,以Russ Cox的libtask協程庫為代表[28],其目標是讓人「寫事件驅動程序而沒有麻煩的事件」,它可用各種類Unix系統上。知名的實現還有:libpcl[29]、lthread[30]、libconcurrency[31]、libcoro[32]、libdill[33]、libaco[34]、libco[35]等等。
此外人們做了只用C語言的子例程和宏實現協程的大量嘗試,並取得了不同程度的成功。受到了達夫設備的啟發,Simon Tatham寫出了很好的協程示範[36],它利用了swtich語句「穿透」(fallthrough)特性[37],和ANSI C提供的包含了源代碼的當前行號的特殊宏名字__LINE__
,它也是Protothreads和類似實現的基礎[38]。在這種不為每個協程維護獨立的棧幀的實現方式下,局部變量在經過從函數yield
之後是不保存的,控制權只能從頂層例程yield
[39]。
C++20介入了作為無棧函數的標準化的協程,它可以在執行中間暫停並在後來某點上恢復。協程的暫停狀態存儲在堆之上[40]。這個標準的實現正在進行中,目前G++和MSVC在新近版本中完全支持了標準協程[41]。
C# 2.0通過迭代器模式增加了半協程(生成器)功能並增加了yield
關鍵字[42][43],C# 5.0包括了await語法支持。
Go擁有內建的「goroutine」概念,它是輕量級的、無關於進程並由Go運行時系統來管理。可以使用go
關鍵字來啟動一個新的goroutine。每個goroutine擁有可變大小的能在需要時擴展的棧。goroutine一般使用Go的內建通道來通信[44][45][46][47]。
Python 2.5基於增強的生成器實現對類似協程功能的更好支持[19]。Python 3.3通過支持委託給子生成器增進了這個能力[20]。Python 3.4介入了綜合性的異步I/O框架標準化,在其中擴展了利用子生成器委託的協程[48],這個擴展在Python 3.8中被棄用[49]。Python 3.5通過async/await語法介入了對協程的顯式支持[50]。從Python 3.7開始async/await成為保留關鍵字[51]。例如:
import asyncio
import random
async def produce(queue, n):
for item in range(n):
# 生产一个项目,使用sleep模拟I/O操作
print(f'producing item {item} ->')
await asyncio.sleep(random.random())
# 将项目放入队列
await queue.put(item)
# 指示生产完毕
await queue.put(None)
async def consume(queue):
while True:
# 等待来自生产者的项目
item = await queue.get()
if item is None:
break
# 消费这个项目,使用sleep模拟I/O操作
print(f'consuming item {item} <-')
await asyncio.sleep(random.random())
async def main():
queue = asyncio.Queue()
task1 = asyncio.create_task(produce(queue, 10))
task2 = asyncio.create_task(consume(queue))
await task1
await task2
asyncio.run(main())
實現協程的第三方庫:
- Eventlet[52]
- Greenlet[53]
- gevent[54]
- Stackless Python[55]
Coro是Perl5中的一種協程實現[56],它使用C作為底層,所以具有良好的執行性能,而且可以配合AnyEvent共同使用,極大的彌補了Perl在線程上劣勢。
在大多數Smalltalk環境中,執行堆棧是頭等公民,實現協程不需要額外的庫或VM支持。
從 Tcl 8.6 開始,Tcl 核心內置協程支持,成為了繼事件循環、線程後的另一種內置的強大功能。
引用
參見
延伸閱讀
外部連結
Wikiwand - on
Seamless Wikipedia browsing. On steroids.
Remove ads