介紹
我們在本文件中介紹的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 個引擎執行緒中的每一個都將被固定(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.”資訊的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
值可能已經準備好。這個重要的案例已經過優化,通常會立即執行延續,而不是註冊到稍後在事件迴圈的下一次迭代中執行。
這種優化通常會進行,但有時會避免: 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";
});
}