按值传递
如果之前写惯了同步代码,那么函数参数写成 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 建议尽量使用普通函数创建协程,当然如果你确切地知道自己在干什么,你对自己的代码极度自信,对所有变量的生命周期了如指掌,你可以适当地跳脱于规则之外,毕竟优雅永不过时!