golang的协程
协程是一种轻量级的线程,它可以实现并发执行的并行操作。协程是Go语言中的一个核心特性,它使得程序能够以并发的方式运行,并且非常高效。与传统的线程相比,协程的创建和销毁成本非常低,可以方便地启动大量的协程来执行并行操作。
Golang的协程不同于其他语言中的线程或进程,它们是由Go语言的运行时系统调度的。协程的调度是基于协作式的,即协程自己主动让出CPU的控制权,而不是依赖于操作系统的调度器。
线程池的缺陷
在高并发应用中频繁创建线程会造成不必要的开销, 所以有了线程池。线程池中预先保存一定数量的线程, 而新任务将不再以创建线程的方式去执行, 而是将任务发布到任务队列, 线程池中的线程不断的从任务队列中取出任务并执行, 可以有效的减少线程创建和销毁所带来的开销。下图是一个简单的线程池的案例:
我们把任务队列中的每一个任务称作G, 而G往往代表一个函数。 线程池中的线程worker线程不断的从任务队列中取出任务并执行。 而worker线程的调度则交给操作系统进行调度。
如果worker线程执行的G任务中发生系统调用, 则操作系统会将该线程置为阻塞状态, 也意味着该线程在怠工, 也意味着消费任务队列的worker线程变少了, 也就是说线程池消费任务队列的能力变弱了。如果任务队列中的大部分任务都会进行系统调用, 则会让这种状态恶化, 大部分worker线程进入阻塞状态, 从而任务队列中的任务产生堆积。
解决这个问题的一个思路就是重新审视线程池中线程的数量, 增加线程池中线程数量可以一定程度上提高消费能力,但随着线程数量增多, 由于过多线程争抢CPU, 消费能力会有上限, 甚至出现消费能力下降。 如下图所示:
Goroutine调度器
线程数过多, 意味着操作系统会不断的切换线程, 频繁的上下文切换就成了性能瓶颈。 Go提供一种机制, 可以在线程中自己实现调度, 上下文切换更轻量, 从而达到了线程数少, 而并发数并不少的效果。 而线程中调度的就是Goroutine。
Goroutine主要概念:
- G( Goroutine) : 即Go协程, 每个go关键字都会创建一个协程。
- M( Machine) : 工作线程,在Go中称为Machine。
- P(Processor): 处理器( Go中定义的一个摡念, 不是指CPU) ,包含运行Go代码的必要资源, 也有调度goroutine的能力
M必须拥有P才可以执行G中的代码, P含有一个包含多个G的队列, P可以调度G交由M执行。 其关系如下图所示:
图中M是交给操作系统调度的线程, M持有一个P, P将G调度进M中执行。 P同时还维护着一个包含G的队列( 图中灰色部分) , 可以按照一定的策略将不能的G调度进M中执行。
P的个数在程序启动时决定, 默认情况下等同于CPU的核数, 由于M必须持有一个P才可以运行Go代码, 所以同时运行的M个数, 也即线程数一般等同于CPU的个数, 以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。
Goroutine调度策略
队列轮转
上图中可见每个P维护着一个包含G的队列, 不考虑G进入系统调用或IO操作的情况下, P周期性的将G调度到M中执行,执行一小段时间, 将上下文保存下来, 然后将G放到队列尾部, 然后从队列中重新取出一个G进行调度。
除了每个P维护的G队列以外, 还有一个全局的队列, 每个P会周期性的查看全局队列中是否有G待运行并将其调度到M中执行, 全局队列中G的来源, 主要有从系统调用中恢复的G。 之所以P会周期性的查看全局队列, 也是为了防止全局队列中的G被饿死。
系统调用
上面说到P的个数默认等于CPU核数, 每个M必须持有一个P才可以执行G, 一般情况下M的个数会略大于P的个数, 这多出来的M将会在G产生系统调用时发挥作用。 类似线程池, Go也提供一个M的池子, 需要时从池子中获取, 用完放回池子, 不够用时就再创建一个。
当M运行的某个G产生系统调用时, 如下图所示:
如图所示, 当G0即将进入系统调用时, M0将释放P, 进而某个空闲的M1获取P, 继续执行P队列中剩下的G。 而M0由于陷入系统调用而进被阻塞, M1接替M0的工作, 只要P不空闲, 就可以保证充分利用CPU。
M1的来源有可能是M的缓存池, 也可能是新建的。 当G0系统调用结束后, 跟据M0是否能获取到P, 将会将G0做不同的处理:
- 如果有空闲的P, 则获取一个P, 继续执行G0。
- 如果没有空闲的P, 则将G0放入全局队列, 等待被其他的P调度。 然后M0将进入缓存池睡眠
工作量窃取
多个P中维护的G队列有可能是不均衡的, 比如下图:
竖线左侧中右边的P已经将G全部执行完, 然后去查询全局队列, 全局队列中也没有G, 而另一个M中除了正在运行的G外, 队列中还有3个G待运行。 此时, 空闲的P会将其他P中的G偷取一部分过来, 一般每次偷取一半。 偷取完如右图所示。
抢占式调度
goroutine设计之初为协作式调度,用户负责在各个goroutine之间协作式执行任务。协作式调度意味着希望协程自己会主动让出执行权,用户在加锁,读写通道时会主动让出执行权。
垃圾回收器是需要stop the world的。如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine合作停下来,这会造成较长时间的等待时间。考虑一种很极端的情况,所有的goroutine都停下来了,只有其中一个没有停,那么垃圾回收就会一直等待着没有停的那一个。
抢占式调度可以解决这种问题,在抢占式情况下,如果一个goroutine运行时间过长,它就会被剥夺运行权。
Golang协程的用法
在Go语言中,要创建一个协程,只需在函数调用前加上关键字"go"。下面是一个简单的示例:
go 函数名()
这样就创建了一个新的协程,并在该协程中执行相应的函数。协程会与主线程并发执行,不会阻塞主线程的执行。
协程之间可以通过通道(Channel)进行通信。通道是一种在多个协程之间同步和传递数据的机制,它能够保证并发安全。通过通道,协程可以发送和接收数据,实现协程之间的协作。
package main
import (
"fmt"
"time"
)
func longRunningTask() (res int) {
time.Sleep(time.Second)
for i := 0; i < 10; i++ {
res += i
}
return res
}
func main() {
result := make(chan int)
go func() {
result <- longRunningTask()
}()
fmt.Println("Waiting for result...")
fmt.Println("Result:", <-result)
}
参考文档
参考文档一