C++ 协程踩坑

按值传递

如果之前写惯了同步代码,那么函数参数写成 const reference 应该是肌肉记忆了,但是在写协程的入参时,你需要确切地知道自己在做什么。

1
2
3
4
5
6
7
8
9
Task<int> func(const std::string &name) {
co_await xxxx;
std::cout << name << std::endl;
}

int main() {
co_await func("hello");
return 0;
}

上面这段代码乍一看没什么问题,但是仔细一想协程可能会在 co_await 时将控制返回上层,那么 std::string 的临时变量还存在吗?对于上面的代码是存在的,因为 co_await func("hello") 这一行代码,在 co_await 等待完成之前,hello 的临时变量会保持存活,等到协程完成后才会被清理,但是如果改成下面这样:

1
2
3
4
5
int main() {
auto task = func("hello");
co_await task;
return 0;
}

这样的写法应该是被允许的,因为生成 task 和等待 task 应该被允许分离,但是你会发现在执行 co_await task 时,hello 隐式转换的 string 已经结束了生命周期,但是 task 还没完成,在将来某一刻它还要访问变量 name
如果可以保证 const reference 的入参只会在第一个等待点前访问,前提是协程是创建即执行的,那么也不会有任何问题。但是后续开发者改动代码产生问题的风险变增大了,他们必须时刻注意这一点,这应该是很难的。在性能和安全性之前我纠结了许久,最后还是建议协程的入参一般都应该传值。

视图

看看下面这段代码:

1
2
3
4
5
6
7
8
9
Task<size_t> read(int fd, std::span<std::byte> data) {
...
}

int main() {
std::byte data[1024];
co_await read(1, data);
return 0;
}

视图不持有对象,它可以被看成封装后的 (void *, size_t),你可以说这里是按值传递了视图,但是并没有按值传递 container。不过这样的传递是没问题的,因为这类接口的目的就是读取完成后访问 data,那么反之可以确定数据在读取操作后还存在,应该不会有人这样写:

1
2
3
4
5
6
7
8
9
10
11
int main() {
Task<size_t> task;

{
std::byte data[1024];
task = read(1, data);
}

co_await task;
return 0;
}

上面这种写法可能就是为了犯错而犯错,再来看另一个例子:

1
2
3
4
5
6
7
8
9
Task<Socket> connect(std::span<const Address> addresses) {
...
}

int main() {
auto addresses = {...};
co_await connect(addresses);
return 0;
}

上面的代码允许连接一系列目标地址直到连接成功,那么上层代码可能关心的只是返回的 Socket,那么可能有小概率出现下面这样错误的写法:

1
2
3
4
5
6
7
8
9
10
11
int main() {
Task<Socket> task;

{
auto addresses = {...};
task = connect(addresses);
}

co_await task;
return 0;
}

另外值得一提的是,还有一种常见的错误:

1
2
3
4
5
int main() {
auto &addresses = co_await dns::query("domain");
co_await connect(addresses);
return 0;
}

上面的 addressesdns 异步结果的引用,可能是一个 vector<Address>,这个值应该是被 promise 持有的。下面的 connect 生成了它的视图,指向的还是 promise 包含的值,但是当 connect 里面某个异步操作挂起时,promise 将会消亡,因为它挂钩的 dns 已经完成了,它也不需要再存在了,所以正确的写法是:

1
2
3
4
5
6
7
8
9
10
11
int main() {
auto addresses = co_await dns::query("domain");
co_await connect(addresses);
return 0;
}

int main() {
auto addresses = std::move(co_await dns::query("domain"));
co_await connect(addresses);
return 0;
}

上面两种写法都可以,但是第二种会直接移走 promise 的值,如果还有别的 promise::then 绑定的回调,那么那些回调将获取不到值了,这种情况需要自己辨别。

任何时候都应该谨慎对待 auto &result = co_await xxx;,大多数情况下它会带来 crash

另外可以多一嘴,我为什么要暴露出 promise 结果的左值引用,因为如果不这样做的话,那么 promise 的值就必须是可复制的,类似于 Socket 这类只能被移动的对象就只能用 std::shared_ptr 包裹住了,我并不是很想这样做。而不默认返回右值引用,是因为 promise 的结果并不是只被一人独享的,如果默认将 promise 的结果移走,会导致其它回调访拿到的是 moved 的值。

Lambda

很不幸,非常不建议使用具有捕获的 lambda 作为协程,看看这个例子:

1
2
3
4
5
6
7
8
9
Task<void> func(std::string host) {
auto task = [=]() -> Task<void> {
auto socket = co_await connect(host, 443);
co_await socket->write(xxx);
std::cout << host << std::endl;
}();

co_await task;
}

在执行 co_await task 时,临时的 lambda 对象已经消亡,而它捕获的 host 生命周期也结束了,那么未结束的 task 在恢复执行后将会访问到一个未知值,虽然大多数情况下栈上的数据还没有被改写,host 所指向的内存还是有效的,但恰恰是这暗藏祸根的代码会带来未知的风险。
虽然极其不优雅,但是如果想使用 lambda 创建协程只能显式地传参:

1
2
3
4
5
6
7
8
9
Task<void> func(std::string host) {
auto task = [](auto host) -> Task<void> {
auto socket = co_await connect(host, 443);
co_await socket->write(xxx);
std::cout << host << std::endl;
}(host);

co_await task;
}

C++ Core Guidelines 建议尽量使用普通函数创建协程,当然如果你确切地知道自己在干什么,你对自己的代码极度自信,对所有变量的生命周期了如指掌,你可以适当地跳脱于规则之外,毕竟优雅永不过时!