溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶(hù)服務(wù)條款》

C++20協(xié)程的使用方法

發(fā)布時(shí)間:2021-06-23 10:35:22 來(lái)源:億速云 閱讀:353 作者:chen 欄目:編程語(yǔ)言

這篇文章主要介紹“C++20協(xié)程的使用方法”,在日常操作中,相信很多人在C++20協(xié)程的使用方法問(wèn)題上存在疑惑,小編查閱了各式資料,整理出簡(jiǎn)單好用的操作方法,希望對(duì)大家解答”C++20協(xié)程的使用方法”的疑惑有所幫助!接下來(lái),請(qǐng)跟著小編一起來(lái)學(xué)習(xí)吧!

摘要:事件驅(qū)動(dòng)(event driven)是一種常見(jiàn)的代碼模型,其通常會(huì)有一個(gè)主循環(huán)(mainloop)不斷的從隊(duì)列中接收事件,然后分發(fā)給相應(yīng)的函數(shù)/模塊處理。常見(jiàn)使用事件驅(qū)動(dòng)模型的軟件包括圖形用戶(hù)界面(GUI),嵌入式設(shè)備軟件,網(wǎng)絡(luò)服務(wù)端等。

嵌入式事件驅(qū)動(dòng)代碼的難題

事件驅(qū)動(dòng)event driven)是一種常見(jiàn)的代碼模型,其通常會(huì)有一個(gè)主循環(huán)mainloop)不斷的從隊(duì)列中接收事件,然后分發(fā)給相應(yīng)的函數(shù)/模塊處理。常見(jiàn)使用事件驅(qū)動(dòng)模型的軟件包括圖形用戶(hù)界面(GUI),嵌入式設(shè)備軟件,網(wǎng)絡(luò)服務(wù)端等。

本文以一個(gè)高度簡(jiǎn)化的嵌入式處理模塊做為事件驅(qū)動(dòng)代碼的例子:假設(shè)該模塊需要處理用戶(hù)命令、外部消息、告警等各種事件,并在主循環(huán)中進(jìn)行分發(fā),那么示例代碼如下:

#include <iostream>
#include <vector>

enum class EventType {
    COMMAND,
    MESSAGE,
    ALARM
};

// 僅用于模擬接收的事件序列
std::vector<EventType> g_events{EventType::MESSAGE, EventType::COMMAND, EventType::MESSAGE};

void ProcessCmd()
{
    std::cout << "Processing Command" << std::endl;
}

void ProcessMsg()
{
    std::cout << "Processing Message" << std::endl;
}

void ProcessAlm()
{
    std::cout << "Processing Alarm" << std::endl;
}

int main() 
{
    for (auto event : g_events) {
        switch (event) {
            case EventType::COMMAND:
                ProcessCmd();
                break;
            case EventType::MESSAGE:
                ProcessMsg();
                break;
            case EventType::ALARM:
                ProcessAlm();
                break;
        }
    }
    return 0;
}

這只是一個(gè)極簡(jiǎn)的模型示例,真實(shí)的代碼要遠(yuǎn)比它復(fù)雜得多,可能還會(huì)包含:從特定接口獲取事件,解析不同的事件類(lèi)型,使用表驅(qū)動(dòng)方法進(jìn)行分發(fā)……不過(guò)這些和本文關(guān)系不大,可暫時(shí)先忽略。

用順序圖表示這個(gè)模型,大體上是這樣:

C++20協(xié)程的使用方法

在實(shí)際項(xiàng)目中,常常碰到的一個(gè)問(wèn)題是:有些事件的處理時(shí)間很長(zhǎng),比如某個(gè)命令可能需要批量的進(jìn)行上千次硬件操作:

void ProcessCmd()
{
    for (int i{0}; i < 1000; ++i) {
        // 操作硬件接口……
    }
}

這種事件處理函數(shù)會(huì)長(zhǎng)時(shí)間的阻塞主循環(huán),導(dǎo)致其他事件一直排隊(duì)等待。如果所有事件對(duì)響應(yīng)速度都沒(méi)有要求,那也不會(huì)造成問(wèn)題。但是實(shí)際場(chǎng)景中經(jīng)常會(huì)有些事件是需要及時(shí)響應(yīng)的,比如某些告警事件出現(xiàn)后,需要很快的執(zhí)行業(yè)務(wù)倒換,否則就會(huì)給用戶(hù)造成損失。這個(gè)時(shí)候,處理時(shí)間很長(zhǎng)的事件就會(huì)產(chǎn)生問(wèn)題。

C++20協(xié)程的使用方法

有人會(huì)想到額外增加一個(gè)線程專(zhuān)用于處理高優(yōu)先級(jí)事件,實(shí)踐中這確實(shí)是個(gè)常用方法。然而在嵌入式系統(tǒng)中,事件處理函數(shù)會(huì)讀寫(xiě)很多公共數(shù)據(jù)結(jié)構(gòu),還會(huì)操作硬件接口,如果并發(fā)調(diào)用,極容易導(dǎo)致各類(lèi)數(shù)據(jù)競(jìng)爭(zhēng)和硬件操作沖突,而且這些問(wèn)題常常很難定位和解決。那在多線程的基礎(chǔ)上加鎖呢?——設(shè)計(jì)哪些鎖,加在哪些地方,也是非常燒腦而且容易出錯(cuò)的工作,如果互斥等待過(guò)多,還會(huì)影響性能,甚至出現(xiàn)死鎖等麻煩的問(wèn)題。

另一種解決方案是:把處理時(shí)間很長(zhǎng)的任務(wù)切割成很多個(gè)小任務(wù),并重新加入到事件隊(duì)列中。這樣就不會(huì)長(zhǎng)時(shí)間的阻塞主循環(huán)。這個(gè)方案避免了并發(fā)編程產(chǎn)生的各種頭疼問(wèn)題,但是卻帶來(lái)另一個(gè)難題:如何把一個(gè)大流程切割成很多獨(dú)立小流程?在編碼時(shí),這需要程序員解析函數(shù)流程的所有上下文信息,設(shè)計(jì)數(shù)據(jù)結(jié)構(gòu)單獨(dú)存儲(chǔ),并建立關(guān)聯(lián)這些數(shù)據(jù)結(jié)構(gòu)的特殊事件。這往往會(huì)帶來(lái)幾倍的額外代碼量和工作量。

這個(gè)問(wèn)題幾乎在所有事件驅(qū)動(dòng)型軟件中都會(huì)存在,但在嵌入式軟件中尤為突出。這是因?yàn)榍度胧江h(huán)境下的CPU、線程等資源受限,而實(shí)時(shí)性要求高,并發(fā)編程受限。

C++20語(yǔ)言給這個(gè)問(wèn)題提供了一種新的解決方案:協(xié)程。

C++20的協(xié)程簡(jiǎn)介

關(guān)于協(xié)程coroutine)是什么,在wikipedia[1]等資料中有很好的介紹,本文就不贅述了。在C++20中,協(xié)程的關(guān)鍵字只是語(yǔ)法糖:編譯器會(huì)將函數(shù)執(zhí)行的上下文(包括局部變量等)打包成一個(gè)對(duì)象,并讓未執(zhí)行完的函數(shù)先返回給調(diào)用者。之后,調(diào)用者使用這個(gè)對(duì)象,可以讓函數(shù)從原來(lái)的“斷點(diǎn)”處繼續(xù)往下執(zhí)行。

使用協(xié)程,編碼時(shí)就不再需要費(fèi)心費(fèi)力的去把函數(shù)“切割”成多個(gè)小任務(wù),只用按照習(xí)慣的流程寫(xiě)函數(shù)內(nèi)部代碼,并在允許暫時(shí)中斷執(zhí)行的地方加上co_yield語(yǔ)句,編譯器就可以將該函數(shù)處理為可“分段執(zhí)行”。

協(xié)程用起來(lái)的感覺(jué)有點(diǎn)像線程切換,因?yàn)楹瘮?shù)的棧幀stack frame)被編譯器保存成了對(duì)象,可以隨時(shí)恢復(fù)出來(lái)接著往下運(yùn)行。但是實(shí)際執(zhí)行時(shí),協(xié)程其實(shí)還是單線程順序運(yùn)行的,并沒(méi)有物理線程切換,一切都只是編譯器的“魔法”。所以用協(xié)程可以完全避免多線程切換的性能開(kāi)銷(xiāo)以及資源占用,也不用擔(dān)心數(shù)據(jù)競(jìng)爭(zhēng)等問(wèn)題。

可惜的是,C++20標(biāo)準(zhǔn)只提供了協(xié)程基礎(chǔ)機(jī)制,并未提供真正實(shí)用的協(xié)程庫(kù)(在C++23中可能會(huì)改善)。目前要用協(xié)程寫(xiě)實(shí)際業(yè)務(wù)的話,可以借助開(kāi)源庫(kù),比如著名的cppcoro[2]。然而對(duì)于本文所述的場(chǎng)景,cppcoro也沒(méi)有直接提供對(duì)應(yīng)的工具(generator經(jīng)過(guò)適當(dāng)?shù)陌b可以解決這個(gè)問(wèn)題,但是不太直觀),因此我自己寫(xiě)了一個(gè)切割任務(wù)的協(xié)程工具類(lèi)用于示例。

自定義的協(xié)程工具

下面是我寫(xiě)的SegmentedTask工具類(lèi)的代碼。這段代碼看起來(lái)相當(dāng)復(fù)雜,但是它作為可重用的工具存在,沒(méi)有必要讓程序員都理解它的內(nèi)部實(shí)現(xiàn),一般只要知道它怎么用就行了。SegmentedTask的使用很容易:它只有3個(gè)對(duì)外接口:Resume、IsFinishedGetReturnValue,其功能可根據(jù)接口名字自解釋。

#include <optional>
#include <coroutine>

template<typename T>
class SegmentedTask {
public:
    struct promise_type {
        SegmentedTask<T> get_return_object() 
        {
            return SegmentedTask{Handle::from_promise(*this)};
        }

        static std::suspend_never initial_suspend() noexcept { return {}; }
        static std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(std::nullopt_t) noexcept { return {}; }

        std::suspend_never return_value(T value) noexcept
        {
            returnValue = value;
            return {};
        }

        static void unhandled_exception() { throw; }

        std::optional<T> returnValue;
    };
 
    using Handle = std::coroutine_handle<promise_type>;
 
    explicit SegmentedTask(const Handle coroutine) : coroutine{coroutine} {}
 
    ~SegmentedTask() 
    { 
        if (coroutine) {
            coroutine.destroy(); 
        }
    }
 
    SegmentedTask(const SegmentedTask&) = delete;
    SegmentedTask& operator=(const SegmentedTask&) = delete;
 
    SegmentedTask(SegmentedTask&& other) noexcept : coroutine(other.coroutine) { other.coroutine = {}; }

    SegmentedTask& operator=(SegmentedTask&& other) noexcept
    {
        if (this != &other) {
            if (coroutine) {
                coroutine.destroy();
            }
            coroutine = other.coroutine;
            other.coroutine = {};
        }
        return *this;
    }

    void Resume() const { coroutine.resume(); }
    bool IsFinished() const { return coroutine.promise().returnValue.has_value(); }
    T GetReturnValue() const { return coroutine.promise().returnValue.value(); }
 
private:
    Handle coroutine;
};

自己編寫(xiě)協(xié)程的工具類(lèi)不光需要深入了解C++協(xié)程機(jī)制,而且很容易產(chǎn)生懸空引用等未定義行為。因此強(qiáng)烈建議項(xiàng)目組統(tǒng)一使用編寫(xiě)好的協(xié)程類(lèi)。如果讀者想深入學(xué)習(xí)協(xié)程工具的編寫(xiě)方法,可以參考Rainer Grimm的博客文章[3]。

接下來(lái),我們使用SegmentedTask來(lái)改造前面的事件處理代碼。當(dāng)一個(gè)C++函數(shù)中使用了co_await、co_yield、co_return中的任何一個(gè)關(guān)鍵字時(shí),這個(gè)函數(shù)就變成了協(xié)程,其返回值也會(huì)變成對(duì)應(yīng)的協(xié)程工具類(lèi)。在示例代碼中,需要內(nèi)層函數(shù)提前返回時(shí),使用的是co_yield。但是C++20的co_yield后必須跟隨一個(gè)表達(dá)式,這個(gè)表達(dá)式在示例場(chǎng)景下并沒(méi)必要,就用了std::nullopt讓其能編譯通過(guò)。實(shí)際業(yè)務(wù)環(huán)境下,co_yield可以返回一個(gè)數(shù)字或者對(duì)象用于表示當(dāng)前任務(wù)執(zhí)行的進(jìn)度,方便外層查詢(xún)。

協(xié)程不能使用普通return語(yǔ)句,必須使用co_return來(lái)返回值,而且其返回類(lèi)型也不直接等同于co_return后面的表達(dá)式類(lèi)型。

enum class EventType {
    COMMAND,
    MESSAGE,
    ALARM
};

std::vector<EventType> g_events{EventType::COMMAND, EventType::ALARM};
std::optional<SegmentedTask<int>> suspended;  // 沒(méi)有執(zhí)行完的任務(wù)保存在這里

SegmentedTask<int> ProcessCmd()
{
    for (int i{0}; i < 10; ++i) {
        std::cout << "Processing step " << i << std::endl;
        co_yield std::nullopt;
    }
    co_return 0;
}

void ProcessMsg()
{
    std::cout << "Processing Message" << std::endl;
}

void ProcessAlm()
{
    std::cout << "Processing Alarm" << std::endl;
}

int main()
{
    for (auto event : g_events) {
        switch (event) {
            case EventType::COMMAND:
                suspended = ProcessCmd();
                break;
            case EventType::MESSAGE:
                ProcessMsg();
                break;
            case EventType::ALARM:
                ProcessAlm();
                break;
        }
    }
    while (suspended.has_value() && !suspended->IsFinished()) {
        suspended->Resume();
    }
    if (suspended.has_value()) {
        std::cout << "Final return: " << suspended->GetReturnValue() << endl;
    }
    return 0;
}

出于讓示例簡(jiǎn)單的目的,事件隊(duì)列中只放入了一個(gè)COMMAND和一個(gè)ALARMCOMMAND是可以分段執(zhí)行的協(xié)程,執(zhí)行完第一段后,主循環(huán)會(huì)優(yōu)先執(zhí)行隊(duì)列中剩下的事件,最后再來(lái)繼續(xù)執(zhí)行COMMAND余下的部分。實(shí)際場(chǎng)景下,可根據(jù)需要靈活選擇各種調(diào)度策略,比如專(zhuān)門(mén)用一個(gè)隊(duì)列存放所有未執(zhí)行完的分段任務(wù),并在空閑時(shí)依次執(zhí)行。

本文中的代碼使用gcc 10.3版本編譯運(yùn)行,編譯時(shí)需要同時(shí)加上-std=c++20-fcoroutines兩個(gè)參數(shù)才能支持協(xié)程。代碼運(yùn)行結(jié)果如下:

Processing step 0
Processing Alarm
Processing step 1
Processing step 2
Processing step 3
Processing step 4
Processing step 5
Processing step 6
Processing step 7
Processing step 8
Processing step 9
Final return: 0

可以看到ProcessCmd函數(shù)(協(xié)程)的for循環(huán)語(yǔ)句并沒(méi)有一次執(zhí)行完,在中間插入了ProcessAlm的執(zhí)行。如果分析運(yùn)行線程還會(huì)發(fā)現(xiàn),整個(gè)過(guò)程中并沒(méi)有物理線程的切換,所有代碼都是在同一個(gè)線程上順序執(zhí)行的。

使用了協(xié)程的順序圖變成了這樣:

C++20協(xié)程的使用方法

事件處理函數(shù)的執(zhí)行時(shí)間長(zhǎng)不再是問(wèn)題,因?yàn)榭梢灾型尽安迦搿逼渌暮瘮?shù)運(yùn)行,之后再返回?cái)帱c(diǎn)繼續(xù)向下運(yùn)行。

總結(jié)

一個(gè)較普遍的認(rèn)識(shí)誤區(qū)是:使用多線程可以提升軟件性能。但事實(shí)上,只要CPU沒(méi)有空跑,那么當(dāng)物理線程數(shù)超過(guò)了CPU核數(shù),就不再會(huì)提升性能,相反還會(huì)由于線程的切換開(kāi)銷(xiāo)而降低性能。大多數(shù)開(kāi)發(fā)實(shí)踐中,并發(fā)編程的主要好處并非為了提升性能,而是為了編碼的方便,因?yàn)楝F(xiàn)實(shí)中的場(chǎng)景模型很多都是并發(fā)的,容易直接對(duì)應(yīng)成多線程代碼。

協(xié)程可以像多線程那樣方便直觀的編碼,但是同時(shí)又沒(méi)有物理線程的開(kāi)銷(xiāo),更沒(méi)有互斥、同步等并發(fā)編程中令人頭大的設(shè)計(jì)負(fù)擔(dān),在嵌入式應(yīng)用等很多場(chǎng)景下,常常是比物理線程更好的選擇。

相信隨著C++20的逐步普及,協(xié)程將來(lái)會(huì)得到越來(lái)越廣泛的使用。

到此,關(guān)于“C++20協(xié)程的使用方法”的學(xué)習(xí)就結(jié)束了,希望能夠解決大家的疑惑。理論與實(shí)踐的搭配能更好的幫助大家學(xué)習(xí),快去試試吧!若想繼續(xù)學(xué)習(xí)更多相關(guān)知識(shí),請(qǐng)繼續(xù)關(guān)注億速云網(wǎng)站,小編會(huì)繼續(xù)努力為大家?guī)?lái)更多實(shí)用的文章!

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

c++
AI