協程
注意:協程需要 C++20 和支援的編譯器。已知 Clang 10 及更高版本可以工作。
使用 Seastar 編寫高效非同步程式碼的最簡單方法是使用協程。協程沒有傳統continuation(如下)的大部分陷阱,因此是編寫新程式碼的首選方式。
協程是一個返回 aseastar::futureco_await
或者co_return
關鍵字的函式。協程對其呼叫者和被呼叫者是不可見的;它們以任一角色與傳統的 Seastar 程式碼整合。如果對 C++ 協程不熟悉,可以參考 A more general introduction to C++ coroutines ;本節重點介紹協程如何與 Seastar 整合。
下面是一個簡單的 Seastar 協程示例:
#include <seastar/core/coroutine.hh>
seastar::future<int> read();
seastar::future<> write(int n);
seastar::future<int> slow_fetch_and_increment() {
auto n = co_await read(); // #1
co_await seastar::sleep(1s); // #2
auto new_n = n + 1; // #3
co_await write(new_n); // #4
co_return n; // #5
}
在#1 中,我們呼叫read()函式,它返回一個future
。co_await
關鍵字指示 Seastar 檢查返回的future
。如果 future
就緒,則從 future 中提取值 (int) 並分配給n
。如果future
還沒有就緒,協程安排自己在未來就緒時被呼叫,並將控制權返回給 Seastar。一旦 future
準備就緒,協程就會被喚醒,並從 future
中提取值並分配給n
.
在 #2 中,我們呼叫seastar::sleep()
並等待返回的 future
就緒,它會在一秒鐘內完成。這表明n
是跨co_await
呼叫保留的,協程的作者不需要為協程區域性變數安排儲存。
第 #3 行演示了加法運算,假定讀者熟悉該運算。
在 #4 中,我們呼叫了一個返回 seastar::future<>
的函式。在這種情況下,future
沒有任何值,因此不會提取和分配任何值。
第 #5 行演示了返回一個值。整數值用於滿足呼叫者在呼叫協程時得到的future<int>
。
協程中的異常
協程自動將異常轉換為future
並返回。
呼叫co_await foo()
,當foo()
返回一個異常的future
時,會丟擲future
攜帶的異常。
類似地,在協程中丟擲將導致協程返回異常的future
。
例子:
#include <seastar/core/coroutine.hh>
seastar::future<> function_returning_an_exceptional_future();
seastar::future<> exception_handling() {
try {
co_await function_returning_an_exceptional_future();
} catch (...) {
// exception will be handled here
}
throw 3; // will be captured by coroutine and returned as
// an exceptional future
}
協程中的併發
co_await
運算子允許簡單的順序執行。多個協程可以並行執行,但每個協程一次只有一個未完成的計算。
類别範本seastar::coroutine::all
允許協程分成幾個同時執行的子協程(或 Seastar 纖程,見下文),並在它們完成時再次加入。考慮這個例子:
#include <seastar/core/coroutines.hh>
#include <seastar/coroutine/all.hh>
seastar::future<int> read(int key);
seastar::future<int> parallel_sum(int key1, int key2) {
int [a, b] = co_await seastar::coroutine::all(
[&] {
return read(key1);
},
[&] {
return read(key2);
}
);
co_return a + b;
}
在這裡,兩個 read()
呼叫同時啟動。協程會暫停,直到兩個讀取都完成,並且返回的值被分配給a
和b
。如果read(key)
是一個涉及 I/O 的操作,那麼併發執行將比我們co_await
單獨呼叫每個呼叫更快完成,因為 I/O 可以重疊。
請注意all
,即使某些子計算丟擲異常,它也會等待它的所有子計算。如果丟擲異常,則將其傳播到呼叫協程。
分解長時間執行的計算
Seastar 通常用於 I/O,協程通常會啟動 I/O 操作並消耗其結果,中間幾乎沒有計算。但偶爾需要長時間執行的計算,這可能會阻止反應器執行 I/O 和排程其他任務。
協程會在co_await
表示式中自動讓出;但是在計算中我們不做co_await
。我們可以在這種情況下使用seastar::coroutine::maybe_yield
類:
#include <seastar/coroutine/maybe_yield>
seastar::future<int> long_loop(int n) {
float acc = 0;
for (int i = 0; i < n; ++i) {
acc += std::sin(float(i));
// Give the Seastar reactor opportunity to perform I/O or schedule
// other tasks.
co_await seastar::coroutine::maybe_yield();
}
co_return acc;
}
Continuation
捕獲continuation狀態
我們已經看到 Seastar continuation
是 lambdas,傳遞給future
的then()
方法。在我們目前看到的例子中,lambdas 只不過是匿名函式。但是 C++11 的 lambdas 還有一個技巧,這對於 Seastar 中基於future
的非同步程式設計非常重要:lambdas 可以捕獲狀態。考慮以下示例:
#include <seastar/core/sleep.hh>
#include <iostream>
seastar::future<int> incr(int i) {
using namespace std::chrono_literals;
return seastar::sleep(10ms).then([i] { return i + 1; });
}
seastar::future<> f() {
return incr(3).then([] (int val) {
std::cout << "Got " << val << "\n";
});
}
未來的操作incr(i)
需要一些時間才能完成(它需要先睡一會兒……),在這段時間內,它需要儲存它正在處理的值i
。在早期的事件驅動程式設計模型中,程式設計師需要顯式定義一個物件來保持這種狀態,並管理所有這些物件。使用 C++11 的 lambda,Seastar 中的一切都變得簡單得多:上面示例中的捕獲語法“[i]”意味著 i 的值,因為它在incr()
被呼叫時存在,被捕獲到 lambda 中。lambda 不僅僅是一個函式 - 它實際上是一個物件, 程式碼和資料。本質上,編譯器自動為我們建立了 state 物件,我們不需要定義它,也不需要跟蹤它(它當 continuation
被延遲時與 continuation
一起儲存,並在 continuation
執行後自動刪除)。
一個值得理解的實現細節是,當一個 continuation
捕獲狀態並立即執行時,此捕獲不會產生執行時開銷。但是,當 continuation
不能立即執行(因為 future
還沒有就緒)並且需要儲存一段時間,需要在堆上為這些資料分配記憶體,並且需要將 continuation
捕獲的資料複製到那裡。這有執行時開銷,但這是不可避免的,並且與執行緒程式設計模型中的相關開銷相比非常小(線上程程式中,這種狀態通常駐留在阻塞執行緒的堆疊中,但堆疊要比我們微小的捕獲狀態大得多,佔用大量記憶體並在這些執行緒之間的上下文切換上造成大量快取汙染)。
在上面的示例中,我們通過值捕獲i
—— 即,將值的副本i
儲存到continuation
中。C++ 有兩個額外的捕獲選項:通過reference
捕獲和通過move
捕獲:
在延續中使用按reference
捕獲通常是錯誤的,並且可能導致嚴重的錯誤。例如,如果在上面的示例中,我們捕獲了對 i
的引用,而不是複製它,
seastar::future<int> incr(int i) {
using namespace std::chrono_literals;
// Oops, the "&" below is wrong:
return seastar::sleep(10ms).then([&i] { return i + 1; });
}
這意味著continuation
將包含 i
的地址,而不是它的值。但是i
是一個堆疊變數,而incr()函式會立即返回,所以當continuation
最終開始執行時,在incr()
返回很久之後,這個地址將包含不相關的內容。
reference捕獲通常是錯誤
規則的一個例外是do_with()成語,我們將在後面介紹。這個習慣用法確保一個物件在continuation
的整個生命週期中都存在,並且使得通過reference
捕獲成為可能,並且非常方便。
在 continuation
中使用move
捕獲也非常有用。通過將一個物件move
到一個continuation
中,我們將這個物件的所有權轉移給continuation
,並且使物件在continuation
結束時很容易被自動刪除。例如,考慮一個使用std::unique_ptr
int do_something(std::unique_ptr<T> obj) {
// do some computation based on the contents of obj, let's say the result is 17
return 17;
// at this point, obj goes out of scope so the compiler delete()s it.
通過以這種方式使用 unique_ptr
,呼叫者將一個物件傳遞給函式,但告訴它該物件現在是它的專屬職責——當函式處理完該物件時,它會自動刪除它。我們如何在continuation
中使用 unique_ptr
?以下將不起作用:
seastar::future<int> slow_do_something(std::unique_ptr<T> obj) {
using namespace std::chrono_literals;
// The following line won't compile...
return seastar::sleep(10ms).then([obj] () mutable { return do_something(std::move(obj)); });
}
問題是 unique_ptr
不能按值傳遞給延續,因為這需要複製它,這是被禁止的,因為它違反了該指標僅存在一個副本的保證。但是,我們可以將obj``move
到continuation
中:
seastar::future<int> slow_do_something(std::unique_ptr<T> obj) {
using namespace std::chrono_literals;
return seastar::sleep(10ms).then([obj = std::move(obj)] () mutable {
return do_something(std::move(obj));
});
}
這裡使用std::move()
引起obj
的 move-assignment
, 用於將物件從外部函式移動到continuation
中。在 C++11 中引入的move
(move
語義)的概念類似於淺拷貝,然後使源拷貝無效(這樣兩個拷貝就不會共存,正如 unique_ptr
所禁止的那樣)。將 obj
移入 continuation
之後,頂層函式就不能再使用它了(這種情況下當然沒問題,因為我們無論如何都要返回)。
我們在這裡使用的[obj = ...]
捕獲語法對於 C++14 來說是新的。這就是 Seastar 需要 C++14 且不支援較舊的 C++11 編譯器的主要原因。
這裡需要額外的() mutable
語法,因為預設情況下,當 C++ 將一個值(在本例中為 std::move(obj) 的值)捕獲到 lambda 中時,它會將此值設為只讀,因此在此示例中,我們的 lambda 不能再次移動。新增mutable
消除了這種人為的限制。
鏈式continuation
我們已經在上面的 slow() 中看到了連結示例。談論從then
返回,並返回一個future
並連結更多的then
。
處理異常
continuation
中丟擲的異常被系統隱式捕獲並儲存在future
。儲存此類異常的 future
類似於準備好的 future
,因為它可以導致其繼續被啟動,但它不包含值,僅包含異常。
在這樣的future
呼叫.then()
會跳過continuation
,並將輸入future
(.then()
被呼叫的物件)的異常轉移到輸出future
(.then()
的返回值)。
此預設處理與正常的異常行為相似——如果在直線程式碼中丟擲異常,則跳過以下所有行:
line1();
line2(); // throws!
line3(); // skipped
類似於
return line1().then([] {
return line2(); // throws!
}).then([] {
return line3(); // skipped
});
通常,中止當前的操作鏈並返回異常是需要的,但有時需要更細粒度的控制。有幾種處理異常的原語:
.then_wrapped()
:不是將future
攜帶的值傳遞給continuation
,.then_wrapped()
將輸入future
傳遞給continuation
。這個future
保證處於就緒狀態,因此continuation
可以檢查它是否包含值或異常,並採取適當的行動。.finally()
: 類似於 Java 的 finally 塊,.finally()
無論其輸入future
是否帶有異常,都會執行continuation
。finally
延續的結果是它的輸入future
,因此.finally()
可用於在無條件執行的流程中插入程式碼,但不會改變流程。
異常 vs. 異常future
非同步函式可以通過以下兩種方式之一失敗:它可以通過丟擲異常立即失敗,或者它可以返回最終將失敗的future
(解析為異常)。這兩種失敗模式看起來很相似,但在嘗試使用 finally()
、handle_exception()
或 then_wrapped()
處理異常時是不一樣的行為。例如,考慮以下程式碼:
#include <seastar/core/future.hh>
#include <iostream>
#include <exception>
class my_exception : public std::exception {
virtual const char* what() const noexcept override { return "my exception"; }
};
seastar::future<> fail() {
return seastar::make_exception_future<>(my_exception());
}
seastar::future<> f() {
return fail().finally([] {
std::cout << "cleaning up\n";
});
}
如預期的那樣,此程式碼將列印“cleaning up”訊息 - 非同步函式fail()
返回解析為失敗的future
,並且finally()
continuation
儘管出現此失敗,但繼續執行。
現在考慮在上面的例子中我們有一個fail()
不同的定義:
seastar::future<> fail() {
throw my_exception();
}
在這裡,fail()
不返回失敗的future
。相反,它根本無法返回future
!它丟擲的異常會停止整個函式f()
,並且finally()
延續不會附加到future
(從未返回),並且永遠不會執行。現在不列印“cleaning up”訊息。
我們建議為了減少此類錯誤的機會,非同步函式應始終返回失敗的future
,而不是丟擲實際的異常。如果非同步函式在返回未來之前呼叫另一個函式,並且第二個函式可能會丟擲,它應該使用try
/catch
來捕獲異常並將其轉換為失敗的future
:
儘管建議非同步函式避免丟擲異常,但一些非同步函式除了返回異常儘管建議非同步函式避免丟擲異常,但一些非同步函式除了返回異常期貨外,還會丟擲異常。一個常見的例子是分配記憶體並在記憶體不足時丟擲
std::bad_alloc
的函式,而不是返回future
。future<> seastar::semaphore::wait()
方法就是這樣一個函式:它返回一個future
,如果訊號量broken()
或等待超時,它可能返回異常的future
,但也可能在分配儲存等待者列表的記憶體失敗時丟擲異常。因此,除非一個函式——包括非同步函式——被顯式標記為“ noexcept”,應用程式應該準備好處理從它丟擲的異常。在現代 C++ 中,程式碼通常使用 RAII 來保證異常安全,而不是使用try
/catch
。seastar::defer()
是一個基於 RAII 的習慣用法,即使丟擲異常也能確保執行一些清理程式碼。
Seastar 有一個方便的通用函式 ,futurize_invoke()
,它在這裡很有用。futurize_invoke(func, args...)
執行一個可以返回future
值或立即值的函式,並且在這兩種情況下都將結果轉換為future
值。futurize_invoke()
,還像我們上面所做的那樣將函式丟擲的立即異常(如果有)轉換為失敗的future
。因此使用futurize_invoke()
,即使fail()丟擲異常,我們也可以使上面的示例工作:
seastar::future<> fail() {
throw my_exception();
}
seastar::future<> f() {
return seastar::futurize_invoke(fail).finally([] {
std::cout << "cleaning up\n";
});
}
請注意,如果異常風險存在於continuation
中,則大部分討論將變得毫無意義。考慮以下程式碼:
seastar::future<> f() {
return seastar::sleep(1s).then([] {
throw my_exception();
}).finally([] {
std::cout << "cleaning up\n";
});
}
在這裡,第一個延續的 lambda 函式確實丟擲了一個異常,而不是返回一個失敗的future
。然而,我們沒有和以前一樣的問題,這只是因為非同步函式在返回一個有效的future
之前丟擲了一個異常。在這裡,f()
確實會立即返回一個有效的未來——只有在sleep()
解決之後才能知道失敗。裡面的資訊finally()
會被列印出來。附加continuation
的方法(例如then()
和finally()
)以相同的方式執行continuation
,因此continuation
函式可能返回立即值,或者在這種情況下,丟擲立即異常,並且仍然正常工作。
生命週期管理
非同步函式啟動一個操作,該操作可能會在函式返回後很長時間繼續:函式本身幾乎立即返回 future<T>
,但可能需要一段時間才能解決這個future
。
當這樣的非同步操作需要對現有物件進行操作,或者使用臨時物件時,我們需要擔心這些物件的生命週期:我們需要確保這些物件在非同步函式完成之前不會被銷燬(否則它會嘗試使用釋放的物件併發生故障或崩潰),並確保物件在不再需要時最終被銷燬(否則我們將發生記憶體洩漏)。Seastar 提供了多種機制來安全有效地讓物件在適當的時間內保持活動狀態。在本節中,我們將探討這些機制,以及何時使用每種機制。
將所有權傳遞給continuation
確保物件在 continuation
執行並隨後被銷燬時處於活動狀態的最直接方法是將其所有權傳遞給 continuation
。當 continuation
擁有該物件時,該物件將一直保留到 continuation
執行,並在不需要 continuation
時立即銷燬(即,它可能已經執行,或者在出現異常和then()``continuation
時跳過)。
我們已經在上面看到,繼續獲取物件所有權的方法是通過捕獲:
seastar::future<> slow_incr(int i) {
return seastar::sleep(10ms).then([i] { return i + 1; });
}
這裡continuation
捕獲i
的值。換句話說,continuation
包含i
的拷貝. 當 continuation
執行 10 毫秒後,它可以訪問此值,並且一旦continuation
完成其物件連同其捕獲的i
的拷貝會被銷燬。continuation
擁有i
的拷貝。
像我們在這裡所做的那樣按值捕獲 —— 拷貝我們在延續中需要的物件 —— 主要用於非常小的物件,例如前面示例中的整數。其他物件的複製成本很高,有時甚至無法複製。例如,以下不是一個好主意:
seastar::future<> slow_op(std::vector<int> v) {
// this makes another copy of v:
return seastar::sleep(10ms).then([v] { /* do something with v */ });
}
這將是低效的 —— 因為 vector v
可能很長,將被複制儲存在continuation
中。在這個例子中,沒有理由複製v —— 它無論如何都是按值傳遞給函式的,並且在將其捕獲到continuation
之後不會再次使用,因為在捕獲之後,函式立即返回並銷燬其副本v
。
對於這種情況,C++14 允許將對move
到continuation
中:
seastar::future<> slow_op(std::vector<int> v) {
// v is not copied again, but instead moved:
return seastar::sleep(10ms).then([v = std::move(v)] { /* do something with v */ });
}
現在,不是將物件複製v到延續中,而是將其移動到延續中。C++11 引入的移動建構函式將向量的資料移動到延續中並清除原始向量。移動是一種快速操作——對於向量來說,它只需要複製一些小欄位,例如指向資料的指標。和以前一樣,一旦延續被解除,向量就會被破壞——它的資料陣列(在移動操作中被移動)最終被釋放。
在某些情況下,move
物件是不可取的。例如,某些程式碼保留對物件或其欄位之一的引用,如果移動物件,引用將變為無效。在一些複雜的物件中,甚至移動建構函式也很慢。對於這些情況,C++ 提供了有用的封裝std::unique_ptr<T>
。一個unique_ptr<T>
物件擁有一個在堆上分配的T
型別的物件。當 unique_ptr<T>
被移動時,型別 T 的物件根本沒有被觸及 —— 只是移動了指向它的指標。std::unique_ptr<T>
在捕獲中使用的一個例子是:
seastar::future<> slow_op(std::unique_ptr<T> p) {
return seastar::sleep(10ms).then([p = std::move(p)] { /* do something with *p */ });
}
std::unique_ptr<T>
是將物件的唯一所有權傳遞給函式的標準 C++ 機制:物件一次僅由一段程式碼擁有,所有權通過移動unique_ptr
物件來轉移。unique_ptr
不能被複制:如果我們試圖通過值而不是move
來捕獲p
,我們會得到一個編譯錯誤。
保持對呼叫者的所有權
我們上面描述的技術——給予它需要處理的物件的持續所有權——是強大而安全的。但通常使用起來會變得困難和冗長。當非同步操作不僅涉及一個continuation
,而是涉及每個都需要處理同一個物件的continuation
鏈時,我們需要在每個連續延續之間傳遞物件的所有權,這可能會變得不方便。當我們需要將同一個物件傳遞給兩個單獨的非同步函式(或continuation
)時,尤其不方便——在我們將物件移入一個之後,需要返回該物件,以便它可以再次移入第二個。例如,
seastar::future<> slow_op(T o) {
return seastar::sleep(10ms).then([o = std::move(o)] {
// first continuation, doing something with o
...
// return o so the next continuation can use it!
return std::move(o);
}).then([](T o) {
// second continuation, doing something with o
...
});
}
之所以會出現這種複雜性,是因為我們希望非同步函式和延續獲取它們所操作的物件的所有權。一種更簡單的方法是讓非同步函式的呼叫者繼續成為物件的所有者,並將對該物件的引用傳遞給需要該物件的各種其他非同步函式和continuation
。例如:
seastar::future<> slow_op(T& o) { // <-- pass by reference
return seastar::sleep(10ms).then([&o] {// <-- capture by reference
// first continuation, doing something with o
...
}).then([&o]) { // <-- another capture by reference
// second continuation, doing something with o
...
});
}
這種方法提出了一個問題: slow_op
的呼叫者現在負責保持物件o
處於活動狀態,而由 slow_op
啟動的非同步程式碼需要這個物件。但是這個呼叫者如何知道它啟動的非同步操作實際需要這個物件多長時間呢?
最合理的答案是非同步函式可能需要訪問它的引數,直到它返回的future
被解析——此時非同步程式碼完成並且不再需要訪問它的引數。因此,我們建議 Seastar 程式碼採用以下約定:
每當非同步函式通過引用獲取引數時,呼叫者必須確保被引用的物件存在,直到函式返回的
future
被解析。
請注意,這只是 Seastar 建議的約定,不幸的是,C++ 語言中沒有強制執行它。非 Seastar 程式中的 C++ 程式設計師經常將大物件作為 const 引用傳遞給函式,只是為了避免慢速複製,並假設被呼叫的函式不會在任何地方儲存此引用。但在 Seastar 程式碼中,這是一種危險的做法,因為即使非同步函式不打算將引用儲存在任何地方,它也可能會通過將此引用傳遞給另一個函式並最終在延續中捕獲它來隱式地執行此操作。
如果未來的 C++ 版本可以幫助我們發現引用的不正確使用,那就太好了。也許我們可以為一種特殊的引用設定一個標籤,一個函式可以立即使用的“立即引用”(即,在返回未來之前),但不能被捕獲到延續中。
有了這個約定,就很容易編寫複雜的非同步函式函式,比如slow_op
通過引用傳遞物件,直到非同步操作完成。但是呼叫者如何確保物件在返回的未來被解決之前一直存在?以下是錯誤的:
seastar::future<> f() {
T obj; // wrong! will be destroyed too soon!
return slow_op(obj);
}
這是錯誤的,因為這裡的物件obj
是呼叫f
的本地物件,並且在f
返回future
時立即銷燬—— 而不是在解決此返回的future
時!呼叫者要做的正確事情是在堆上建立obj
物件(因此它不會在f
返回時立即被銷燬),然後執行slow_op(obj)
,當future
解決(即使用.finally()
)時,銷燬物件。
Seastar 提供了一個方便的習慣用法,do_with()
用於正確執行此操作:
seastar::future<> f() {
return seastar::do_with(T(), [] (auto& obj) {
// obj is passed by reference to slow_op, and this is fine:
return slow_op(obj);
}
}
do_with
將使用給定的物件執行給定的功能。
do_with
將給定的物件儲存在堆上,並使用對新物件的引用呼叫給定的 lambda。最後,它確保在返回的未來解決後新物件被銷燬。通常, do_with
被賦予一個rvalue
,即一個未命名的臨時物件或一個std::move()
物件,do_with
將該物件移動到它在堆上的最終位置。do_with
返回一個在完成上述所有操作後解析的future
(lambda 的future
被解析並且物件被銷燬)。
為方便起見,do_with
也可以賦予多個物件來保持存活。例如在這裡我們建立兩個物件並保持它們直到未來解決:
seastar::future<> f() {
return seastar::do_with(T1(), T2(), [] (auto& obj1, auto& obj2) {
return slow_op(obj1, obj2);
}
}
雖然do_with
打包了它擁有的物件的生命週期,但如果使用者不小心複製了這些物件,這些副本可能具有錯誤的生命週期。不幸的是,像忘記“&”這樣的簡單錯字可能會導致此類意外複製。例如,以下程式碼被破壞:
seastar::future<> f() {
return seastar::do_with(T(), [] (T obj) { // WRONG: should be T&, not T
return slow_op(obj);
}
}
在這個錯誤的程式碼片段中,obj
不是對do_with
分配物件的引用,而是它的副本 —— 一個在 lambda 函式返回時被銷燬的副本,而不是在它返回的future
解決時。這樣的程式碼很可能會崩潰,因為物件在被釋放後被使用。不幸的是,編譯器不會警告此類錯誤。使用者應該習慣於總是使用“auto&”型別do_with
——如上面正確的例子——以減少發生此類錯誤的機會。
同理,下面的程式碼片段也是錯誤的:
seastar::future<> slow_op(T obj); // WRONG: should be T&, not T
seastar::future<> f() {
return seastar::do_with(T(), [] (auto& obj) {
return slow_op(obj);
}
}
在這裡,雖然obj
被正確的通過引用傳遞給了lambda,但是我們後來不小心傳遞給slow_op()
它的一個副本(因為這裡slow_op
是通過值而不是通過引用來獲取物件的),並且這個副本會在slow_op
返回時立即銷燬,而不是等到返回未來解決。
使用 do_with
時,請始終記住它需要遵守上述約定:我們在do_with
內部呼叫的非同步函式不能在返回的future
解析後使用do_with
所持有的物件。這是一個嚴重的use-after-free
錯誤:非同步函式返回一個future
,同時仍然使用do_with()
的物件進行後臺操作。
通常,在保留後臺操作的同時解決非同步函式並不是一個好主意——即使這些操作不使用do_with()
的 物件。我們不等待的後臺操作可能會導致我們記憶體不足(如果我們不限制它們的數量),並且很難乾淨地關閉應用程式。
共享所有權(引用計數)
在本章的開頭,我們已經注意到將物件的副本捕獲到continuation
中是確保物件在continuation
執行時處於活動狀態並隨後被銷燬的最簡單方法。但是,複雜物件的複製通常很昂貴(時間和記憶體)。有些物件根本無法複製,或者是讀寫的,延續應該修改原始物件,而不是新副本。所有這些問題的解決方案都是引用計數,也就是共享物件:
Seastar 中引用計數物件的一個簡單示例是seastar::file
,該物件包含一個開啟的檔案物件(我們將seastar::file
在後面的部分中介紹)。file
物件可以被複制,但複製不涉及複製檔案描述符(更不用說檔案)。相反,兩個副本都指向同一個開啟的檔案,並且引用計數增加 1。當檔案物件被銷燬時,檔案的引用計數減少 1,只有當引用計數達到 0 時,底層檔案才真正關閉.
file
物件可以非常快速地複製,並且所有副本實際上都指向同一個檔案,這使得將它們傳遞給非同步程式碼非常方便;例如,
seastar::future<uint64_t> slow_size(file f) {
return seastar::sleep(10ms).then([f] {
return f.size();
});
}
請注意,呼叫slow_size
與呼叫slow_size(f)
一樣簡單,傳遞 f
的副本,無需執行任何特殊操作以確保f
僅在不再需要時才將其銷燬。f
什麼也沒有做時,這很自然地發生了。
你可能想知道為什麼上面的例子return f.size()
是安全的:它不會啟動f
的非同步操作嗎(檔案的大小可能儲存在磁碟上,所以不能立即可用),f
當我們返回時可能會立即銷燬並且沒有任何東西保留f
的副本?如果f
真的是最後一個引用,那確實是一個錯誤,但還有一個錯誤:檔案永遠不會關閉。使程式碼有效的假設是有另一個f
的引用將用於關閉它。close 成員函式保持該物件的引用計數,因此即使沒有其他任何東西繼續保持它,它也會繼續存在。由於檔案物件生成的所有future
在關閉之前都已完成,因此正確性所需要的只是記住始終關閉檔案。
引用計數有執行時開銷,但通常很小;重要的是要記住,Seastar 物件始終僅由單個 CPU 使用,因此引用計數遞增和遞減操作不是通常用於引用計數的慢速原子操作,而只是常規的 CPU 本地整數操作。而且,明智地使用std::move()
和編譯器的優化器可以減少引用計數的不必要的來回遞增和遞減的次數。
C++11 提供了一種建立引用計數共享物件的標準方法——使用模板std::shared_ptr<T>
。shared_ptr
可用於將任何型別包裝到像上面的seastar::file
的引用計數共享物件中。但是,標準std::shared_ptr
在設計時考慮了多執行緒應用程式,因此它對引用計數使用緩慢的原子遞增/遞減操作,我們已經注意到在 Seastar 中是不必要的。出於這個原因,Seastar 提供了它自己的這個模板的單執行緒實現,seastar::shared_ptr<T>
. 除了不使用原子操作外,它類似於std::shared_ptr<T>
。
此外,Seastar 還提供了一種開銷更低的變體shared_ptr
:seastar::lw_shared_ptr<T>
. shared_ptr
由於需要正確支援多型型別(由一個類建立的共享物件,並通過指向基類的指標訪問),因此全功能變得複雜。shared_ptr
需要向共享物件新增兩個字,併為每個shared_ptr
副本新增兩個字。簡化版lw_shared_ptr
——不支援多型型別——只在物件中新增一個字(引用計數),每個副本只有一個字——就像複製常規指標一樣。出於這個原因,如果可能(不是多型型別),應該首選輕量級seastar::lw_shared_ptr<T>
,否則seastar::shared_ptr<T>
。較慢的std::shared_ptr<T>
絕不應在分片 Seastar 應用程式中使用。
在堆疊上儲存物件
如果我們可以像通常在同步程式碼中那樣將物件儲存在堆疊中,那不是很方便嗎?即,類似:
int i = ...;
seastar::sleep(10ms).get();
return i;
Seastar 允許通過使用帶有自己堆疊的seastar::thread
物件來編寫此類程式碼。使用seastar::thread
的完整示例可能如下所示:
seastar::future<> slow_incr(int i) {
return seastar::async([i] {
seastar::sleep(10ms).get();
// We get here after the 10ms of wait, i is still available.
return i + 1;
});
}
我們在 [seastar::thread
] 部分介紹seastar::thread
,seastar::async()
和seastar::future::get()
。