libco 协程(1)腾讯开源库环境切换分析
协程是什么?
协程可以理解为用户自定义的一种线程,是在用户态下的线程。近几年协程慢慢取代了线程的地位,线程作为CPU执行调度的最小单位,相较于进程有CPU切换开销小的优点。那么协程凭什么能再某些场景下能够取代线程的地位呢?
线程和协程的区别
共同点:一样都拥有独立的堆栈和局部变量,稳定性是一样的,崩溃了同样会影响到整个程序。
线程:调度是由CPU进行调度的,在不用同步机制的情况下用户并不知道哪个线程先运行。由于调度是由CPU来进行的,在需要频繁的在用户态和内核态之间切换,在访问一些全局变量需要进行加锁。
协程:调度由用户自主完成,对于自己封装的协程库更需要自己实现一个调度器来实现并发功能。由于是用户自己进行切换的,并不需要像线程一样切换到内核态,只需要保存当前协程的上下文(寄存器),再切换到需要切换到的协程的上下文状态就可以了,有点类似于保存了调用函数状态的函数调用。由于是用户自己进行切换的,在访问一些全局变量上并不会出现多个协程同时对一个变量进行操作的情况,只需要在操作前判断一下状态就可以。
为什么要用协程
协程主要实现的功能是能够在将要进行IO操作的时候先切换到其他协程中,等到有IO事件到来的时候再切换回刚刚切换出去的地方继续执行,最大程度的避免了IO阻塞时CPU的浪费,同时也能尽可能的利用CPU的性能。
但是协程并没有真正意义上实现并发,他对于IO密集的应用场景下的处理比线程更好,但是对于并发要求高,每个线程相互独立的时候协程就比不上线程了。
为什么在IO密集的时候比线程更好? 这一点在我刚接触协程的时候困扰了我很久,后来我想明白了。
举个例子。
我有一个EPOLL服务器,在监听大量的FD请求的同时还做一些其他的操作,当大量IO请求到来的时候,每个请求的时间极短但是存在一些数据是全局的,这时候需要加锁,那么此时CPU在上下文切换和锁机制中的开销就远比实际工作的开销大了。如果换成协程的话可以避免上述两个开销。
前驱知识-函数调用过程
在了解协程实现前,需要知道函数调用过程中堆栈的操作。
堆: 在用户空间中的增长是从低地址到高地址
栈: 增长是从高地址到低地址
ebp EBP是”基址指针”(BASE POINTER), 它最经常被用作高级语言函数调用的”框架指针”(frame pointer).
esp ESP为栈指针,用于指向栈的栈顶(下一个压入栈的活动记录的顶部) 当一个函数内出现函数调用的时候,会在这个函数的汇编代码中更新esp的值,因为存在函数调用意味着有新的活动记录会压栈
eax EAX 是”累加器”(accumulator), 它是很多加法乘法指令的缺省寄存器。
ebx EBX 是”基地址”(base)寄存器, 在内存寻址时存放基地址。
ecx ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。
edx EDX 则总是被用来放整数除法产生的余数。
esi/edi ESI/EDI分别叫做”源/目标索引寄存器”(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串.
实验
下面的实验环境是在visual stdio 2019下
# include <stdio.h>
int add(int a,int b)
{
int sum=0;
sum=a+b;
return sum;
}
int main()
{
int a=3,b=2,sum;
sum=add(a,b);
return 0;
}
将断点下在main函数的入口上。
在进程空间的栈段,我们所调用的每一段函数都会在栈中拥有一段自己的栈帧(可以理解为函数自己的内部空间,局部变量和寄存器值)。
如图,main的结尾并不是在随着函数块结束而结束,而是后面还有一段出栈操作,后面会详细讲。
我们可以看到在调用sum()入口处会先从右到左的将参数压栈,之后的call(将下一个指令的地址压栈并跳转到函数入口处)。
至此,从下图中我们可以看到main的栈帧是从哪里到哪里。在push ebp(保存main函数的栈底,方便后续恢复现场)后,首先是进行一个创建局部变量的空间(大小为0x0cc),之后再将寄存器的值进行压栈,这就是保存main()的上下文操作。
回到一开始的,main()函数结束后的出栈操作是什么?联想函数调用的过程,实际上就是在恢复现场,其实还有个内置函数用于调用main函数并获取其返回值用于当进程结束退出后收集其状态。
(一定要记得栈的增长是从高到低的,也就是说EBP 的值永远会比ESP高)
Libco中的coctx_swap.S(最早版本的)
在上面了解了函数调用的过程后, 我们就能更好的理解协程是怎么实现的了。
下述的coctx_swap.S是最早的一个开源版本,但是因为存在一个BUG: 因为在执行coctx_swap过程中esp指针的位置是被改变的。当这个时候发生了一个信号中断(例如SIGINT),由于此时的栈顶指针不是指向主栈的栈顶,在压栈出栈的过程中会发生错误。后面改成了move操作来代替pop和push就好多了。但是没有用新的是我觉得这个更好理解,思想是一样的。
/*
* Tencent is pleased to support the open source community by making Libco available.
* Copyright (C) 2014 THL A29 Limited, a Tencent company. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.globl coctx_swap //用来让一个符号(coctx_swap)对链接器可见,可以供其他链接对象模块使用。
# if !defined( __APPLE__ )
.type coctx_swap, @function
# endif
coctx_swap:
# if defined(__i386__)//在执行下面的汇编代码之前已经执行了将参数压栈,将返回地址压栈的操作,当前esp指向的内存的值应该为下一条指令的地址,即调用coctx_swap之后的指令(见co_routine.cpp co_swap函数)
leal 4(%esp), %eax //sp R[eax]=R[esp]+4 R[eax]的值应该为coctx_swap的第一个参数在栈中的地址
movl 4(%esp), %esp // R[esp]=Mem[R[esp]+4] 将esp所指向的地址改为第一个参数的实际地址。由于coctx_t 是在堆上开辟的空间,所以此时指向的是:
| *ss_sp | high address
| ss_size |
| regs[7] |
| regs[6] |
| regs[5] |
| regs[4] |
| regs[3] |
| regs[2] |
| regs[1] |
| regs[0] | low address
-------------- <---ESP
leal 32(%esp), %esp //parm a : ®s[7] + sizeof(void*) push 操作是以esp的值为基准,push一个值,则esp的值减一个单位(因为是按栈区的操作逻辑,从高位往低位分配地址),但ctx是在堆区,所以应将esp指向reg[7],然后从eax到-4(%eax)push
//保存寄存器值到栈中,实际对应coctx_t->regs 数组在栈中的位置(参见coctx.h 中coctx_t的定义)
此时:
| *ss_sp | high address
| ss_size |
-------------- <---ESP
| regs[7] |
| regs[6] |
| regs[5] |
| regs[4] |
| regs[3] |
| regs[2] |
| regs[1] |
| regs[0] | low address
pushl %eax // 压入的第一个参数的地址
pushl %ebp
pushl %esi
pushl %edi
pushl %edx
pushl %ecx
pushl %ebx
pushl -4(%eax) //将函数返回地址压栈,即coctx_swap 之后的指令地址,保存返回地址,保存到coctx_t->regs[0]
//恢复运行目标routine时的环境(各个寄存器的值和栈状态)
movl 4(%eax), %esp //parm b -> ®s[0] //切换esp到目标 routine ctx在栈中的起始地址,这个地址正好对应regs[0],pop一次 esp会加一个单位的值
popl %eax //ret func addr regs[0] 暂存返回地址到 EAX
//恢复当时的寄存器状态
popl %ebx // regs(https://banthink.com/usr/uploads/2020/03/416489951.jpg)
popl %ecx // regs(https://banthink.com/usr/uploads/2020/03/2786122543.jpg)
popl %edx // regs(https://banthink.com/usr/uploads/2020/03/1877499498.jpg)
popl %edi // regs[4]
popl %esi // regs[5]
popl %ebp // regs[6]
popl %esp // regs[7]
//将返回地址压栈
pushl %eax //将返回地址压栈
xorl %eax, %eax //异或操作 将结果送到eax中(实际就是把EAX清0)
//返回,这里返回之后就切换到目标routine了,C++代码中调用coctx_swap的地方之后的代码将得不到立即执行
ret
# elif defined(__x86_64__)
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax //ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax
xorl %eax, %eax
ret
# endif
Libco协程库
下面以32位的API为例,coctx_make 主要用于构建要切换的协程的上下文。
coctx_t : 目标协程的上下文结构体。
struct coctx_t{
char *regs[8], //存放当前环境的寄存器值
int ss_size, //栈帧大小
char *ss_sp //栈帧的栈顶
}
coctx_pfn_t :函数指针-->typedef void* (*coctx_pfn_t)( void* s, void* s2 )
const void *s : 被调函数所在的函数上下文(coctx_t)
const void * s2: 被调函数的上下文
在libco库中的堆是有一个内存管理器进行统一的申请和删除的。也就是说ctx所在的空间实际是堆,但是我们要把它当做栈来使用。
int coctx_make( coctx_t *ctx,coctx_pfn_t pfn,const void *s,const void *s1 )
{
//make room for coctx_param
char *sp = ctx->ss_sp + ctx->ss_size - sizeof(coctx_param_t);//分配参数所需栈空间
sp = (char*)((unsigned long)sp & -16L);//将地址低4位置零,地址应该是一个栈区一个存储单位的大小的整数倍。这里涉及到字节对齐的方法,为了加快内存读取效率
//将参数存放在栈内,发生函数调用时,参数是从右往左压栈,即最右边的参数应该在高地址,第一个参数应该在相对的低地址区域
coctx_param_t* param = (coctx_param_t*)sp ;
//coctx_param_t 的两个参数 s1,s2由于其声明顺序,则s1的地址相对于s2是低地址(其实还会考虑的内存优化,当结构体内部是不同类型参数时,其存储顺序可能与声明顺序不一致,但这里不存在这个问题)
param->s1 = s;
param->s2 = s1;
memset(ctx->regs, 0, sizeof(ctx->regs));//将寄存器信息初始化为0
ctx->regs[ kESP ] = (char*)(sp) - sizeof(void*);//设置栈顶指针,并留出函数调用结束后返回地址的空间
ctx->regs[ kEIP ] = (char*)pfn;//设置下一条指令的地址
return 0;
}
//------- ss_sp + ss_size [high address]
//|pading| 这里是对齐区域
//|s2 |
//|s1 |
//|预留IP|
//------- <- sp
//| |
//------- ss_sp [low address]
//在构建完毕后的coctx
ctx->regs[ kESP ] = (char*)(sp) - sizeof(void*)
这一句是为汇编代码中的popl %esp // regs[7]
pushl %eax
这两句做准备的,因为当pop后ESP所指向的位置就是上图中构建完毕后的coctx的SP所指向的位置,这是后进行push操作,就可以把eax中存储的返回地址压栈而不会覆盖参数s1。(push操作是先压栈,后esp-4)
Q1:那么此时有个疑问就是sp到ss_sp这段空间是干嘛用的?
A:这段空间就是上述我自己画的图里面局部变量的位置。但是这里存在一个问题,当栈中所需要的内存空间过大超出了我们预先开辟的ss_size空间的话,可能存在一个越界现象
,所以我们一般要尽可能的使得这段空间够大不会越界。(一般也不至于越界)
Q2: 每个协程实际上用不了默认开辟的空间这么多,不就造成空间浪费?
A: 是的,这样就引出了共享栈的概念。
共享栈
先解释一下这两个的概念:
共享栈指的是在所有协程运行的过程中,它们用的任务栈是同一个栈
独立栈指的是在所有协程运行的过程中,它们用的任务栈是各自的栈,我们上面所涉及的都是独立栈
那么共享栈的目的实际就是将一个大的空间抽分出来作为共享栈,所有的协程的任务栈都在这个上面,当需要保存自己当前的任务栈时,先重新申请一块大小为当前任务栈的空间,再将任务栈保存进去。然后将共享栈的所有权切换为被调用的协程。
这样一来,相当于我们将每个保存任务栈的空间都进行了压缩处理,相较于每个协程都使用一个独立栈节约了很多空间,但是我们同时也付出了内存拷贝和额外空间申请的开销,时间换空间。
至此,现在协程的实现原理都已经搞懂了吧。