我是靠谱客的博主 曾经小霸王,最近开发中收集的这篇文章主要介绍一个C/C++协程库的思考与实现之协程栈的动态按需增长,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

https://github.com/DoasIsay/ToyCoroutine

如何检测协程是否需要进行栈扩充?

我们先思考一个问题,glibc的pthread_create创建的线程是如何检测到用户栈的溢出而及时终止线程的?

如下代码

g++ test.cpp -lpthread

strace ./a.out 结果如下图

 

由strace 结果可知pthread_create先使用mmap为线程申请了的用户空间stack,然后使用mprotect对stack的栈顶进行保护,即栈顶的4kb无读写权限,一旦被读写就说明产生了栈溢出,操作系统就会向读写此4kb的任务发送SIGSEGV信号,最后才是调用clone创建线程

如果越过这4kb访问前面的内存会怎样?如果刚好是另一个线程的stack?只要不访问这4kb或其它线程的这4kb都不会有问题,顶多就是会破坏其它线程的栈,或自已的栈被其它线程破坏,然后进程core掉,,,

因此这篇博客《一个C/C++协程库的思考与实现之栈溢出检测

https://blog.csdn.net/DoasIsay/article/details/107396105

就需要更新一下了,因为我们找到了一种更好的方法mprotect来主动检测栈溢出

对于操作系统的任务(进程或线程)而言,任务所需的栈内存,堆内存,并不是任务启动后或发起内存申请(brk/mmap/malloc/new)后操作系统立即为其分配物理内存,而是先为其在进程的虚拟地址空间中找到一块空闲的空间标记其大小起止地址及访问权限,当CPU真正访问到任务未分配物理内存的虚拟页内的地址时MMU会产生一个内存缺页中断,此时在缺页中断处理中操作系统才会真正的为任务分配一页物理内存并更新进程的页表

对于在用户空间实现的协程而言并不能使用操作系统及CPU提供的这种按需延迟分配的机制,但操作系统向用户提供了信号处理这种软中断及mprotect这种接口,但是它还是不能支撑我们在用户空间模拟实现这种机制,如在内存越界访问时,发出信号,由于协程栈是在堆上分配的,当栈溢出时就会发生堆内存越界访问,此时如果对协程的栈顶即堆的起始一段内存进行mprotect,当栈溢出时就会触发SIGSEGV信号,在信号处理函数中我们可以为当前触发SIGSEGV信号的协程扩充栈空间

想法甚好,但是,,,

问题1

如何获得触发SIGSEGV信号的协程?

在多线程环境中当向一个进程发送信号后,信号被投递到那个线程完成完全是随机的,除了硬件错误与定时器触发的信号,SIGSEGV是一个硬件错误?如果它不是一个硬件错误,有可能当前进行信号处理的线程就不是触发SIGSEGV信号的协程所在的线程,我们获取当前线程正在调度运行的协程是通过__thread线程局部变量current,此变量是一个指向当前线程正在执行的程协对象的指针,因此处理SIGSEGV信号的线程一定要是触发SIGSEGV信号的协程所在的线程,才能获取到触发SIGSEGV信号的协程

问题2

如何区分是因协程栈溢出导致触发SIGSEGV信号,还是野指针导致的?

如果我们能在信号处理函数中得到导致触发SIGSEGV信号的内存地址,再与当前触发SIGSEGV信号的协程的栈地址进行比较,如果相差不远,那就不会是野指针导致的,但是我们无法在信号处理函数中获取到触发SIGSEGV信号的内存地址

经过测试,触发SIGSEGV信号的线程会收到SIGSEGV信号,但是在信号处理函数中无法完成问题2的操作,而且就算问题2可以解决,我们在SIGSEGV的信号处理函数中为协程扩充了栈空间后,此线程也只会被内核不断的发送SIGSEGV信号,因为信号处理函数会返回到触发SIGSEGV信号的那条指令继续执行,而我们无法修改这条指令所使用的地址,也就是在信号处理函数中对a变量的修改无法被fun函数再次获取到

比如下代码

 

因此就真的不能在用户空间为协程实现栈的动态按需增长

一种比较朴素的实现方法,在协程的函数调用的入口加入检测代码就像栈溢出检测那样丑陋的代码,检测cpu当前sp寄存器的值与栈的未尾做对比,比如还剩80%的空间就进行栈的扩充,但是如下代码会令你的检测失效,比如栈空间是2kb,假设进入fun函数前已经使用了1kb,进入fun函数后进行检测发现只使用了50%的栈空间,但检测后立即在栈上申请了1kb的空间,此时代码继续运行就有可能产生栈溢出

void fun(){

       check_stack();

       char a[1024];

       xxxxxxx;

       xxxxxxx;

}

因此应尽量提高栈扩充的检测条件,比如栈空间使用超过50%后就扩充,另外尽量不在栈中创建大的临时变量

如何进行栈的扩充?

使用malloc分配新的栈空间拷贝老栈的内容到新栈,这会有个问题就是栈中的局部变量的地址还是老栈的,因此我们需要修改每一个局部变量的地址?不,不需要

如下代码

因为在函数调用的栈帧中是通过bp基栈指针+相对地址去访问栈上的变量的,我们仅需修改协程的函数调用链中每个栈帧上保存的bp,new_bp=new_stack_start+(old_bp-old_stack_start)通过老的值计算出相对偏移量然后与新栈起始地址相加计算出新值回填到栈帧上,此处就涉及到栈的回溯,其实很简单,取出当前栈帧上保存的上一个栈帧的bp,以此类推直至协程的入口函数调用的栈帧,然后再修改协程context的bp/sp就可以了

步骤如下:

  1. 检测到需进行栈扩充
  2. 保存协程当前上下文到协程context
  3. 切换到调度器栈
  4. 分配内存进行栈拷贝
  5. 进行栈回溯计算修改每个栈帧上保存的bp
  6. 当遇到栈帧的bp与协程栈的起始地址相等时就停止回溯,这时已经到达了协程的入口函数
  7. 计算修改context的bp/sp,new_sp=(old_sp-old_stack_start)+new_start_stack
  8. 恢复协程的context,此时协程会继续在新扩充的栈上进行函数的调用

在每个函数调用中加入检测代码是丑陋且繁琐的,而且我们也无法在所有的函数调用中加入检测代码,因为还有第三方库的函数,因此在协程库中用这种方式实现栈的动态扩充并不是很优雅,除非是编译器支持,如gcc提供的分段栈,就算这样我们也不能保证我们使用的所有依赖库在编译时都打开了分段栈的选项,对于协程栈的动态扩充还是别想了吧

既然操作系统提供了虚拟内存,任务申请的内存只是虚拟内存,申请了不用就不会占用物理内存,那么我们直接给足协程的栈,不就行了?只不过会导致进程占用的虚拟内存变大而已,还搞什么协程栈的动态按需增长,,,

最后

以上就是曾经小霸王为你收集整理的一个C/C++协程库的思考与实现之协程栈的动态按需增长的全部内容,希望文章能够帮你解决一个C/C++协程库的思考与实现之协程栈的动态按需增长所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(48)

评论列表共有 0 条评论

立即
投稿
返回
顶部