0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

何选择一个合适的协程来获得CPU执行权

科技绿洲 来源:Linux开发架构之路 作者:Linux开发架构之路 2023-11-13 14:10 次阅读

如今虽不敢说协程已经是红的发紫,但确实是越来越受到了大家的重视。Golang中的已经是只有goroutine,以至于很多go程序员是只知有协程,不知有线程了。就连C++也在最新的C++20中原生支持协程。更不用说很多活跃的语言如pythonjava等,也都是支持协程的。尽管这些协程可能名称不同,甚至用法也不同,但它们都可以被划分为两大类,一类是有(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] |
	--------------   < ---EAX

    movl %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), %ebx
    movl 8(%eax), %ecx
    movl 12(%eax), %edx  
    movl 16(%eax), %edi
    movl 20(%eax), %esi
    movl 24(%eax), %ebp
    movl 28(%eax), %esp

	ret
	// 这样我们就完成了一次协程的切换

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

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

主协程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执行权,正如上面所说,此协程可能正在等待事件。当然如果调度算法足够优秀的话,对称协程也是可取的。

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • cpu
    cpu
    +关注

    关注

    68

    文章

    10831

    浏览量

    211207
  • 程序
    +关注

    关注

    116

    文章

    3778

    浏览量

    80863
  • 函数
    +关注

    关注

    3

    文章

    4309

    浏览量

    62449
  • C++
    C++
    +关注

    关注

    22

    文章

    2105

    浏览量

    73509
收藏 人收藏

    评论

    相关推荐

    谈谈的那些事儿

    随着异步编程的发展以及各种并发框架的普及,作为种异步编程规范在各类语言中地位逐步提高。我们不单单会在自己的程序中使用,各类框架如f
    的头像 发表于 01-26 11:36 1093次阅读
    谈谈<b class='flag-5'>协</b><b class='flag-5'>程</b>的那些事儿

    和线程有什么区别

    和线程的区别和线程的共同目的之是实现系统资源的上下文调用,不过它们的实现层级不同;线程(Thraed)是比进程小
    发表于 12-10 06:23

    Python中的多核CPU共享数据之详解

    又称微线程,coroutne,种用户态的轻量级线程。通俗点讲就是周末我在家里休息,假如我先洗漱,再煮饭,再下载电影看会很慢,用了
    的头像 发表于 12-07 10:23 6585次阅读
    Python中的多核<b class='flag-5'>CPU</b>共享数据之<b class='flag-5'>协</b><b class='flag-5'>程</b>详解

    Python自动化运维之函数赋值过程

    及同步的开销(3)方便切换控制流,简化编程模型(4)高并发+高扩展性+低成本:CPU支持上万的都不是问题。所以很适合用于高并发处理。
    的头像 发表于 03-18 11:22 3712次阅读

    关于C++ 20最全面详解

    花了一两周的时间后,我想写写 C++20 的基本用法,因为 C++ 的让我感到很奇怪,写个协
    的头像 发表于 04-12 11:10 1.3w次阅读
    关于C++ 20<b class='flag-5'>协</b><b class='flag-5'>程</b>最全面详解

    Python后端项目的是什么

    最近公司 Python 后端项目进行重构,整个后端逻辑基本都变更为采用“异步”的方式实现。看着满屏幕经过 async await(在 Python 中的实现)修饰的代码,我顿时
    的头像 发表于 09-23 14:38 1310次阅读

    Python与JavaScript的对比及经验技巧

    对这两语言有兴趣的新人理解和吸收。 共同诉求随着 cpu 多核化,都需要实现由于自身历史原因(单线程环境)下的并发功能 简化代码,避免回调地狱,关键字支持 有效利用操作系统资源和硬件:
    的头像 发表于 10-20 14:30 1890次阅读

    通过例子由浅入深的理解yield

    send:send() 方法致使程前进到下一个yield 语句,另外,生成器可以作为使用
    的头像 发表于 08-23 11:12 2002次阅读

    的概念及的挂起函数介绍

    种轻量级的线程,它可以在单个线程中实现并发执行。与线程不同,不需要操作系统的上下文切
    的头像 发表于 04-19 10:20 872次阅读

    Kotlin实战进阶之筑基篇1

    。 Android 中的每个应用都会运行主线程,它主要是用来处理 UI,如果主线程上需要处理的任务太多,应用就感觉被卡主样影响用户体验,得让那些耗时的任务不阻塞主线程的运行。要做到处理网络请求不会阻塞主线程,
    的头像 发表于 05-30 16:24 682次阅读
    Kotlin<b class='flag-5'>协</b><b class='flag-5'>程</b>实战进阶之筑基篇1

    Kotlin实战进阶之筑基篇3

    。 Android 中的每个应用都会运行主线程,它主要是用来处理 UI,如果主线程上需要处理的任务太多,应用就感觉被卡主样影响用户体验,得让那些耗时的任务不阻塞主线程的运行。要做到处理网络请求不会阻塞主线程,
    的头像 发表于 05-30 16:26 672次阅读

    FreeRTOS任务与介绍

    FreeRTOS 中应用既可以使用任务,也可以使用(Co-Routine),或者两者混合使用。但是任务和协使用不同的API函数,因此不能通过队列(或信号量)将数据从任务发送给
    的头像 发表于 09-28 11:02 952次阅读

    的作用、结构及原理

    本文介绍了的作用、结构、原理,并使用C++和汇编实现了64位系统下的池。文章内容避免了
    的头像 发表于 11-08 16:39 1078次阅读
    <b class='flag-5'>协</b><b class='flag-5'>程</b>的作用、结构及原理

    C/C++编程的相关概念和技巧

    、引言 的定义和背景 (Coroutine),又称为微线程或者轻量级线程,是种用户态
    的头像 发表于 11-09 11:34 723次阅读

    的实现与原理

    前言 这个概念很久了,好多程序员是实现过这个组件的,网上关于的文章,博客,论坛都是汗牛充栋,在知乎,github上面也有很多大牛写了关于
    的头像 发表于 11-10 10:57 415次阅读