stackless vs stackful
本文是关于Fibers under the magnifying glass论文或者杂志的总结
1. 介绍
正常
subrotine: 类似函数调用,从开始执行到最终结束返回,当返回之后就表示整个过程已经结束了;
coroutine: 本质上和函数调用类似,区别在于可以有多个返回点,执行到一半被暂停,一会再回来这样的形态;当然正常函数也会因为线程调度来完成,但是从主观角度来说,函数本身没有能力暂停,一旦返回就结束了;
目前有两种实现方式:
stackless: 无stack式协程,是基于编译器将协程实现为状态机的方式;stackful: 有stack式协程,是基于用户态协程调度器的模式来实现协程的;
2. Thread和fiber的各方面的比较

这是几种比较流行的用户态线程和内核线程的模型,基本上分为1-1,1-M,n-m ,在C++中创建一个thread会对应到真正的一条kernel thread, 理论上算是1-1的模型,但是这种方式为了提高并发能力只能通过提升线程的个数,会被最终的机器资源所限制的;1-M这个模型就是fiber,或者是早期的协程模型,用户态的线程对应到内核线程是多对1的,用户态的线程和真是的os的线程是不一样的,早期的协程调度都是单线程的,虽然有局限,但是整体的cpu使用率就高了很多的;可以看到对于1-M模型,多个fiber需要公用一个内核线程的,而内核线程在执行的时候都会自己的执行空间的,所以你会发现多了一个UM Context 这个很重要,因为这个是用来区别不同fiber执行的环境;这可能就是所谓的代价;
1-m的模型只能利用单核优势,随着cpu慢慢多核化,这个肯定是不符合预期的;慢慢的就推延到了n-m的模型,比较好的例子是golang,它的协程调度器就是可以利用多核的,并且可以做fiber的多核心调度,这个可能也是慢慢的推演过程;
会发现操作系统的发展过程也是从简单到复杂,慢慢演进的, 为何要复杂本质上是太过简单粗暴的东西在后期的要求中不能完成任务;
早期的1-1,比较简单,通过上下文切换,这个过程os会去保证对应的context;能符合要求;随着业务场景的要求越来越高,并且简单的1-1会遇到cpu利用率不高,并行度等等的问题
1-M,这个模型的好处在于可以充分利用单核的性能,唯一要做的就是
UM Context的维护和一点点的切换的cpu的消耗,但是当单核出现的物理上限之后,现实的cpu的发展方向往多核,那么这个模型就显得有点尴尬n-m,在保证高效的单核cpu利用率的情况下,又可以充分利用多核
2.1 内存方面的比较

因为多个fiber对应一个内核线程,所以整体上来说在达到相同功能的情况下,fiber的内存使用率会更加低;但是可以看到大部分是使用在User Stack上;
使用stackful的协程实现方式的话,可能会面临一些内存相关的技术的问题,因为stackful需要有地方来保存stack的信息,大部分实现是通过heap来存放fiber的stack信息的,那么就需要面对的:
stack size多大的呢?这个很重要,因为如果出现内存访问越界的问题会带来安全性问题,所谓的非定义性行为,所以这块是很重要的; 目前基本方式是:
动态stack大小+一个保护page;大概是一次申请比较大的虚拟空间,在合理范围内可以动态扩展,会设置一个保护区,到了保护区的page就会有两种行为:1. 挂了 2. 分配更加到的内存来存放当天的stack; 这种方式其实和os thread类似,比如内核stack的大小是8K,超过了就会出现错误等,让程序以一定的错误明确行为;第二方面是虽然申请了虚拟空间,但是真正使用的物理空间可能没那么多,所以本质上是虚占用;
如果超过了stack size初始化的大小之后,目前有哪几种方式可以进行扩容呢?
golang在最初的几年使用的非连续的stack空间,类似于一个一个chunk的list的方式;这种好处是不需要内存连续,但是问题是性能比较差,在go1.3之后就弃用了;

golang后期选择使用的方式:reallocate + copy stack的方式的;类似于C++的vector的动态的实现原理,好处是连续,性能上是分配的时候会有影响,但是分配完毕之后性能就比较好的;但是这种方式只能被用于有gc的语言,C++这种是不行的,因为你的pointer指向的是虚拟地址控制,你重新分配之后的pointer是无效的,C++是不会知道你有哪些pointer指向这个位置,所以后来Rust就放弃了用这种方式来实现fiber,转战用
stackless
关于
stackless的stack size是否会遇到问题呢?其实也遇到,但是问题是它不需要单独的维护这个stack的内存空间,因为stackless的stack是依托于运行它的线程stack,所以总的来说就是os thread的限制是多少就是多少; 本身存储空间应该是要小于stackful的
2.2 上下文切换的消耗

fiber的切换肯定是小于os 线程的切换;毕竟只是user-mode的切换,都不设置到内核层面的切换
filber的切换虽然代价比较小,但是依然要高于普通的函数调用或者用
stackless实现的coroutine
2.3 兼容性和扩展性
不得不说,stackful的fiber在这两个方面都着很多的问题;
fiber在n-m模型的时候会比较危险,这边描述的thread_local的问题,当fiber进行切换时候,切换到了其他的线程的时候,访问TLS的地址的时候会出现一些不确定行为,因为tls的地址被缓存了,当又开始访问的时候会导致访问到是之前thread的tls的地址,这样就会出现问题;
所以正常的fiber只能支持1-M的模型; 这样就限制了fiber的整体使用场景;关于stackful的第二个问题是: green,也就是将所有的会导致阻塞的操作都全部重写变成非阻塞的,这其实就需要有一个user-mode调度器的运行时,一方面用于调度另外一方面将block操作从底层替换掉;即使这样,如果用户一不小心调用block的操作依然会导致整个fiber被block住;
尤其是一个程序依赖多种语言的库或者什么的,这个时候并非所有的控制都有runtime去掌握,这个时候就极端容易出现问题,比如依赖的C++底层库使用了tls的功能,那么就可能导致上层的goroutine获得已经被使用的错误的地址什么的,这样也是问题一堆,看到一个帖子说,可以通过[thread local storage of C library from go application]中所说的LockOSThread来保证goroutine不会去其他的线程上被调度;
stackless就好很多,不会有上面的问题,但是如果调用blocking操作也依然会导致阻塞,这个没办法避免的;rust的解决方法是依靠tokio这种库的封装,将常规的interface异步化,如果实在不能异步就创建线程来进行包装;
3. case studies
伴随着研究人员半个世纪的努力和尝试的经验,内核开发者目前的推荐是:不要使用fiber; 不过说来搞笑,目前golang如此流行貌似也打了这些内核人员的脸,不过golang并没有解决上面的问题,尤其是兼容性这块的问题的,但是之所以流行本质上还是fiber的这种写法会更加符合人类的思考模型,并且可以极大的降低程序员的门槛 ,随便写写能写出比较好性能要求的程序;但是很多时候一定要注意只要你不要超过它的runtime的范围,大部分时候golang依然还是很不错的选择,runtime帮你做了很多事情,如果你非要go + c++这种蛋疼的模型来做事情的话,极大概率会碰到上面的问题;
windows、solaris、linux、posix和facebook都有比较多的fiber的经验,最终都选择放弃使用fiber,要不继续简单的模型要不转战stackless;

从这个表可以看出来,也就golang在fiber取得了成功,但是当于其他的语言进行交互的时候,其实本质上代价也很高,我还是那个看法,既然用go,那就在go的runtime里面玩,不要玩花里胡哨的东西,代价很大的;
4. 总结
不推荐使用Fiber; 下面是一个两种实现的优缺点对比:

看下对比就大概知道应该用stackless这种方式,目前rust用了这种方式实现的,这个从各方面来说都应该是比较好的选择;