Seastar 教程(一)

介紹

我們在本文檔中介紹的Seastar是一個 C++ 庫,用於在現代多核機器上編寫高效的複雜伺服器應用程式。

傳統上,用於編寫伺服器應用程式的程式語言庫和框架分為兩個不同的陣營:專註於效率的陣營和專註於複雜性的陣營。一些框架非常高效,但只允許構建簡單的應用程式(例如,DPDK 允許單獨處理數據包的應用程式),而其他框架允許構建極其複雜的應用程式,但以犧牲運行時效率為代價。Seastar 是我們兩全其美的嘗試:創建一個允許構建高度複雜的伺服器應用程式並實現最佳性能的庫。

Seastar 的靈感和第一個用例是 Scylla,它是對 Apache Cassandra 的重寫。Cassandra 是一個非常複雜的應用程式,然而,藉助 Seastar,我們能夠以高達 10 倍的吞吐量增加以及顯著降低和更一致的延遲重新實現它。

Seastar 提供了一個完整的非同步編程框架,它使用兩個概念——futurescontinuations——來統一表示和處理各種類型的非同步事件,包括網路 I/O、磁碟 I/O 以及其他事件的複雜組合。

由於現代多核和多插槽機器在內核之間共享數據(原子指令、快取行彈跳[1]和記憶體柵欄)有嚴重的懲罰,Seastar 程式使用無共享編程模型,即,可用記憶體在內核之間分配,每個核心都在其自己的記憶體部分中處理數據,並且核心之間的通訊通過顯式消息傳遞實現(當然,自己的通訊使用 SMP 的共享記憶體硬體實現)。

  • [1] 快取行彈跳(cache line bouncing):為了以較低的成本大幅提高性能,現代CPU都有cache。CPU cache已經發展到了三級快取結構,基本上現在買的個人電腦都是L3結構。其中L1和L2cache為每個核獨有,L3則所有核共享。為了保證所有的核看到正確的記憶體數據,一個核在寫入自己的L1 cache後,CPU會執行Cache一致性演算法把對應的cache line(一般是64位元組)同步到其他核。這個過程並不很快,是微秒級的,相比之下寫入L1 cache只需要若干納秒。當很多執行緒在頻繁修改某個欄位時,這個欄位所在的cacheline被不停地同步到不同的核上,就像在核間彈來彈去,這個現象就叫做cache bouncing。由於實現cache一致性往往有硬體鎖,cache bouncing是一種隱式的的全局競爭。

非同步編程

用於網路協議的伺服器,例如經典的 HTTP(Web)或 SMTP(電子郵件)伺服器,天生需要處理並行性。會存在多個客戶端並行地發送請求,我們沒辦法保證在開始處理下一個請求之前完成前一個請求的處理。一個請求可能而且經常確實需要阻塞,一個完整的 TCP 窗口(即慢速連接)、磁碟 I/O,甚至是維持非活動連接的客戶端。但是伺服器也還是要處理其他連接。

經典網路伺服器(如 Inetd、Apache Httpd 和 Sendmail)採用的處理這種並行連接的最直接方法是每個連接使用單獨的作業系統進程。這種技術的性能的提高經過了多年的發展:起初,每個新連接都產生一個新進程來處理;後來,保留了一個事先生成的進程池,並將每個新連接分配給該池中的一個未使用的進程;最後,進程被執行緒取代。然而,所有這些實現背後的共同想法是,在每個時刻,每個進程都只處理一個連接。因此,伺服器程式碼可以自由使用阻塞系統調用,例如讀取或寫入連接,或從磁碟讀取,如果此進程阻塞,

對每個連接使用一個進程(或執行緒)的伺服器進行編程稱為同步編程,因為程式碼是線性編寫的,並且一行程式碼在前一行完成後開始運行。例如,程式碼可能從套接字讀取請求,解析請求,然後從磁碟中讀取文件並將其寫回套接字。這樣的程式碼很容易編寫,幾乎就像傳統的非並行程式一樣。事實上,甚至可以運行一個外部的非並行程式來處理每個請求——例如 Apache HTTPd 如何運行”CGI”程式,這是動態網頁生成的第一個實現。

注意:雖然同步伺服器應用程式是以線性、非並行的方式編寫的,但在幕後,內核有助於確保一切並行發生,並且機器的資源——CPU、磁碟和網路——得到充分利用。除了進程並行(我們有多個進程並行處理多個連接)之外,內核甚至可以並行處理一個單獨的連接的工作——例如處理一個未完成的磁碟請求(例如,從磁碟文件讀取)與處理並行網路連接(發送緩衝但尚未發送的數據,並緩衝新接收的數據,直到應用程式準備好讀取它)。

但是同步的、每個連接的進程、伺服器編程並非沒有缺點和成本。慢慢地但肯定地,伺服器開發人員意識到啟動一個新進程很慢,上下文切換很慢,並且每個進程都有很大的開銷——最明顯的是它的堆棧大小。伺服器和內核開發人員努力減輕這些開銷:他們從進程切換到執行緒,從創建新執行緒到執行緒池,他們降低了每個執行緒的默認堆棧大小,並增加了虛擬記憶體大小以允許更多部分使用的堆棧。但是,採用同步設計的伺服器的性能仍不能令人滿意,並且隨著並發連接數量的增加,擴展性也很差。1999 年,Dan Kigel 普及了”C10K 問題”,需要單台伺服器高效處理 10k 個並發的連接——它們大多數很慢甚至是不活躍的。

在接下來的十年中流行的解決方案是放棄舒適但低效的同步伺服器設計,轉而使用一種新型的伺服器設計——非同步或事件驅動的伺服器。事件驅動伺服器只有一個執行緒,或者更準確地說,每個 CPU 一個執行緒。這個單執行緒運行一個緊密的循環,在每次迭代中,檢查、使用poll()(或更有效的epoll) 用於許多打開文件描述符(例如套接字)上的新事件。例如,一個事件可以是一個套接字變得可讀(新數據已經從遠程端到達)或變得可寫(我們可以在這個連接上發送更多數據)。應用程式通過執行一些非阻塞操作、修改一個或多個文件描述符以及保持其對該連接狀態的了解來處理此事件。

然而,非同步伺服器應用程式的編寫者面臨並且今天仍然面臨兩個重大挑戰:

  • 複雜性:編寫一個簡單的非同步伺服器很簡單。但是編寫一個複雜的非同步伺服器是出了名的困難。單個連接的處理,不再是一個簡單易讀的函數調用,現在涉及大量的小回調函數,以及一個複雜的狀態機來記住每個事件發生時需要調用哪個函數。

  • 非阻塞:每個核心只有一個執行緒對於伺服器應用程式的性能很重要,因為上下文切換很慢。但是,如果我們每個核心只有一個執行緒,則事件處理函數絕不能阻塞,否則核心將保持空閑狀態。但是一些現有的程式語言和框架讓伺服器作者別無選擇,只能使用阻塞函數,因此是多執行緒。例如,Cassandra被編寫為非同步伺服器應用程式;但是由於磁碟 I/O 是用mmap文件實現的,在訪問時會不可控地阻塞整個執行緒,因此它們被迫在每個 CPU 上運行多個執行緒。

此外,當需要儘可能好的性能時,伺服器應用程式及其編程框架別無選擇,只能考慮以下因素:

  • 現代機器:現代機器與 10 年前的機器大不相同。它們有許多內核和深記憶體層次結構(從 L1 快取到 NUMA),這會獎勵某些編程實踐並懲罰其他實踐:不可擴展的編程實踐(例如獲取鎖)可能會破壞多核的性能;共享記憶體和無鎖同步原語雖然可以使用(即原子操作和memory-ordering fences),但比僅涉及單個內核快取中的數據的操作要慢得多,並且還會阻止應用程式擴展到多個內核。

  • 程式語言: Java、Javascript 和類似的”現代”語言等高級語言很方便,但每種語言都有自己的一組假設,這些假設與上面列出的要求相衝突。這些旨在可移植的語言也使程式設計師對關鍵程式碼的性能的控制更少。為了真正獲得最佳性能,我們需要一種程式語言,它可以讓程式設計師完全控制、零運行時開銷,另一方面——複雜的編譯時程式碼生成和優化。

Seastar 是一個用於編寫非同步伺服器應用程式的框架,旨在解決上述所有四個挑戰: 它是一個用於編寫涉及網路和磁碟 I/O的複雜非同步應用程式的框架。該框架的快速路徑完全是單執行緒的(每個內核),可擴展到多個內核,並最大限度地減少內核之間昂貴的記憶體共享的使用。它是一個 C++14 庫,為用戶提供複雜的編譯時功能和對性能的完全控制,而沒有運行時開銷。

Seastar

Seastar 是一個事件驅動的框架,允許您以相對簡單的方式(一旦理解)編寫非阻塞、非同步程式碼。它的 API 基於future。Seastar 利用以下概念實現極致性能:

  • 協作式微任務調度器:每個核心都運行一個協作式任務調度器,而不是運行執行緒。每個任務通常都是非常輕量級的——只在處理最後一個 I/O 操作的結果並提交一個新操作的時候運行。
  • Share-nothing SMP 架構:每個核心獨立於 SMP 系統中的其他核心運行。記憶體、數據結構和 CPU 時間不共享;相反,內核間通訊使用顯式消息傳遞。Seastar 核心通常稱為分片。TODO:更多在這裡//github.com/scylladb/seastar/wiki/SMP
  • 基於 Future 的 API:futures 允許您提交 I/O 操作並在 I/O 操作完成時鏈接要執行的任務。並行運行多個 I/O 操作很容易——例如,為了響應來自 TCP 連接的請求,您可以發出多個磁碟 I/O 請求,向同一系統上的其他內核發送消息,或發送請求到集群中的其他節點,等待部分或全部結果完成,聚合結果並發送響應。
  • Share-nothing TCP 棧:Seastar 可以使用主機作業系統的 TCP 棧,它還提供了自己的高性能 TCP/IP 棧,構建在任務調度器和 share-nothing 架構之上。堆棧在兩個方向上都提供零拷貝:您可以直接從 TCP 堆棧的緩衝區處理數據,並將您自己的數據結構的內容作為消息的一部分發送而不會產生拷貝。
  • 基於 DMA 的存儲 API:與網路堆棧一樣,Seastar 提供零拷貝存儲 API,允許您將數據 DMA 進出存儲設備。

本教程面向已經熟悉 C++ 語言的開發人員,將介紹如何使用 Seastar 創建新應用程式。

入門

最簡單的 Seastar 程式是這樣的:

#include <seastar/core/app-template.hh>
#include <seastar/core/reactor.hh>
#include <iostream>

int main(int argc, char** argv) {
	seastar::app_template app;
	app.run(argc, argv, [] {
			std::cout << "Hello world\n";
			return seastar::make_ready_future<>();
	});
}

正如我們在本例中所做的那樣,每個 Seastar 程式都必須定義並運行一個app_template對象。該對象在一個或多個 CPU 上啟動主事件循環(Seastar引擎),然後運行給定函數 —— 在本例中是一個未命名的函數,一個lambda —— 一次。

return make_ready_future<>();導致事件循環和整個應用程式在列印”Hello World”消息後立即退出。在更典型的 Seastar 應用程式中,我們希望事件循環保持活動狀態並處理傳入的數據包(例如),直到顯式退出。此類應用程式將返回一個確定何時退出應用程式的未來。我們將在下面介紹future以及如何使用它們。在任何情況下,都不應使用常規 C exit(),因為它會阻止 Seastar 或應用程式進行適當的清理。

如本例所示,所有 Seastar 函數和類型都位於 “seastar” 命名空間中。用戶可以每次都輸入這個命名空間前綴,或者使用”using seastar::app_template“甚至” using namespace seastar“之類的快捷方式來避免輸入這個前綴。我們通常建議顯式地使用命名空間前綴seastar和std,並將在下面的所有示例中遵循這種風格。

要編譯這個程式,首先要確保你已經下載、編譯和安裝了 Seastar,然後把上面的程式放在你想要的源文件中,我們把這個文件叫做getting-started.cc.

Linux 的pkg-config是一種輕鬆確定使用各種庫(例如 Seastar)所需的編譯和鏈接參數的方法。例如,如果 Seastar 已在該目錄$SEASTAR中構建但未安裝,則可以使用以下命令對getting-started.cc進行編譯:

c++ getting-started.cc `pkg-config --cflags --libs --static $SEASTAR/build/release/seastar.pc`

之所以需要”--static“,是因為目前 Seastar 是作為靜態庫構建的,所以我們需要告訴pkg-config在鏈接命令中包含它的依賴項(而如果 Seastar 是一個共享庫,它可能會引入它自己的依賴項)。

如果安裝了 Seastar,命令pkg-config行會更短:

c++ getting-started.cc `pkg-config --cflags --libs --static seastar`

或者,可以使用 CMake 輕鬆構建 Seastar 程式。鑒於以下CMakeLists.txt

cmake_minimum_required (VERSION 3.5)

project (SeastarExample)

find_package (Seastar REQUIRED)

add_executable (example
  getting-started.cc)

target_link_libraries (example
  PRIVATE Seastar::seastar)

您可以使用以下命令編譯示例:

$ mkdir build
$ cd build
$ cmake ..
$ make

該程式現在按預期運行:

$ ./example
Hello world
$

執行緒和記憶體

Seastar 執行緒

如簡介中所述,基於 Seastar 的程式在每個 CPU 上運行一個執行緒。這些執行緒中的每一個都運行自己的事件循環,在 Seastar 命名法中稱為引擎。默認情況下,Seastar 應用程式將接管所有可用內核,每個內核啟動一個執行緒。我們可以通過以下程式看到這一點,列印seastar::smp::count啟動執行緒的數量:

#include <seastar/core/app-template.hh>
#include <seastar/core/reactor.hh>
#include <iostream>

int main(int argc, char** argv) {
	seastar::app_template app;
	app.run(argc, argv, [] {
			std::cout << seastar::smp::count << "\n";
			return seastar::make_ready_future<>();
	});
}

在具有 4 個硬體執行緒(兩個內核,並啟用超執行緒)的機器上,Seastar 將默認啟動 4 個引擎執行緒:

$ ./a.out
4

這 4 個引擎執行緒中的每一個都將被固定(la taskset(1))到不同的硬體執行緒。請注意,如上所述,應用程式的初始化函數僅在一個執行緒上運行,因此我們只看到輸出”4″一次。在本教程的後面,我們將看到如何使用所有執行緒。

用戶可以傳遞命令行參數-c來告訴 Seastar 啟動的執行緒數少於可用的硬體執行緒數。例如,要僅在 2 個執行緒上啟動 Seastar,用戶可以執行以下操作:

$ ./a.out -c2
2

假設機器有兩個內核,每個內核各有兩個超執行緒,當機器按照上面的示例進行配置只請求兩個執行緒時,Seastar 確保每個執行緒都固定到不同的內核,並且我們不會讓兩個執行緒作為超執行緒競爭相同的核心(當然,這會損害性能)。

我們不能啟動比硬體執行緒數更多的執行緒,因為這樣做會非常低效。嘗試設置更大的值會導致錯誤:

$ ./a.out -c5
Could not initialize seastar: std::runtime_error (insufficient processing units)

該錯誤是app.run拋出的異常,被 seastar 自己捕獲並轉化為非零退出程式碼。請注意,以這種方式捕獲異常不會捕獲應用程式實際非同步程式碼中拋出的異常。我們將在本教程後面討論這些。

Seastar 記憶體

正如介紹中所解釋的,Seastar 應用程式對它們的記憶體進行分片。每個執行緒都預先分配了一大塊記憶體(在它運行的同一個 NUMA 節點上),並且只使用該記憶體進行分配(例如malloc()new)。

默認情況下,機器的整個記憶體除了為作業系統保留的特定保留(默認為最大 1.5G 或總記憶體的 7%)以這種方式預分配給應用程式。可以通過使用--reserve-memory選項更改為作業系統保留的數量(Seastar 不使用)或通過使用-m選項顯式指定給予 Seastar 應用程式的記憶體量來更改此默認值。此記憶體量可以以位元組為單位,也可以使用單位「k」、「M」、「G」或「T」。這些單位使用二的冪值:「M」是mebibyte,2^20 (=1,048,576) 位元組,而不是megabyte(10^6 或 1,000,000 位元組)。

嘗試為 Seastar 提供比物理記憶體更多的記憶體會立即失敗:

$ ./a.out -m10T
Couldn't start application: std::runtime_error (insufficient physical memory)

介紹 futures 和 continuations

我們現在將介紹的 Futures 和 continuations 是 Seastar 中非同步編程的構建塊。它們的優勢在於可以輕鬆地將它們組合成一個大型、複雜的非同步程式,同時保持程式碼的可讀性和可理解性。

future是可能尚不可用的計算的結果。示例包括:

  • 我們從網路中讀取的數據緩衝區
  • 計時器到期
  • 磁碟寫入完成
  • 需要來自一個或多個其他future的值的計算結果。

future<int>變數包含一個最終可用的int —— 此時可能已經可用,或者可能還不可用。available()方法測試一個值是否已經可用,get() 方法獲取該值。類型future<>表示最終將完成但不返回任何值。

future通常由非同步函數返回,該函數返回future並安排最終解決該future。因為非同步函數承諾最終解決它們返回的future,所以非同步函數有時被稱為「承諾」;但是我們將避免使用這個術語,因為它往往會比它所解釋的更容易混淆。

一個簡單的非同步函數示例是 Seastar 的函數 sleep()

future<> sleep(std::chrono::duration<Rep, Period> dur);

此函數安排一個計時器,以便在給定的持續時間過去時返回的future變得可用(沒有關聯的值)。

continuation 是在未來可用時運行的回調(通常是 lambda)。使用該方法將延續附加到未來then()。這是一個簡單的例子:

#include <seastar/core/app-template.hh>
#include <seastar/core/sleep.hh>
#include <iostream>

int main(int argc, char** argv) {
	seastar::app_template app;
	app.run(argc, argv, [] {
		std::cout << "Sleeping... " << std::flush;
		using namespace std::chrono_literals;
		return seastar::sleep(1s).then([] {
			std::cout << "Done.\n";
		});
	});
}	

在這個例子中,我們看到我們從seastar::sleep(1s)獲得一個future,並附加一個列印「Done.」資訊的continuationfuture將在 1 秒後變為可用,此時繼續執行。運行這個程式,我們確實立即看到消息「Sleeping…」,一秒鐘後看到消息「Done.」出現並且程式退出。

then()的返回值本身就是一個future,它對於一個接一個地鏈接多個延續很有用,我們將在下面解釋。但是這裡我們只注意我們從app.run的函數return這個future ,這樣程式只有在sleep和它的continuation 都完成後才會退出。

為了避免在本教程的每個程式碼示例中重複樣板「app_engine」部分,讓我們創建一個簡單的 main(),我們將使用它來編譯以下示例。這個 main 只是調用 function future<> f(),進行適當的異常處理,並在f解決返回的 future 時退出:

#include <seastar/core/app-template.hh>
#include <seastar/util/log.hh>
#include <iostream>
#include <stdexcept>

extern seastar::future<> f();

int main(int argc, char** argv) {
	seastar::app_template app;
	try {
		app.run(argc, argv, f);
	} catch(...) {
		std::cerr << "Couldn't start application: "
				  << std::current_exception() << "\n";
		return 1;
	}
	return 0;
}

與這個main.cc一起編譯,上面的 sleep() 示例程式碼變為:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<> f() {
	std::cout << "Sleeping... " << std::flush;
	using namespace std::chrono_literals;
	return seastar::sleep(1s).then([] {
		std::cout << "Done.\n";
	});
}

到目前為止,這個例子並不是很有趣——沒有並行性,同樣的事情也可以通過普通的阻塞 POSIX 來實現sleep()。當我們並行啟動多個sleep()期貨並為每個期貨附加不同的延續時,事情變得更加有趣。futurescontinuation使並行性變得非常容易和自然:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<> f() {
	std::cout << "Sleeping... " << std::flush;
	using namespace std::chrono_literals;
	seastar::sleep(200ms).then([] { std::cout << "200ms " << std::flush; });
	seastar::sleep(100ms).then([] { std::cout << "100ms " << std::flush; });
	return seastar::sleep(1s).then([] { std::cout << "Done.\n"; });
}

每個sleep()then()調用立即返回:sleep()只是啟動請求的計時器,並then()設置在計時器到期時調用的函數。所以所有三行都立即發生並且 f 返回。只有這樣,事件循環才開始等待三個未完成的future就緒,當每個都就緒時,附加到它的continuation運行。上述程式的輸出當然是:

$ ./a.out
Sleeping... 100ms 200ms Done.

sleep()返回future<>,這意味著它將在將來完成,但一旦完成,就不會返回任何值。更有趣的future確實指定了稍後將可用的任何類型(或多個值)的值。在下面的示例中,我們有一個返回future<int> 的函數,以及一個在該值可用時運行的 continuation。請注意continuation如何將未來的值作為參數:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<int> slow() {
	using namespace std::chrono_literals;
	return seastar::sleep(100ms).then([] { return 3; });
}

seastar::future<> f() {
	return slow().then([] (int val) {
		std::cout << "Got " << val << "\n";
	});
}

函數slow()值得更詳細的解釋。像往常一樣,此函數立即返回future<int>,並且不等待 sleep 完成,並且程式碼中的程式碼f()可以將continuation鏈接到此future 的完成。slow()返回的future本身就是一個future鏈:一旦sleep的future就緒,它就會就緒,然後返回值3。我們將在下面更詳細地解釋then()如何返回future,以及這如何允許鏈接future

這個例子開始展示期貨編程模型的便利性,它允許程式設計師巧妙地封裝複雜的非同步操作。slow()可能涉及需要多個步驟的複雜非同步操作,但它的用戶可以像簡單地使用sleep()一樣輕鬆地使用它,並且 Seastar 的引擎負責在正確時間運行其future已就緒的continuation

就緒的future

then()被調用以將continuation鏈接到它時future值可能已經準備好。這個重要的案例已經過優化,通常會立即運行延續,而不是註冊到稍後在事件循環的下一次迭代中運行。

這種優化通常會進行,但有時會避免: then()的實現持有這樣一個立即運行的continuation的計數器,並且在立即運行許多continuation而不返回事件循環(當前限制為 256)之後,下一個continuation會被推遲到事件循環。這很重要,因為在某些情況下(例如後面討論的未來循環),我們會發現每個準備好的continuation都會產生一個新的continuation,如果沒有這個限制,我們可能會餓死事件循環。重要的是不要讓事件循環餓死,因為這會餓死那些尚未準備好但已經準備好的futurecontinuation,也會餓死由事件循環完成的重要的輪詢(例如,檢查網卡上是否有新活動)。

make_ready_future<>可用於返回已經準備好的future。以下示例與前一個示例相同,除了承諾函數fast()返回一個已經準備好的future,而不是像上一個示例那樣在一秒鐘內準備好。好消息是future的消費者並不關心,並且在兩種情況下都以相同的方式使用future

#include <seastar/core/future.hh>
#include <iostream>

seastar::future<int> fast() {
	return seastar::make_ready_future<int>(3);
}

seastar::future<> f() {
	return fast().then([] (int val) {
		std::cout << "Got " << val << "\n";
	});
}