协程切换会引起什么问题

在微信的业务中协程被大规模应用,在使用的过程中遇到了一些和业务场景相关的问题。本文自下而上从 CPU 中断到 Linux 内核态、再到用户态进程、最后到协程,由这种视角去分析协程切换时引起问题的原因,希望可以更好地理解协程。

计算机里有两类大的资源:CPU 资源和 IO 资源。计算型的任务主要消耗 CPU 资源,比如对字符串进行 base64 编码,我之前遇到 3W/min 的线上接口加了 base64 编码把 CPU 跑满;输入输出类的系统调用主要消耗 IO 资源,部分 IO 和硬件中断相关。CPU 芯片引脚上接入了很多控制芯片,比如中断控制器芯片 8259A。当键盘打字,中断芯片触发 CPU 上的硬件中断,CPU 被调度来处理键盘输入。

计算机上有这么多的任务需要消耗 CPU 和 IO 资源,操作系统怎么去优化资源利用呢?

对于计算密集型任务,可以用多进程/线程将任务分发到不同的 CPU 核上并行处理来提高效率;对于 IO 密集型任务,操作系统已经有中断回调机制,对于正在到来的 IO 事件进行处理。


中断机制

外围硬件设备连接到中断控制器芯片上,产生的电信号经过中断控制器芯片编码后写到 CPU 的控制寄存器中。那 CPU 怎么知道寄存器发生了改变?

当执行了一条指令后,cs 和 eip 这对寄存器包含了下一条将要执行的指令的逻辑地址。在处理那条指令之前,控制单元会检查在运行前一条指令时是否已经发生了一个中断或异常,如果发生了一个中断或异常,那么控制单元执行下列操作 ——「深入理解 linux 内核」

也就是 CPU 在每个指令周期都会去查看中断寄存器,这是硬件级别的轮询。用这种轮询来实现了中断控制,这种中断回调存在于内核态。


操作系统之上再造系统

业务的进程中,既需要使用 CPU,又需要使用大量 IO。怎么去优化这种场景?

在进程中再开线程?在线程中出现 IO 调用时(主要讨论同步 IO,Linux 中还没有完善的异步 IO),让线程睡眠,让其他的线程去处理任务。假设现在 8 核 CPU 上有 4 个进程,每个进程开 10 个线程,理论上也只能同步并行跑 8 个线程,其他线程都是假性的并行运行。如果这时出现大量的阻塞 IO 调用,所有的线程都会进入睡眠,等待同步 IO 的数据返回;如果是非阻塞 IO 调用(O_NONBLOCK),使用 poll/epoll 来轮询事件到来,虽然不会进入睡眠,但线程不断从内核态到用户态的上下文切换效率较低。

有什么办法可以让用户态的进程/线程中拥有一种异步回调的能力,在发生 IO 调用时切换到其他进程/线程,又能保证不睡眠进入内核态。在 IO 事件到来时,再切换回这个进程/线程,整个过程都是在用户态完成。这相当于在操作系统上重新造了一个操作系统来进行进程/线程调度,梦境之上再造梦境。


协程是什么

想象一个人进入到进程中,现在有两个子函数:一个函数负责倒开水,一个函数负责晾衣服。当人看到开水烧开了,调用函数去倒开水;当看到衣服洗好了,调用函数晾衣服。这个人就是程序员本人,他来负责子程序的调度。子程序就是协程,这个人就是程序员本人,可以看作是人肉协程调度器。

ucoroutine 是一个协程调度的示例,使用 Linux getcontext、makecontext、swapcontext 函数簇来实现协程切换。

那么人肉调度器可以由什么来自动化替代呢?这个调度器需要实现这些功能

  • hook 掉同步 IO 调用函数

比如对于网络应用场景,hook 掉 socket 簇 read、write、connect、send、recv 函数,让这些函数调用时发生用户态协程切换,同时记录下相应的上下文信息

  • 加入异步回调机制,在 IO 事件到来时回调到事件对应的处理协程

这个东西是不是可以用 epoll 实现?在 IO 调用时把事件注入到 epoll 事件池,同时发生协程切换,等 IO 事件到来时由主协程用 epoll 去回调切换到对应的协程。

void ucoroutine_body(schedule_t *ps)
{
	int id = ps->running_coroutine;

	if (id != -1) {
		ucoroutine_t *t = &(ps->coroutines[id]);
		t->func(t->arg);
		// 模拟函数阻塞,进行调度。在这里将事件注入到了 epoll 中即可
		puts("before yield");
		ucoroutine_yield(*ps);
		puts("after yield");
		t->state = IDLE;
		ps->running_coroutine = -1;
	}
}

这样就实现了用户态的操作系统。

常见的协程库实现有三种方式:

  • 使用 glibc 的 ucontext 库
  • 使用汇编代码切换上下文,微信 libco 使用这种方式
  • 利用 C 语言的 setjmp 和 longjmp

这些方式的原理都是通过保存和恢复寄存器的状态,来进行各协程上下文的保存和切换。


切换引起的问题

在使用 libco 时,已 hook 掉所有的 socket read/write/send/recv 系统调用,在发生这些系统调用时,libco 会切换协程,将当前的上下文保存,切换到其他协程使用 CPU。所以在业务代码中发起 RPC 调用会触发协程切换,这种切换会引起一些问题。

问题 1:”幻读“

最近在进行一个项目的改造,有这样一段代码

m_config = config; // 进程变量
m_config->cmdid() = 100; // 这个值根据请求不同会变化

id_a = m_config->cmdid()
CallRPC()
id_b = m_config->cmdid()

这个代码段有多个协程共用,最后发现 id_a != id_b,出现了”幻读“问题。

这是因为 CallRPC 时会发生协程切换,等再次切换回来,cmdid 已经被其他协程修改了。

使用协程原则:全局变量和静态变量为所有协程共享,不要使用全局变量,静态变量。

问题 2:死锁

NoticeConfig::NoticeConfig()
{
	CallRPC();
}

NoticeConfig* NoticeConfig::GetDefault()
{
	static NoticeConfig conf; // 卡在这里
	return &conf;
}

func()
{
	auto conf = NoticeConfig::GetDefault();
}

协程 A 中 func 调用 GetDefault 获取单例,GetDefault 中初始化 conf 时,构造函数调用了 RPC 发生了协程切换,CPU 让给协程 B。而对于 static 变量,gcc 在构造时会加锁,所以 A 获得了这把锁。当协程 B 调用 func 时,conf 还没初始化完成,会尝试再次加锁。所以其他协程都会卡死在这里。

这种场景出现需要满足两个条件

  • 构造函数中加了 RPC 操作
  • 用 static 来实现单例

总结

从上面的分析可以知道协程的使用场景

  • 频繁发生 RPC 调用,可以最大程度使用协程的能力。相比线程节省系统资源
  • 非计算密集型任务,CPU 计算不复杂。CPU 计算会占用大量时间,会让协程占用 CPU 时间过长,影响其他协程正常运行。同理 sleep 也不能使用,会让进程睡眠而无法进行切换,使用 poll(NULL, 0, sec) 代替。

另外操作系统中存在这三种层次的回调

1、硬件设备往内核态的回调通过硬件中断实现

2、内核态往用户态的回调通过睡眠队列唤醒 + 进程切换实现

3、用户态中进程/线程往协程的回调通过协程调度器(epoll)实现


参考

微信 libco

一次系统调用开销到底有多大?

GCC 优化编译指南



Previous     Next
/
Published under (CC) BY-NC-SA in categories tagged with