Go 语言并发机制初探
区别并行与并发
进程、线程与处理器
在现代操作系统中,线程是处理器调度和分配的基本单位,进程则作为资源拥有的基本单位。 每个进程是由私有的虚拟地址空间、代码、数据和其它各种系统资源组成。线程是进程内部的一个执行单元。 每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。 用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。
并行与并发
并行与并发(Concurrency and Parallelism)是两个不同的概念,理解它们对于理解多线程模型非常重要。
在描述程序的并发或者并行时,应该说明从进程或者线程的角度出发。
- 并发:一个时间段内有很多的线程或进程在执行,但何时间点上都只有一个在执行,多个线程或进程争抢时间片轮流执行
- 并行:一个时间段和时间点上都有多个线程或进程在执行
非并发的程序只有一个垂直的控制逻辑,在任何时刻,程序只会处在这个控制逻辑的某个位置,也就是顺序执行。如果一个程序在某一时刻被多个 CPU 流水线同时进行处理,那么我们就说这个程序是以并行的形式在运行。
并行需要硬件支持,单核处理器只能是并发,多核处理器才能做到并行执行。
- 并发是并行的必要条件,如果一个程序本身就不是并发的,也就是只有一个逻辑执行顺序,那么我们不可能让其被并行处理。
- 并发不是并行的充分条件,一个并发的程序,如果只被一个 CPU 进行处理 (通过分时),那么它就不是并行的。
举一个例子,编写一个最简单的程序输出 “Hello World”,它就是非并发的,如果在程序中增加多线程,每个线程打印一个 “Hello World”,那么这个程序就是并发的。运行时只给这个程序分配单个 CPU,这个并发程序还不是并行的,需要用多核处理器的操作系统来运行它,才能实现程序的并行。
几种不同的多线程模型
用户线程与内核级线程
线程的实现可以分为两类:用户级线程 (User-LevelThread, ULT) 和内核级线程 (Kemel-LevelThread, KLT)。用户线程由用户代码支持,内核线程由操作系统内核支持。
多线程模型
多线程模型即用户级线程和内核级线程的不同连接方式。
-
多对一模型(M : 1)
将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。 此模式中,用户级线程对操作系统不可见(即透明)。
- 优点:这种模型的好处是线程上下文切换都发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。
- 缺点:所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,在多处理环境下这是不能够被接受的,本质上,用户线程只解决了并发问题,但是没有解决并行问题。
如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞,用户空间也可以使用非阻塞而 I/O,但是还是有性能及复杂度问题。
-
一对一模型(1:1)
将每个用户级线程映射到一个内核级线程。
每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。
- 优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
- 缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
-
多对多模型(M : N)
内核线程和用户线程的数量比为 M : N,内核用户空间综合了前两种的优点。
这种模型需要内核线程调度器和用户空间线程调度器相互操作,本质上是多个线程被绑定到了多个内核线程上,这使得大部分的线程上下文切换都发生在用户空间,而多个内核线程又可以充分利用处理器资源。
goroutine 机制的调度实现
goroutine 机制实现了 M : N 的线程模型,goroutine 机制是协程(coroutine)的一种实现,golang 内置的调度器,可以让多核 CPU 中每个 CPU 执行一个协程。
理解 goroutine 机制的原理,关键是理解 Go 语言 scheduler 的实现。
调度器是如何工作的
Go 语言中支撑整个 scheduler 实现的主要有 4 个重要结构,分别是 M、G、P、Sched, 前三个定义在 runtime.h 中,Sched 定义在 proc.c 中。
- Sched 结构就是调度器,它维护有存储 M 和 G 的队列以及调度器的一些状态信息等。
- M 结构是 Machine,系统线程,它由操作系统管理的,goroutine 就是跑在 M 之上的;M 是一个很大的结构,里面维护小对象内存 cache(mcache)、当前执行的 goroutine、随机数发生器等等非常多的信息。
- P 结构是 Processor,处理器,它的主要用途就是用来执行 goroutine 的,它维护了一个 goroutine 队列,即 runqueue。Processor 是让我们从 N:1 调度到 M:N 调度的重要部分。
- G 是 goroutine 实现的核心结构,它包含了栈,指令指针,以及其他对调度 goroutine 很重要的信息,例如其阻塞的 channel。
M 代表了一个内核线程。在大多数情况下,创建一个 M 都是因为没有足够的 M 来关联 P 并运行其中可运行的 G。不过,在运行时系统执行系统监控或垃圾回收等任务的时候,也会导致新 M 的创建。
P 的数量是在启动时被设置为环境变量 GOMAXPROCS 的值,或者通过运行时调用函数 GOMAXPROCS() 进行设置。P 数量固定意味着任意时刻只有 GOMAXPROCS 个线程在运行 go 代码。
为了提高 G 的复用率,当使用 go 语句欲启用一个 G 的时候,运行时系统会先试图从相应的 P 的自由 G 列表中获取一个现成的 G,来封装这个 GO 语句携带的函数,当获取不到这样一个 G 时才可能创建一个新的 G。同时,考虑到如果 P 的自由 G 列表为空,调度器会从自己的自由 G 列表中转移一些 G,只有在调度器的自由 G 列表也空了时,才会创建新的 G。