图文上下切换代码_也来说说协程(2)——上下文切换

248fcbc7c125a72784911e73d75dbdf6.png

接上文

Coldwings:也来说说协程(1)——由来和概念​zhuanlan.zhihu.com
4ae1f34f3d93d006327d37a7e82cd73d.png

我们已经知道了这个玩意,接下来说说看“怎么做一个协程”。

下文的有栈协程部分,讨论到栈切换的场景,主要针对x86-64环境下(AMD64)的Linux操作系统(Unix、BSD及Darwin也使用了相同的形式,但相应汇编标注略有不同)

怎么做

本质上来讲,协程需要实现一个可以挂起的流程——一个函数,运行到中间位置,如果能暂停(让出执行机会),并且能通过外部恢复到暂停时到状态继续执行。

我们姑且称这个保存运行状态,并让出执行机会的行为叫yield。实现yield,就有了实现协程的基础。

现在展开来看,yield需要做如下工作:

  1. 上下文切换(scope内的变量、状态,以及当前运行位置)
  2. 把当前任务变为等待状态,让出执行机会给调度器

所以需要考虑的就是实现这二者。

这里,我们先来看看第一个问题:上下文切换。这里需要具备一定的操作系统、X86-64 ABI以及部分汇编知识——实在看不懂也没关系,后续自己做的时候抄一遍也不是不行。

上下文切换

上下文是指代码运行到yield时,其使用到的变量,包括局部变量、调用逻辑等相关状态。在一般操作系统线程实现里,上下文包括若干寄存器量和存在于内存中的一片栈空间,以及一些其他状态量。

这里有个很讨巧的点——我们谈及的是“上下文”。如果有操作系统线程相关知识,那么很容易就会注意到:上下文不是就在栈上么?为什么不说栈切换呢?

无栈协程

栈作为天然的上下文,切换了栈就能切换程序当前线程的运行状态和返回地址,似乎是默认选项。但毕竟协程与线程不是一回事,如果协程不使用任何栈相关状态(或只使用有限的部分栈状态),所有的局部量都保存为堆上的上下文对象,我们可以不去考虑栈,而把协程切换变为单纯的函数调用,把上下文保存在堆里。在许多语言VM/Runtime中,“栈”本就不是与操作系统意义上的栈同等的概念,完全可以使用堆上上下文量替换;即使语言本身使用栈,只要约定使用协程的部分不使用栈上量直接作为上下文,而只是保留一个堆上上下文指针,就可以留下操作空间。

无栈协程的实现相对简单一些,至少不需要和栈机制去死磕了。诸如Python就使用了解释器维护的栈,用Generator实现协程(可以从genobj源码顺藤摸瓜查看实现);而Generator则是在解释器层面内自行维护了切换点,并不借助实际线程栈的切换。但是需要用户或者语言达成约定避免直接使用栈上量,仍需要牺牲一些便捷性。

我们还是把精力姑且先收回来,这里主要讲的还是更贴近通常使用习惯中的有栈协程。

有栈协程

所幸,CPU实现栈切换是有标准的。针对主流64位CPU(毕竟2020年想买到新崭崭的X86-32CPU还是有点难),我不太熟悉Windows下的情况,但姑且X86-64(AMD64)在SystemV的ABI下:

Projects · x86 psABIs / x86-64 psABI​gitlab.com
f61d1f18711cc033783623ef1d2dd0d8.png

可以窥探到栈和函数调用在操作系统里约定的使用方式。当然如果觉得太长不看,又或者编译整堆LaTex太麻烦,也可以直接看图:

593ba3139ba289013f9ec15e30a103c4.png

ABI里规定了调用者用rsp来保存栈顶指针,栈的切换就成为了rsp这个寄存器值的切换了。除此之外,还应该保留调用者的相关信息,包括rbp、rbx、r12-r15这些寄存器。(至于说额外的mxcsr,其实可以用来处理可变参数调用……然而我们都假设就一个参数了,不管;x87 SW/CW……更是不管)。

只要保存和切换了这几个寄存器,就可以实现栈切换(例如这个实现Coroutines,不过里面是Intel汇编,要扔GCC里还得转成AT&T的形式;下面的例子则可直接编译)。

姑且先看一眼这一段实现(顺带一提,知乎居然没有X86汇编的高亮……):

.globl _swap64
#if !defined(__APPLE__) && !defined(__FreeBSD__)
.type _swap64, @function
#endif
_swap64:

push %rbx ;// push
push %rbp
push %r12
push %r13
push %r14
push %r15
mov %rsp, (%rdi) ;// rdi is `from`
mov (%rsi), %rsp ;// rsi is `to`
pop %r15 ;// pop
pop %r14
pop %r13
pop %r12
pop %rbp
pop %rbx
ret

现在来看看我们的汇编代码:

结合之前的ABI文档,我们知道%rdi, %rsi分别为第一、第二个参数。上述汇编可以简单描述如下:

801eb308ef368cc98e95c973af3bd691.png

至此,我们只需要传递两个栈指针给swap64(当然,也需要分配对应的空间——扔堆上),就可以实现栈的保存与恢复了。

额外的,对于一个新栈,我们需要在寄存器之外传入一个虚拟的返回地址和入口函数地址。

对于C++,可以简单地实现如下一个类,来传递虚拟栈:

struct Stack {
    void *_top;
    std::unique_ptr<char[]> _buf;

    Stack(uint64_t size, void(*entry)()) //参数为栈大小及入口函数指针
        :_buf(new char[size]) { //稍微多分配一点点空间以便对齐Cache line
        _top = _buf.get() + size;
        push(0); //返回地址,新栈就空地址吧
        push(entry); //入口地址
        (uint64_t*&)_top -= 6; //额外留下6个寄存器保存空间
    }

    void** refptr() { return &_top; }

    template <typename T> //实际要求T一定是64位宽的,这里主要是为了塞花式指针
    void push(T&& x) {
        *--(uint64_t*&)_top = (uint64_t)x;
    }

    Stack(Stack&& rhs) { //可以移动
        _top = rhs.top;
        _buf = std::move(rhs._buf);
    }

    Stack(const Stack&) = delete; //不可复制
    Stack& operator=(const Stack&) = delete;
}

extern void swap64(Stack*, Stack*) asm("_swap64")

实际上我们刻意地让栈顶指针_top保持为Stack的第一个成员,这样一个指向Stack的指针也实质性指向了栈顶指针的存储单元_top

利用这样一个简单的Stack类,以及上述的swap64,就可以愉快地进行有栈的上下文切换了。

——————————————————————————————————

顺带一提,我所在的阿里云基础产品事业部存储加速器团队招人啦!

团队实力的话……似乎可以参考一下我们在USENIX ATC `20上发表的论文

https://www.usenix.org/conference/atc20/presentation/li-huiba​www.usenix.org

团队目前集中于大规模场景下的高速Cache、数据访问加速等方面,我们的产品也在集团内得到了大量使用。如果有兴趣,欢迎私信我进行内推~

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值