libgo 源碼剖析(2. libgo調度策略源碼實現)

本文將從源碼實現上對 libgo 的調度策略進行分析,主要涉及到上一篇文章中的三個結構體的定義:

  • 調度器 Scheduler(簡稱 S)
  • 執行器 Processer(簡稱 P)
  • 協程 Task(簡稱 T)

三者的關係如下圖所示:

libgo 源碼剖析(2. libgo調度策略源碼實現)


本文會列出類內的主要成員和主要函數做以分析。


1. 協程調度器:class Scheduler

libgo/scheduler/scheduler.h

class Scheduler{
public:

    /*
    *  創建一個調度器,初始化 libgo
    *  創建主線程的執行器,如果後續 STart 的時候沒有參數,默認只有一個執行器去做
    *  當僅使用一個線程進行協程調度時, 協程地執行會嚴格地遵循其創建順序.
    * */
    static Scheduler* Create();

    /*
    * 創建一個協程 Task 對象,並添加到當前的執行器 processer 的任務隊列中,
    * 調度器的任務數 taskCount_ +1
    * */
    void CreateTask(TaskF const& fn, TaskOpt const& opt);

    /* 啓動調度器
    * @minThreadNumber : 最小調度線程數, 爲0時, 設置爲cpu核心數.
    * @maxThreadNumber : 最大調度線程數, 爲0時, 設置爲minThreadNumber.
    *          如果maxThreadNumber大於minThreadNumber, 則當協程產生長時間阻塞時,
    *          可以自動擴展調度線程數.
    *  喚醒定時器線程
    *  每個調度線程都會調用 Process 開始調度,最後開啓 id 爲 0 的調度線程
    * 如果 maxThreadNumber_ > 1 的話,會開啓調度線程 DispatcherThread
    * */
    void Start(int minThreadNumber = 1, int maxThreadNumber = 0);

    /*
    *  停止調度,停止後無法恢復, 僅用於安全退出main函數
    *  如果某個調度線程被協程阻塞, 必須等待阻塞結束才能退出.
    * */
    void Stop();

private:
    /*
    *  調度線程,主要爲平衡多個 processer 的負載將高負載或阻塞的 p 中的協程 steal 給低負載的 p
    *  如果全部阻塞但是還有協程待執行,會起新線程,線程數不超過
    maxThreadNumber_
    *  會將阻塞 P 中的協程分攤給負載較少的 P
    * */
    void DispatcherThread();

    /*
    *  創建一個新的 Processer,並添加到雙端隊列 processers_ 中
    * */
    void NewProcessThread();

private:
    atomic_t<uint32_t> taskCount_{0};   // 用來統計協程數量
    Deque<Processer*> processers_;    // DispatcherThread雙端隊列,用來存放所有的執行器,每個執行器都會單獨開一個線程去執行,線程中回調 Process() 方法。
    LFLock started_;    // libgo 提供的自選鎖

};

調度器負責管理 1~N 個調度線程,每個調度線程一個執行器 Processer。調度器僅負責均衡各個執行器的負載,防止全部卡住的情況,並不涉及協程的切換等工作。

使用

ligbo提供了默認的協程調度器 co_sched

#define g_Scheduler ::co::Scheduler::getInstance()
#define co_sched g_Scheduler

用戶也可以創建自己的協程調度器

co::Scheduler* my_sched = co::Scheduler::Create();

啓動調度

std::thread t([my_sched]{mysched->Start();});
t.detach();

調度器原理

  1. schedule 負責整個系統的協程調度,協程的運行依賴於執行器 Processer(簡稱 P),因此在調度器初始化的時候會選擇創建 P 的數量(支持動態增長),所有的執行器會添加到雙端隊列中。主線程也作爲一個執行器,在創建 Scheduler 對象的時候創建,位於雙端隊列下標爲 0 的位置(注意:只是創建對象,並沒有開始運行);

  2. 當調用了 Start() 函數後,會正式開始運行。在 Start 函數內部,會創建指定數量的執行器 P,具體數量取決於參數,默認會創建 minThreadNumber 個,當全部執行器都阻塞之後,會動態擴展,最多 maxThreadNumber 個執行器。每個執行器都會運行於一個單獨的線程,執行器負責該線程內部協程的切換和執行;

  3. 當創建協程時,會將協程添加到某一個處於活躍狀態的執行器,如果恰好都不活躍,也會添加到某一個 P 中,這並不影響執行器的正常工作,因爲調度器的調度線程會去處理它;

  4. Start 函數內部,除了上述執行器所在線程,還會開啓調度線程 DispatcherThread,調度線程會平衡各個 P 的協程數量和負載,進行 steal,如果所有 P 都阻塞,會根據 maxThreadNumber 動態增加 P 的數量,如果僅僅部分 P 阻塞,會將阻塞的 P 中的協程全部拿出(steal),均攤到負載最小的 P 中;

  5. Schedule 也會選擇性開啓協程的定時器線程;

  6. 開啓 FastSteadyClock 線程。

關於定時器以及時鐘的實現,會在之後的文章中討論。


2. 協程執行器:class Processer

libgo/scheduler/processer.h

每個協程執行器對應一個線程,負責本線程的協程調度,但並非線程安全的,是協程調度的核心。

class Processer
{
public:
    // 協程掛起標識,用於後續進行喚醒和超時判斷
    struct SuspendEntry {
             // ...
    };

    // 協程切出
    ALWAYS_INLINE static void StaticCoYield();

    // 掛起當前協程
    static SuspendEntry Suspend();

    // 掛起當前協程, 並在指定時間後自動喚醒
    static SuspendEntry Suspend(FastSteadyClock::duration dur);

    // 喚醒協程
    static bool Wakeup(SuspendEntry const& entry);

private:
    /*
    *  執行器對協程的調度,也是執行器所在現在的主處理邏輯
    * */
    void Process();

    /*
    * 從當前執行器中偷 n 個協程並返回
    * n 爲0則全部偷出來,否則取出相應的個數
    * */
    SList<Task> Steal(std::size_t n);

    /*
    *  給當前執行器打標記,用於檢測協程是否阻塞
    * */
    void Mark();

private:
    int id_;    // 線程 id,與 shcedule 中的 _processer 下標對應
    Scheduler * scheduler_;     // 該執行器依賴的調度器
    volatile bool active_ = true;   // 該執行器的活躍狀態,活躍表明該執行器未被阻塞,由調度器的調度線程控制

    volatile int64_t markTick_ = 0;     // mark 的時間戳
    volatile uint64_t markSwitch_ = 0;  // mark 的時候處於第幾次協程調度
    volatile uint64_t switchCount_ = 0; // 協程調度的次數

    // 當前正在運行的協程
    Task* runningTask_{nullptr};
    Task* nextTask_{nullptr};

    // 協程隊列
    typedef TSQueue<Task, true> TaskQueue;
    TaskQueue runnableQueue_;   // 運行協程隊列
    TaskQueue waitQueue_;   // 等待協程隊列
    TSQueue<Task, false> gcQueue_;  // 待回收的協程隊列,協程運行完畢之後,會被添加到該隊列中,等待回收
    TaskQueue newQueue_;    // 新添加到該執行器中的協程,包括剛剛 steal 過來的協程,該隊列中的協程暫不會執行,會由 Process() 函數將該隊列中的協程不斷添加到 runnableQueue_ 中

    volatile uint64_t switchCount_ = 0; // 協程調度的次數

    // 執行器等待的條件變量
    std::mutex cvMutex_;
    std::condition_variable cv_;
    std::atomic_bool waiting_{false};
};

執行器對協程的調度 Process()

執行器 Processer 維護了三個線程安全的協程隊列:

  • runnableQueue_:可運行協程隊列;
  • waitQueue_:存放掛起的協程;
  • newQueue_:該隊列中存放的是新加入的協程,包括新創建的協程,喚醒掛起的協程,還有 steal 來的協程;

void Processer::Process()
{
    GetCurrentProcesser() = this;

    bool & isStop = *stop_;

    while (!isStop)
    {
        runnableQueue_.front(runningTask_);

        // 獲取一個可以運行對協程對象
        if (!runningTask_) {
            if (AddNewTasks())
                runnableQueue_.front(runningTask_);

            if (!runningTask_) {
                WaitCondition();    // 沒有可以執行的協程,wait 條件變量
                AddNewTasks();
                continue;
            }
        }

        addNewQuota_ = 1;
        while (runningTask_ && !isStop) {
            runningTask_->state_ = TaskState::runnable;
            runningTask_->proc_ = this;

            ++switchCount_;
            runningTask_->SwapIn();
            switch (runningTask_->state_) {
                case TaskState::runnable:
                    {
                        std::unique_lock<TaskQueue::lock_t> lock(runnableQueue_.LockRef());
                        auto next = (Task*)runningTask_->next;
                        if (next) {
                            runningTask_ = next;
                            runningTask_->check_ = runnableQueue_.check_;
                            break;
                        }

                        if (addNewQuota_ < 1 || newQueue_.emptyUnsafe()) {
                            runningTask_ = nullptr;
                        } else {
                            lock.unlock();
                            if (AddNewTasks()) {
                                runnableQueue_.next(runningTask_, runningTask_);
                                -- addNewQuota_;
                            } else {
                                std::unique_lock<TaskQueue::lock_t> lock2(runnableQueue_.LockRef());
                                runningTask_ = nullptr;
                            }
                        }
                    }
                    break;

                case TaskState::block:
                    {
                        std::unique_lock<TaskQueue::lock_t> lock(runnableQueue_.LockRef());
                        runningTask_ = nextTask_;
                        nextTask_ = nullptr;
                    }
                    break;

                case TaskState::done:
                default:
                    {
                        runnableQueue_.next(runningTask_, nextTask_);
                        if (!nextTask_ && addNewQuota_ > 0) {
                            if (AddNewTasks()) {
                                runnableQueue_.next(runningTask_, nextTask_);
                                -- addNewQuota_;
                            }
                        }

                        DebugPrint(dbg_task, "task(%s) done.", runningTask_->DebugInfo());
                        runnableQueue_.erase(runningTask_);
                        if (gcQueue_.size() > 16)    // 執行完畢的協程,需要回收資源
                            GC();
                        gcQueue_.push(runningTask_);
                        if (runningTask_->eptr_) {
                            std::exception_ptr ep = runningTask_->eptr_;
                            std::rethrow_exception(ep);
                        }

                        std::unique_lock<TaskQueue::lock_t> lock(runnableQueue_.LockRef());
                        runningTask_ = nextTask_;
                        nextTask_ = nullptr;
                    }
                    break;
            }
        }
    }
}

在調度器 Schedule 執行 Stop() 函數之前,執行器 P 會一直處於調度協程階段 Process()。在期間,執行器 P 會將運行隊列 runnableQueue 中的第一個協程獲取進行執行,如果可運行隊列爲空,執行器會嘗試將處於 newQueue 中的協程添加到可運行隊列中去,如果 newQueue_ 爲空,說明此時該執行器處於無協程可調度狀態,通過設置條件變量,將執行器設置爲等待狀態;

當獲取到一個可執行協程之後,會執行該協程的任務。協程的執行流程是通過狀態機來實現的。(協程有三個狀態:運行中,阻塞,執行完畢)

  • 對於運行中的協程,我們只需要確定下一個要執行的協程對象即可;
  • 對於阻塞的協程,只有當協程掛起時(調用了 Suspend 方法),狀態纔會切換到這裏,因此,這時候只需要去執行 nextTask 即可;
  • 對於運行完畢的協程,只有當 Task 處理函數執行完成之後,狀態纔會切換到這裏,因此,需要考慮對該協程資源進行回收;

條件變量

Processer 使用了 std::mutex,並且提供了條件變量用來喚醒。當調度器嘗試獲取下一個可運行的協程對象時,若此時無可用協程對象,就會主動去等待該條件變量,默認100毫秒的超時時間。

void Processer::WaitCondition()
{
    GC();
    std::unique_lock<std::mutex> lock(cvMutex_);
    waiting_ = true;
    cv_.wait_for(lock, std::chrono::milliseconds(100));
    waiting_ = false;
}

void Processer::NotifyCondition()
{
    cv_.notify_all();
}

當調度器向該執行器中增加了新的協程對象時,會喚醒該條件變量,繼續執行 Process 流程。使用條件變量喚醒的效率,要遠遠高於不斷去輪詢。

爲什麼在使用了條件變量後還要設置超時時間,定時輪詢,即使條件變量沒有被喚醒也希望它返回呢?
因爲我們不希望線程會在這裏阻塞,只要沒有新的協程加入,就一直在死等。我們希望線程在等待的同時,也可以定時跳出,執行一些其它的檢測工作等。

從執行器中偷指定數量的協程出來 -> steal()

簡單來說,從執行器中取協程出來,就是從執行器維護的雙端隊列中獲取執行個數的結點。

爲什麼要取出來?前面提到過,要麼該執行器負載過大,要麼該執行器處於阻塞的狀態。

SList<Task> Processer::Steal(std::size_t n)
{
    if (n > 0) {
        // steal 指定個數協程
        newQueue_.AssertLink();
        auto slist = newQueue_.pop_back(n);
        newQueue_.AssertLink();
        if (slist.size() >= n)
            return slist;

        std::unique_lock<TaskQueue::lock_t> lock(runnableQueue_.LockRef());
        bool pushRunningTask = false, pushNextTask = false;
        if (runningTask_)
            pushRunningTask = runnableQueue_.eraseWithoutLock(runningTask_, true) || slist.erase(runningTask_, newQueue_.check_);
        if (nextTask_)
            pushNextTask = runnableQueue_.eraseWithoutLock(nextTask_, true) || slist.erase(nextTask_, newQueue_.check_);
        auto slist2 = runnableQueue_.pop_backWithoutLock(n - slist.size());
        if (pushRunningTask)
            runnableQueue_.pushWithoutLock(runningTask_);
        if (pushNextTask)
            runnableQueue_.pushWithoutLock(nextTask_);
        lock.unlock();

        slist2.append(std::move(slist));
        if (!slist2.empty())
            DebugPrint(dbg_scheduler, "Proc(%d).Stealed = %d", id_, (int)slist2.size());
        return slist2;
    } else {
        // steal all
        newQueue_.AssertLink();
        auto slist = newQueue_.pop_all();
        newQueue_.AssertLink();

        std::unique_lock<TaskQueue::lock_t> lock(runnableQueue_.LockRef());
        bool pushRunningTask = false, pushNextTask = false;
        if (runningTask_)
            pushRunningTask = runnableQueue_.eraseWithoutLock(runningTask_, true) || slist.erase(runningTask_, newQueue_.check_);
        if (nextTask_)
            pushNextTask = runnableQueue_.eraseWithoutLock(nextTask_, true) || slist.erase(nextTask_, newQueue_.check_);
        auto slist2 = runnableQueue_.pop_allWithoutLock();
        if (pushRunningTask)
            runnableQueue_.pushWithoutLock(runningTask_);
        if (pushNextTask)
            runnableQueue_.pushWithoutLock(nextTask_);
        lock.unlock();

        slist2.append(std::move(slist));
        if (!slist2.empty())
            DebugPrint(dbg_scheduler, "Proc(%d).Stealed all = %d", id_, (int)slist2.size());
        return slist2;
    }
}

首先,會從 newQueue 隊列中獲取協程結點,因爲 newQueue 中的結點還沒有添加到運行隊列中,因此可以直接取出;如果 newQueue 中協程數量不足,會從 runnableQueue 隊列尾部中繼續獲取結點。由於 runnableQueue 隊列中我們記錄了正在執行的協程和下一次將執行的協程(runningTask & nextTask),需要特殊處理。在從 runnableQueue 偷協程之前,會將 runningTask & nextTask 從隊列刪除,待偷完結點之後再次添加到當前 runnableQueue_ 隊列中。

簡單說,偷協程的工作,不會從隊列中獲取到 runningTask & nextTask 標識的協程。

阻塞判斷

void Processer::Mark()
{
    if (runningTask_ && markSwitch_ != switchCount_) {
        markSwitch_ = switchCount_;
        markTick_ = NowMicrosecond();
    }
}

uint32_t cycle_timeout_us = 10 * 1000; 

bool Processer::IsBlocking()
{
    if (!markSwitch_ || markSwitch_ != switchCount_) return false;
    return NowMicrosecond() > markTick_ + CoroutineOptions::getInstance().cycle_timeout_us;
}

Mark 函數會在調度器的調度函數中被調用,需要注意的是,只有執行器處於活躍狀態時纔會調用。Mark 顧名思義,是給該執行打標記,會記錄mark的時間戳,並記錄下是在第多少次協程調度的過程中做了標記,Mark 的作用是用來進行執行器的阻塞檢測。

處於活躍狀態的執行器,總是在執行着協程的切換,因此,會不斷自增 switchCount_ 的值,根據 IsBlocking 函數得知,當我們此時標籤記錄的協程調度次數超過10ms沒有發生改變,我們認爲該執行器發生阻塞,Scheduler 會進行 Steal 操作。

協程掛起 Suspend

static SuspendEntry Suspend();

一種方式是直接掛起,會將該協程狀態轉換爲 TaskState::block,然後將該協程從 runnableQueue 中刪除,再添加到 waitQueue 中;

另外一種方式是掛起之後(第一種方式執行完畢之後),允許配置一個時間段之後去自動喚醒該協程。

wakeup

用於喚醒協程

喚醒協程要做的,就是講待喚醒的協程從 waitQueue_ 中刪除並重新添加到 newQueue_中去。

StaticCoYield

用於在一個執行器中切出當前協程

有兩種可能,一種是協程被阻塞需要掛起;另外一種是協程執行完畢,主動切出。

具體實現是通過獲取當前執行器正在執行的協程 Task,調用 SwapOut() 方法實現。

ALWAYS_INLINE void Processer::StaticCoYield()
{
    auto proc = GetCurrentProcesser();
    if (proc) proc->CoYield();
}

ALWAYS_INLINE void Processer::CoYield()
{
    Task *tk = GetCurrentTask();
    assert(tk);

    ++ tk->yieldCount_;

#if ENABLE_DEBUGGER
    DebugPrint(dbg_yield, "yield task(%s) state = %s", tk->DebugInfo(), GetTaskStateName(tk->state_));
    if (Listener::GetTaskListener())
        Listener::GetTaskListener()->onSwapOut(tk->id_);
#endif

    tk->SwapOut();
}

幾個需要注意的問題

> 可能會切出協程上下文的幾種情況:

  1. 協程被掛起;
  2. 協程執行完畢;
  3. 用戶主動切出 co_yield。
    #define co_yield do { ::co::Processer::StaticCoYield(); } while (0)

> 協程被掛起的幾種情況:

  1. 系統函數被 hook;
  2. libgo_poll (被 hook 的 io 操作函數會調用 libgo_poll 實現切換)
  3. select
  4. sleep、usleep、nanosleep
  5. 調用了協程鎖 CoMutex(co_mutex),協程讀寫鎖 CoRWMutex(co_rwmutex),或者使用了 channel。

> 切入協程上下文的幾種情況:

  1. 執行器在調度(Process)期間;
  2. 喚醒掛起協程不會切入上下文,只是從等待隊列中重新添加到 newQueue_。

3. 協程對象:struct Task

# 協程狀態
enum class TaskState
{
    runnable,   // 可運行
    block,      // 阻塞
    done,       // 協程運行完畢
};

typedef std::function<void()> TaskF;    // c++11提供的函數模板

struct Task
{
    TaskState state_ = TaskState::runnable;
    uint64_t id_;       // 當前調度器下協程編號,從0開始
    TaskF fn_;          // 協程運行的函數
    uint64_t yieldCount_ = 0;   // 協程切出的次數
    Context ctx_;       // 上下文信息
    Processer* proc_ = nullptr;     // 歸屬於哪個執行器

    // 提供了協程切入、切出、切換到指定線程三個函數
    ALWAYS_INLINE void SwapIn();
    ALWAYS_INLINE void SwapTo(Task* other);
    ALWAYS_INLINE void SwapOut();

private:
    static void StaticRun(intptr_t vp);     // 參數爲 Task*,函數會去執行該 Task 的 fn_(),執行完畢後,協程狀態改爲 TaskState::done,並在執行器 P 中切出
};

每個 Task 對象是一個協程,在使用過程中,創建一個協程實際就是創建了一個 Task 對象,再添加到對應的執行器 P 中。之前提到過,執行器進行協程調度是通過一個狀態機來實現的,這裏的 TaskState 就是協程狀態,協程函數 fn_ 會在 StaticRun 靜態方法中調用,該靜態方法註冊到了協程上下文 _ctx 中。

除此之外,Task 類內部,也提供了協程的切入切出方法,本質也是調用了上下文的切換。

StaticRun

控制協程的運行,內部調用了 Task::Run() 方法,會在協程函數 fn_ 執行完畢之後,將協程狀態轉換爲 TaskState::done,並將協程切出。

void Task::Run()
{
    auto call_fn = [this]() {
        this->fn_();
        this->fn_ = TaskF(); //讓協程function對象的析構也在協程中執行
    };

    \\ ...
        call_fn();
    \\ ...
    state_ = TaskState::done;
    Processer::StaticCoYield();
}

void Task::StaticRun(intptr_t vp)
{
    Task* tk = (Task*)vp;
    tk->Run();
}

這裏就是對 libgo 調度相關實現的描述,本文跳過了對定時器和時鐘部分的實現,這個會在之後單獨敘述。本文涉及到的代碼在源碼目錄下的

libgo-master/libgo/scheduler/processer.cpp   
libgo-master/libgo/scheduler/processer.h
libgo-master/libgo/scheduler/scheduler.cpp
libgo-master/libgo/scheduler/scheduler.h

有興趣的讀者可以對照源碼學習,歡迎討論學習

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章