Go语言从语言层面原生支持高并发,这应该是Go语言核心区别于其他编程语言的“精髓”所在!也是大部分程序员从PHP转向Go语言的“核心原因”。在Go语言高并发编程里有一句话你不得不知道(在 通道channel 一节我们也学习过):
Don’t communicate by sharing memory; share memory by communicating.
不要通过共享内存来通信,而应该通过通信来共享内存。
在Go语言中有两种并发模型来实现并发:支持“顺序通信进程”的并发编程模式CSP(Communicating Sequential Processes)和 多线程共享内存(更传统的并发模型)模型。
那我们就开始揭秘Go语言的并发原理吧!
一、学习并发前需要了解和明晰的基本概念
进程(Processes)和线程(Threads)
- 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
- 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
- 一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。
协程(Goroutine)和线程(Threads)
- 协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
- 线程:一个线程上可以跑多个协程,协程是轻量级的线程。
- goroutine 只是由官方实现的超级"线程池"。
每个协程实例4~5KB的栈内存占用和实现机制而大幅减少的创建和销毁开销是Go高并发的根本原因。
Goroutine 非常轻量,主要体现在以下两个方面:
- 上下文切换代价小: Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP...等寄存器的刷新;
- 内存占用少:线程栈空间通常是 2M,Goroutine 栈空间最小 2K;Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。
内核级线程(Kernel-Level Thread)和用户线程(User-Level Thread)
- 内核级线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用多核CPU
- 用户级线程:内核的切换由用户态程序自己控制内核切换,不需要内核干涉,少了进出内核态的消耗,但不能很好的利用多核CPU
- goroutine协程:使用的是轻量级线程,即M:N模型,本质是用户级线程,其调用是由goroutine调度器实现,调度逻辑对外透明,并不由内核来调用。优势在于上下文切换在完全用户态进行,无需像线程一样频繁在用户态与内核态之间切换,节约了资源消耗。
并发(Concurrency)和并行(Parallelism)
- 多线程程序在一个核的cpu上运行,就是并发。
- 多线程程序在多个核的cpu上运行,就是并行。
来个比喻的话,并发和并行的区别就是一个人同时吃三个馒头和三个人同时吃三个馒头。下图形象的表示了 并发 和并行的区别:
二、Go语言高并发的核心:协程Goroutine
goroutine是Go并行设计的核心。协程比线程更小,占用的资源更低,Go语言内部帮你实现了这些goroutine之间的内存共享(借助 通道channel 完成)。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。
关于 协程goroutine 更加详细的讲解,将在下一节中进行总结。
三、被Go语言隐藏起来的“高并发”秘密:Go调度器实现机制
Go 程序通过调度器来调度Goroutine 在内核线程上执行,但是 G - Goroutine 并不直接绑定 OS 线程 M - Machine运行,而是由 Goroutine Scheduler 中的 P - Processor (逻辑处理器)来作获取内核线程资源的『中介』。Go 调度器模型我们通常叫做G-P-M 模型,他包括 4 个重要结构,分别是G、P、M、Sched:
- G:Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行;
- P: Processor,表示逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P 才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量)。由用户设置的 GoMAXPROCS 决定,但是不论 GoMAXPROCS 设置为多大,P 的数量最大为 256。
- M: Machine,OS 内核线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取。M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
- Sched:Go 调度器,它维护有存储 M 和 G 的队列以及调度器的一些状态信息等。
可以用下图来表示G-M-P模型的结构:
Go 调度器中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。每个 P 都有一个 LRQ,用于管理分配给在 P 的上下文中执行的 Goroutines,这些 Goroutine 轮流被和 P 绑定的 M 进行上下文切换。GRQ 适用于尚未分配给 P 的 Goroutines。
关于整个调度过程,有一个非常经典的描述:“地鼠推车搬砖模型”。
地鼠的工作任务是:工地上有若干砖头,地鼠借助小车把砖头运送到火种上去烧制。M 就可以看作图中的地鼠,P 就是小车,G 就是小车里装的砖。
- Processor(P):根据用户设置的 GoMAXPROCS 值来创建一批小车(P)。
- Goroutine(G):通过 Go 关键字就是用来创建一个 Goroutine,也就相当于制造一块砖(G),然后将这块砖(G)放入当前这辆小车(P)中。
- Machine (M):地鼠(M)不能通过外部创建出来,只能砖(G)太多了,地鼠(M)又太少了,实在忙不过来,刚好还有空闲的小车(P)没有使用,那就从别处再借些地鼠(M)过来直到把小车(P)用完为止。这里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。
需要注意的是:地鼠(M) 如果没有小车(P)是没办法运砖的,小车(P)的数量决定了能够干活的地鼠(M)数量,在 Go 程序里面对应的是活动线程数。
至此,我们已经初步了解了Go语言是如何通过语言层面就支持高并发的,以及它神秘的调度模型。其实关于调度这部分还有更加深入的部分可以了解,比如采取的调度策略,如何减少阻塞等,但这些在这里暂时先不做深入总结,有深入了解的需要时,我们再进一步学习归纳。
关于Go语言高并发原理,我们就总结这么多,接下来详细讲一下协程goroutine。