Rust 學習心得<3>:無棧協程

2020-08-12 22:26:46

Rust作爲一門新興語言,主打系統程式設計。提供了多種編寫程式碼的模式。2019年底正式推出了 async/await語法,標誌着Rust也進入了協程時代。下面 下麪讓我們來看一看。Rust協程和Go協程究竟有什麼不同。

有棧協程 vs. 無棧協程

協程的需求來自於C10K問題,這裏不做更多探討。早期解決此類問題的辦法是依賴於操作系統提供的I/O複用操作,也就是 epoll/IOCP 多路複用加執行緒池技術來實現的。本質上這類程式會維護一個複雜的狀態機,採用非同步的方式編碼,訊息機制 機製或者是回撥函數。很多用 C/C++ 實現的框架都是這個套路,缺點在於這樣的程式碼一般比較複雜,特別是非同步編碼加狀態機的模式對於程式設計師是一個很大的挑戰。但是從另外一個角度看,符合人類邏輯思維的操作方式卻恰恰是同步的。

考慮一個web server的場景:每次一個連線一般是請求下載一些數據,如果可以用一個執行緒來處理每一次新連線,那麼這個內部的程式碼邏輯就可以用同步的方式一路寫下來:首先接收數據,然後完成HTTP request解析。根據HTTP頭部的資訊存取數據庫,然後將取得的結果封裝在HTTP response中,返回給使用者,最後關閉連線。如果是這樣,你會發現這裏並不需要狀態機,也沒有什麼回撥函數,很可能也不需要定時器,整個的過程就是一個流水賬,而這正是人類最容易理解的思維方式。然而,我們不能簡單地用多執行緒來解決C10K問題,因爲操作系統的執行緒資源是很有限的,而且是昂貴的。操作系統會限制可以開啓的執行緒數,同時執行緒之間的切換開銷也是比較大的。

Go 有棧協程

Go語言的出現提供了一種新的思路。Go語言的協程則相當於提供了一種很低成本的類似於多執行緒的執行體。在Go語言中,協程的實現與操作系統多執行緒非常相似。操作系統一般使用搶佔的方式來排程系統中的多執行緒,而Go語言中,依託於操作系統的多執行緒,在執行時刻庫中實現了一個共同作業式的排程器。這裏的排程真正實現了上下文的切換,簡單地說,Go系統呼叫執行時,排程器可能會儲存當前執行協程的上下文到堆疊中。然後將當前協程設定爲睡眠,轉而執行其他的協程。這裏需要注意,所謂的Go系統呼叫並不是真正的操作系統的系統呼叫,而是Go執行時刻庫提供的對底層操作系統呼叫的一個封裝。舉例說明:Socket recv。我們知道這是一個系統呼叫,Go的執行時刻庫也提供了幾乎一模一樣的呼叫方式,但這只是建立在 epoll 之上的模擬層,底層的socket是工作在非阻塞的方式,而模擬層提供給我們了看上去是阻塞模式的socket。讀寫這個模擬的socket會進入排程器,最終導致協程切換。目前Go排程器實現在使用者空間,本質上是一種共同作業式的排程器。這也是爲什麼如果寫了一個死回圈在協程裡,則協程永遠沒有機會被換出,一個Processor相當於就被浪費掉了。

有棧的協程和操作系統多執行緒是很相似的。考慮以下虛擬碼:

func routine() int
{
	var a = 5
	sleep(1000)
	a += 1
	return a
}

sleep呼叫時,會發生上下文的切換,當前的執行體被掛起,直到約定的時間再被喚醒。區域性變數a 在切換時會被儲存在棧中,切換回來後從棧中恢復,從而得以繼續執行。所謂有棧就是指執行體本身的棧。每次建立一個協程,需要爲它分配棧空間。究竟分配多大的棧的空間是一個技術活。分的多了,浪費,分的少了,可能會溢位。Go在這裏實現了一個協程棧擴容的機制 機製,相對比較優雅的解決了這個問題。另外一個問題,關於上下文切換,這一般是跟平臺或者CPU相關的程式碼,因爲要涉及到暫存器操作。同時上下文切換也是有一點代價的,因爲畢竟需要額外執行一些指令(個人覺得這一點可以忽略掉,無棧的協程實現難道不是也需要一些額外的指令來完成程式邏輯的跳轉?)。

有棧協程看起來還是比較直觀,特別是對於開發人員比較友好。如果對比一下Rust實現的無棧協程,就會知道因爲引入這個棧,儲存上下文,從而解決了很多很麻煩的問題。

關於Go,講一點題外話。

Go有一個比較龐大的執行時刻庫。從上文我們瞭解到,因爲Go排程器的需要,執行時刻庫把所有的系統呼叫都做了封裝,這些所謂系統呼叫都被引入了排程器的排程點,也就是說,執行這類系統呼叫會進行協程的上下文切換。所以換一句話說。Go的系統呼叫,其實都是被包裝過的,能夠感知協程的系統呼叫。所以從這個角度也可以理解爲什麼Go的執行時刻庫是比較龐大的。另外,cgo的執行也是類似的過程。因爲呼叫的C程式碼非常有可能通過C庫來執行系統呼叫,這樣會使執行緒進入阻塞,從而影響Go的排程器的行爲。所以我們看到cgo總會執行entersyscallexitsyscall,就是這個原因。

Rust 協程

綠色執行緒 GreenThread

早期的Rust支援一個所謂的綠色執行緒,其實就是有棧協程的實現,與Go協程實現很相似。在0.7之後,綠色執行緒就被刪除了。其中一個原因是,如果引入這樣的機制 機製,那麼執行時刻庫也必須如Go語言一樣能夠支援有棧協程,也就是之前討論Go題外話提到的內容。Go沒有Native thread的概念,語言層面只支援協程,選擇封裝全部的系統呼叫很合理。然而,如果Rust也打算這麼做,那麼Native thread和協程執行庫API統一的問題將很難解決。

無棧協程

無棧協程顧名思義就是不使用棧和上下文切換來執行非同步程式碼邏輯的機制 機製。這裏非同步程式碼雖然是非同步的,但執行起來看起來是一個同步的過程。從這一點上來看Rust協程與Go協程也沒什麼兩樣。舉例說明:

async fn routine() 
{
	let mut a = 5;
	sleep(1000).await;
	a = a + 1;
	a
}

幾乎是一樣的流程。Sleep會導致睡眠,當時間已到,重新返回執行,區域性變數a 內容應該還是5。Go協程是有棧的,所以這個區域性變數儲存在棧中,而Rust是怎麼實現的呢?答案就是 Generator 生成的狀態機。Generator 和閉包類似,能夠捕獲變數a,放入一個匿名的結構中,在程式碼中看起來是區域性變數的數據 a,會被放入結構,儲存在全域性(執行緒)棧中。另外值得一提的是,Generator 生成了一個狀態機以保證程式碼正確的流程。從sleep.await 返回之後會執行 a=a+1 這行程式碼。async routine() 會根據內部的 .await 呼叫生成這樣的狀態機,驅動程式碼按照既定的流程去執行。

按照一般的說法。無棧協程有很多好處。首先不用分配棧。因爲究竟給協程分配多大的棧是個大問題。特別是在32位元的系統下,地址空間是有限的。每個協程都需要專門的棧,很明顯會影響到可以建立的協程總數。其次,沒有上下文切換,貌似效能也許會好一些?當然,更大的好處是並不需要與CPU體系相關程式碼,也就有了更好的跨平臺的能力。當然,無棧問題也不少。例如,Rust著名的PIN問題。另外,個人覺得Rust的無棧協程主要問題是不那麼直觀,理解起來會稍微吃力一些。

協程解決的問題

Rust語言真正實現 async/await 語法只是去年底的事情。在那之前,有一些其他臨時使用宏的替代做法。所以現在去看一些開源的軟體專案,真正採用 await 寫程式碼還是很少的,主要是 poll 的方式,這樣的程式碼需要自己維護各種狀態。一個經典的例子就是Sink發送的三件套:poll_ready/start_send/poll_flush,首先需要檢查是否緩衝區有待發送的數據,若是,則優先處理這一部分數據。然後檢查底層是否就緒,否則無法發送,這時候需要把當前發送的東西轉存下來,也就是前面提到的發送緩衝區。如果用C語言寫過epoll 相關的程式碼,那麼會發現和這裏也沒有什麼大的區別。因爲這就是非同步程式設計大致的模式。而事實上,如果可以用await來寫程式碼,直接呼叫SinkExt的send().await方法,一切煩惱都消失了。SinkExt::send 內部實現了包含發送緩衝的Sink的三件套,而await 用一種簡潔的方式將這一切優雅地呈現出來。這種利用.await 寫出來的程式碼,看似是用同步的方式在做非同步的程式設計,比較簡潔,易於理解。

總之,個人覺得Rust非同步程式設計的未來是 await。早期手動來寫各種poll方法,實在是太繁瑣了。語言實則是一種工具,被髮明出來是用來幫助程式設計師的,而不是造成更多的負擔。我相信這也是Rust .await 最大的意義。

下一篇文章,我們來研究下 async/await 究竟做了什麼。