Fiber 库
基于 ucontext 封装的 Fiber 库
协程优点:
- 切换速度快
- 切换灵活
- 为降低竞争提供另一种思路
- 实现异步 epoll
协程是轻量级的线程,由于协程切换属于 用户级别的上下文切换,不会陷入内核,因此切换速度比线程更快自然延迟更低。根据陈海波老师的《现代操作系统 原理与实现》一书的105页中: 经过测试,在使用AArch64架构的华为鲲鹏916服务器上,如果使用内核态线程,那么生产者线程切换到消费者线程需要话费约1900ns;而如果使用纤程,该切换时间降低到约500ns。可以看到差距的巨大。
由于协程切换是在用户级别进行的,因此完全由用户自主操控,想要在哪里切换就在哪里切换,十分灵活。
可以使用多进程(单线程)+协程尽可能的避免race condition,降低各种竞争,从而降低延迟。例如一个线程就可以实现生产者消费者模型,并且不需要加锁。
怎么实现异步 epoll?
Fiber 库特性:
- 非对称协程
- 基于 POSIX ucontext.h
我设计的 Fiber 库,每个线程都有一个主协程,并能创建多个子协程;子协程只能把 CPU 控制权交还给主协程,而不能交给其他子协程,故而为非对称协程。
该 Fiber 库基于 ucontext.h 实现
Fiber 库应该具有的功能:
- 隐藏主协程创建的接口,暴露子协程创建接口
- 从主协程切换(swapIn)到子协程
- 从子协程切换(swapOut)到主协程
- 子协程执行代码的入口函数
- 协程清理
1 | class Fiber { |
这里的重点在于为每一个线程创建一个 thread_local 用于存储 主协程指针 t_threadFiber
和 当前协程指针 t_curFiber
,这样的话就能根据 这两个变量进行 主协程与子协程 间的切换了。
1 | static thread_local Fiber::ptr t_curFiber = nullptr; // 当前协程 |
性能测试
鲲鹏通用计算增强型 2G、1CPU 、1Core:
- 协程:切换 100w 次 需要 814ms
- 线程:通过生产者消费者队列(队列长度为 1)进行测试(当然有加锁,解锁,条件变量等额外的操作)。切换 100w 次 需要 3300ms(3微秒/切换)
Inter(R) Core(TM) i7-9700 CPU @ 3.00GHz ,7 Core、4G虚拟机:
- 协程:切换 100w 次 需要 430ms
- 线程:通过生产者消费者队列(队列长度为 1)进行测试(当然有加锁,解锁,条件变量等额外的操作)。切换 100w 次 需要 32000ms(32微秒/切换)
分析:
协程比线程快这是不言而喻的,但是线程上表现出来的数值就很奇怪了,在 1 Core 的鲲鹏虚拟机上 切换一次线程费时 大约 3微秒;但在更快的 i7-9700 CPU 虚拟机上却要 32微秒;这是为什么呢?
其实这两台实验设备上最大的差别就是一个是单核一个是多核。多个核共享一个变量,并把变量保存在 cacheline 中,当某个核要对该变量进行读写操作时,就要保证能看到其他核的 cacheline 中关于该变量的最新的值。这就存在 cacheline 同步的问题,只能等待 CPU 完成一致性同步之后才能继续用户操作。这就导致速度变得很慢。
至理名言:要提高性能,就要避免让CPU频繁同步cacheline。这不单和原子指令本身的性能有关,还会影响到程序的整体性能。最有效的解决方法很直白:尽量避免共享。 —–bRPC
一个相关的编程陷阱是false sharing:对那些不怎么被修改甚至只读变量的访问,由于同一个cacheline中的其他变量被频繁修改,而不得不经常等待cacheline同步而显著变慢了。多线程中的变量尽量按访问规律排列,频繁被其他线程修改的变量要放在独立的cacheline中。要让一个变量或结构体按cacheline对齐
存在的问题:
如果用户调用 swapIn 和 swapOut 的顺序不对,可能会导致 Fiber 对象无法正常释放资源,导致资源泄露。因此要设计一个自动回收永远不会再用到的 Fiber 对象的类。
当然这一点可以通过再设计一个协程调度器类来解决,把所有的 Fiber 实现都隐藏起来,也就是说用户不能自己去 swapIn、swapOut 了,全权交给调度器去做,仅仅支持用户注册一个协程或想要执行的实例。