Go 语言并发机制初探

区别并行与并发

进程、线程与处理器

在现代操作系统中,线程是处理器调度和分配的基本单位,进程则作为资源拥有的基本单位。 每个进程是由私有的虚拟地址空间、代码、数据和其它各种系统资源组成。线程是进程内部的一个执行单元。 每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。 用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。

并行与并发

并行与并发(Concurrency and Parallelism)是两个不同的概念,理解它们对于理解多线程模型非常重要。

在描述程序的并发或者并行时,应该说明从进程或者线程的角度出发。

非并发的程序只有一个垂直的控制逻辑,在任何时刻,程序只会处在这个控制逻辑的某个位置,也就是顺序执行。如果一个程序在某一时刻被多个 CPU 流水线同时进行处理,那么我们就说这个程序是以并行的形式在运行。

并行需要硬件支持,单核处理器只能是并发,多核处理器才能做到并行执行。

举一个例子,编写一个最简单的程序输出 “Hello World”,它就是非并发的,如果在程序中增加多线程,每个线程打印一个 “Hello World”,那么这个程序就是并发的。运行时只给这个程序分配单个 CPU,这个并发程序还不是并行的,需要用多核处理器的操作系统来运行它,才能实现程序的并行。

几种不同的多线程模型

用户线程与内核级线程

线程的实现可以分为两类:用户级线程 (User-LevelThread, ULT) 和内核级线程 (Kemel-LevelThread, KLT)。用户线程由用户代码支持,内核线程由操作系统内核支持。

多线程模型

多线程模型即用户级线程和内核级线程的不同连接方式。

  1. 多对一模型(M : 1)

    将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。 此模式中,用户级线程对操作系统不可见(即透明)。

    多对一模型

    • 优点:这种模型的好处是线程上下文切换都发生在用户空间,避免的模态切换(mode switch),从而对于性能有积极的影响。
    • 缺点:所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,在多处理环境下这是不能够被接受的,本质上,用户线程只解决了并发问题,但是没有解决并行问题。

    如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞,用户空间也可以使用非阻塞而 I/O,但是还是有性能及复杂度问题。

  2. 一对一模型(1:1)

    将每个用户级线程映射到一个内核级线程。

    一对一模型

    每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。

    • 优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
    • 缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
  3. 多对多模型(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 中。

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。

-->