教程翻譯自Seastar官方文檔:https://github.com/scylladb/seastar/blob/master/doc/tutorial.md
轉載請注明出處:https://www.cnblogs.com/morningli/p/15920469.html
介紹
我們在本文檔中介紹的Seastar是一個 C++ 庫,用於在現代多核機器上編寫高效的復雜服務器應用程序。
傳統上,用於編寫服務器應用程序的編程語言庫和框架分為兩個不同的陣營:專注於效率的陣營和專注於復雜性的陣營。一些框架非常高效,但只允許構建簡單的應用程序(例如,DPDK 允許單獨處理數據包的應用程序),而其他框架允許構建極其復雜的應用程序,但以犧牲運行時效率為代價。Seastar 是我們兩全其美的嘗試:創建一個允許構建高度復雜的服務器應用程序並實現最佳性能的庫。
Seastar 的靈感和第一個用例是 Scylla,它是對 Apache Cassandra 的重寫。Cassandra 是一個非常復雜的應用程序,然而,借助 Seastar,我們能夠以高達 10 倍的吞吐量增加以及顯着降低和更一致的延遲重新實現它。
Seastar 提供了一個完整的異步編程框架,它使用兩個概念——futures和continuations——來統一表示和處理各種類型的異步事件,包括網絡 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:更多在這里https://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 個引擎線程中的每一個都將被固定(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.”信息的continuation。future將在 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()期貨並為每個期貨附加不同的延續時,事情變得更加有趣。futures和 continuation使並行性變得非常容易和自然:
#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時,future值可能已經就緒了。這個重要的案例已經過優化,通常會立即運行continuation,而不是注冊到稍后在事件循環的下一次迭代中運行。
通常會進行這種優化,但有時不會: then()的實現持有一個立即運行的continuation的計數器,在立即運行許多continuation而不返回事件循環(當前限制為 256)后,下一個continuation無論如何都會被推遲到事件循環。這很重要,因為在某些情況下(例如后面討論的未來循環),我們會發現每個准備好的continuation都會產生一個新的continuation,如果沒有這個限制,我們可能會餓死事件循環。重要的是不要讓事件循環餓死,因為這會餓死那些尚未准備好但以后會准備好的future的continuation,也會餓死由事件循環完成的重要的輪詢(例如,檢查網卡上是否有新活動)。
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";
});
}
