本文已参与「新人创作礼」活动,一起开启掘金创作之路。
1 前言
最近在用Golang刷leecode,2022年2月24日的每日一题1706. 球会落何处 - 力扣(LeetCode) (leetcode-cn.com)非常适合用多线程的思维去解答。正好之前补了一下进程、线程、协程的相关知识,所以打算以这个题目为例并结合其他实例谈谈我对进程、线程和协程的认知。示例的完整代码见Github。
本文非教科书式的总结,而是通过实例展现自己的思考,个人觉得能更好地帮助理解协程这一概念
2 相关概念
- 进程:操作系统分配资源的基本单位
- 线程:操作系统调度的基本单位
- 协程:又称“轻量级线程”,协程之间的切换不需要陷入内核
2.1 协程的本质
协程虽然是最近几年,随着Kotlin、Golang等语言才慢慢火起来的编程模式,但是出现时间其实是早于线程的。如果有小伙伴上过操作系统课的,应该对“用户级线程”有印象,其指的是完全由用户控制的线程,操作系统根本不知道它的存在,是不是和协程有一点像?确实协程本质就是用户级的线程。
2.2 为什么需要协程
简单来说就是线程的切换需要操作系统的介入、需要上下文的切换、需要从用户态切换到内核态,这些操作其实也要消耗时间。当线程数量过多,且又需要频繁切换时,就会有许多时间浪费在线程的切换上。而上面提到协程完全由用户控制,那么其切换就是完全在用户态进行的,可以省掉这部分时间。
2.3 IO密集型场景
假设现在有一个任务需要我们处理一段数据将其写入到远程数据库,数据库的写入视作一个IO请求。这就是一个典型的IO密集型场景,程序要不停的发起IO操作,IO操作相对于计算操作来说非常耗时。通常一个线程/协程遇到IO操做时不会占着CPU,会将控制权让给另外的线程/协程。下图展示了在遇到IO密集型场景时多线程、多协程的差异,线程之间的切换消耗了一些时间。
2.4 计算密集型场景
那么上面的例子是否就说明协程一定就比线程效率高呢?考虑一下计算密集型的场景,假设程序的所有计算任务需要1000个CPU时间片。此时系统自身有50个线程,程序开启50线程,那么在完全公平的抢占式调度下程序就有50%的概率抢到CPU时间片。若程序只开启了10个线程,每个线程开了50个协程,虽然总的有500协程但是只有10个线程去抢占CPU时间片,抢到的概率只有1/6。后面的例子我会验证一下,在计算密集型场景下只增加协程不增加线程对程序运行时间的影响。
3 模拟计算密集型场景
3.1 LeetCode题目大意
题目大意是模拟小球的滚动,计算这个一系列的小球会不会落到网格的底部。每个网格内有1个挡板,为1表示挡板方向向右,为-1表示挡板方向向左。小球若能落到底部则输出它从第几列落出来,若不能则输出-1。最终形成一个int数组,数组每个元素记录了小球落点的坐标。如[1,-1,-1,2]表示第1个小球从第1列落下来,第2、3个小球无法落下来,第4个小球从第2列落下来。
3.2 解答方法
本题基本只有一种解答方法,就是“模拟”,对每个小球都按照题目地规则模拟移动,看最终能否落出网格。细心一点地同学可能已经看出来了,每个小球模拟移动时都只改变自身地状态,对于共享的信息,如网格二维数组里的方向数据只有读取操作。所以这是一道非常适合用多线程来解答的题目,我自己的Golang代码如下:
func findBall(grid [][]int) []int {
var wg sync.WaitGroup
lr := len(grid) // 行数
lc := len(grid[0]) // 列数
res := make([]int, lc) // res记录所有小球的位置
for index, _ := range grid[0] {
wg.Add(1)
// 对每个小球开启一个Goroutine
go func(k int) {
defer wg.Done()
i := 0
j := k
// 模拟每个小球移动
for i < lr {
dir := grid[i][j]
j += dir
// 碰壁或左右挡板方向不同就设为-1
if j < 0 || j >= lc || grid[i][j] != dir {
j = -1
break
}
i++
}
res[k] = j
}(index)
}
wg.Wait()
return res
}
3.3 测试协程效率
编写以下测试文件,给findBall函数传入一个20000X20000的超大grid,计算20000个小球的落下位置。顺序执行和并发执行的代码各一份,运行go test命令记录两种方法的运行时间,完整代码见Github。
// 测试代码
func Benchmark_Nomal(b *testing.B) {
N := 20000
grid := make([][]int, N)
for i := 0; i < N; i++ {
grid[i] = make([]int, N)
tmp := 1
if i%2 == 0 {
tmp = -1
}
for j := 0; j < N; j++ {
grid[i][j] = tmp
}
}
b.StartTimer()
findBall(grid) // 函数内部顺序执行和并发执行各测试一次
}
首先不限制并发使用的核心数,得到如下运行结果,可以看到,在开启多个协程的情况下运行时间由42.5s缩短到了5.7s
之后使用runtime.GOMAXPROCS(1)限制并发时使用的核心数为1,此时就相当于单线程对应多个协程的情况,再次运行发现多个协程并没有提高多少运行效率,因为始终只有1个线程去抢占CPU时间片。(按照理论来说设置核心数为1后,多协程和顺序执行的运行时间应该一样,这里的几秒误差不知到怎么来的,有知道的小伙伴可以在评论区交流一下)
4 模拟IO密集型场景
IO操做通常比较耗时,因此一般是阻塞的方式运行,这里使用sleep来模拟IO操做,进入函数后就sleep一秒然后退出,sleep前后有少量运算操做贴近真实使用场景。测试入口里调用模拟函数50次。
// 顺序执行测试代码
func Benchmark_single(b *testing.B) {
N := 50
b.StartTimer()
for i := 0; i < N; i++ {
IO()
}
}
func IO() {
// 在sleep前后做少量运算
for i := 0; i < 10; {
i++
}
time.Sleep(1 * time.Second)
for i := 0; i < 10; {
i++
}
}
// 并发执行测试代码
var wg sync.WaitGroup
func Benchmark_muti(b *testing.B) {
// runtime.GOMAXPROCS(1)
N := 50
b.StartTimer()
for i := 0; i < N; i++ {
wg.Add(1)
go IoWithGoroutine()
}
wg.Wait()
}
func IoWithGoroutine() {
defer wg.Done()
// 在sleep前后做少量运算
for i := 0; i < 10; {
i++
}
time.Sleep(1 * time.Second)
for i := 0; i < 10; {
i++
}
}
按照之前的理论,若是顺序执行的则程序运行时间应该在50s左右,使用协程并发(无论核心是1还是n)程序的运行时间应该在1s左右。首先不限制并发核心数,得到如下测试结果,可以看到顺序执行运行时间约为50s、多协程并发执行时间约1s,符合理论推断。
之后
runtime.GOMAXPROCS(1)限制并发时使用的核心数为1,再次运行测试得到如下结果,可以看到并发执行时间还是1s左右,证明在不用抢占CPU时间片的IO密集型场景下,非常适合使用协程。
5 总结
本文总结了自己对线程和协程的思考,用两个简单实例展示了计算密集型、IO密集型场景下多线程对协程的影响。总的来说协程并不一定比多线程效率高,要视具体场景而定:在IO密集型场景下使用协程优势更明显,因为可以节约线程切换的时间;在计算密集型场景下,应该开更多的线程去抢占CPU时间片来做运算。实例的完整源码见我的Github。