我在使用golang时遇到了一个并发设置问题,由于golang这两年更新也很快,底层调度器在不断的优化,导致搜索网上资料,有很多已经过时,将资料整理并修正记录一下。

用户线程和内核线程关系

用户空间线程和内核空间线程之间的映射关系有:N:1,1:1和M:N。N:1是指多个(N)用户线程始终在一个内核线程上跑,上下文切换确实很快,但是无法真正的利用多核。1:1是指 一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文切换很慢。M:N是指多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。golang的调度器就是用M:N映射关系实现。

golang中M:N的实现

Go的调度器内部有三个重要的结构:M,P,G。M:代表真正的内核OS线程,真正干活的人。G:代表一个goroutine,它有自己的栈,指令指针和其他信息(正在等待的channel等等),用于调度。P:代表调度的上下文,可以把它看做一个局部的调度器,使go代码在一个线程上跑,它是实现从N:1到N:M映射的关键。

1

图中看,有2个物理线程M,每一个M都拥有一个context(P),每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。

图中虚线连接的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出一个goroutine执行。到底是取出哪个goroutine来执行,是golang的调度器根据各种因素决定的。

ps:在1.3版本以前,G的调度是非抢占式的,只要你的状态共享的代码块不跨越调度点或者IO调度点, 是不需要加锁的, 天然的无锁状态。1.3以后实现了类似抢占式的调度(编译器会在代码中插内容触发调度),上面的天然无锁状态也就不存在了。就算用GOMAXPROCS把最大并行数(P)设置为1, 也无法用无锁的模型玩耍了。

那么如果G中执行了带阻塞的系统调用,调度会有什么样的变化呢?如下图所示:

2

P转而在OS线程M1上运行,这样就不会因为一个阻塞调用,而阻塞了P上所有的G。图中的M1可能是被创建,或者从线程池中取出。当被丢弃的M0-G对完成系统调用变成可执行状态时,它必须尝试取得一个context P来运行goroutine,一般情况下,它会从其他的OS线程那里偷一个P过来,如果没有偷到的话,它就把goroutine放在一个global runqueue里,然后自己就去睡大觉了(回到线程池里)。Contexts们也会周期性的检查global runqueue,否则global runqueue上的goroutine永远无法执行。这也就是为什么即使GOMAXPROCS P被设置成1,Goroutine还是能用到多核处理。

当一个P对象将维护的runqueue里的G全部执行完之后,可以从别的P的runqueue底部拿到一半的G放入自己的runqueue中执行,这也就是为什么叫做Work stealing算法,这也是Goroutine为何高效的一个很大原因。如下图所示:

3

G比系统线程要简单许多,相对于在内核态进行上下文切换,G的切换代价低了很多,调度策略非常简单,毕竟操作系统要为各种复杂的场景提供完整的解决方案,而通常我们应用程序层面解决的问题都相对简单。

非阻塞IO与IO多路复用

现在我们知道协程的创建和上线文切换都非常“轻”,但是在进行带阻塞系统调用时执行体M会被阻塞,这就需要创建新的系统资源,而在高并发的web场景下如果使用阻塞的IO调用,网络IO大概率阻塞较长的时间,导致我们还是要创建大量的系统线程,所以Go需要尽量使用非阻塞的系统调用,虽然Go的标准库提供的是同步阻塞的IO模型,但底层其实是使用内核提供的非阻塞的IO模型。当Goroutine进行IO操作而数据未就绪时,syscall返回error,当前执行的Goroutine被置为阻塞态而M并没有被阻塞,P就可以继续使用当前执行体M继续执行下一个G,这样P就不需要再跑到别的M,从而也就不会去创建新的M。

当然只有非阻塞IO还不够,Go抽象了netpoller对象来进行IO多路复用,在linux下通过epoll来实现IO多路复用。当G由于IO未就绪而被置为阻塞态时,netpoller将对应的文件描述符注册到epoll实例中进行epoll_wait,就绪的文件描述符回调通知给阻塞的G,G更新为就绪状态等待调度继续执行,这种实现使得Golang在进行高并发的网络通信时变得非常强大,相比于php-fpm的多进程模型,Golang Http Server使用很少的线程资源运行非常多的Goroutine,而且尽可能的让每一个线程都忙碌起来,而不是阻塞在IO调用上,提高了CPU的利用率。

最后

Golang依靠协程和底层的IO多路复用模型,让我们可以简单的通过同步编程的方式来解决高并发的IO密集型操作,语言本身工程性也非常强。Golang基本保持着每半年发布一个正式版本的迭代速度。现在的版本已经是1.8,GC的优化也已经非常好了,国内已经有很多公司纷纷采用,相信在未来会越来越好的。

整理资料来源

www.zhihu.com/question/20862617

m.yl1001.com/group_article/3231471449287668.htm