基于 ucontext 封装的 Fiber 库

协程优点:

  • 切换速度快
  • 切换灵活
  • 为降低竞争提供另一种思路
  • 实现异步 epoll

​ 协程是轻量级的线程,由于协程切换属于 用户级别的上下文切换,不会陷入内核,因此切换速度比线程更快自然延迟更低。根据陈海波老师的《现代操作系统 原理与实现》一书的105页中: 经过测试,在使用AArch64架构的华为鲲鹏916服务器上,如果使用内核态线程,那么生产者线程切换到消费者线程需要话费约1900ns;而如果使用纤程,该切换时间降低到约500ns。可以看到差距的巨大。

​ 由于协程切换是在用户级别进行的,因此完全由用户自主操控,想要在哪里切换就在哪里切换,十分灵活。

​ 可以使用多进程(单线程)+协程尽可能的避免race condition,降低各种竞争,从而降低延迟。例如一个线程就可以实现生产者消费者模型,并且不需要加锁。

​ 怎么实现异步 epoll?

Fiber 库特性:

  • 非对称协程
  • 基于 POSIX ucontext.h

​ 我设计的 Fiber 库,每个线程都有一个主协程,并能创建多个子协程;子协程只能把 CPU 控制权交还给主协程,而不能交给其他子协程,故而为非对称协程。

​ 该 Fiber 库基于 ucontext.h 实现


Fiber 库应该具有的功能:

  • 隐藏主协程创建的接口,暴露子协程创建接口
  • 从主协程切换(swapIn)到子协程
  • 从子协程切换(swapOut)到主协程
  • 子协程执行代码的入口函数
  • 协程清理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Fiber {
public:
...
// 暴露子协程创建接口
Fiber(const Fiber::Callback& cb, size_t stackSize);
// 获取当前协程
static ptr GetThis();
// 设置当前协程
static void SetThis(ptr);

// 切换到该协程
void swapIn();
// 切换到主协程
void swapOut();
...
private:
// 隐藏主协程创建的接口
Fiber();
// 所有协程入口函数
// 如同所有进程的入口 main 一样
static void MainFunc();
private:
...
ucontext_t _ctx;
...
};

这里的重点在于为每一个线程创建一个 thread_local 用于存储 主协程指针 t_threadFiber 和 当前协程指针 t_curFiber,这样的话就能根据 这两个变量进行 主协程与子协程 间的切换了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static thread_local Fiber::ptr t_curFiber = nullptr; // 当前协程
static thread_local Fiber::ptr t_threadFiber = nullptr; // 主协程,只有当线程退出时,才会将主协程析构
// 切换到该协程
void Fiber::swapIn() {
SetThis(shared_from_this());
// 主协程即将切换出去
// 把上下文保存到 主协程的 ctx 中,并恢复该协程的上下文
::swapcontext(&t_threadFiber->_ctx, &_ctx);
}
// 切换到主协程
void Fiber::swapOut() {
SetThis(t_threadFiber);
// 即将从子协程切换出去
// 把上下文保存到 该协程的 ctx 中,并恢复主协程的上下文
::swapcontext(&_ctx, &t_threadFiber->_ctx);
}

性能测试

鲲鹏通用计算增强型 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 了,全权交给调度器去做,仅仅支持用户注册一个协程或想要执行的实例。