并发和并行的目的都是为了充分利用 CPU 的多核(多处理器)架构,但两者却有着本质的区别。
并发:在同一时间段内,多条指令在 CPU 上执行。
并行:在同一时刻内,多条指令在 CPU 上执行。
并发程序 并不要求 CPU 具备多核计算能力,只要求多个线程在同一个 Core 上进行 “分时轮询” 处理。可以在宏观上实现多线程同时执行的效果。并发程序的执行通常是不确定的,这种不确定性来源于资源之间的相关依赖和竞态条件,可能导致执行的线程间相互等待,使并发程序即使在多核环境上也无法做到真正并行执行而降级为串行执行。简而言之,并发程序通常是有状态的(非幂等性)。
并行程序 则要求 CPU 具备多核计算能力,在同一时刻内,多个线程分别在不同的 Core 上同时执行。并行程序的每个执行模块在逻辑上都是独立的,即线程执行时可以独立地完成任务,从而做到同一时刻多个指令能够同时执行。并行程序通常是无状态的(幂等性)。
综上可知,我们在 Golang 中讨论的并发,并非是单存的多线程并行问题(并行不需要交互),而是 多线程间如何调度以及如何交互 的问题。
Golang 支持两种并发模式:
CSP 最初在 1977 年 Tony Hoare 发表的论文中提出,它倡导使用通信的手段来共享内存。CSP 的两个核心概念:
可见,CSP 最大的特征就是并发实体之间没有共享的内存空间,并发实体之间的数据交换使用通道来完成。并发实体在通道中发送数据或接受数据都会导致并发实体的阻塞,直到通道中的数据被发送或接受完成。并发实体通过这种方式实现交互及同步。
CSP 类似于同步队列(会阻塞),关注的是消息传输的方式,发送和接收信息的并发实体可能不知道对方是谁,它们之间是互相解耦的。通道与并发实体也不是紧耦合的,通道可以独立地进行创建和释放,并在不同的并发实体中传递使用。
通道(Channel)的特性给并发编程带来了极大的灵活性,通道作为独立的对象,可以被任意的创建、释放、读取、放入数据,并在不同的并发实体中被使用。但是它也很容易导致死锁,如果一个并发实体在读取一个永远没有数据放入的通道或者把数据放入一个永远不会被读取的通道中,那么它会将被永远阻塞。
并发或并行编程必然会涉及到操作系统对线程的分配与调度。根据访问权限的不同,操作系统会把内存分为内核空间和用户空间:
线程作为操作系统能够调度的最小单位,也分为用户线程和内核线程:
用户线程是无法被操作系统感知的,用户线程所属的进程或者内核线程才能被操作系统直接调度,分配 CPU 的使用时间。对此衍生出了不同的线程模型,它们之间对 CPU 资源的使用程度也各有千秋。
用户级线程模型中基本是一个进程对应一个内核线程。进程内的多线程管理由用户代码完成,这使得线程的创建、切换和同步等工作显得异常轻量级和高效,但是这些复杂的逻辑需要在用户代码中实现。同时进程内的多线程无法很好利用 CPU 多核的优势,只能通过分时复用的方式轮换执行。当进程内的任意线程阻塞,比如线程 A 请求 I/O 操作被阻塞,很可能导致整个进程范围内的阻塞,因为此时进程对应的内核线程因为线程 A 的 I/O 阻塞而被剥夺 CPU 执行时间。
内核级线程模型中,进程中的每个线程都会对应一个内核线程。进程内每创建一个新的线程都会进行系统调用在内核创建一个新的内核线程与对应,线程的管理和调度由操作系统负责,这将导致每次线程切换(线程在 Core 上切换)上下文时都会从用户态切换到内核态,会有不小的资源消耗,同时创建线程的数量也会受制于操作系统内核创建可创建的内核线程数量。好处是多线程能够充分利用 CPU 的多核并行计算能力,因为每个线程可以独立被操作系统调度分配到 CPU 上执行指令,同时某个线程的阻塞并不会影响到进程内其他线程工作的执行。
两级线程模型相当于用户级线程模式和内核级线程模型的结合,一个进程将会对应多个内核线程,由进程内的调度器决定进程内的线程如何与申请的内核线程对应。进程会预先申请一定数量的内核线程,然后将自身创建的线程与内核进程进行对应。线程的调用和管理由进程内的调度器进行,而内核线程的调度由操作系统负责。这种线程模型即能够有效降低线程创建和管理的资源消耗,也能够很好提供线程并行计算的能力,但是给开发人员带来较大的实现难度。
Golang 的 GMP 线程模型在两级线程模型的基础上进行一定程度的改进,使它能够更加灵活地进行线程之间的调度。它由三个主要模块构成:
M、P、G 三者组成了 Golang 的 M: N 调度模型:每个 M 都会与一个内核线程绑定,每个 P 又会与 M 进行一对一的绑定,而 P 和 G 的关系则是一对多。在运行过程中,P 的数量通常是固定的,M 的数量则会增长。M 和内核线程之间对应关系的不会变化,在 M 的生命周期内,它只会与一个内核线程绑定,而 M 和 P 以及 P 和 G 之间的关系则是动态可变的。
Go Runtime Scheduler 是 Golang GMP 线程模型的调度器。在 Golang 实际的运行过程中,M 和 P 的组合作为 G 的有效运行环境,而多个可执行 G 将会顺序排成一个队列挂在某个 P 上面,等待调度和执行,如下图所示:
M 和 P 共同构成了 G 基本的运行环境,此时 G0 中的代码片段处于正在运行的状态,而右边的 G 队列处于待执行状态。
Golang 会不断地在 M 上循环查找可运行的 G 来执行相应的任务。
在很多时候 M 的数量可能会比 P 要多,如果没有足够的 M 来和 P 组合以为 G 提供运行环境,Golang 就会创建出新的 M。在单个 Golang 进程中,P 的最大数量决定了并发的规模,且 P 的最大数量是由程序决定的。可以通过修改环境变量 GOMAXPROCS 和调用函数 runtime#GOMAXPROCS 来设定 P 的最大值。
Golang 会维护两种类型队列,全局队列和 P 的本地队列,在功能上来讲两者都是用于存放正在等待运行的 G,区别在于:本地队列有数量限制,不允许超过 256 个。新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列,这其实可以理解为调度资源的共享和再平衡。
在上图中,还有 steal 的行为:我们知道当新建的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。当 P 执行 G 完毕后,P 会将 G 从本地队列中弹出,同时会检查当前本地队列是否为空,如果为空,则会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。
在上述例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的 P,例如 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。
另外,M 执行任务时必须绑定到一个 P,没有绑定到 P 的 M 就是空闲的,或者游离态的。这样的设计为 P 和 M 分离增加了扩展性。也就是说 M 和 P 会适时的组合和分离,保证 P 中的待执行 G 队列能够得到及时运行。举两个例子:
注意,有些特殊的 M,比如 sysmon 是不绑定 P 的。这个用于监控一些阻塞的异常情况,比如一个 M 长时间阻塞超过 10ms,那么强制把 M-P 解绑,把 M 游离出去,P 绑定到一个空闲的 M 上,继续执行队列里的 G 任务。
Golang 支持 G-M 锁定功能,通过 lockOSThread 和 unlockOSThread 来实现。主要是用于有些要求固定在一个线程上跑的库。
你会发现,G_a 只在 M0 上运行,锁定这段期间,M0 也只执行了 G_a 的任务。
设置 GODEBUG 可以让 Golang 程序在运行时输出调试信息,包括可以直观的 GMP 调度器或垃圾回收等详细信息。GODEBUG 参数以逗号分隔,格式为:name=val。
对于 Go Runtime Scheduler,具有以下两个关键参数:
示例:
package main
import "sync"
func main() {
wg := sync.WaitGroup{}
wg.Add(10)
for i := 0; i <= 10; i++ {
go func(wg *sync.WaitGroup) {
var counter int
for i := 0; i <= 10; i++ {
counter++
}
wg.Done()
}(&wg)
}
wg.Wait()
}
运行:
$ GODEBUG=schedtrace=1000 go run main.go
SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0]
# command-line-arguments
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0]
SCHED 1008ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0]
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0]
注:自旋线程(Spinning Thread)是 Go Scheduler 设计者在考虑了 “OS 的资源利用率” 以及 “频繁的线程抢占给 OS 带来的负载” 之后,提出的概念。也就是当 “自旋线程” 没有找到可供其调度执行的 Goroutine 时,并不会销毁该线程 ,而是采取 “自旋” 的操作保存了下来。虽然看起来这是浪费了一些资源,但比起 “自旋",线程间频繁的抢占以及频繁的创建和销毁操作可能带来的损耗会更大。
如果我们想要更详细的看到调度器的完整信息时,可以增加 scheddetail 参数:
$ GODEBUG=scheddetail=1,schedtrace=1000 go run main.go
...
SCHED 10ms: gomaxprocs=4 idleprocs=2 threads=5 spinningthreads=0 idlethreads=2 runqueue=0 gcwaiting=1 nmidlelocked=0 stopwait=2147483647 sysmonwait=0
P0: status=1 schedtick=6 syscalltick=0 m=0 runqsize=0 gfreecnt=6 timerslen=0
P1: status=0 schedtick=2 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0
P2: status=1 schedtick=5 syscalltick=0 m=4 runqsize=0 gfreecnt=4 timerslen=0
P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0
M4: p=2 curg=10 mallocing=1 throwing=0 preemptoff= locks=2 dying=1 spinning=false blocked=false lockedg=-1
M3: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
M2: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
M0: p=0 curg=1 mallocing=1 throwing=0 preemptoff= locks=2 dying=1 spinning=false blocked=false lockedg=-1
G1: status=2(semacquire) m=0 lockedm=-1
G2: status=4(force gc (idle)) m=-1 lockedm=-1
G3: status=4(GC sweep wait) m=-1 lockedm=-1
G4: status=4(GC scavenge wait) m=-1 lockedm=-1
G5: status=6() m=-1 lockedm=-1
G6: status=6() m=-1 lockedm=-1
G7: status=6() m=-1 lockedm=-1
G8: status=6() m=-1 lockedm=-1
G9: status=6() m=-1 lockedm=-1
G10: status=2() m=4 lockedm=-1
G11: status=6() m=-1 lockedm=-1
G12: status=6() m=-1 lockedm=-1
G13: status=6() m=-1 lockedm=-1
G14: status=6() m=-1 lockedm=-1
G15: status=6() m=-1 lockedm=-1
...
M:
P:
G:
结合上述案例看看,如下:
G1: status=4(semacquire) m=-1 lockedm=-1
G2: status=4(force gc (idle)) m=-1 lockedm=-1
G3: status=4(GC sweep wait) m=-1 lockedm=-1
G17: status=1() m=-1 lockedm=-1
G18: status=2() m=4 lockedm=-1
在这个片段中,G1 的运行状态为 _Gwaiting,并没有分配 M 和锁定,表示 Goroutine 在运行时时被阻止,而阻止它的就是 semacquire 事件,是因为 semacquire 会检查信号量的情况,在合适的时机就调用 goparkunlock 函数,把当前 Goroutine 放进等待队列,并把它设为 _Gwaiting 状态。
实际运行中还有以下原因会导致这种现象:
waitReasonZero // ""
waitReasonGCAssistMarking // "GC assist marking"
waitReasonIOWait // "IO wait"
waitReasonChanReceiveNilChan // "chan receive (nil chan)"
waitReasonChanSendNilChan // "chan send (nil chan)"
waitReasonDumpingHeap // "dumping heap"
waitReasonGarbageCollection // "garbage collection"
waitReasonGarbageCollectionScan // "garbage collection scan"
waitReasonPanicWait // "panicwait"
waitReasonSelect // "select"
waitReasonSelectNoCases // "select (no cases)"
waitReasonGCAssistWait // "GC assist wait"
waitReasonGCSweepWait // "GC sweep wait"
waitReasonChanReceive // "chan receive"
waitReasonChanSend // "chan send"
waitReasonFinalizerWait // "finalizer wait"
waitReasonForceGGIdle // "force gc (idle)"
waitReasonSemacquire // "semacquire"
waitReasonSleep // "sleep"
waitReasonSyncCondWait // "sync.Cond.Wait"
waitReasonTimerGoroutineIdle // "timer goroutine (idle)"
waitReasonTraceReaderBlocked // "trace reader (blocked)"
waitReasonWaitForGCCycle // "wait for GC cycle"
waitReasonGCWorkerIdle // "GC worker (idle)"
通过以上 waitReason 可以了解到 Goroutine 会被暂停运行的原因,也就是会出现在括号中的事件。