这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战
一些收获
- pstree -p pid:查看进程中的线程
- Go 语言中的引用类数据类型有 func, interface, slice, map, chan, *Type
- Go语言函数调用都是值传递,即使是引用类型,也是传递的值,参考:又吵起来了,Go 是传值还是传引用?
Goroutine
虚拟机
go不像java,运行时是编译到程序中的,所以一个hello world程序有2MB多大,一个使用Go写的SDK有100多MB(依赖的包都是源码编译进程序的)。
Goroutine和线程的区别
内存占用,创建一个 goroutine 的栈内存消耗为 2 KB(Linux AMD64 Go v1.4后,POSIX Thread为1-8MB),运行过程中,如果栈空间不够用,会自动进行扩容。创建/销毁,线程创建和销毀都会有巨大的消耗,是内核级的交互(trap)。调度切换:抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine 的切换约为 200 ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此,goroutines 切换成本比 threads 要小得多复杂性:线程的创建和退出复杂,多个thread间通讯复杂(share memory)
M:N调度模型
Go 创建 M 个线程(CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines 的个数应该是远大于线程的个数的(phread 是内核线程?)。同一个时刻,一个线程只能跑一个 goroutine。当 goroutine 发生阻塞 (chan 阻塞的情况,syscall系统调用goroutine和M一起阻塞 ) 时,Go 会把当前的 M调度走,让其他 goroutine 来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU 忙。
Per Connection Per Routine
一个协程一个连接,这是区别于C++/Java等使用epoll等I/O复用实现高性能的地方之一。
我们通过看http包的server实现,就能发现,一个客户的请求会启动一个routine来处理。
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := srv.setupHTTP2_Serve(); err != nil {
return err
}
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
defer srv.trackListener(&l, false)
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return
// 启动一个go routine
go c.serve(connCtx)
}
}
因为routine栈很小,只有2KB,再结合GMP调度模型,同时活跃的永远只有CPU个数的内核线程,不会爆掉。另外routine上下午切换代价很小,相比epoll的各种模型(单reactor单线程,单reactor多线程,主从reactor多线程)来解决吞吐问题而带来的复杂性,我认为在go里面,per connection per routine是一种新的解决方案。
PS:我猜测gRPC中也是(目前还没来得及看源码),一个调用就是一个go routine。
GMP调度模型
GMP概念
- G:
Goroutine 的缩写,每次 go func() 都代表一个 G,无限制,但受内存影响。使用 struct runtime.g,包含了当前 goroutine 的状态、堆栈、上下文。 - M:
工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M 是有线程栈的。M 的默认数量限制是 10000(来源),可以通过debug.SetMaxThreads修改。 - P:Processor,
是一个抽象的概念,并不是真正的物理 CPU,P 表示执行 Go 代码所需的资源,可以通过 GOMAXPROCS 进行修改。当 M 执行 Go 代码时,会先关联 P。当 M 空闲或者处在系统调用时,就需要 P。且在 Go1.5 之后GOMAXPROCS 被默认设置可用的核数,而之前则默认为1。更多的可以看这几篇文章:再见 Go 面试官:GMP 模型,为什么要有 P?、Go 群友提问:Goroutine 数量控制在多少合适,会影响 GC 和调度?、work-stealing scheduler
PS:通过理解GM模型(有点类似生产消费模型)的问题以理解GMP,主要看:《Scalable Go Scheduler Design Doc》会比较清晰。
调度模型演变一:GM模型
GM模型(Go1.2之前的模型),这种模型限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。
概念
每个 goroutine 对应于 runtime 中的一个抽象结构:G,而 thread 作为“物理 CPU”的存在而被抽象为一个结构:M(machine)。当 goroutine 调用了一个阻塞的系统调用,运行这个 goroutine 的线程就会被阻塞,这时至少应该再创建一个线程来运行别的没有阻塞的 goroutine。线程这里可以创建不止一个,可以按需不断地创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS中。
问题
要知道GM调度模型的问题,必看:《Scalable Go Scheduler Design Doc》
单一全局互斥锁(Sched.Lock)和集中状态存储。导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁。Goroutine 传递问题。M 经常在 M 之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)Per-M 持有内存缓存 (M.mcache)。每个 M 持有 mcache 和 stack alloc,然而只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达2mb),当 M 在处于 syscall 时并不需要。运行 Go 代码和阻塞在 syscall 的 M 的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差。G 当前在 M运 行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。严重的线程阻塞/解锁。在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行。by Dmitry Vyukov “Scalable Go Scheduler Design Doc”
调度模型演变二:GMP模型
为了解决GM全局queue带来的性能问题,引入了P的概念,并在 P 之上实现“可窃取调度”(Work-stealing)
引入P带来的改变
- 每个 P 有自己的本地队列,大幅度的减轻了对全局队列的直接依赖,所带来的效果就是锁竞争的减少。而 GM 模型的性能开销大头就是锁竞争。
- 每个 P 相对的平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行,减少空转,提高了资源利用率。
为什么要有 P?
如果是想实现本地队列、Work Stealing 算法,那为什么不直接在 M 上加呢,M 也照样可以实现类似的功能),结合 M(系统线程) 的定位来看,若这么做,有以下问题:
- 一般来讲,M 的数量都会多于 P。像在 Go 中,
M 的数量最大限制是 10000,P 的默认数量的 CPU 核数。另外由于 M 的属性,也就是如果存在系统阻塞调用,阻塞了 M,又不够用的情况下,M 会不断增加。 - M 不断增加的话,如果本地队列挂载在 M 上,那就意味着本地队列也会随之增加。这显然是不合理的,因为本地队列的管理会变得复杂,且 Work Stealing 性能会大幅度下降。
- M 被系统调用阻塞后,我们是期望把他既有未执行的任务分配给其他继续运行的,而不是一阻塞就导致全部停止。
总结GMP调度模型
调度流程
-
- 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine,我们称它为 G。
-
- 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。
-
- 唤醒或创建 M 以便执行 G。
-
- 不断地进行事件循环
-
- 寻找在可用状态下的 G 进行执行任务
-
- 清除后,重新进入事件循环
PS:在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个。并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。这可以理解为调度资源的共享和再平衡。
工作窃取
我们可以看到图上有 steal 行为,这是用来做什么的呢,我们都知道当你创建新的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。其实当 P 执行 G 完毕后,它也会 “干活”,它会将其从本地队列中弹出 G,同时会检查当前本地队列是否为空,如果为空会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。
在这个例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的处理器 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。
至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转