在 Linux 中进程和线程的上下文切换开销,大约是 3-5us 之间。但是在高并发场景下,每秒钟数千万请求,即使 3-5us 的开销,如果上下文切换量特别大的话,仍然会显得性能低下。为避免频繁的上下文切换,有一种异步非阻塞的开发模型,就是用一个进程或线程去接受一大堆用户的请求,然后通过 IO 多路复用的方式来提高性能(进程或线程不阻塞,省去了上下文切换的开销)。Nginx 和 Node.js 就是这种模型的典型代表产品。但是这种编程模型的问题在于对开发不友好,于是在这个基础上就设计出了不需要进程/线程上下文切换的“线程”,协程。用协程去处理高并发的应用场景,既能够让开发者使用正常思维处理业务,也能够省去昂贵的进程/线程上下文切换开销。因此,协程是 Linux 处理海量请求应用场景里的进程模型的一个很好的补丁。
我们知道协程的封装虽然轻量,但是还是需要引入一些额外的开销的,下面我们来测试一下这个开销具体是多少吧。
协程切换 CPU 开销
测试过程是不断在协程直接让出 CPU。核心代码如下:
func cal() {
for i := 0; i < 1000000; i++ {
// 让当前的 goroutine 让出 CPU
// 好让其他的 goroutine 获得执行机会
// 同时当前的 goroutine 也会在未来某个时间点继续执行
runtime.Gosched()
}
}
func main() {
// 设置逻辑 CPU 数量
// 如果在线程模型中,开发者需要维护线程池中线程与 CPU 核心数的对应关系
// 在 Go 中只需要用 runtime.GOMAXPROCS() 就行
// Go 运行时实现了一个小型任务调度器
// 可以高效地将 CPU 资源分配给每一个任务
// 另外还可以使用 runtime.NumCPU() 查询逻辑 CPU 核心数
runtime.GOMAXPROCS(1)
// time.Now() 获取当前时间
// 2022-02-03 11:49:55.993545 +0800 CST m=+0.000074543
// time.Now().Unix() 获取单位为秒的时间戳
// time.Now().UnixNano() 获取单位为纳秒的时间戳
// 如果想要格式化时间,可以用
// time.Now().Format("2006-01-02 15:04:05")
// 2006-01-02 15:04:05 固定写法,据说是 Golang 的诞生时间
currentTime := time.Now().UnixNano()
go cal()
cal()
currentTime = time.Now().UnixNano() - currentTime
fmt.Println("===", currentTime)
}
平均每次协程切换的开销是 120ns 左右,相对于进程切换的开销大约 3.5us,大约是其的三十分之一,比系统调用造成的开销还要低。
协程内存开销
在空间上,协程初始化创建的时候为其分配的栈有 2KB,而线程栈要比这更数字大的多。可以使用 ulimit -a
命令查看,一般都在几兆,例如本人的机器是 8M:
~ ulimit -a
-t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
-d: data seg size (kbytes) unlimited
-s: stack size (kbytes) 8176
如果对每个用户创建一个协程去处理,100 万并发请求只需要 2G 内存就够了,而如果用线程模型则需要 10T。