C-- 使用協程需要注意的問題

在異步操作裏,如異步連接、異步讀寫之類的協程,co_await 這些協程時需要注意線程切換的細節。

以 asio 異步連接協程爲例:

class client {
public:
    client() {
        thd_ = std::thread([this]{
            io_ctx_.run();
        });
    }

    async_simple::coro::Lazy<bool> async_connect(auto host, auto port) {
        bool ret = co_await util::async_connect(host, port);  #1
        co_return ret;      #2                                 
    }

    ~client() {
        io_ctx_.stop();
        if(thd_.joinable()) {
            thd_.join();
        }
    }

private:
  asio::io_context io_ctx_;
  std::thread thd_;
};

int main() {
    client c;
    async_simple::coro::syncAwait(c.async_connect());
    std::cout<<"quit\n"; #3
}

這個例子很簡單,client 在連接之後就析構了,看起來沒什麼問題。但是運行之後就會發生線程 join 的錯誤,錯誤的意思是在線程裏 join 自己了。這是怎麼回事?co_await 一個異步連接的協程,當連接成功後協程返回,這時候發生了線程切換。異步連接返回的時候是在 io_context 的線程裏,代碼中的 #1 在主線程,#2 在 io_context 線程,之後就 co_return 返回到 main 函數的 #3,這時候 #3 仍然在 io_context 線程裏,接着 client 就會析構了,這時候仍然在 io_context 線程裏,析構的時候會調用 thd_.join(); 然後就導致了在 io_context 的線程裏 join 自己的錯誤。

這是使用協程時容易犯錯的一個地方,解決方法就是避免 co_await 回來之後去析構 client,或者 co_await 回來仍然回到主線程。這裏可以考慮用協程條件變量,在異步連接的時候發起一個新的協程並傳入協程條件變量並在連接返回後 set_value,主線程去 co_await 這個條件變量,這樣連接返回後就回到主線程了,就可以解決在 io 線程裏 join 自己的問題了。

還是以上面的異步連接爲例子,需要對之前的 async_connect 協程增加一個超時功能,代碼稍作修改:

class client {
public:
    client() : socket_(io_ctx_) {
        thd_ = std::thread([this]{
            io_ctx_.run();
        });
    }

    async_simple::coro::Lazy<bool> async_connect(auto host, auto port, auto duration) {
        coro_timer timer(io_ctx_);
        timeout(timer, duration).start([](auto&&){}); // #1 啓動一個新協程做超時處理
        bool ret = co_await util::async_connect(host, port, socket_);//假設這裏co_await返回後回到主線程
        co_return ret;                                      
    }

    ~client() {
        io_ctx_.stop();
        if(thd_.joinable()) {
            thd_.join();
        }
    }


private:
  async_simple::coro::Lazy<void> timeout(auto &timer, auto duration) {
    bool is_timeout = co_await timer.async_wait(duration);
    if(is_timeout) {
        asio::error_code ignored_ec;
        socket_.shutdown(tcp::socket::shutdown_both, ignored_ec);
        socket_.close(ignored_ec);
    }

    co_return;
  }

  asio::io_context io_ctx_;
  tcp::socket socket_;
  std::thread thd_;
  bool is_timeout_;
};

int main() {
    client c;
    async_simple::coro::syncAwait(c.async_connect("localhost""9000", 5s));
    std::cout<<"quit\n"; #3
}

這個代碼增加連接超時處理的協程,注意 #1 那裏爲什麼需要新啓動一個協程,而不能用 co_await 呢?因爲 co_await 是阻塞語義,co_await 會導致永遠超時,啓動一個新的協程不會阻塞當前協程從而可以去調用 async_connect。

當 timeout 超時發生時就關閉 socket,這時候 async_connect 就會返回錯誤然後返回到調用者,這看起來似乎可以對異步連接做超時處理了,但是這個代碼是有問題的。假如異步連接沒有超時會發生什麼?沒有超時的話就返回到 main 函數了,然後 client 就析構了,當 timeout 協程 resume 回來的時候 client 其實已經析構了,這時候再去調用成員變量 socket_ close 將會導致一個訪問已經析構對象的錯誤。

也許有人會說,那就在 co_return 之前去取消 timer 不就好了嗎?這個辦法也不行,因爲取消 timer,timeout 協程並不會立即返回,仍然會存在訪問已經析構對象的問題。

正確的做法應該是對兩個協程進行同步,timeout 協程和 async_connect 協程需要同步,在 async_connect 協程返回之前需要確保 timeout 協程已經完成,這樣就可以避免訪問已經析構對象的問題了。

這個問題其實也是異步回調安全返回的一個經典問題,協程也同樣會遇到這個問題,上面提到的對兩個協程進行同步是解決方法之一,另外一個方法就是使用 shared_from_this,就像異步安全回調那樣處理。

還是以異步連接爲例:

async_simple::coro::Lazy<bool> async_connect(const std::string &host, const std::string& port) {
    co_return co_await util::async_connect(host, port);
}

async_simple::coro::Lazy<void> test_connect() {
    bool ok = co_await async_connect("localhost""8000");
    if(!ok){
        std::cout<<"connect failed\n";
    }

    std::cout<<"connect ok\n";
}

int main() {
    async_simple::coro::syncAwait(test_connect());
}

這個代碼簡單明瞭,就是測試一下異步連接是否成功,運行也是正常的。如果稍微改一下 test_connect:

async_simple::coro::Lazy<void> test_connect() {
    auto lazy = async_connect("localhost""8000");
    bool ok = co_await lazy;
    if(!ok){
        std::cout<<"connect failed\n";
    }

    std::cout<<"connect ok\n";
}

很遺憾,這個代碼會導致連接總是失敗,似乎很奇怪,後面發現原因是因爲 async_connect 的兩個參數失效了,但是寫法和剛開始的寫法幾乎一樣,爲啥後面這種寫法會導致參數失效呢?

原因是 co_await 一個協程函數時,其實做了兩件事:

回過頭來看 auto lazy = async_connect("localhost", "8000"); 這個代碼調用協程函數創建了協程,這時候拷貝到協程幀裏面的是兩個臨時變量,在這一行結束的時候臨時變量就析構了,在下一行去 co_await 執行這個協程的時候就會出現參數失效的問題了。

co_await async_connect("localhost", "8000"); 這樣爲什麼沒問題呢,因爲協程創建和協程調用都在一行完成的,臨時變量知道協程執行之後纔會失效,因此不會有問題。

問題的本質其實是 C++ 臨時變量生命週期的問題。使用協程的時候稍微注意一下就好了,可以把 const std::string & 改成 std::string,這樣就不會臨時變量生命週期的問題了,如果不想改參數類型就 co_await 協程函數就好了,不分成兩行去執行協程。

文章轉載自:www.purecpp.cn

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ztbmkvpFvXmeEJW3vUQ60g