兴义网站开发,义乌国际贸易综合信息服务平台,百度指数排行榜哪里看,广告去哪个网站做文章目录 C20 协程#xff08;coroutine#xff09;入门什么是协程无栈协程和有栈协程有栈协程的例子例 1例 2 对称协程与非对称协程无栈协程的模型无栈协程的调度器朴素的单线程调度器让协程学会等待Python 中的异步函数可等待对象M:N 调度器——C# 中的异步函数 小结 C20 中… 文章目录 C20 协程coroutine入门什么是协程无栈协程和有栈协程有栈协程的例子例 1例 2 对称协程与非对称协程无栈协程的模型无栈协程的调度器朴素的单线程调度器让协程学会等待Python 中的异步函数可等待对象M:N 调度器——C# 中的异步函数 小结 C20 中的协程对象未完待续 在阅读下面的内容之前建议先入门至少三门除 C 外的其他编程语言最好还支持协程。 可以参考渡劫 C 协程0前言 | Benny Huo C20 协程coroutine入门
什么是协程 可以参考初识协程 | 楚权的世界 (chuquan.me) 老生常谈协程的核心思想是允许放弃执行当前函数转而执行其他函数但之后还能恢复之前函数的执行状态。学过 Python 的人很快就能想到这不就是生成器吗
程序 1Python生成器 def my_range(to): # 是一个生成器。for i in range(1, to 1):yield i # 1. 放弃执行当前函数。if __name__ __main__:for i in my_range(3): # 3. 恢复之前函数的执行状态。print(i) # 2. 转而执行其他函数。但这个和什么所谓的“亿级别流量洪峰”有什么关系怎么做到让数亿协程“宏观并行”即表现出并发特征如果没有新的线程被创建网络调用仍然只能在主线程执行这个矛盾怎么解决我相信即使你不会 Python看不懂上面的代码在看别人对协程的介绍时也能想到这些问题。
我们一步一步来先巩固协程相关的基本概念再来回答以上刁钻的问题。
无栈协程和有栈协程 可以参考浅谈有栈协程与无栈协程 - 知乎 (zhihu.com)。 可以参考协程和纤程的区别是什么 - tearshark的回答 - 知乎。 可以参考有栈协程与无栈协程 (mthli.xyz) 协程coroutine也就是协作co-的过程routine离不开过程二字也就是说协程也是一个函数function, method, routine, etc.。同时可以顾名思义互相“协作”的“过程”生来就是用于解决并发问题的。
我们都知道一般的线程一定存在一个函数调用栈记录着函数之间的调用关系、局部变量、返回地址等等。那对于协程来说它和我们熟知的那个栈有什么关系呢
有什么关系其实取决于“协作”的具体实现。不同的实现会与我们熟知的那个栈产生不同的联系。大体上可以分为两类 有栈协程stackful。 创建一个有栈协程时运行时runtime会申请一片内存空间作为协程的栈空间。之后该协程都将这片空间视为自己的栈空间。如果已经开始执行该协程的代码这个协程就好像在一个新的线程上运行一样。 但创建一个有栈协程并不会创建一个内核态的线程如何使得协程具有并发特征其实关键还是在于让协程自己放弃当前的执行权。 无栈协程stackless。 创建一个无栈协程时运行时会申请一片内存空间保存协程的栈帧。之后该协程仍然在某个线程的栈空间上运行只不过协程可以选择保存当前栈帧后放弃执行权再之后还可以恢复到此前的状态继续执行。
简单地说这两类协程可以描述为不一定准确主要是为了方便理解
有栈协程就是不由操作系统内核调度的“线程”。取决于具体实现可能没有线程本地存储Thread Local Storage, TLS等等总而言之只是长得像线程。无栈协程就是一个可以断断续续执行的函数。
Python 的生成器可以看作是无栈协程C20 提供的协程也是无栈协程。
有栈协程的例子
介绍以上分类其实对理解协程提供并发能力并没有任何帮助。一方面一开始提到的 Python 的生成器也是无栈协程但我们可能并没有见过用生成器解决并发问题的场景所以之前的提问一个也没有被解答。
为了更直观地看到协程如何解决并发问题我们来看几个有栈协程的例子。
例 1
程序 2C 语言Windows 中的纤程fiber是有栈协程的一种实现在单线程中实现并发 #include stdbool.h
#include stdio.h#include Windows.hPVOID fiber_main;
PVOID fiber_anothers[2];void inner(int id) {printf(Task %d\n, id);// Note放弃当前纤程执行权转换到其他纤程。SwitchToFiber(fiber_main);
}void WINAPI another(LPVOID param) {while (true) {inner((int)param);}
}int main() {// 将当前的线程转换为纤程允许参与纤程的调度。fiber_main ConvertThreadToFiber(NULL);// 创建纤程但不执行。for (unsigned i 0; i 2; i) {// 参数 1 是栈空间0 表示取默认值。fiber_anothers[i] CreateFiber(0, another, (LPVOID)(i 1));}printf(Fiber demo started\n);for (unsigned i 0; i 3; i) {for (unsigned j 0; j 2; j) {// Note放弃当前纤程执行权转换到其他纤程。SwitchToFiber(fiber_anothers[j]);}}printf(Done!\n);// 回收资源。for (unsigned i 0; i 2; i) {// 即使两个任务是死循环也因为放弃执行权而没有运行。// 由于纤程是我们自己调度所以可以安全地删除它们。DeleteFiber(fiber_anothers[i]);}ConvertFiberToThread();
}运行结果
Fiber demo started
Task 1
Task 2
Task 1
Task 2
Task 1
Task 2
Done!程序 1 的 another 函数是一个典型的协程。它运行时可以表现出并发的特征前提是我们需要自己放弃当前协程的执行权SwitchToFiber 函数。即使是在协程调用的子函数中inner 函数也可以主动放弃当前协程的执行权所以 Windows 中的纤程是有栈协程的一种实现。
例 2
程序 3Go 语言goroutine 是有栈协程的一种实现通过运行时调度器实现并发 package mainimport (fmttime
)func inner(id int) {fmt.Println(Task, id)// Note: 放弃当前 goroutine 执行权转换到其他 goroutine。time.Sleep(100 * time.Millisecond)// Note: 运行时会帮助我们尽可能在 100 毫秒后重新取得执行权。
}func another(id int) {for true {inner(id)}
}func main() {fmt.Println(goroutine demo started)for i : 0; i 2; i {// 创建 goroutine是否立即开始在其他线程中执行取决于运行时。go another(i)}// Note: 放弃当前 goroutine 执行权转换到其他 goroutine。time.Sleep(300 * time.Millisecond)fmt.Println(Done!)// Note: 主 goroutine 被销毁后其他 goroutine 也被销毁。
}可能的运行结果
goroutine demo started
Task 0
Task 1
Task 0
Task 1
Task 1
Task 0
Done!通过这两个例子我们大致看到了有栈协程在实现并发时不可或缺的东西调度schedule。例 1 中调度完全由手工实现SwitchToFiber 函数费时费力而例 2 中调度由 Go 语言的**调度器scheduler**实现写程序时只用自然地让当前 goroutine 睡眠即可time.Sleep 函数。
为了实现 M:N 模型Go 语言运行时提供的调度器颇为复杂但使用 Go 语言时就不用考虑这么多了就程序 3 而言把 goroutine 看作一个线程也无妨。Go 语言的调度器让有栈协程具有了很多类似线程的功能从而可以像线程一样使用 goroutine同时让创建 goroutine 的代价很低也就实现了高并发。
从 Go 语言可以看出如果一个语言支持有栈协程那么把原有的线程函数迁移为协程函数并不会太复杂因为它们长得挺像。但对于无栈协程来说没有长得像一说所以代码迁移可能会花更多时间。但无栈协程所占空间明显小于有栈协程这是无栈协程特有的优势。
对称协程与非对称协程 可以参考协程学习对称和非对称 - 知乎 (zhihu.com) 可以参考一文彻底弄懂C开源协程库libco——原理及应用 - 知乎 (zhihu.com) 程序 3 中goroutine 通过 go 语句被创建后就好像一个单独的线程一样被创建的协程只能自己选择放弃yield执行权至于放弃之后谁执行只由调度器决定不由协程的创建者决定这种就是对称协程symmetric coroutine。对称协程之间不存在明显的从属关系大家都是平等的。
程序 2 中我们完全自己调度纤程。如果规定在放弃执行权时只能回到纤程的创建者则可以形成纤程的调用关系链。这种具有明显调用关系的协程就是非对称协程asymmetric coroutine。
由此可以注意到无栈与有栈、对称与非对称是两个不同的概念。Python 的生成器可以看作是非对称协程C20 提供的协程也是非对称协程。
应该没有无栈对称协程……
无栈协程的模型
我们终于来到与 C20 有关的东西了无栈非对称协程。如果它不能表现得类似于一个线程又有什么用该怎么用
图 1程序 1 的大致执行流程 图 1 中main() 表示主流程是一个普通的函数不妨把 Python 的主过程看成一个函数my_range() 是生成器也就是一个协程。图中黑点表示可以进入的点普通函数只有开头一个而无栈非对称协程则可以有任意多个每个对应 Python 中的 yield 语句。
因此可以把协程看作一个状态机图 1 中协程内的黑点就对应一个状态协程内的箭头就对应状态的转移。需要注意的是这个状态机还有大量隐藏的状态以局部变量的形式存在于协程中随图中可见状态的转移而转移。
无栈协程在逻辑上总是可以用闭包的形式实现但实际上很难写甚至可能会写不出来。尽管如此尝试将无栈协程和闭包相互转换对理解无栈协程的工作原理会很有帮助。
程序 4C使用闭包实现一个简单的无栈协程 #include iostreamauto my_range() {// 每一个 lambda 表达式都对应图 1 协程中的一个黑点。int value 0;// 通过按值捕获变量将局部变量作为状态保存在闭包中。return []() mutable {std::cout value std::endl;// 通过按引用捕获变量模拟局部变量的状态转移。return []() {std::cout value std::endl;return []() {std::cout value std::endl;return []() - void {// 没有返回值。};};};};
}int main() {// 类似于 Python 中的生成器对象状态均保存在名为 coroutine 的对象中。auto coroutine my_range();// resume_point_* 不保存变量状态只保存执行位置。const auto resume_point_1 coroutine();const auto resume_point_2 resume_point_1();resume_point_2();
}运行结果
1
2
3程序 4 对应程序 1 和图 1是使用 C 中的闭包模拟无栈协程的结果。从中可以感受到如果编译器不支持协程相关的语法只用闭包模拟无栈协程会有相当多的困难 协程中的状态点越多闭包的层数就越深。 如果尝试将闭包作为回调函数复杂逻辑就会导致很深的闭包称为回调地狱callback hell。如果能把程序 4 转换成程序 1 那样回掉地狱问题就解决了。 # 更接近程序 4 模拟无栈协程的 Python 生成器。
def my_range():value 0# 没有回调地狱yield (value : value 1)yield (value : value 1)yield (value : value 1)可以参考Java如何实现一个回调地狱Callback Hell - 掘金 (juejin.cn) 通过诉诸协程解决回调地狱靠的是扩展处理器的日常使用方法过去我们只想到函数调用、中断现在还可以通过自己保存栈帧来实现协程。除了向计算机底层寻求方法还可以向更抽象的 协程中的局部变量作为内部状态很难正确地处理。 比如程序 4 中一会儿按值捕获一会儿按引用捕获很难弄清楚特别是有更多零散的局部变量时。 如果有复杂的结构例如循环结构很难、甚至不能用闭包实现。 比如程序 4 就没有写出程序 1 中的循环结构。 闭包无法实现协程中的数据传递。
现在我们大致明白了使用协程实现并发的方法关键在于存在一个调度器也知道了无栈协程的状态机模型。但我们仍然不知道如何用无栈协程实现并发这是因为我们不知道无栈协程应该有怎样的调度器。
无栈协程的调度器 可以参考万字好文从无栈协程到C异步框架 - 腾讯云技术社区 - SegmentFault 思否 可以参考python中的yield、yield from、async/await中的区别与联系 - 简书 (jianshu.com) 可以参考await 运算符 - 异步等待任务完成 | Microsoft Learn 可以参考【译】图与例解读Async/Await - 知乎 (zhihu.com) 作为入门教程我们当然不讨论无栈协程的调度器具体该怎么写但是我们必须至少弄清楚无栈协程的调度器长什么样不然怎么知道如何用它实现并发怎么发挥协程的优势
朴素的单线程调度器
很容易想到可以让调度器变成一个死循环不断轮流执行尚未完成的所有协程就可以了。
程序 5Python最朴素的想法 def my_range(to):for i in range(1, to 1):yield iif __name__ __main__:coroutines [my_range(3) for _ in range(4)]# 如果不是所有协程都已经结束就继续执行。while not all(coroutine.gi_frame is None for coroutine in coroutines):# 轮流执行每个协程。for coroutine in coroutines:try:print(next(coroutine))except StopIteration:pass运行结果
1
1
1
1
2
2
2
2
3
3
3
3图 2最朴素的想法 虽然程序 5 似乎没啥用但是我们得知了 调度器一定是一个普通函数而不是协程。因为我们讨论的是非对称协程所以这些协程放弃执行权后会自动回到调度器上次执行的位置对调度器而言执行协程就好比执行函数一样。 这意味着当我们希望协程表现出并发的特征时首先需要调用一个调度器函数。 这种最朴素的调度器并不调度协程内创建的协程。比如程序 5 中my_range 里面创建了 range它也是一个协程但 main 调度器看不见也管不着它。 这意味着要想有栈协程那样允许在任意子调用中放弃执行权会很困难。 这种最朴素的调度器没有办法处理协程之间的依赖关系。比如程序 5 中各个 my_range 产生的结果都是无关的。 这意味着想要使用另一个协程的运行结果会很困难。
对于后两个问题如果像程序 5 中 my_range 使用 range 那样让协程 my_range 自己调度另一个协程 range并且又希望使用另一个协程的最后运行结果因为我们通常更关心函数的返回值代码就会变得很繁琐。请看下面的 Python 程序。
程序 6Python最失败的 man def my_complex_task(id):for i in range(3):print(fTask {id})yield# 需要拿到这个结果。yield id 1def my_print(id):inner_coroutine my_complex_task(id)# 繁琐怎么拿到协程的返回值last_yield Nonefor result in inner_coroutine:last_yield result# 繁琐我自己调度怎么知道什么时候自己该 yieldyield# 繁琐如果这个协程也只是返回结果然后在 main 里才进行输出是不是以上繁琐还要再来一次print(fResult of {id}: {last_yield})if __name__ __main__:coroutines [my_print(i 1) for i in range(2)]# 如果不是所有协程都已经结束就继续执行。while not all(coroutine.gi_frame is None for coroutine in coroutines):# 轮流执行每个协程。for coroutine in coroutines:try:next(coroutine)except StopIteration:pass运行结果
Task 1
Task 2
Task 1
Task 2
Task 1
Task 2
Result of 1: 2
Result of 2: 3程序 6 确实让 my_print 协程用到了 my_complex_task 协程的结果并且成功表现出了并发的特征但写出来实在是太繁琐了。如果 my_print 要用到 my_complex_task 的结果怎么做更优美
让协程学会等待
既然 my_print 要用到 my_complex_task 的结果那就等 my_complex_task 结束吧。
图 3如果协程学会等待 事实上“学会等待”是无栈协程的基本操作因为这样就可以实现栈式的函数调用同时保留了并发能力。在编程语言中等待await就会导致协程被挂起suspend直到通知恢复assume协程才能继续被调度。用于并发操作的无栈协程本身常被称为异步async函数。
Python 中的异步函数
Python 的生成器虽然是无栈协程但实际上不会用于并发场景原因可以见程序 6。用于并发场景的无栈协程也就是异步函数在 Python 中的基本使用方法如下所示。
程序 7Python异步函数 import asyncio# async 关键字表示这是一个协程。
async def my_complex_task(id):for i in range(3):print(fTask {id})# 主动放弃执行权。await asyncio.sleep(0)# 需要拿到这个结果。return id 1# 结束通知调用方my_print使其恢复。async def my_print(id):# 声称自己要等。等到结果后才会被继续调度。result await my_complex_task(id)print(fResult of {id}: {result})if __name__ __main__:# 直接“调用”协程将会得到一个协程对象并没有开始执行。tasks [my_print(i) for i in range(3)]# 创建调度器。loop asyncio.new_event_loop()# 调用调度器函数。loop.run_until_complete(asyncio.wait(tasks))# 回收调度器。loop.close()运行结果
Task 2
Task 1
Task 0
Task 2
Task 1
Task 0
Task 2
Task 1
Task 0
Result of 2: 3
Result of 1: 2
Result of 0: 1程序 7 和程序 6 的功能一样在单个线程中具有并发能力。但程序 7 的编写比程序 6 简单许多正是“等待”使得无栈协程可以在调用其他协程的同时保持并发能力。缺点是所有被调用的协程都需要用 async 关键字修饰称这种现象为 async 传染。
图 3 说await 会使新的协程被加入调度器但这一点似乎从程序 7 中看不明白。事实上要看透这一点必须深入协程调度器的具体实现所以这个问题需要留到讲解 C20 的协程库时才能解决。
可等待对象
图 4如果协程学会抽象的等待 图 4 的意思是协程必须等待的是另一个协程吗只要等待的对象能够恢复resume调用方协程、能提供运行的结果那就可以拿来等这种对象就称为可等待对象awaitable object。
虽然可等待对象可以不是协程但一般都是协程。图 4 中的 my_task 也有可能是协程吗事实上是可能的只要 main_task 在首次恢复时不被调度器指派到主线程上即可。
M:N 调度器——C# 中的异步函数 可以参考await 运算符 - 异步等待任务完成 | Microsoft Learn 至此为止我们只实现了并发还没有实现并行。很容易想到要让协程拥有并行的能力只需要让调度器支持创建多个内核态线程就好了。
实现并行的关键是在恢复协程时为它分配到另一个线程上。我们直接看看 C# 的一个例子。
程序 8C#异步函数修改自官网的例子 public class AwaitOperator
{public static async Task Main(){Taskint downloading DownloadDocsMainPageAsync(); // 立即开始执行直到 await。返回值是 Task。Console.WriteLine(${nameof(Main)}: Launched downloading. (on {Thread.CurrentThread.ManagedThreadId}));int bytesLoaded await downloading;Console.WriteLine(${nameof(Main)}: Downloaded {bytesLoaded} bytes. (on {Thread.CurrentThread.ManagedThreadId}));}private static async Taskint DownloadDocsMainPageAsync(){Console.WriteLine(${nameof(DownloadDocsMainPageAsync)}: About to start downloading. (on {Thread.CurrentThread.ManagedThreadId}));var client new HttpClient();byte[] content await client.GetByteArrayAsync(https://learn.microsoft.com/en-us/);Console.WriteLine(${nameof(DownloadDocsMainPageAsync)}: Finished downloading. (on {Thread.CurrentThread.ManagedThreadId}));return content.Length;}
}可能的运行结果
DownloadDocsMainPageAsync: About to start downloading. (on 1)
Main: Launched downloading. (on 1)
DownloadDocsMainPageAsync: Finished downloading. (on 7)
Main: Downloaded 39995 bytes. (on 7)程序 8 告诉我们
C# 可以在后台自动运行一个调度器并且是 M:N 调度器。调度器的调度工作在 await 语句处发生。协程挂起后再次恢复时在哪个线程上由调度器决定。
小结
C20 的协程是无栈非对称协程。无栈协程可以用于生成器也可以用于并发场景。用于并发场景的协程也被称为异步函数。并发场景下协程的调度器不可或缺。
无栈协程可以抽象为一个状态机也可以用闭包模拟简单的无栈协程。使无栈协程并发的关键是 await 语句可以等待对象返回结果后再接受调度。使无栈协程并行的关键是调度器调度器可以在协程恢复运行时指派线程。不同编程语言实现的调度器各不相同不同场景下所需的调度器也不相同使用前需要充分调研所用调度器的特征。
C20 中的协程对象
前面举了这么多例子只是为了说明协程的功能。C20 中的协程具体是怎样的很遗憾C20 根本没提供协程的调度器一切都需要自己写所以大家才说 C20 的协程是为库开发者准备的。
但如果学习了 C20 中的协程便可以说了解了协程的底层原理处理其他语言中的协程也就游刃有余了。
未完待续
on 7) 程序 8 告诉我们1. C# 可以在后台自动运行一个调度器并且是 M:N 调度器。
2. 调度器的调度工作在 await 语句处发生。协程挂起后再次恢复时在哪个线程上由调度器决定。### 小结C20 的协程是无栈非对称协程。无栈协程可以用于生成器也可以用于并发场景。用于并发场景的协程也被称为异步函数。并发场景下协程的调度器不可或缺。无栈协程可以抽象为一个状态机也可以用闭包模拟简单的无栈协程。使无栈协程并发的关键是 await 语句可以等待对象返回结果后再接受调度。使无栈协程并行的关键是调度器调度器可以在协程恢复运行时指派线程。不同编程语言实现的调度器各不相同不同场景下所需的调度器也不相同使用前需要充分调研所用调度器的特征。## C20 中的协程对象前面举了这么多例子只是为了说明协程的功能。C20 中的协程具体是怎样的很遗憾C20 根本没提供协程的调度器一切都需要自己写所以大家才说 C20 的协程是为库开发者准备的。但如果学习了 C20 中的协程便可以说了解了协程的底层原理处理其他语言中的协程也就游刃有余了。### 未完待续