按值传递
如果之前写惯了同步代码,那么函数参数写成 const reference
应该是肌肉记忆了,但是在写协程的入参时,你需要确切地知道自己在做什么。
1 | Task<int> func(const std::string &name) { |
上面这段代码乍一看没什么问题,但是仔细一想协程可能会在 co_await
时将控制返回上层,那么 std::string
的临时变量还存在吗?对于上面的代码是存在的,因为 co_await func("hello")
这一行代码,在 co_await
等待完成之前,hello
的临时变量会保持存活,等到协程完成后才会被清理,但是如果改成下面这样:
1 | int main() { |
这样的写法应该是被允许的,因为生成 task
和等待 task
应该被允许分离,但是你会发现在执行 co_await task
时,hello
隐式转换的 string
已经结束了生命周期,但是 task
还没完成,在将来某一刻它还要访问变量 name
。
如果可以保证 const reference
的入参只会在第一个等待点前访问,前提是协程是创建即执行的,那么也不会有任何问题。但是后续开发者改动代码产生问题的风险变增大了,他们必须时刻注意这一点,这应该是很难的。在性能和安全性之前我纠结了许久,最后还是建议协程的入参一般都应该传值。
视图
看看下面这段代码:
1 | Task<size_t> read(int fd, std::span<std::byte> data) { |
视图不持有对象,它可以被看成封装后的 (void *, size_t)
,你可以说这里是按值传递了视图,但是并没有按值传递 container
。不过这样的传递是没问题的,因为这类接口的目的就是读取完成后访问 data
,那么反之可以确定数据在读取操作后还存在,应该不会有人这样写:
1 | int main() { |
上面这种写法可能就是为了犯错而犯错,再来看另一个例子:
1 | Task<Socket> connect(std::span<const Address> addresses) { |
上面的代码允许连接一系列目标地址直到连接成功,那么上层代码可能关心的只是返回的 Socket
,那么可能有小概率出现下面这样错误的写法:
1 | int main() { |
另外值得一提的是,还有一种常见的错误:
1 | int main() { |
上面的 addresses
是 dns
异步结果的引用,可能是一个 vector<Address>
,这个值应该是被 promise
持有的。下面的 connect
生成了它的视图,指向的还是 promise
包含的值,但是当 connect
里面某个异步操作挂起时,promise
将会消亡,因为它挂钩的 dns
已经完成了,它也不需要再存在了,所以正确的写法是:
1 | int main() { |
上面两种写法都可以,但是第二种会直接移走 promise
的值,如果还有别的 promise::then
绑定的回调,那么那些回调将获取不到值了,这种情况需要自己辨别。
任何时候都应该谨慎对待
auto &result = co_await xxx;
,大多数情况下它会带来crash
。
另外可以多一嘴,我为什么要暴露出 promise
结果的左值引用,因为如果不这样做的话,那么 promise
的值就必须是可复制的,类似于 Socket
这类只能被移动的对象就只能用 std::shared_ptr
包裹住了,我并不是很想这样做。而不默认返回右值引用,是因为 promise
的结果并不是只被一人独享的,如果默认将 promise
的结果移走,会导致其它回调访拿到的是 moved
的值。
Lambda
很不幸,非常不建议使用具有捕获的 lambda
作为协程,看看这个例子:
1 | Task<void> func(std::string host) { |
在执行 co_await task
时,临时的 lambda
对象已经消亡,而它捕获的 host
生命周期也结束了,那么未结束的 task
在恢复执行后将会访问到一个未知值,虽然大多数情况下栈上的数据还没有被改写,host
所指向的内存还是有效的,但恰恰是这暗藏祸根的代码会带来未知的风险。
虽然极其不优雅,但是如果想使用 lambda
创建协程只能显式地传参:
1 | Task<void> func(std::string host) { |
C++ Core Guidelines 建议尽量使用普通函数创建协程,当然如果你确切地知道自己在干什么,你对自己的代码极度自信,对所有变量的生命周期了如指掌,你可以适当地跳脱于规则之外,毕竟优雅永不过时!