協程模組概述
一、概念
可以簡單的認為:協程就是使用者態的執行緒,但是上下文切換的時機是靠呼叫方(寫程式碼的開發人員)自身去控制的;
對比
首先介紹一下為什麼要使用協程。從瞭解程序,執行緒,協程之間的區別開始。
- 從定義來看
- 程序是資源分配和擁有的基本單位。程序透過記憶體對映擁有獨立的程式碼和資料空間,若沒有記憶體對映給程序獨立的空間,則沒有程序的概念了。
- 執行緒是程式執行的基本單位。執行緒都處在一個程序空間中,可以相互訪問,沒有限制,所以使用執行緒進行多工變成十分便利,所以當一個執行緒崩潰,其他任何一個執行緒都不能倖免。每個程序中都有唯一的主執行緒,且只能有一個,主執行緒和程序是相互依存的關係,主執行緒結束程序也會結束。
- 協程是使用者態的輕量級執行緒,執行緒內部排程的基本單位。協程線上程上執行。
- 從系統呼叫來看
- 程序由作業系統進行切換,會在使用者態與核心態之間來回切換。在切換程序時需要切換虛擬記憶體空間,切換頁表,切換核心棧以及硬體上下文等,開銷非常大。
- 執行緒由作業系統進行切換,會在使用者態與核心態之間來回切換。在切換執行緒時需要儲存和設定少量暫存器內容,開銷很小。
- 協程由使用者進行切換,並不會陷入核心態。先將暫存器上下文和棧儲存,等切換回來的時候再進行恢復,上下文的切換非常快
- 從併發性來看
- 不同程序之間切換實現併發,各自佔有CPU實現並行
- 一個程序內部的多個執行緒併發執行
- 同一時間只能執行一個協程,而其他協程處於休眠狀態,適合對任務進行分時處理
相比於多開一個執行緒來操作,使用協程的好處:
- 減少了執行緒的重複高頻建立;
- 儘量避免執行緒的阻塞;
- 提升程式碼的可維護與可理解性(畢竟不需要考慮多執行緒那一套東西了);
注1:因為協程是在單執行緒上執行的,並不是併發執行的,是順序執行的,所以不能使用鎖來做協程的同步,這樣會直接導致執行緒的死鎖。
理解
最簡單的理解,可以將協程當成一種看起來花裡胡哨,並且使用起來也花裡胡哨的函式。
每個協程在建立時都會指定一個入口函式,這點可以類比執行緒。協程的本質就是函式和函式執行狀態的組合 。
協程和函式的不同之處是,函式一旦被呼叫,只能從頭開始執行,直到函式執行結束退出,而協程則可以執行到一半就退出(稱為yield),但此時協程並未真正結束,只是暫時讓出CPU執行權,在後面適當的時機協程可以重新恢復執行(稱為resume),在這段時間裡其他的協程可以獲得CPU並執行,所以協程也稱為輕量級執行緒。
協程能夠半路yield、再重新resume的關鍵是協程儲存了函式在yield時間點的執行狀態,這個狀態稱為協程上下文。協程上下文包含了函式在當前執行狀態下的全部CPU暫存器的值,這些暫存器值記錄了函式棧幀、程式碼的執行位置等資訊,如果將這些暫存器的值重新設定給CPU,就相當於重新恢復了函式的執行。在Linux系統裡這個上下文用ucontext_t結構體來表示,通getcontext()來獲取。
搞清楚協程和執行緒的區別。協程雖然被稱為輕量級執行緒,但在單執行緒內,協程並不能併發執行,只能是一個協程結束或yield後,再執行另一個協程,而執行緒則是可以真正併發執行的。其實這點也好理解,畢竟協程只是以一種花裡胡哨的方式去執行一個函式,不管實現得如何巧妙,也不可能在單執行緒裡做到同時執行兩個函式,否則還要多執行緒有何用?
因為單執行緒下協程並不是併發執行,而是順序執行的,所以不要在協程裡使用執行緒級別的鎖來做協程同步,比如pthread_mutex_t。如果一個協程在持有鎖之後讓出執行,那麼同執行緒的其他任何協程一旦嘗試再次持有這個鎖,整個執行緒就鎖死了,這和單執行緒環境下,連續兩次對同一個鎖進行加鎖導致的死鎖道理完全一樣。
同樣是單執行緒環境下,協程的yield和resume一定是同步進行的,一個協程的yield,必然對應另一個協程的resume,因為執行緒不可能沒有執行主體。並且,協程的yield和resume是完全由應用程式來控制的。與執行緒不同,執行緒建立之後,執行緒的執行和排程也是由作業系統自動完成的,但協程建立後,協程的執行和排程都要由應用程式來完成,就和呼叫函式一樣,所以協程也被稱為使用者態執行緒。
協程的特點
-
協程可以主動讓出 CPU 時間片;
-
協程可以恢復 CPU 上下文;當另一個協程繼續執行時,其需要恢復 CPU 上下文環境;
-
協程有個管理者,管理者可以選擇一個協程來執行,其他協程要麼阻塞,要麼ready,或者died;
-
執行中的協程將佔有當前執行緒的所有計算資源;
-
協程天生有棧屬性,而且是 lock free;
對稱協程與非對稱協程
參考資料:https://zhuanlan.zhihu.com/p/363775637
對於“對稱”這個名詞,闡述的實際是:協程之間的關係;用大白話來說就是:對稱協程就是說協程之間人人平等,沒有誰呼叫誰一說,大家都是一樣的,而非對稱協程就是協程之間存在明顯的呼叫關係;
- 對稱協程:任何一個協程都是相互獨立且平等的,排程權可以在任意協程之間轉移;在對稱協程中,子協程可以直接和子協程切換,也就是說每個協程不僅要執行自己的入口函式程式碼,還要負責選出下一個合適的協程進行切換,相當於每個協程都要充當排程器的角色,這樣程式設計起來會比較麻煩,並且程式的控制流也會變得複雜和難以管理。
- 非對稱協程:協程出讓排程權的目標只能是它的呼叫者,即協程之間存在呼叫和被呼叫關係。其排程可以藉助專門的排程器來負責排程協程,每個協程只需要執行自己的入口函式,然後結束時將執行權交回給排程器,由排程器來選出下一個要執行的協程即可。
二、實現基礎:ucontext_t
ucontext_t介紹
協程模組基於ucontext_t實現,基本結構如下
- ucontext_t結構體
#include <ucontext.h>
typedef struct ucontext_t {
struct ucontext_t* uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
...
};
- 類成員解釋:
uc_link:為當前context執行結束之後要執行的下一個context,若uc_link為空,執行完當前context之後退出程式。
uc_sigmask:執行當前上下文過程中需要遮蔽的訊號列表,即訊號掩碼
uc_stack:為當前context執行的棧資訊。 uc_stack.ss_sp:棧指標指向stack uc_stack.ss_sp = stack;
uc_stack.ss_size:棧大小 uc_stack.ss_size = stacksize;
uc_mcontext:儲存具體的程式執行上下文,如PC值,堆疊指標以及暫存器值等資訊。它的實現依賴於底層,是平臺硬體相關的。此實現不透明。
#include <ucontext.h>
void makecontext(ucontext_t* ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t* olducp, ucontext_t* newucp);
int getcontext(ucontext_t* ucp);
int setcontext(const ucontext_t* ucp);
- 類函式族:
makecontext:初始化一個ucontext_t,func引數指明瞭該context的入口函式,argc為入口引數的個數,每個引數的型別必須是int型別。另外在makecontext前,一般需要顯示的初始化棧資訊以及訊號掩碼集同時也需要初始化uc_link,以便程式退出上下文後繼續執行。
swapcontext:原子操作,該函式的工作是儲存當前上下文並將上下文切換到新的上下文執行。
getcontext:將當前的執行上下文儲存在cpu中,以便後續恢復上下文
setcontext:將當前程式切換到新的context,在執行正確的情況下該函式直接切換到新的執行狀態,不會返回。
注2:setcontext執行成功不返回,getcontext執行成功返回0,若執行失敗都返回-1。若uc_link為NULL,執行完新的上下文之後程式結束。
注3:執行緒暫存器的上下文一般包括以下內容:通用暫存器;程式計數器;棧指標;基址指標;標誌暫存器;段暫存器;浮點暫存器與擴充套件暫存器
實現思路
使用非對稱協程的設計思路,透過主協程建立新協程,主協程由swapIn()讓出執行權執行子協程的任務,子協程可以透過YieldToHold()讓出執行權繼續執行主協程的任務,不能在子協程之間做相互的轉化,這樣會導致回不到main函式的上下文。這裡使用了兩個執行緒區域性變數儲存當前協程和主協程,切換協程時呼叫swapcontext,若兩個變數都儲存子協程,則無法回到原來的主協程中。
Fiber::GetThis() 獲得主協程
swapIn()
Thread->man_fiber --------> sub_fiber (new(Fiber(cb)))
^
| Fiber::YieldToHold()
|
sub_fiber
三、具體實現
協程狀態
這裡在sylar的基礎上進行簡化,對每個協程,只設計了3種狀態,分別是READY,代表就緒態,RUNNING,代表正在執行,TERM,代表執行結束。
與sylar版本的實現相比,去掉了INIT狀態,HOLD狀態,和EXCEPT狀態。
sylar的INIT狀態是協程物件剛建立時的狀態,這個狀態可以直接歸到READY狀態裡,sylar的HOLD狀態和READY狀態與協程排程有關,READY狀態的協程會被排程器自動重新排程,而HOLD狀態的協程需要顯式地再次將協程加入排程,這兩個狀態也可以歸到READY狀態裡,反正都表示可執行狀態。sylar還給協程設計了一個EXCEPT狀態,表示協程入口函式執行時出現異常的狀態,這個狀態可以不管,具體到協程排程模組再討論。
去掉這幾個狀態後,協程的狀態模型就簡單得一目瞭然了,一個協程要麼正在執行(RUNNING),要麼準備執行(READY),要執行結束(TERM)。
狀態簡化後,唯一的缺陷是無法區分一個READY狀態的協程物件是剛建立,還是已經執行到一半yield了,這在重置協程物件時有影響。重置協程時,如果協程物件只是剛建立但一次都沒執行過,那應該是允許重置的,但如果協程的狀態是執行到一半yield了,那應該不允許重置。雖然可以把INIT狀態加上以區分READY狀態,但既然簡化了狀態,那就簡化到底,讓協程只有在TERM狀態下才允許重置,問題迎刃而解
class Fiber(協程類)
設定靜態變數
Fiber的原始碼定義了兩個全域性靜態變數,用於生成協程id和統計當前的協程數,對於每個執行緒,sylar設計了以下兩個執行緒區域性變數用於儲存協程上下文資訊:
// 用於生成協程id
static std::atomic<uint64_t> s_fiber_id {0};
// 用於統計當前的協程數
static std::atomic<uint64_t> s_fiber_count {0};
// 約定協程棧的大小1MB
static ConfigVar<uint32_t>::ptr g_fiber_stack_size =
Config::Lookup<uint32_t>("fiber.stack_size", 1024 * 1024, "fiber stack size");
// 當前協程
static thread_local Fiber *t_fiber = nullptr;
// 主協程
static thread_local Fiber::ptr t_threadFiber = nullptr;
t_fiber:指向當前執行的協程,初始化時,指向執行緒主協程
t_threadFiber:指向執行緒的主協程,初始化時,指向執行緒主協程,當子協程resume時,主協程讓出執行權,並儲存上下文到t_threadFiber的ucontext_t中,同時啟用子協程的ucontext_t的上下文。當子協程yield時,子協程讓出執行權,從t_threadFiber獲得主協程上下文恢復執行。
成員變數
// 協程id
uint64_t m_id = 0;
// 協程執行棧大小
uint32_t m_stacksize = 0;
// 協程狀態
State m_state = INIT;
// 上下文
ucontext_t m_ctx;
// 協程執行棧指標
void* m_stack = nullptr;
// 協程執行方法
std::function<void()> m_cb;
成員函式:
- 建構函式
/*
* @brief 建構函式
* @attention 無參建構函式只用於建立執行緒的第一個協程,也就是執行緒主函式對應的協程,
* 這個協程只能由GetThis()方法呼叫,所以定義成私有方法
*/
Fiber::Fiber(){
SetThis(this);
m_state = RUNNING;
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
++s_fiber_count;
m_id = s_fiber_id++; // 協程id從0開始,用完加1
SYLAR_LOG_DEBUG(g_logger) << "Fiber::Fiber() main id = " << m_id;
}
/**
* @brief 建構函式,用於建立使用者協程
* @param[] cb 協程入口函式
* @param[] stacksize 棧大小,預設為128k
*/
Fiber::Fiber(std::function<void()> cb, size_t stacksize)
: m_id(s_fiber_id++)
, m_cb(cb) {
++s_fiber_count;
m_stacksize = stacksize ? stacksize : g_fiber_stack_size->getValue();
m_stack = StackAllocator::Alloc(m_stacksize);
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
m_ctx.uc_link = nullptr;
m_ctx.uc_stack.ss_sp = m_stack;
m_ctx.uc_stack.ss_size = m_stacksize;
makecontext(&m_ctx, &Fiber::MainFunc, 0);
SYLAR_LOG_DEBUG(g_logger) << "Fiber::Fiber() id = " << m_id;
}
/**
* @brief 返回當前執行緒正在執行的協程
* @details 如果當前執行緒還未建立協程,則建立執行緒的第一個協程,
* 且該協程為當前執行緒的主協程,其他協程都透過這個協程來排程,也就是說,其他協程
* 結束時,都要切回到主協程,由主協程重新選擇新的協程進行resume
* @attention 執行緒如果要建立協程,那麼應該首先執行一下Fiber::GetThis()操作,以初始化主函式協程
*/
Fiber::ptr GetThis(){
if (t_fiber) {
return t_fiber->shared_from_this();
}
Fiber::ptr main_fiber(new Fiber);
SYLAR_ASSERT(t_fiber == main_fiber.get());
t_thread_fiber = main_fiber;
return t_fiber->shared_from_this();
}
- 協程原語:包括resume與yeild
/**
* @brief 將當前協程切到到執行狀態
* @details 當前協程和正在執行的協程進行交換,前者狀態變為RUNNING,後者狀態變為READY
*/
void Fiber::resume() {
SYLAR_ASSERT(m_state != TERM && m_state != RUNNING);
SetThis(this);
m_state = RUNNING;
if (swapcontext(&(t_thread_fiber->m_ctx), &m_ctx)) {
SYLAR_ASSERT2(false, "swapcontext");
}
}
/**
* @brief 當前協程讓出執行權
* @details 當前協程與上次resume時退到後臺的協程進行交換,前者狀態變為READY,後者狀態變為RUNNING
*/
void Fiber::yield() {
/// 協程執行完之後會自動yield一次,用於回到主協程,此時狀態已為結束狀態
SYLAR_ASSERT(m_state == RUNNING || m_state == TERM);
SetThis(t_thread_fiber.get());
if (m_state != TERM) {
m_state = READY;
}
if (swapcontext(&m_ctx, &(t_thread_fiber->m_ctx))) {
SYLAR_ASSERT2(false, "swapcontext");
}
}
- 協程入口函式
/**
* @brief 協程入口函式
* @note 這裡沒有處理協程函式出現異常的情況
*/
void Fiber::MainFunc() {
Fiber::ptr cur = GetThis(); // GetThis()的shared_from_this()方法讓引用計數加1
SYLAR_ASSERT(cur);
cur->m_cb(); // 這裡真正執行協程的入口函式
cur->m_cb = nullptr;
cur->m_state = TERM;
auto raw_ptr = cur.get(); // 手動讓t_fiber的引用計數減1
cur.reset();
raw_ptr->yield(); // 協程結束時自動yield,以回到主協程
}
- 協程重置函式
/**
* 這裡為了簡化狀態管理,強制只有TERM狀態的協程才可以重置,但其實剛建立好但沒執行過的協程也應該允許重置的
*/
void Fiber::reset(std::function<void()> cb) {
SYLAR_ASSERT(m_stack);
SYLAR_ASSERT(m_state == TERM);
m_cb = cb;
if (getcontext(&m_ctx)) {
SYLAR_ASSERT2(false, "getcontext");
}
m_ctx.uc_link = nullptr;
m_ctx.uc_stack.ss_sp = m_stack;
m_ctx.uc_stack.ss_size = m_stacksize;
makecontext(&m_ctx, &Fiber::MainFunc, 0);
m_state = READY;
}
參考文獻
- https://juejin.cn/post/7241818703457140793
- https://www.midlane.top/wiki/pages/viewpage.action?pageId=10060957