前天去面试,被问到golang是如何实现高并发的,之前在 GO并发编程实战 这本书看到过介绍,但是没有引起重视。
传统的并发形式:多线程共享内存,这也是Java、C#或者C++等语言中的多线程开发的常规方法,其实golang语言也支持这种传统模式,另外一种是Go语言特有的,也是Go语言推荐的:CSP(communicating sequential processes)并发模型。不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。
“不要以共享内存的方式来通信,相反,要通过通信来共享内存。”
go语言使用MPG模式来实现CSP
在传统的并发中起很多线程只会加大CPU和内存的开销,太多的线程会大量的消耗计算机硬件资源,造成并发量的瓶颈。
M指的是Machine,一个M直接关联了一个内核线程。
P指的是”processor”,代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器。
G指的是Goroutine(协程),其实本质上也是一种轻量级的线程。
我个人的理解:M关联了一个内核线程,通过调度器P(上下文)的调度,可以连接1个或者多个G,相当于把一个内核线程切分成了了N个用户线程,M和P是一对一关系(但是实际调度中关系多变),通过P调度N个G(P和G是一对多关系),实现内核线程和G的多对多关系(M:N),通过这个方式,一个内核线程就可以起N个Goroutine(协程),同样硬件配置的机器可用的用户线程就成几何级增长,并发性大幅提高。
这篇文章详细介绍了相关原理:
https://i6448038.github.io/2017/12/04/golang-concurrency-principle/
每次go调用的时候,都会:
- 创建一个G对象,优先加入到本地队列(每个P最多256个),如果本地队列满了就会加入全局队列。
- 如果还有空闲的P,则创建一个M
- M会启动一个底层线程,循环执行能找到的G任务
- G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找(一次性转移(
全局G个数/P个数)个,再去其它P中找(一次性转移一半),
以上的G任务执行是按照队列顺序(也就是go调用的顺序)执行的。
这篇文章《go语言的并发原理(goroutine)》介绍的很详细
goroutine调度
goroutine 的调度是Go语言运行时(runtime)层面的实现,是完全由 Go 语言本身实现的一套调度系统——go scheduler。它的作用是按照一定的规则将所有的 goroutine 调度到操作系统线程上执行。
在经历数个版本的迭代之后,目前 Go 语言的调度器采用的是 GPM 调度模型
其中:
G:表示 goroutine,每执行一次go f()就创建一个 G,包含要执行的函数和上下文信息。
全局队列(Global Queue):存放等待运行的 G。
P:表示 goroutine 执行所需的资源,最多有 GOMAXPROCS 个,在golang中,可以通过设置GOMAXPROCS环境变量来设置CPU核心数(即设置P的数量)。
P 的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G 时,G 优先加入到 P 的本地队列,如果本地队列满了会批量移动部分 G 到全局队列。
M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和操作系统调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的, goroutine 则是由Go运行时(runtime)自己的调度器调度的,完全是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身 goroutine 的超轻量级,以上种种特性保证了 goroutine 调度方面的性能。
M:N:把m个goroutine分配给n个操作系统线程去执行
goroutine的初始栈大小是2K,能够轻松建立上万个goroutine
而线程的初始栈内存高达2M,明显比goroutine大的多