原文
了解对称转移
协程组提供了个编写异步代码
的绝妙方法,与同步代码一样.只需要在合适地点加上协待
,编译器就会负责挂起
协程,跨挂起点
保留状态,并在操作
完成后恢复
协程.
但是,最初有个令人讨厌
的限制,如果不小心,很容易导致栈溢出
.如果想避免它,则必须引入额外
同步成本,以便在任务<T>
类型中安全地避免它.
好的是,在2018
年调整了协程的设计
,以添加一个叫"对称转移
"的功能,来允许挂起A并恢复B协程,而不消耗额外栈空间
.
此功能解除
了协程组的一个关键限制
,并允许更简单,更高效地
实现异步
协程类型,而不会为栈溢出
付出成本.
本文,试解释栈溢出
,及"对称转移
"如何解决它.
协程工作原理背景
请考虑以下协程:
任务 福(){协中;
}
任务 条(){协待 福();
}
假设有个当另一个
协程等待它时,会懒执行主体,且不支持返回值的简单
任务类型.
分析条()
计算协待 福()
:
1,条()
协程调用福()
函数.注意,从调用者角度,协程
只是个普通函数.
2,调用福()
执行以下几个步骤:
1,为(一般在堆上
)协程帧
分配存储
2,复制
参数到协程帧
中(本例中无参,因此这是无操作).
3,在协程帧
中构造承诺
对象
4,调用承诺.取中()
以取福()
的返回值.这生成返回
的任务对象
,并使用刚刚创建的引用协程帧
的标::协柄
来初化它.
5,在初挂起
(即左大括号
)处挂起
协程
6,返回任务对象
到条()
.
3,接着,条()
协程计算从福()
返回的任务上的协待
式.
1,挂起条()
协程,然后在返回
任务上传递引用条()
的协程帧的标::协柄
,来调用挂起协()
方法.
2,然后,在福()
的承诺
对象中,挂起协()
方法存储条()
的标::协柄
,然后在福()
的标::协柄
上调用.恢复()
来恢复福()
协程.
4,福()
协程同步
执行并运行到完成.
5,在终挂起
(即右大括号
)挂起福()
协程,然后恢复,在启动前在其承诺
对象中存储的以标::协柄
标识的即.条()
协程.
6,恢复并连续执行条()
协程,最终到达调用从福()
返回的临时任务对象的析构器中包含协待
式的语句的末尾.
7,然后,在福()
的协程句柄上,任务
析构器调用.消灭()
方法,然后析构
协程帧及承诺
对象和参数的副本.
好的,简单调用,似乎步骤太多.
为了帮助更深入
理解,看看使用协程组(不支持对称转移)设计实现此任务类
的简单实现时会怎样.
任务实现大概
类 任务{
公:类 承诺类型{/*见下*/};任务(任务&&t)无异:协程_(标::交换(t.协程_,{})){}~任务(){如(协程_)协程_.消灭();}类 等待器{/*见下*/};等待器 符号 协待()&&无异;
私:显 任务(标::协柄<承诺类型>h)无异:协程_(h){}标::协柄<承诺类型>协程_;
};
任务
对与调用协程
时创建的协程帧关联的标::协柄
有独占所有权.任务
对象是个可确保任务
对象出域时,在标::协柄
上调用.消灭()
的资取化
对象.
因此,现在扩展一下承诺类型
.
实现任务::promise_type
上篇已知,承诺类型
成员定义
了在协程帧
内创建并控制
协程行为的承诺
对象的类型.
首先,要实现取中()
来构造调用协程
时返回的任务对象
.此方法只要用新创建的协程帧
的标::协柄
初化任务.
可用标::协柄::从承诺()
方法从承诺
对象构建这些句柄.
类 任务::承诺类型{
公:任务 取中()无异{中 任务{标::协柄<承诺类型>::从承诺(*本)};}
接着,期望协程最初
在开大括号
处挂起,以便等待
返回的任务时,可稍后从此恢复
协程.
懒启动
协程有几个好处:
1,表明可在开始
执行协程前,附加连续
的标::协柄
.表明不必用线程同步
来仲裁稍后附加连续
和协程运行完成间的竞争
.
2,这表明任务
可无条件地析构协程帧
,不必担心是否可能在另一个线程上执行协程
,因为在等待它之前不会开始执行协程
,且在它执行
时挂起了调用协程
,因此在完成执行协程
前,不会试调用任务
析构器.
3,这样,编译器更好内联分配
协程帧到调用者帧中.见P0981R0
这里来了解(哈楼)
堆分配优化的更多信息.
4,它还提高了协程
代码的异常安全性
.如果没有立即协待
返回的任务,并执行其他可能触发
异常的操作,从而导致栈展开
和并运行任务析构器
,则可安全
析构协程,因为知道它尚未启动.
5,没有分离
,悬挂
引用,析构器
中阻塞,终止
或未定义行为
.我在这里关于结构化并发的CppCon2019
演讲中更详细
介绍的内容.
为了使协程在左大括号处初挂起
,定义了个返回内置总是挂起
类型的初挂起()
方法.
标::总是挂起 初挂起()无异{中{};}
接着,需要定义执行协中
时或在协程结束
时调用的中空()
方法.此方法可无操作,只要有它,编译器就知道在此协程类型中协中;
有效.
空 中空()无异{}
还要添加如果异常
逃逸协程
时则会调用的对异常()
方法.这里,可按无异
调用任务
协程体,并在有异常时调用标::终止()
.
空 对异常()无异{标::终止();}
最后,执行协程
到达右大括号时,期望在终挂起
点挂起协程
,然后恢复
连续,即等待
此协程完成的协程
.
为此,需要在承诺
中的数据成员
保存连续的标::协柄
.还需要定义,返回
在当前协程在终挂起
点挂起后恢复连续
的可等待
对象的终挂起()
方法.
在挂起当前协程
后,懒恢复
连续非常重要,因为连续
可能会立即调用,在协程帧
上调用.消灭()
的任务析构器
.
.消灭()
方法仅对挂起
协程有效,因此在挂起当前协程
前,恢复
连续是未定义行为
.
编译器在右大括号
处,插入代码来计算协待承诺.终挂起();
语句.
注意,调用终挂起()
方法时,尚未挂起协程
.挂起
协程前,需要等到调用
返回的可等待
的挂起协()
方法.
构 止等待器{极 直接协()无异{中 假;}空 挂起协(标::协柄<承诺类型>h)无异{//现在在终挂起点挂起`协程`.在承诺中查找`连续`并恢复它.h.承诺().连续.恢复();}空 恢复协()无异{}};止等待器 终挂起()无异{中{};}标::协柄<>连续;
};
好的,这就是完整的承诺类型
.最后需要实现任务::符号 协待()
.
实现任务::符号 协待()
在理解协待()
帖子中这里,在计算协待
式时,(如果定义了协待符号,)编译器生成调用协待()
符号,然后返回对象
必须定义直接协(),挂起协()
和恢复协()
方法.
当协程
等待任务时,期望总是挂起等待协程
,然后,一旦挂起,在要恢复
的协程的承诺
中存储
等待协程的句柄
,然后在任务的标::协柄
上调用.恢复()
来开始执行任务.
因此,相对直接代码:
类 任务::等待器{
公:极 直接协()无异{中 假;}空 挂起协(标::协柄<>连续)无异{//在任务的`承诺`中存储`连续`,以便在任务完成时,`终挂起()`知道`恢复`此协程.协程_.承诺().连续=连续;//然后恢复当前在`初挂起`(即在左大括号处)挂起的任务的协程.协程_.恢复();}空 恢复协()无异{}
私:显 等待器(标::协柄<任务::承诺类型>h)无异:协程_(h){}标::协柄<任务::承诺类型>协程_;
};
任务::等待器 任务::符号 协待()&&无异{中 等待器{协程_};
}
从而完成任务
类型必要代码.
栈溢出问题
但是,当你在协程中开始
写循环
,且协待
可在该循环体
中同步
完成的任务
时,就会出现实现限制
.
如:
任务 同步完成(){协中;
}
任务 同步循环(整 数){对(整 i=0;i<数;++i){协待 同步完成();}
}
上述简单
任务实现,计数为10
,1000
甚至100'000
时,同步循环()
函数(可能)正常
工作.但是,可能会传递一个值(如100万)时,会导致此协程
崩溃.
崩溃的原因是栈溢出
.
为什么会导致栈溢出?
首次开始执行同步循环()
协程时,可能是因为其他协程在协待
返回的任务.这会依次
挂起等待协程并调用在任务的标::协柄
上调用恢复()
的任务::等待器::挂起协()
.
因此,启动同步循环()
时,栈将如下:
栈 堆
+-------------+<--栈顶+------
|同步循环$恢复|活动协程循环帧|
+-------------+|+---------
|协柄::恢复|| |任务::承诺
+-------------+|-连续--.||
|任务::等待器::挂起协||+--
+-------------+|... |v
|等待协程$恢复|+-----------
+-------------+|等待协程帧|
//`连续`指向`等待协程帧`
注意:编译
协程函数时,编译器一般会将其拆分
为两部分:
1,处理协程帧的构造
,复制参数
,构造承诺
和生成返回值
的斜坡
函数",及
2,包含协程体用户编写的"协程体
"逻辑.
用$恢复
后缀来表明协程
的"协程体
"部分.
然后,当同步循环()
等待从同步完成()
返回的任务
时,挂起当前协程
并调用任务::等待器::挂起协()
.
然后,挂起协()
方法,在与同步完成()
协程关联的协程句柄
上调用.恢复()
.
这恢复了同步运行完成的同步完成()
协程,并在终挂起
挂起.然后,它调用,与同步循环()
关联的协程句柄
上调用.恢复()
的任务::承诺::止等待器::挂起协()
.
最终
结果是,如果在恢复同步循环()
协程后及,在分号处析构同步完成()
返回的临时任务
前查看程序状态
,则栈/堆
应该像这样:
栈 堆
+-------------栈顶
|同步循环$恢复|活动协程指向`上个顶`
+-------------+|
|协柄::恢复|.------'
+-------------+|
|止等待器::挂起协||
+-------------+|+-
|同步完成$恢复|||同步完成帧||
|协柄::恢复 ||+----------+|
|任务::等待器::挂起协|V|
+------------+<--上个栈顶+-+|
|同步循环$恢复| |同步循环帧||
+------------+|+----------------------+||
|协柄::恢复|||任务::承诺|||
+------------+||-连续--.|||
|任务::等待器::挂起协||+--|---+||
+------------+|-任务临时指向同步完成帧
|等待协程$恢复|+-----------
+-------------+|等待协程帧|
接着是调用
析构同步完成()
帧的任务析构器
.然后,递增计数
变量并再次循环,创建一个新的同步完成()
帧并恢复它.
事情是最终同步循环()
和同步完成()
递归地相互
调用.每次都消耗
更多的栈空间
,最终,溢出栈
并进入未定义行为
状态,导致程序立即崩溃.
这样构建的协程
中,非常容易编写循环
并造成无限递归
.
协程组解决方法
好的,如何避免无限递归
上面实现中,使用返回空
的挂起协()
变体.在协程组中,还有个返回极
的挂起协()
版本,如果它返回真
,则挂起协程
,执行返回到恢复()
的调用者,否则,如果返回假
,则立即恢复
协程,但这次
不消耗额外栈空间
.
因此,为避免无限相互递归
,可利用挂起协()
的布尔返回版本,如果同步完成任务
,则通过从任务::等待器::挂起协()
方法返回假
来恢复当前协程
,而不用标::协柄::恢复()
递归恢复协程.
为此
实现通用方法,要有两个部分.
在任务::等待器::挂起协()
方法中,可调用.恢复()
开始执行协程.然后,调用.恢复()
返回时,检查
是否已完成协程
.
如果已运行完,则可返回假
,来表示应该立即恢复等待协程
,或可返回真
,指示执行应该返回
到标::协柄::恢复()
的调用者.
在运行完协程
时运行的任务::承诺类型:::止等待器::挂起协()
中,要检查等待协程
是否已从任务::等待器::挂起协()
返回真
,如果是,则调用.恢复()
来恢复它.
否则,需要避免
恢复协程并通知任务::等待器::挂起协()
,它需要返回假
.
但是,还有个额外问题,因为协程
可在当前线程
上开始执行,然后挂起
,然后,在调用.恢复()
之前,在不同
线程上,恢复运行
至完成.
因此,要解决上述第1部分和第2部分
之间同时有的潜在竞争
.
要用标::原子
值来决定竞赛
的获胜者.
现在是代码
.可如下修改:
类 任务::承诺类型{...标::协柄<>连续;标::原子<极>准备好=假;
};
极 任务::等待器::挂起协(标::协柄<>连续)无异{承诺类型&承诺=协程_.承诺();承诺.连续=连续;协程_.恢复();中!承诺.准备好.交换(真,标::内存序取释放);
}
空 任务::承诺类型::止等待器::挂起协(标::协柄<承诺类型>h)无异{承诺类型&承诺=h.承诺();如(承诺.准备好.交换(真,标::内存序取释放)){//未同步完成`协程`,请在此处恢复.承诺.连续.恢复();}
}
表明,c++协程::任务<T>
实现这里为避免无限递归
的方法,且运行良好.
呜呼!问题解决了吗?
问题所在
虽然上述
方法确实解决了递归问题,但它有几个缺点.
1,首先,它引入了非常昂贵的标::原子
操作.挂起
等待协程时,调用者上有个原子交换
,运行到完成时,调用者
上有另一个原子交换
.
2,如果只在单线程上执行应用
,则即使不必,也支付了同步线程
的原子
操作成本.
3,其次,它引入
了额外的分支
.一个在调用者
中,要决定是挂起
还是立即
恢复协程,另一个在被调
中,要决定是恢复
还是挂起
连续.
4,注意,额外
分支的成本,甚至可能是原子操作
的成本,一般相比协程中的业务逻辑
,相形见绌.然而,按零成本抽象宣传
协程,有人甚至
使用协程
来挂起函数,以避免等待L1
缓存未命中,这里.
5,第三,可能也是最重要的一点,在等待协程
恢复的执行环境
中引入了一些不确定性
.
假设有以下代码:
c++协程::静线程池 tp;
任务 福()
{标::输出<<"福1"<<标::本线程::取标识()<<"\n";//挂起协程并重新分发到线程池线程.协待 tp.调度();标::输出<<"福2"<<标::本线程::取标识()<<"\n";
}
任务 条()
{标::输出<<"条1"<<标::本线程::取标识()<<"\n";协待 福();标::输出<<"条2"<<标::本线程::取标识()<<"\n";
}
使用原始实现,保证在协待 福()
之后运行的代码
,在完成福()
的同一线程上内联运行
.
如,一个可能的输出是:
条1`1234`
福1`1234`
福2`3456`
条2`3456`
然而,随着使用原子
,完成福()
可能会与挂起条()
竞争,因此表明,有时协待 福()
之后的代码
可能会,在条()
开始执行的原始线程
上运行.
如,现在可如下输出:
条1`1234`
福1`1234`
福2`3456`
条2`1234`
对许多
用例,该行为
可能不会有影响.但是,对旨在转换
执行环境的算法
,会有问题.
如,通过()
算法等待一些可等待
,然后在指定
分发的执行环境
中生成它.此算法的简化版本
如下:
元<型名 可等待,型名 调度器>
任务<等待结果型<可等待>>通过(可等待 a,调度器 s)
{动 结果=协待 标::移动(a);协待 s.调度();协中 结果;
}
任务<T>取值();
空 消费(常 T&);
任务<空>消费者(静线程池::调度器 s)
{T 结果=协待 通过(取值(),s);消费(结果);
}
对原始版本,总是可保证在s线程池
上调用消费()
.但是,对原子版本,可能会在与s
调度器关联的线程
上执行消费()
,或在消费()
协程开始执行的线程
上执行.
如何无原子操作,额外分支和非确定性恢复环境
的成本的解决栈溢出
?
“对称转移”
GorNishanov(0913)
的论文P0R2018"
,提出"对称协程
控制转移",来允许不消耗额外栈空间
的,挂起
,A
协程然后对称
恢复B
协程.
它提出了两个
关键变化:
1,允许从挂起协()
返回标::协柄<T>
,来指示应对称转移
执行到由返回的句柄
标识的协程
.
2,添加返回特殊的标::协柄
的标::实验性::无操协程()
函数,它可从挂起协()
返回该函数以挂起
当前协程,并从调用.恢复()
中返回,而不是执行转移
到另一个协程.
"对称转移
"的意思
在标::协柄
上调用.恢复()
来恢复协程时,执行恢复协程
时,.恢复()
的调用者在栈
上仍活着.
下一次挂起此协程
,且对该挂起点
的挂起协()
调用返回空
(表示无条件挂起)或真
(指示条件挂起
)时,返回调用.恢复()
.
这可类比
协程执行的"非对称转移
",其行为与普通的函数调用一样..恢复()
的调用者可是任一
函数(也可不是协程).
挂起该协程
,并从挂起协()
返回真
或空
时,执行调用从.恢复()
返回,且每次调用.恢复()
恢复协程时,都会创建新栈帧
来执行该协程
.
但是,使用"对称转移
",只是挂起
一个协程并恢复
另一个协程.两个协程
间没有隐式调用者/被调
关系,挂起协程
时,可把执行
转移到挂起的任一协程
(包括自身),且在下次
挂起或完成时,不必把执行
转移回上一个
协程.
看看等待者
使用对称转移
时,编译器降级协待
式为什么:
{推导(动)值=<式>;推导(动)可等待=取可等待(承诺,静转<推导(值)&&>(值));推导(动)等待器=取等待器(静转<推导(可等待)&&>(可等待));如(!等待器.直接协()){用 句柄型=标::协柄<P>;//<挂起协程>动 h=等待器.挂起协(句柄型::从承诺(p));h.恢复();//<返回到调用者或恢复者>//<恢复点>}中 等待器.恢复协();
}
放大与其他协待
形式不同的关键部分:
动 h=等待器.挂起协(句柄型::从承诺(p));
h.恢复();
//<返回调用者或恢复者>
一旦降级
协程状态机,<返回到调用者或恢复者>
部分基本上变成了返回;
语句,导致调用上次
恢复协程
来返回到其调用者的.恢复()
.
表明从当前函数
自身是标::协柄::恢复()
的调用体,有个调用与标::协柄::恢复()
有相同签名
函数的另一个函数
,然后是返回;
.
一些编译器在启用优化
时,可优化
,只要满足某些条件,就可把调用转换为尾调用
.
碰巧,该尾调用
优化正可避免
之前遇见的栈溢出
问题.但是,要保证转换尾调用
.
尾调用
尾调用
是指在调用
结束前弹出当前栈帧
,且当前函数的返回地址
成为被调
返回地址.即.被调
直接返回此函数
调用者.
在X86/X64
架构上,一般表明编译器生成
首先弹出
当前栈帧,然后使用跳
指令而不是调用
指令跳转
到被调
函数入口,然后在调用
返回后弹出
当前栈帧的代码
.
但是,该优化
一般有限.即,它要求:
1,调用约定
支持尾调用
,且对调用者和被调
相同;
2,返回
类型相同;
3,在调用
后到返回
调用者前,不需要运行非平凡
析构器;及
4,调用不在试/抓
块内.
协待
的对称转移
形式是专门
为协程
满足所有这些要求
而设计的.
1,调用约定,当编译器降级
协程为机器代码
时,它将协程
分为两部分:斜坡
(分配和初化
协程帧)和主体
(包含用户编写的协程体
的状态机).
协程的函数签名
(及用户指定的调用约定
)仅影响斜坡
,而主体
受编译器控制,且用户代码
永远不会直接调用它,仅由斜坡
函数和标::协柄:::恢复()
调用.
协程主体
的调用约定
不是用户可见
的,完全依赖编译器,因此可选择支持尾调用
并由所有协程体
使用的适当调用约定
.
2,返回类型
相同,源和目标
协程的.恢复()
方法的返回类型
都是空
,因此可轻松
满足此要求.
3,没有非平凡
析构器,尾调用
时,要可在调用
目标函数前释放
当前栈帧,这要求所有栈分配
对象生命期
在调用
前结束.
一般,只要域
内有非平凡
析构器的对象,就有问题
,因为这些对象
的生命期尚未结束,且在栈上
分配这些对象.
但是,挂起协程
时,它会在不退出
域时就这样,它是,在协程帧
中而不是在栈
中保存生命期跨挂起点
的对象.
可在栈上
分配生命期不跨挂起点
的局部变量
,但这些对象
生命期已结束,且在下一次挂起协程
前调用
它们的析构器
.
因此,对要在尾调用
返回后运行的栈分配对象
,不应有非平凡
析构器.
4,调用不在试/抓
块内,这有点麻烦,因为在每个协程
中都有个隐式的包含用户
编写协程体的试/抓
块.
从规范中,看到协程定义:
{承诺类型 承诺;协待 承诺.初挂起();试{F;}抓(...){承诺.对异常();}
终挂起:协待 承诺.终挂起();
}
其中F是协程体
用户部分.
因此,每个用户
编写的协待
式(初挂起/终挂起
式除外)都在试/抓
块的环境中.
但是,实现通过在试
块环境外实际调用.恢复()
来解决.
因此,执行对称转移
的协程,一般满足可执行尾调用
的所有要求.无论是否启用
优化,编译器保证
总是一个尾调用
.
这表明用挂起协()
的标::协柄
的返回风格,可挂起
当前协程,并在不会消耗额外栈空间
时,就把执行
转移到另一个协程
.
这样允许编写相互递归
地恢复
彼此到任意深度
,而不必担心栈溢出
的协程.
重新审视任务
因此,借助新"对称转移
"功能,修复任务
类型实现.
为此,要在实现中更改两个挂起协()
方法:
1,首先,等待
任务时,执行对称转移
来恢复任务的协程.
2,其次,任务
的协程完成时,执行对称转移
以恢复等待协程
.
为了解决等待
方向,需要在此更改任务::等待器
方法:
空 任务::等待器::挂起协(标::协柄<>连续)无异{//在任务的承诺中`存储`连续,以便在任务完成时,`终挂起()`知道恢复此协程.协程_.承诺().连续=连续;//然后恢复当前在初挂起(即在左大括号处)挂起的`任务协程`.协程_.恢复();
}
转为:
标::协柄<>任务::等待器::挂起协(标::协柄<>连续)无异{//在任务的承诺中`存储`连续,以便在任务完成时,`终挂起()`知道恢复此协程.协程_.承诺().连续=连续;//然后,从`挂起协()`返回其句柄,来`尾恢复`当前在初挂起(即在打开的大括号处)挂起的`任务协程`.中 协程_;
}
为了解决返回路径
,需要从下面
更新任务::承诺类型::止等待器
方法:
空 任务::承诺类型::止等待器::挂起协(标::协柄<承诺类型>h)无异{//现在在终挂起点挂起`协程`.在承诺中查找其`连续`并恢复它.h.承诺().连续.恢复();
}
为:
标::协柄<>任务::承诺类型::止等待器::挂起协(标::协柄<承诺类型>h)无异{//现在在终挂起点挂起`协程`.在承诺中查找其`连续`并`对称`恢复它.中 h.承诺().连续;
}
现在有个既没有空
返回挂起协
风味所有的栈溢出
问题,也没有布尔
返回挂起协
风味的不确定性
恢复环境问题的任务
实现.
可视化栈
现在再看看原始示例:
任务 同步完成(){协中;
}
任务 同步循环(整 数){对(整 i=0;i<数;++i){协待 同步完成();}
}
首次开始执行同步循环()
协程时,这是因为其他某个协程协待
返回的任务.调用标::协柄::恢复()
来恢复的其他协程
,会对称转移
来启动它.
因此,启动同步循环()
时,栈将如下:
栈 堆
+-------------+<--栈顶+-- |
|同步循环$恢复|活动协程->|同步循环帧|
+-------------+|+--------+|
|协柄::恢复|||任务::承诺||
+-------------+||-连续指向下面的等待协程帧||等待协程帧|
现在,执行协待 同步完成()
时,它会对称转移
到同步完成
协程.
它如下完成:
1,调用返回任务::等待器
对象的任务::符号 协待()
2,然后挂起并调用返回同步完成
协程的协柄
的任务::等待器::挂起协()
.
3,然后尾调用/跳转
到同步完成
协程.这在激活同步完成
帧前,弹出同步循环
帧.
如果现在在恢复同步完成
后查看栈,将是如下:
栈 堆.->+-------+<-.||同步完成||||帧||||+--------+|||||任务::承诺||||||-连续--.|||||+--------+|||-,+-------+||V|
+---------------+<--栈顶|++ |
|同步完成$恢复|||同步循环帧||
+---------------+活动协程---|++||
|协柄::恢复|||任务::承诺|||
+---------------+||-连续--.|||
|...||+----------|---+||
+---------------+|任务临时||||-协程_-----|---------|+--------------------------+|等待协程 帧|+--------------------------+
注意,此处栈帧数
没有增加.
在同步完成
协程完成且执行到达右大括号
后,它计算协待 承诺.终挂起()
.
这会挂起协程,并调用返回连续
的标::协柄
(即指向同步循环
协程的句柄)的止等待器::挂起协()
.
然后,执行对称转移/尾调用
以恢复同步循环
协程.
如果在恢复同步循环
后查看栈,将如下:
栈 堆+--------------+<-.|同步完成|||帧|||+----------+||||任务::承诺|||||-连续--.||||+------------------|---+||V|
+----------------+<--栈顶
|同步循环$恢复|活动 协程->|同步循环 帧||
|协柄::恢复()|||任务::承诺|||
+--------------+||-连续--.|||
|...||+--------+||
+--------------+|任务 临时||||-协程_-----|---------||等待协程 帧|
恢复同步循环
协程后,先是在到达分号时执行
调用从同步完成
调用返回的临时任务
的析构器
.它析构
协程帧,释放
其内存,剩下:
栈 堆
+-----------+<--栈顶+--+
|同步循环$恢复|活动协程->|同步循环帧|
|协柄::恢复|||任务::承诺||
+-----------------+||-连续--.|||等待协程帧|
现在又回到了执行同步循环
协程,现在拥有与开始
时相同数量的栈帧和协程帧
,且每次循环
时都会这样做.
因此,可按需
任意次迭代循环
,且只会使用固定大小
存储空间.
对称转移为挂起协
的通用形式
对称转移
理论上可取代挂起协()
的空和布尔
返回形式.
但先看看添加到协程设计中的P0913R0
提案这里的另一部分:标::无操协程()
.
终止递归
使用对称转移
协程,每次挂起协程
时,都会对称
地恢复另一个协程
.只要有另一个要恢复的协程
,这很好,但有时没有要执行的另一个协程
,只需要挂起
并让执行返回至标::协柄::恢复()
的调用者.
挂起协()
的空
返回和布尔
返回风格都允许挂起协程
并从标::协柄::恢复()
返回,但如何对对称转移
这样?
答案是使用特殊的由标::无操协程()
函数生成的叫"无操
协程句柄"的内置标::协柄
.
"无操
协程句柄"命名,是因为它的.恢复()
实现仅使它立即
返回.即是无操作恢复
协程.一般,它的实现包含单个中
指令.
如果挂起协()
方法返回标::无操协程()
句柄,则它不会把执行转移
到下个协程,而是把执行传输回标::协柄::恢复()
的调用者.
表示await_suspend()
的其他风格
有了这些信息,现在可展示如何使用对称转移
形式来表示挂起协()
的其他风格.
空返回
:
空 我等待器::挂起协(标::协柄<>h){本->协程=h;入列(本);
}
也可用布尔
返回形式如下编写:
极 我等待器::挂起协(标::协柄<>h){本->协程=h;入列(本);中 真;
}
也可用对称转移
形式编写:
标::无操协程句柄 我等待器::挂起协(标::协柄<>h){本->协程=h;入列(本);中 标::无操协程();
}
布尔
返回形式:
极 我等待器::挂起协(标::协柄<>h){本->协程=h;如(试开始(本)){//异步完成操作.返回`真`以把执行`转移`到`协柄::恢复()`的调用者.中 真;}//同步完成`操作`.返回`假`可立即恢复当前协程.中 假;
}
也可用对称转移
形式编写:
标::协柄<>我等待器::挂起协(标::协柄<>h){本->协程=h;如(试开始(本)){//异步完成操作.返回`标::无操协程()`以把`执行`转移到`协柄::恢复()`的调用者.中 标::无操协程();}//操作同步完成.返回`当前协程`句柄以`立即恢复`当前协程.中 h;
}
为什么要有三个风格?
拥有对称转移
风格时,为什么仍有挂起协()
的空和布尔
返回风格呢?
原因部分是历史
的,部分是务实
的,部分是性能
的.
空
返回版本可通过从挂起协()
返回标::无操协程句柄
类型来完全替换
,因为这是编译器表明协程
无条件地把执行转移
到标::协柄::恢复()
的调用者的等效
信号.
部分是在引入对称转移
前已使用它,部分原因是空
形式导致无条件
挂起时,代码/键入
更少.
然而,与对称转移
形式相比,布尔
返回版本有时在可优化性
方面可能会略有优势.
考虑在另一个
翻译单元中定义的布尔
返回挂起协()
方法.此时,编译器可在挂起
当前协程的等待协程
中生成代码,然后调用挂起协()
返回后通过执行下一段
代码有条件地恢复
它.
如果挂起协()
返回假
,它确切
地知道要执行的代码段.
而对称转移
风格,仍需要或返回到调用者/恢复
或恢复
当前协程,来表示相同
结果.或需要返回标::无操协程()
或当前协程
句柄,而不是返回真
或假
.
可强制转换
这两个句柄为标::协柄<空>
类型并返回
它.
但是,现在,因为挂起协()
方法是在另一个
翻译单元中定义的,编译器无法看到返回句柄
引用的协程
,因此恢复
协程时,它现在必须执行一些更昂贵的间接调用
,且可能执行一些分支
来恢复协程,再对比布尔
返回的单个分支
.
未来可能可内联定义挂起协()
,但调用远方定义的布尔
返回方法,然后有条件
地返回适当的句柄.
如:
构 我等待器{极 直接协();//理论上,编译器应该可按与`布尔`返回版本相同的优化,但目前没有.标::协柄<>挂起协(标::协柄<>h){如(试开始(h)){中 标::无操协程();}异{中 h;}}空 恢复协();
私://此方法在远方单独的翻译单元中定义.极 试开始(标::协柄<>h);
}
因此,目前而言,一般
规则是:
1,如果需要无条件
返回.恢复()
调用者,请使用空
返回风格.
2,如果需要有条件
返回到.恢复()
调用者或恢复当前协程,请使用布尔
返回风格.
3,如果需要恢复
另一个协程,请使用对称转移
风格.
添加到C++20
的协程中的新对称转移
功能使得不必担心栈溢出
的,编写递归
相互恢复
协程更加容易.此功能是创建高效且安全
的异步协程
类型(如任务
)的关键.