
本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。
文章结尾有最新热度的文章,感兴趣的可以去看看。
文章有点长(3965字阅读时长:10分),期望您能坚持看完,并有所收获
大家好,今天我继续给大家分享干货。熟悉我的人,都知道我真正的干货一般在中间和末尾部分。请耐心看完!谢谢。

C++20为我们带来了对协程的初始支持。在本文中,我们将通过多个相互关联的示例来深入了解协程。不过需要提醒的是,C++20中的协程支持主要面向库的实现者。C++23应该会带来更多支持,至少能覆盖最常见的使用场景。
那么,什么是协程呢?协程是指任何包含co_return、co_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_never和std::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++统一环境
国外Rust程序员分享:Crossbeam使多线程通信如此简单高效
国外Rust程序员分享:从头开始编写一个实时操作系统(RTOS)
C++开发中被低估的std::vector,竟是性能与安全的双重保障!
C++ 高手进阶之路:精通 std::mutex,拿捏多线程同步
码农必看:C++23 新特性揭秘:std::expected 告别繁琐错误码与异常处理
用C++构建音乐播放器MusikCube,两三兆却功能强大令人惊叹
为什么大家都在用Rust提升Python性能?真相让人意想不到!
必看!Discord从Go切换到Rust语言背后带来提升性能并降低延迟
参考文献:《图片来源网络》《C++20 Coroutines — Complete* Guide》