距離2020年已經過去很久了,各大編譯器對於C++20各項標準的支援也日趨完善,無棧協程也是其中之一,所以我就嘗試著拿協程與io_uring
實現了一下proactor模式,這篇文章用來記錄一下我的設計和想法。除此之外,我們能在網路上找到許多優秀的C++20協程的教程以及許多優秀的協程應用(庫),但從協程入門到架構出成熟的應用(庫)之間還存在著不小的鴻溝,而直接去啃大型工程的原始碼絕對不算是一種高效率的學習方式。所以,如果這篇文章能夠在這方面提供一定的幫助的話那就再好不過了。
正如上所述,這篇文章是介紹基於C++20協程實現非同步IO的,而不是介紹C++20協程的,因此有一定的閱讀門檻。在閱讀之前,你應當至少熟悉一下C++20協程。
為什麼要使用協程
因為協程能夠讓我們像寫同步IO那樣來實現非同步IO,如下所示:
auto foo(tcp_connection connection) -> task<void> {
char buffer[1024];
int result = co_await connection.recv(buffer, sizeof(buffer));
// do something...
result = co_await connection.send(buffer, result);
// do something...
co_return;
}
如果我們合理地實現了協程的掛起、恢復等操作,那麼當我們執行co_await connection.recv
時,我們實際上希望程式碼執行的操作如下:
- 告訴作業系統,監聽
recv
操作,等待對方傳送資料; - 掛起當前協程;
- 去處理別的事情。
當作業系統接收到recv
的資料時,執行以下操作:
- 處理recv,把資料讀進來;
- 恢復之前掛起的協程,從掛起的地方恢復執行。
這就是我們需要協程做的事情。如果你熟悉reactor模式的話,這應該並不陌生——我們只是把回撥函式換成了協程而已。那麼回到這一部分的標題——我們為什麼要使用協程而不是回撥函式呢?——因為使用協程寫出來的程式碼更好看,也更好維護,僅此而已。
關於為什麼要使用非同步IO:非同步IO能夠提高程式的吞吐量。試想一下一臺基於同步IO的HTTP伺服器,一種不難想到的實現方式是每accept
一個連線,就建立一個新的執行緒來處理這個連線的IO,最後當這個連線斷開時銷燬這個執行緒。這麼實現當然可以,但建立和銷燬執行緒的開銷是很大的,而且這要求執行緒排程器能夠很好地分配執行緒之間的時間。使用IO多路複用的方式能夠利用有限(甚至單執行緒)處理許多連線的IO,而不至於浪費過多的資源。
關於協程和回撥函式的效能:我想二者應當是差不多的,或者協程可能還會更差一點,因為掛起協程和恢復協程需要執行一些額外操作。不過既然效能還沒有緊張到需要去摳dpdk,那麼和這一點點的效能優勢相比較的話,程式碼的可維護性和可讀性絕對也是不容忽視的問題。
關於非同步IO的效能:我們通常講非同步IO效能更好指的是吞吐量,而不是低延時。不論是reactor模式還是proactor模式,其設計主旨都是要讓CPU在等待IO的時候去處理別的事情,不要讓CPU閒下來。如果低延時很重要的話,應當考慮使用同步IO與輪詢的方式。
設計思路
根據第一部分,設計的基調就能夠定下來了。我們重新考慮一下需要做的事情:
- 當我們執行到
co_await read(...)
等非同步IO時,掛起當前協程,去處理其他事情; - 當非同步IO執行完畢時,恢復協程的執行。
仔細思考一下上述兩點,我們就能夠得到所有要做的事情:
- 我們需要適時掛起協程,所以首先我們要實現協程
task
; - 協程可能會呼叫協程,所以需要維護一下協程的呼叫棧(我是在
promise
裡維護的); - 協程是用來處理非同步IO的,所以我們需要有一些元件來處理
io_uring
的IO(我是在io_context_worker
中處理的); - 當非同步IO執行完畢時,需要有什麼東西恢復協程的執行(這也是在
io_context_worker
中處理的); - 當整個協程執行完畢時,需要銷燬協程(這也是在
io_context_worker
中處理的)。
在繼續閱讀之前,我先貼一下程式碼。對照著程式碼看的話會舒服一些:GitHub。
task
與promise
task
和promise
均在coco/task.hpp
中定義。我對task
的定位正如協程最基本的功能——能夠掛起和恢復的函式。task
類本身只是對std::coroutine_handle
的簡易封裝。在這裡我只介紹一下task
的operator co_await
。
task
的operator co_await
只是返回task_awaitable
,所以co_await
處理的重點實際上是在task_awaitable
中實現的。考慮一下,當我們co_await
一個task
時,我們究竟是在幹什麼:
- 掛起當前協程
- 維護協程的呼叫棧
- 啟動被
co_await
的協程
在task_awaitable::await_suspend()
中很容易看出這三點:
template <class T>
template <class Promise>
auto task_awaitable<T>::await_suspend(
std::coroutine_handle<Promise> caller) noexcept -> coroutine_handle {
// Set caller for this coroutine.
promise_base &base = static_cast<promise_base &>(m_coroutine.promise());
promise_base &caller_base = static_cast<promise_base &>(caller.promise());
base.m_caller_or_top = &caller_base;
// Maintain stack bottom and top.
promise_base *stack_bottom = caller_base.m_stack_bottom;
assert(stack_bottom == stack_bottom->m_stack_bottom);
base.m_stack_bottom = stack_bottom;
stack_bottom->m_caller_or_top = &base;
return m_coroutine;
}
這裡著重講一下維護協程的呼叫棧。對於協程而言,至少存在一個協程,它是在協程外建立的(比如main
函式)。因為它不在協程中,所以它也無法被co_await
,這個協程我們稱之為協程的棧底。在這個協程的執行過程中,它可能會建立和執行新的協程。當新的協程執行完畢時,它們會被清理,並且將執行權交還給呼叫者,這與普通函式的呼叫棧是一樣的,只不過這個功能需要我們自己來實現。
因為promise
在記憶體中的位置是不可移動的(我禁止了promise
的複製與移動),所以我直接採用了類似連結串列的方式將協程的呼叫棧串了起來。在promise_base
中,有兩個成員變數用來維護這個呼叫棧:
promise_base *m_caller_or_top;
promise_base *m_stack_bottom;
因為第一個變數被複用了(具備不同的含義),所以可能有點亂。對於棧底協程而言,m_caller_or_top
指向當前呼叫棧的棧頂協程,對於其他協程而言,m_caller_or_top
指向自己的呼叫者(父協程)。這麼設計是因為棧底協程不存在呼叫者,所以就乾脆用這個變數存一下棧頂了。m_stack_bottom
顧名思義,就是指向棧底的協程。對於棧底協程而言,這個變數指向的就是它自己了。
有了m_caller_or_top
,當一個協程執行完畢時,就能方便地找到它的父協程並交換執行權。有了m_stack_bottom
和m_caller_or_top
,我們就能很方便地找到協程的棧底和棧頂。當需要恢復task
時,就能夠保證總是恢復棧頂的協程。
當協程執行完畢時,需要將控制權交還給父協程。我們考慮一下交還控制權需要做的事情:
- 維護呼叫棧,變更棧頂
- 如果不是棧底,則恢復父協程的執行
協程執行完畢時會去嘗試執行promise
的final_suspend()
,因此這部分程式碼在promise
的final_suspend()
中實現。final_suspend()
返回的型別叫promise_awaitable
,其對應的程式碼如下:
template <class Promise>
auto promise_awaitable::await_suspend(
std::coroutine_handle<Promise> coroutine) noexcept
-> std::coroutine_handle<> {
promise_base &base = static_cast<promise_base &>(coroutine.promise());
promise_base *stack = base.m_stack_bottom;
// Stack bottom completed. Nothing to resume.
if (stack == &base)
return std::noop_coroutine();
// Set caller coroutine as the top of the stack.
promise_base *caller = base.m_caller_or_top;
stack->m_caller_or_top = caller;
// Resume caller.
return caller->m_coroutine;
}
這段程式碼應該非常易懂,不過我們很容易聯想到一個問題:既然交還了控制權,那麼它是在何時銷燬的?
其實這也不難想到,協程是由父協程銷燬的。協程的返回值存放在promise
中,當父協程co_await sometask
時,父協程還需要讀取子協程的promise
以獲取返回值。當子協程task<T>
析構時,子協程才真正被銷燬。
io_context
的設計
既然協程需要非同步地處理IO,那麼必然需要個處理IO的地方,就是io_context
。io_context
維護了一個執行緒池,執行緒池中每一個執行緒均執行一個worker
,每個worker
均維護一個io_uring
來處理本執行緒的IO事件和協程。當需要提交新的task
給執行緒池的時候,由io_context
分配給某一個worker
執行。
這聽起來和reactor模式好像沒啥區別,用epoll
寫reactor模式的時候基本上也是這麼幹的,這是因為我本來就是從reactor模式那邊搬過來的。不過相比於reactor模式,這麼做還是有不少細節要處理的。在使用io_uring
時,每次我們啟動非同步IO時,都需要獲取到io_uring
物件——要使用io_uring_prep_<io_operation>
系列函式,我們必須從io_uring
物件中獲取一個sqe
。而如上所述,io_uring
物件在worker
中,這就造成了一個麻煩:我們無法在io_context
給task
分配worker
的時候將io_uring
物件的引用(指標)傳遞給task
。雖然使用全域性變數不失為一種選擇,但我不想這麼做,因為也許使用者想要在一個程序中建立幾個不同的io_context
用呢。
雖然無法直接將io_uring
的引用傳遞給task
,但還有一種方法可以進行互動。在所有awaitable
的await_suspend
中,我們可以拿到當前協程的coroutine_handle
,而在io_context
中,我們也能拿到task
的coroutine_handle
,因此可以透過promise
來傳遞io_uring
的引用。
具體在實現時,我沒有傳遞io_uring
的引用,而是傳遞了worker
的指標。這麼做是因為當初我想同時支援IOCP
,傳遞worker
可以省掉一些麻煩,雖然後來放棄了。worker
的指標只被放在協程棧的棧底,這麼做是因為當協程在不同worker
之間轉移時,能夠很方便地修改協程所屬的worker
(只需要修改棧底就可以了),儘管後來也沒有實現work-stealing佇列。在promise
中,處理worker
的方法如下所示:
/// \brief
/// Set I/O context for current coroutine.
/// \param[in] io_ctx
/// I/O context to be set for current coroutine.
auto set_worker(io_context_worker *io_ctx) noexcept -> void {
m_stack_bottom->m_worker.store(io_ctx, std::memory_order_release);
}
/// \brief
/// Get I/O context for current coroutine.
/// \return
/// I/O context for current coroutine.
[[nodiscard]] auto worker() const noexcept -> io_context_worker * {
return m_stack_bottom->m_worker.load(std::memory_order_acquire);
}
不過這麼做也有一個缺點,就是io_context
侵入了promise
的設計,使得task
必須在io_context
中才能發揮作用。
awaitable
的設計
awaitable
在coco/io.hpp
中定義。各種awaitable
的設計就比較簡單了。以read_awaitable
為例,它在await_suspend()
中獲取當前協程所屬的worker
,然後啟用非同步IO,如下所示:
template <class Promise>
auto await_suspend(std::coroutine_handle<Promise> coro) noexcept -> bool {
m_userdata.coroutine = coro.address();
return this->suspend(coro.promise().worker());
}
獲取了當前協程的worker
後,就轉入this->suspend()
函式中去執行了。suspend()
方法主要的工作是啟動非同步IO操作,並掛起當前協程:
auto coco::read_awaitable::suspend(io_context_worker *worker) noexcept -> bool {
assert(worker != nullptr);
m_userdata.cqe_res = 0;
m_userdata.cqe_flags = 0;
io_uring *ring = worker->io_ring();
io_uring_sqe *sqe = io_uring_get_sqe(ring);
while (sqe == nullptr) [[unlikely]] {
io_uring_submit(ring);
sqe = io_uring_get_sqe(ring);
}
io_uring_prep_read(sqe, m_file, m_buffer, m_size, m_offset);
sqe->user_data = reinterpret_cast<uint64_t>(&m_userdata);
int result = io_uring_submit(ring);
if (result < 0) [[unlikely]] { // Result is -errno.
m_userdata.cqe_res = result;
return false;
}
return true;
}
我沒有把啟用非同步IO部分放到模板函式中,這是因為對於各種不同的IO操作,這部分的程式碼實際上大同小異。但考慮到這部分程式碼的長度,放到模板中可能會導致比較嚴重的二進位制膨脹,所以就單獨拿出來放到.cpp
檔案中了。
如果去翻我之前的commit記錄的話,會發現起初我並沒有把各種awaitable
暴露出來,而是讓各種非同步操作(比如connection.receive()
)返回task
。後來將awaitable
暴露出來是考慮到諸如read
、write
等操作可能會被頻繁地呼叫,而每次建立一個協程都需要申請一次堆記憶體,在迴圈中執行的話可能對執行效率有比較嚴重的影響。
關於程式碼
再放一遍程式碼地址:GitHub
這份程式碼不長,總共兩三千行,而且其中一多半都是註釋,結合本文的話應該不會很難讀。這本身只是一份實驗性質的程式碼,同時我希望它適合拿來學習,所以我並沒有打算塞入太多的功能。除此之外,我不是很建議你拿來放到工程中使用,因為我可能會一時興起做出一些breaking change。如果你真的有這個需要的話,我建議你fork一份程式碼自己維護。
一些可能會被問到的問題
- 為什麼沒有實現UDP相關的IO?
因為io_uring
似乎還沒有支援recvfrom
,至少我實現的時候還沒有。
- 為什麼不使用
mmap
和核心共享記憶體/為什麼不向io_uring
註冊檔案描述符等效能相關的問題
因為我不是io_uring
專家。我寫這個庫的目的是學習用C++20的協程架構一個非同步IO庫,做這些效能最佳化會加大架構難度,並且花掉我大量的頭髮和時間,使我本不茂密的頭髮雪上加霜。除此之外,你會發現我也沒有實現work-stealing佇列,原因同理。Round Robin的效能雖然不至於最優,但也不會太差。
- 考不考慮加上HTTP支援?
考慮過,太懶所以放棄了。一方面是手寫HTTP parser還是挺麻煩的,就算能用bison自動生成也還得去啃RFC。另一方面是,TCP作為一種基於流的協議,我沒有想好如何處理連線的快取能夠兼顧效能和使用的便捷性。如果你有這方面的需求的話,不妨先用著其他的HTTP parser,比如llhttp。
- 為什麼沒有實現
yield
?
我覺得非同步IO一般用不到這東西,所以就沒寫。如果需要的話就自己實現吧。
- 會支援Windows(IOCP)嗎?
我有考慮過支援IOCP,但IOCP不支援定時器,我又不想刪除Linux這邊的timer
,所以暫且沒有這個想法。
- 作者你README寫得好水啊
確實,我也覺得好水啊,有沒有好心人幫忙修一修啊。
- 為什麼不用中文寫註釋和README?
不用中文寫註釋是因為clang-format沒法處理中文的斷行。不用中文寫README是因為懶,不想寫兩份README。說不定哪天心情好了就寫一份中文版。
碎碎念
要不要塞張插圖呢?