有栈协程与无栈协程

2023-11-22 00:40
文章标签 协程 有栈 无栈

本文主要是介绍有栈协程与无栈协程,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

引言

关于协程是什么这类基本概念我们不再多提,有兴趣的朋友可以看看我写的这篇文章《聊聊协程》。写这篇文章的原因是当我对这个问题感到疑惑的时候发现CSDN上并没有相关的文章,遂在有了一点理解以后想写下一点对这个问题的看法,以帮助后来学习的朋友。

正文

如今虽不敢说协程已经是红的发紫,但确实是越来越受到了大家的重视。Golang中的已经是只有goroutine,以至于很多go程序员是只知有协程,不知有线程了。就连C++这样的“老顽固”也在最新的C++20中原生支持协程。更不用说很多活跃的语言如python,java等,也都是支持协程的。尽管这些协程可能名称不同,甚至用法也不同,但它们都可以被划分为两大类,一类是有(stackful) 协程,例如 goroutine,libco;一类是无栈 (stackless) 协程,例如C++的协程。

这里我们想说的一点是所谓的有栈,无栈并不是说这个协程运行的时候有没有栈,而是说协程之间是否存在调用栈(callbackStack)。其实仔细一想即可,但凡是个正在运行的程序,不管你是协程也好,线程也好,怎么可能在运行的时候不使用栈空间呢,调用参数往哪搁,局部变量往哪搁。我们知道基本所有的主流语言在调用另外一个函数的时候都存在一个调用栈,我们来解释一下调用栈这个词:(图片来源 维基百科)
在这里插入图片描述
这幅图是有两个栈帧的调用栈,我在这篇文章中对栈帧下过定义,即:函数的栈帧是指esp和ebp之间的一块地址。拿上图来说ebp存储着Frame Pointer指向的地址,Return Address当然就是我们在执行完最新的栈帧以后下一步要执行的指令地址。esp当然就是当前指向栈顶的指针了。

有栈协程

很多地方又把协程称为subroutine,subroutine是什么,就是函数。上古时期的计算机科学家们早就给出了概念,coroutine就是可以中断并恢复执行的subroutine,从这个角度来看协程拥有调用栈并不是一个奇怪的事情。我们再来思考coroutine与subroutinue相比有什么区别,你会发现区别仅有一个,就是coroutinue可以中断并恢复,对应的操作就是yield/resume,这样看来subroutinue不过是coroutinue的一个子集罢了。也就是说把协程当做一个特殊的函数调用,有栈协程就是我们理想中协程该有的模样。

既然把其当做一个特殊的函数调用,对我们来说最严峻的挑战就是如何像切换函数一样去切换协程,难点在于除了像函数一样切换出去,还要在某种条件满足的时候切换回来,我们的做法可以是在协程内部存储自身的上下文,并在需要切换的时候把上下文切换就可以了,我们知道上下文其实本质上就是寄存器,所以保存上下文实际上就是把寄存器的值保存下来,有两种方法,一种是使用汇编,libco就使用了这种方法。还有一种是使用ucontext.h,这个封装好的库也可以帮我们完成相关工作。

汇编的话我们来看一看libco中对于32位机器的上下文切换操作是如何完成的:

	// 获取第一个参数movl 4(%esp), %eax // 参数的类型我们暂且理解为一个拥有八个指针的数组,即regs| regs[7] || regs[6] || regs[5] || regs[4] || regs[3] || regs[2] || regs[1] || regs[0] |--------------   <---EAXmovl %esp,  28(%eax)  movl %ebp, 24(%eax)movl %esi, 20(%eax)movl %edi, 16(%eax)movl %edx, 12(%eax)movl %ecx, 8(%eax)movl %ebx, 4(%eax)// 想想看,这里eax加偏移不就是对应了regs中的值吗?这样就把所有寄存器中的值保存在了参数中// ESP偏移八位就是第二个参数的偏移了,这样我们就可以把第二个参数regs中的上下文切换到寄存器中了movl 8(%esp), %eax movl 4(%eax), %ebxmovl 8(%eax), %ecxmovl 12(%eax), %edx  movl 16(%eax), %edimovl 20(%eax), %esimovl 24(%eax), %ebpmovl 28(%eax), %espret// 这样我们就完成了一次协程的切换

我们可以看到其实就是参数中传入两个协程的上下文结构,然后第一个参数执行保存上下文,然后把第二个参数的上下文存入寄存器,这样就执行了两个协程的切换。

当然我们上面提到了调用栈,那么既然有调用栈,那么肯定有一个执行的顺序,即一定要把栈顶的协程全部运行完才可以运行下一层的协程,这样说可能比较抽象,我们举一个简单的例子:

主协程A中执行协程B,此时调用栈是在[A,B]和[A]之间切换,因为B会主动让出执行权,然后调用栈上此时就只有一个A了

B协程中执行C,D协程,此时调用栈是在[A,B,C],[A,B],[A,B,D]之间转换的,

这样看来我们总是只能在调用栈顶的协程运行完以后才能去执行更低一层的协程,当然,这也是典型的非对称协程,即协程之间有明显的调用关系

当然在我的描述中也可以看出有栈协程涉及到对于寄存器的保存和修改,也涉及到对每一个协程栈(实际运行的栈)的分配。对于寄存器来说,现代寄存器基本都是上百个字节的数据,还有每一个协程的栈,如果选择了共享栈,又涉及到对栈上数据的拷贝,显然在效率上来说相比无栈协程的确是有一些损失的。

无栈协程

那么所谓的无栈协程是什么呢?其实无栈协程的本质就是一个状态机(state machine),它可以理解为在另一个角度去看问题,即同一协程协程的切换本质不过是指令指针寄存器的改变。这里推荐一篇文章,其内容是用C语言实现一个协程,其实就是一个无栈协程的实现。

我们来看一个使用libco的协程的例子,当然libco是一个有栈协程:

void* test(void* para){co_enable_hook_sys();int i = 0;poll(0, 0, 0. 1000); // 协程切换执行权,1000ms后返回i++;poll(0, 0, 0. 1000); // 协程切换执行权,1000ms后返回i--;return 0;
}int main(){stCoRoutine_t* routine;co_create(&routine, NULL, test, 0);// 创建一个协程co_resume(routine); co_eventloop( co_get_epoll_ct(),0,0 );return 0;
}

这段代码实际的意义就是主协程跑一个协程去执行test函数,在test中我们需要两次从协程中切换出去,这里对应了两个poll操作(hook机制,有兴趣的朋友可以点击这里),hook后的poll所做的事情就是把当前协程的CPU执行权切换到调用栈的上一层,并在超时或注册的fd就绪时返回(当然样例这里就只是超时了)。那么无栈协程跑相同的代码是怎么样的呢?其实就是翻译成类似于以下代码:

struct test_coroutine {int i;int __state = 0;void MoveNext() {switch(__state) {case 0:return frist();case 1:return second();case 2:return third();}}void frist() {i = 0;__state = 1;}void second() {i++;_state = 2;}void third() {i--;}
};

我们可以看到相比与有栈协程中的test函数,这里把整个协程抽象成一个类,以原本需要执行切换的语句处为界限,把函数划分为几个部分,并在某一个部分执行完以后进行状态转移,在下一次调用此函数的时候就会执行下一部分,这样的话我们就完全没有必要像有栈协程那样显式的执行上下文切换了,我们只需要一个简易的调度器来调度这些函数即可。

从执行时栈的角度来看,其实所有的协程共用的都是一个栈,即系统栈,也就也不必我们自行去给协程分配栈,因为是函数调用,我们当然也不必去显示的保存寄存器的值,而且相比有栈协程把局部变量放在新开的空间上,无栈协程直接使用系统栈使得CPU cache局部性更好,同时也使得无栈协程的中断和函数返回几乎没有区别,这样也可以凸显出无栈协程的高效。

对称协程与非对称协程

其实对于“对称”这个名词,阐述的实际是协程之间的关系,用大白话来说就是对称协程就是说协程之间人人平等,没有谁调用谁一说,大家都是一样的,而非对称协程就是协程之间存在明显的调用关系。

简单来说就是这样:

  • 对称协程 Symmetric Coroutine:任何一个协程都是相互独立且平等的,调度权可以在任意协程之间转移。
  • 非对称协程 Asymmetric Coroutine:协程出让调度权的目标只能是它的调用者,即协程之间存在调用和被调用关系。

其实两者的实现我觉得其实差异不大,非对称协程其实就是拥有调用栈,而非对称协程则是大家都平等,不需要调用栈,只需要一个数据结构存储所有未执行完的协程即可。至于哪种更优?我觉得分情况,如果你使用协程的目的是为了优化一些IO密集型应用,那么协程切换出去的时候就是它等待事件到来的时候,此时你就算切换过去也没有什么意义,还不如等到事件到来的时候自动切换回去。

其实上面说的是有一些问题,因为这个执行权的切换实际上是(调用者–被调用者)之间的切换,对称就是它们之间都是平等的,就是假如A协程执行了B,C协程,那么B协程可以切换回A,也可以切换回C。而非对称只能是B切换回A,A切换回C,C再切换回A,以此类推。

这样看起来显然非对称协程相比之下更为符合我们的认知,因为对称协程目前我不知道如何选择一个合适的协程来获得CPU执行权,正如上面所说,此协程可能正在等待事件。当然如果调度算法足够优秀的话,对称协程也是可取的。


有兴趣的朋友可以详细了解libco,这是我对libco的一系列源码解析与看法

  • 《libco源码解析 》
  • 《对libco的一点看法》

2020年11月22日:
上面有提到对称协程的调度情况,这里我认为Goroutinue的实现是对这个问题非常好的解释,这是我当时写这篇文章是所不知道的,有兴趣的朋友可以自行了解一下相关的知识点。

参考:

  • 博文《有栈协程与无栈协程》
  • 博文《使用ucontext 来手动切换上下文》
  • 博文《c +±无栈协程与有栈协程有何不同?》
  • 博文《使用 C 语言实现协程》
  • 博文《什么是coroutine》
  • 博文《协程(一)快速理解协程的定义和分类》
  • 博文《破解 Kotlin 协程 番外篇(2) - 协程的几类常见的实现》

这篇关于有栈协程与无栈协程的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/406229

相关文章

Python协程探秘:async/await的魔法

Python协程探秘:async/await的魔法 在Python的并发编程世界中,协程(Coroutines)和async/await关键字正逐渐崭露头角,它们提供了一种高效、轻量级的并发解决方案。本文将深入解释协程的概念,探讨async/await关键字的作用,并通过示例展示如何在Python中使用它们。 一、协程简介 协程,又称为微线程(Microthreads)或用户态线程(User

协程: Flow 异步流 /

以异步方式返回多个返回值的方案: 在 Kotlin 协程 Coroutine 中 , 使用 suspend 挂起函数 以异步的方式 返回单个返回值肯定可以实现 , 如果要 以异步的方式 返回多个元素的返回值 , 可以使用如下方案 : 集合序列Suspend 挂起函数Flow 异步流 同步调用返回多个值的弊端   鸣谢: mAndroid面试题之Kotlin异步流、冷流Flow

纤程与协程以及有栈协程和无栈协程的区别

纤程与协程区别以及有栈协程和无栈协程的区别 参考纤程与协程区别有栈协程和无栈协程有栈协程为什么需要申请内存而无栈为什么不需要 参考 当谈论协程时,我们在谈论什么 从无栈协程到 C++异步框架(上) 从无栈协程到 C++异步框架(下) libco flare 这里不得不感叹下,鹅厂是真的不吝分享!!! 纤程与协程区别 本质上来,纤程和协程没有啥区别,都是可以暂停和恢复执行的函

go的有栈和无栈

在 Go 的 HTTP 处理中,“有栈”和“无栈”通常是指处理并发请求时的不同方式。 “有栈”的方式通常是指使用传统的基于线程或协程的并发模型,每个并发请求都有自己独立的栈空间。 例如,使用标准库中的 net/http 处理并发请求,默认情况下每个请求都会在一个独立的 Goroutine 中处理,每个 Goroutine 都有自己的栈。 package mainimport ("log""net

异步开发的终极答案—协程

我们在之前的文章中讲过,在并发场景下,传统的基于多线程的命令式开发模型虽然比较简单,但并发数高了之后资源占用较高,大量线程会阻塞;而响应式编程模式我们可以通过异步化处理提升系统资源的利用效率,但异步开发有违人的直觉,门槛比较高。作为成年人,我们肯定希望全都要呀,那么能实现吗?今天我们就来介绍另一种并发的开发模式—协程 背景知识 在正式介绍协程的定义时,我们还需要先了解一些操作系统的基础知

协程(coroutine)应用实例:计时器过期事件响应

协程应用实例:计时器过期事件响应 序1. 计时调度中心2.基于协程的事件处理 序   早期我曾把弄过War3 的WE编辑器,算是我编程的启蒙教育了。其事件响应系统在我心中一直印象深刻,特别是每个事件都可以用等待函数延迟执行,昨天我看到了协程,心血来潮便写了个简陋的计时器响应机制。 1. 计时调度中心   计时调度中心采用linux时间轮式设计,网上资源很全面,不做过多叙述。

Lua 的协程在并发编程中有哪些独特的应用场景和优势?

Lua的协程在并发编程中有以下独特的应用场景和优势: 高效的并发模型:Lua的协程提供了一种轻量级的并发模型,可以在单个线程中实现并发操作。相比于多线程或多进程的并发模型,协程的切换成本更低,可以避免线程切换的开销,提高程序的性能。 简单易用的并发编程:Lua的协程通过yield和resume操作实现协程的切换,编程模型相对简单易懂。开发者可以通过编写协程函数来实现协程的逻辑,而不需要关注线

30 张图解 | 高并发服务模型多线程多进程还是协程?

微信搜「后端技术学堂」有干货,本文已收录于Github:https://github.com/imcoderlemon/CodeClass 内含原创干货文章,千本计算机电子书,3本LeetCode题解,各类编程资源 面试中经常会被问到高性能服务模型选择对比,以及如何提高服务性能和处理能力,这其中涉及操作系统软件和计算机硬件知识,其实都是在考察面试者的基础知识掌握程度,但如果没准备的话容易一

协程-在单个线程内部执行

协程(Coroutine):需要用户手动管理的,在用户态进行调度的轻量级并发模式 协程的特点 轻量级:协程在同一个线程内执行,不需要操作系统线程切换,比线程更轻量级的并发处理方式。暂停与恢复:协程可以在执行过程中暂停,将控制权交还给调用方,并在将来某个时刻继续执行。非抢占式调度:协程的调度由程序显式控制,而不是由操作系统的调度器决定。简化异步编程:使用协程可以避免回调地狱(callback h

asyncio协程提高执行效率

from fastapi import FastAPIimport asyncioapp = FastAPI()async def task1():# 模拟执行任务1print("开始执行任务1")await asyncio.sleep(1)print("结束执行任务1")return "Result from Task 1"async def task2():# 模拟执行任务2print("开