国外C++程序员分享:从零开始学C++20协程

创作不易,方便的话点点关注,谢谢
本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。

文章结尾有最新热度的文章,感兴趣的可以去看看。

文章有点长(3965字阅读时长:10分),期望您能坚持看完,并有所收获


大家好,今天我继续给大家分享干货。熟悉我的人,都知道我真正的干货一般在中间和末尾部分。请耐心看完!谢谢。

 

C++20为我们带来了对协程的初始支持。在本文中,我们将通过多个相互关联的示例来深入了解协程。不过需要提醒的是,C++20中的协程支持主要面向库的实现者。C++23应该会带来更多支持,至少能覆盖最常见的使用场景。

那么,什么是协程呢?协程是指任何包含co_returnco_yield或者co_await的函数。

从根本上讲,C++20协程是基于函数对象的语法。编译器会围绕你的协程生成一个代码框架。这个代码依赖于用户自定义的返回类型和promise类型。在C++23引入一些标准类型之前,你需要自行编写这些类型。

一个什么都不做的协程

让我们来看一下目前你能编写的最简单的协程:

#include 
// 调用者层面的类型
structTask {
    // 协程层面的类型
    structpromise_type {
        Task get_return_object() return {}; }
        std::suspend_never initial_suspend() return {}; }
        std::suspend_never final_suspend() noexcept return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

Task myCoroutine() {
    co_return// 使其成为一个协程
}

int main() {
    Task x = myCoroutine();
}

好吧,对于这样一个立即返回且什么都不做的协程来说,似乎有很多样板代码。但这是一个很好的切入点,能让我们开始查看编译器生成的框架代码。

稍后我们会讨论co_await,但在这个示意图中我故意将其标记为虚线,因为对std::suspend_never的实例调用co_await会立即返回。如果你运行示例的插装版本(你可以在文章末尾的关联仓库中找到),就能看到这种情况。

在继续之前,让我们再深入探究一下这个示例。如果我们把initial_suspend()的返回类型改为std::suspend_always会怎样呢?

//...
std::suspend_always initial_suspend() return {}; }
//...

这样修改就产生了一个问题。现在这个协程会陷入停滞(产生泄漏)。

std::suspend_always{}的实例调用co_await会导致协程暂停,进而将控制权交还给调用者。然而,调用者(main函数)却没有办法恢复这个协程。那我们来解决这个问题。

#include 
// 调用者层面的类型
structTask {
    // 协程层面的类型
    structpromise_type {
        using Handle = std::coroutine_handle;
        Task get_return_object() {
            return Task{Handle::from_promise(*this)};
        }
        std::suspend_always initial_suspend() return {}; }
        std::suspend_never final_suspend() noexcept return {}; }
        void return_void() { }
        void unhandled_exception() { }
    };
    explicit Task(promise_type::Handle coro) : coro_(coro) {}
    void destroy() { coro_.destroy(); }
    void resume() { coro_.resume(); }
private:
    promise_type::Handle coro_;
};

Task myCoroutine() {
    co_return// 使其成为一个协程
}

int main() {
    auto c = myCoroutine();
    c.resume();
    // c.destroy();
}

我们需要让调用者能够获取到coroutine_handle。为此,我们在get_return_object()调用中传递它,在那里我们从承诺实例中创建它。然后调用者就可以对暂停的协程调用resume()或者destroy()了。需要注意的是,对未暂停的协程调用这些方法属于未定义行为。将调用者层面的类型设置为仅可移动也是合理的,这样可以避免在句柄所有权方面产生混淆。

// 使Task仅可移动:
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
Task(Task&& t) noexcept : coro_(t.coro_) { t.coro_ = {} }
Task& operator=(Task&& t) noexcept {
    if (this == &t) return *this;
    if (coro_) coro_.destroy();
    coro_ = t.coro_;
    t.coro_ = {};
    return *this;
}

一个暂停的协程以纯数据的形式存在。因此,我们可以像处理数据一样对其进行操作,例如在线程之间传递它。稍后在处理co_await时我们会用到这个特性。

到目前为止,我们演示的协程都没做什么实际的事。那么让我们来看第一个有用的示例,也就是生成器(generator)。

生成器协程

生成器依赖于co_yield关键字。co_yield expr;表达式是co_await promise.yield_value(expr);的简写形式。承诺类型控制着产生一个值意味着什么,以及协程是否暂停以及如何暂停。在这里我们使用std::suspend_always,因为我们希望协程停止运行,直到下一次调用get_next()

#include 
#include 
// 调用者层面的类型
structGenerator {
    // 协程层面的类型
    structpromise_type {
        using Handle = std::coroutine_handle;
        Generator get_return_object() {
            return Generator{Handle::from_promise(*this)};
        }
        std::suspend_always initial_suspend() return {}; }
        std::suspend_always final_suspend() noexcept return {}; }
        std::suspend_always yield_value(int value) {
            current_value = value;
            return {};
        }
        void unhandled_exception() { }
        int current_value;
    };
    explicit Generator(promise_type::Handle coro) : coro_(coro) {}
    ~Generator() {
        if (coro_) coro_.destroy();
    }
    // 设置为仅可移动
    Generator(const Generator&) = delete;
    Generator& operator=(const Generator&) = delete;
    Generator(Generator&& t) noexcept : coro_(t.coro_) {
        t.coro_ = {};
    }
    Generator& operator=(Generator&& t) noexcept {
        if (this == &t) return *this;
        if (coro_) coro_.destroy();
        coro_ = t.coro_;
        t.coro_ = {};
        return *this;
    }
    int get_next() {
        coro_.resume();
        return coro_.promise().current_value;
    }
private:
    promise_type::Handle coro_;
};

Generator myCoroutine() {
    int x = 0;
    while (true) {
        co_yield x++;
    }
}

int main() {
    auto c = myCoroutine();
    int x = 0;
    while ((x = c.get_next()) < 10) {
        std::cout << x << "\\n";
    }
}

因为这个协程包含一个无限循环,所以它永远不会自然地被清理。不过,由于协程只在get_next()调用内部运行,我们可以在Generator的析构函数中安全地调用destroy()来清理它。

现在,由于get_next()函数的存在,这个协程的可用性有点别扭。但我们可以轻松地添加一些样板代码,将其转换为一个范围(range),就像cppreference上的这个示例一样:https://en.cppreference.com/w/cpp/coroutine/coroutine_handle#Example

可等待对象(Awaitable)

我们已经使用了两个可等待对象,std::suspend_neverstd::suspend_always。让我们来看一下可等待对象是如何工作的,以及如何编写自己的可等待对象来与异步库进行交互。

与承诺类型类似,编译器会围绕可等待对象类型生成代码。如果承诺类型提供了await_transform(expr);方法,那么co_await expr;调用将会被转换为co_await promise.await_transform(expr);。因此,承诺类型可以控制哪些可等待对象类型允许出现在协程体内部,并且有可能根据表达式返回不同的可等待对象(注意,这里的expr不一定是可等待对象)。

struct promise_type {
    // 只允许std::suspend_always出现在协程内部
    std::suspend_always await_transform(std::suspend_always s) {
        return std::suspend_always{};
    }
};

可等待对象类型需要提供三个方法:

struct awaitable {
    bool await_ready();
    // 以下三者选一:
    void await_suspend(std::coroutine_handle<>) {}
    bool await_suspend(std::coroutine_handle<>) {}
    std::coroutine_handle<>
        await_suspend(std::coroutine_handle<>) {}
    void await_resume() {}
};

让我们来看一个简单(尽管没什么实际意义)的示例。

struct Sleeper {
    constexpr bool await_ready() const noexcept return false; }
    void await_suspend(std::coroutine_handle<> h) const {
        auto t = std::jthread([h, l = length] {
            std::this_thread::sleep_for(l);
            h.resume();
        });
    }
    constexpr void await_resume() const noexcept {}
    const std::chrono::duration<int, std::milli> length;
};

协程在进入await_suspend方法之前会暂停。由于我们在这个方法内部(在暂停点之后)创建了一个新线程,所以不会出现数据竞争。还要注意的是,虽然我们遵循前面示意图中“控制权交还给调用者”的分支,但我们是在新创建的线程中恢复协程(而不是在调用者线程中)。

然后我们可以在协程中使用它:

Task myCoroutine() {
    using namespace std::chrono_literals;
    auto before = std::chrono::steady_clock::now();
    co_await Sleeper{200ms};
    auto after = std::chrono::steady_clock::now();
    std::cout << "Slept for " << (after - before) / 1ms << " ms\\n";
}

何时使用协程

最后,我想谈谈你可能会在什么时候使用协程,以及它们的优缺点。首先,生成器是很有用的,如果你有合适的使用场景,即便现在也应该使用它们。

单线程环境

如果你受限于单线程环境,协程是一种进行异步处理的解决方案,否则你可能无法进行异步处理。利用当前的协程支持,应该可以实现一个类似JavaScript风格的事件循环。

超多线程环境

另一种使用场景则处于另一个极端。如果你的产品需要大量的轻量级线程,协程可能会节省内存和文件描述符。

使用带有可等待对象支持的异步库

这一点比较明显。如果你编写用户代码,并且你的库为你提供了预构建的可等待对象以及协程支持类型,那么编写协程可能是更简洁的方法。因为所有与协程的同步操作都在co_await调用背后进行,所以你可以避免在代码中充斥大量互斥量。

容量受限环境

那么其他情况呢?我发现协程在容量受限环境中存在一些问题。对于同步处理,你可以通过控制线程数量来控制服务的容量。当达到过载状态时,你可以在请求到达时拒绝它们。不幸的是,在协程中防止过载变得复杂得多,因为在处理请求的过程中,你很容易就陷入过载的情况。

超时处理

另一个我还没搞清楚如何用协程处理的棘手特性就是超时处理。例如,在我过去工作过的大多数系统中,都有如下这样的代码片段:

Throttler tr;
// mu是absl::Mutex(std::mutex + std::condition_variable的组合)
auto has_slot = [&tr]() { return!tr.Full(); }
if (mu.LockWhenWithTimeout(Condition(&has_slot), 200/*ms*/)) {
// 成功获取到槽位
do_work();
else {
// 超时分支
bail_out();
}
mu.Unlock();

我很难想出与之对应的协程实现方式,特别是如何安全地实现超时分支。

点个“在看”不失联

最新热门文章推荐:

告别选择困难:用VSCode和Docker构建跨平台C++统一环境

国外C++程序员分享:C++23多线程竟让图像处理速度飙升

国外Rust程序员分享:Crossbeam使多线程通信如此简单高效

国外Rust程序员分享:用与不用线程池的差距竟如此之大!

国外Rust程序员分享:从头开始编写一个实时操作系统(RTOS)

C++编程中最容易忽视的关键点,你注意到了吗?

高手必备:如何像专业人士一样优化C++代码?

C++开发中被低估的std::vector,竟是性能与安全的双重保障!

Go并发模式:我后悔没有早点掌握的5种并发模式

告别繁琐!Go1.23中的iter包如何简化你的代码逻辑

一文读懂C++20std::span:语法、用例全攻略

C++ 高手进阶之路:精通 std::mutex,拿捏多线程同步

码农必看:C++23 新特性揭秘:std::expected 告别繁琐错误码与异常处理

码农必看:C++20编译时计算如何优化你的代码?

C++虚函数:看似神秘,实则背后都是精心设计

用C++构建音乐播放器MusikCube,两三兆却功能强大令人惊叹

别再盲目找库!Rust 编程最关键的10个库都在这儿了

为什么大家都在用Rust提升Python性能?真相让人意想不到!

必看!Discord从Go切换到Rust语言背后带来提升性能并降低延迟

参考文献:《图片来源网络》《C++20 Coroutines — Complete* Guide》

 

请使用浏览器的分享功能分享到微信等