基於協程的高效能高併發伺服器框架—協程模組

机械心發表於2024-05-22

協程模組概述

一、概念

可以簡單的認為:協程就是使用者態的執行緒,但是上下文切換的時機是靠呼叫方(寫程式碼的開發人員)自身去控制的;

對比

首先介紹一下為什麼要使用協程。從瞭解程序,執行緒,協程之間的區別開始。

  1. 從定義來看
  • 程序是資源分配和擁有的基本單位。程序透過記憶體對映擁有獨立的程式碼和資料空間,若沒有記憶體對映給程序獨立的空間,則沒有程序的概念了。
  • 執行緒是程式執行的基本單位。執行緒都處在一個程序空間中,可以相互訪問,沒有限制,所以使用執行緒進行多工變成十分便利,所以當一個執行緒崩潰,其他任何一個執行緒都不能倖免。每個程序中都有唯一的主執行緒,且只能有一個,主執行緒和程序是相互依存的關係,主執行緒結束程序也會結束。
  • 協程是使用者態的輕量級執行緒,執行緒內部排程的基本單位。協程線上程上執行。
  1. 從系統呼叫來看
  • 程序由作業系統進行切換,會在使用者態與核心態之間來回切換。在切換程序時需要切換虛擬記憶體空間,切換頁表,切換核心棧以及硬體上下文等,開銷非常大。
  • 執行緒由作業系統進行切換,會在使用者態與核心態之間來回切換。在切換執行緒時需要儲存和設定少量暫存器內容,開銷很小。
  • 協程由使用者進行切換,並不會陷入核心態。先將暫存器上下文和棧儲存,等切換回來的時候再進行恢復,上下文的切換非常快
  1. 從併發性來看
  • 不同程序之間切換實現併發,各自佔有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

相關文章