go 协程切换的性能分析

Pasted image 20240320201037
上一篇[[004-blog/计算机中的时间 线程上下文切换会用掉你多少CPU?]]文章中,中我们用实验的方式验证了Linux进程和线程的上下文切换开销,大约是3-5us之间。这个开销确实不算大,但是海量互联网服务端和一般的计算机程序相比,特点是:

  • 高并发:每秒钟需要处理成千上万的用户请求。
  • 周期短:每个用户处理耗时越短越好,经常是ms级别的
  • 高网络IO:经常需要从其它机器上进行网络IO、如Redis、Mysql等等
  • 低计算:一般CPU密集型的计算操作并不多
    即使3-5us的开销,如果上下文切换量特别大的话,也仍然会显得是有那么一些性能低下。例如之前的Web Server之Apache,就是这种模型下的软件产品。(其实当时Linux操作系统在设计的时候,目标是一个通用的操作系统,并不是专门针对服务端高并发来设计的)

为了避免频繁的上下文切换,还有一种异步非阻塞的开发模型。那就是用一个进程或线程去接收一大堆用户的请求,然后通过IO多路复用的方式来提高性能(进程或线程不阻塞,省去了上下文切换的开销)。Nginx和Node Js就是这种模型的典型代表产品。平心而论,从程序运行效率上来,这种模型最为机器友好,运行效率是最高的(比下面提到的协程开发模型要好)。所以Nginx已经取代了Apache成为了Web Server里的首选。但是这种编程模型的问题在于开发不友好,说白了就是过于机器化,离进程概念被抽象出来的初衷背道而驰。人类正常的线性思维被打乱,应用层开发们被逼得以非人类的思维去编写代码,代码调试也变得异常困难。

于是就有一些聪明的脑袋们继续在应用层又动起了主意,设计出了不需要进程/线程上下文切换的“线程”,协程。用协程去处理高并发的应用场景,既能够符合进程涉及的初衷,让开发者们用人类正常的线性的思维去处理自己的业务,也同样能够省去昂贵的进程/线程上下文切换的开销。因此可以说,协程就是Linux处理海量请求应用场景里的进程模型的一个很好的的补丁。

背景介绍完了,那么我想说的是,毕竟协程的封装虽然轻量,但是毕竟还是需要引入了一些额外的代价的。那么我们来看看这些额外的代价具体多小吧。

协程开销测试

本文基于 go 1.22.1

协程切换CPU开销

测试过程是不断在协程之间让出CPU。核心代码如下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main  

import (
"fmt"
"runtime" "time")

func cal() {
for i := 0; i < 1000000; i++ {
runtime.Gosched()
}
}

func main() {
runtime.GOMAXPROCS(1)
currentTime := time.Now()
fmt.Println(currentTime)
go cal()
for i := 0; i < 1000000; i++ {
runtime.Gosched()
}

fmt.Println(time.Now().Sub(currentTime) / 2000000)
}

编译运行

1
2
3
4
5
➜  trace git:(main) ✗ go run main.go              
2024-03-20 19:52:24.772579 +0800 CST m=+0.000114834
54ns
➜ trace git:(main) ✗

平均每次协程切换的开销是 54ns 相对于前面文章测得的进程切换开销大约3.5us,大约是其1/70。比系统调用的造成的开销还要低。

协程内存开销

在空间上,协程初始化创建的时候为其分配的栈有2KB。而线程栈要比这个数字大的多,可以通过ulimit 命令查看,一般都在几兆,我的mac 上是8m 。如果对每个用户创建一个协程去处理,100万并发用户请求只需要2G内存就够了,而如果用线程模型则需要8T。

1
2
3
4
5
6
7
8
9
10
➜  trace git:(main) ✗ ulimit -a   
-t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
-d: data seg size (kbytes) unlimited
-s: stack size (kbytes) 8176
-c: core file size (blocks) 0
-v: address space (kbytes) unlimited
-l: locked-in-memory size (kbytes) unlimited
-u: processes 2666
-n: file descriptors 12544

结论

协程由于是在用户态来完成上下文切换的,所以切换耗时只有区区50ns多一些,比进程切换要高70倍。单个协程需要的栈内存也足够小,只需要2KB。所以,近几年来协程大火,在互联网后端的高并发场景里大放光彩。

无论是空间还是时间性能都比进程(线程)好这么多,那么Linus为啥不把它在操作系统里实现?操作系统为了实现实时性更好的目的,对一些优先级比较高的进程是会抢占其它进程的CPU的。而协程无法实现这一点,还得依赖于挡前使用CPU的协程主动释放,于操作系统的实现目的不相吻合。所以协程的高效是以牺牲可抢占性为代价的。
协程最终也是附着在操作系统线程上执行的。
我们需要考虑的一个问题是。
难道用了协程,线程就不切换了吗?线程的切换频率,基本取决于线程的数量,使用协程,需要指定每个线程的任务,同样的任务量,协程需要的线程数量应该始终高于自动分配的线程池。
因而:
使用线程 = 线程切换开销(小)
使用协程 = 线程切换开销(大)+ 协程切换开销
然后CPU开销:
线程的指令周期 = 中断检测 + 指令执行(包括取指、转换和执行)
协程的指令周期 = 中断检测 + 指令执行 + 中断检测 + 协程信号检测
所以我有以下结论:
性能上,io多路复用 + 线程池是完全碾压协程的;但是在方便程度上,还是协程好用

由于go的协程调用起来太方便了,所以一些go的程序员就很随意使用go 这个关键字。要知道go这条指令在切换到协程之前,得先把协程创建出来。而一次创建加上调度开销就涨到400ns,差不多相当于一次系统调用的耗时了。虽然协程很高效,但是也不要乱用,否则Rob Pike花大精力优化出来的性能,被你随意一go又给葬送掉了。