Go

一文了解 Go 并发模型


今天我们来了解一下 Go 语言的协程并发机制,这也可能是 Go 语言最为吸引人的特性了,了解它的原理和底层机制对于掌握 Go 语言大有裨益,话不多说开始吧!

并发和并行

并发和并行都是为了充分利用 CPU 多核计算资源所提出来的概念,相信我们或多或少都对二者的概念有所了解:
  • 并发指的是在同一时间段内,多条指令在 CPU 上同时执行;
  • 并行指的是在同一时刻内,多条指令在 CPU 上同时执行。


并发程序并不要求 CPU 具备多核计算的能力。在同一时间段内,多个线程会被分配一定的执行时间片,在 CPU 上被快速轮换执行。线程执行的时间片时间耗尽或者任务完成了,会被 CPU 调度换下,执行其他的线程任务。通过这样的方式,可以在宏观上模拟出多个线程同时执行的效果。

而并行程序要求 CPU 提供多核并行计算的能力。在同一时刻内,就有多个线程在 CPU 上的多个核上同时执行指令。无论从宏观还是微观上观察,都会多个线程在同时执行。

并发程序的执行通常是不确定的,这种不确定来源于资源之间的相关依赖和竞态条件,这可能导致执行线程之间的相互等待,使得并发程序即时在多核环境上也无法做到真正并行执行而降级为串行执行。并行程序的每个执行模块在逻辑上是独立的,即线程执行时可以独立完成任务,从而做到同一时刻多个指令能够同时执行。

CSP 并发模型

Golang 语言中实现了两种并发模式,一种是我们熟悉的线程与锁并发模型,它主要依赖于共享内存实现。线程与锁模型类似于对底层硬件运行过程的形式化,程序的正确运行很大程度依赖于开发人员的能力和技巧,程序在出错时不易排查。另一种是 Golang 中倡导使用的 CSP(communicating sequential processes)通信顺序进程模型。

CSP 并发模型最初由 Tony Hoare 于 1977 年的论文中被提出,它倡导使用通信的手段来共享内存。CSP 模型中存在两个关键的概念:
  • 并发实体,通常可以理解为执行线程,它们相互独立,且并发执行;
  • 通道,并发实体之间使用通道发送信息。


与共享内存的线程与锁并发模型不同,CSP 中的并发实体是独立的,它们之间没有共享的内存空间。并发实体之间的数据交换通过通道实现,无论在通道中放数据还是从通道中取数据,都会导致并发实体的阻塞,直到通道中的数据被取出或者通道中被放入新的数据,并发实体通过这种方式实现同步。

CSP 类似于我们常用的同步队列,它关注的是消息传输的方式,即通道,消息的具体发送实体和具体接收实体并不关注。发送和接收信息的并发实体可能不知道对方具体是谁,它们之间是互相解耦的。通道与并发实体也不是紧耦合的,通道可以独立地进行创建和放取,并在不同的并发实体中传递使用。

CSP 通道的特性给并发编程提供了极大的灵活性,通道作为独立的对象,可以被任意创建、读取、放入数据,并在不同的并发实体中被使用。但是它也极易导致死锁,如果一个并发实体在读取一个永远没有数据放入的通道或者把数据放入一个永远不会被读取的通道中,那么它将被永远阻塞。

常见的线程模型

线程之间的调度永远是一个复杂的话题,但是并发编程必然会涉及到操作系统对线程的调度。根据资源访问权限的不同,操作系统会把内存分为内核空间和用户空间,内核空间的指令代码具备直接调度计算机底层资源的能力,比如说 I/O 资源等;用户空间的代码没有访问计算底层资源的能力,需要通过系统调用等方式切换为内核态来实现对计算机底层资源的申请和调度。

线程作为操作系统能够调度的最小单位,也分为用户线程和内核线程:
  • 用户线程由用户空间的代码创建、管理和销毁,线程的调度由用户空间的线程库完成(可能是编程语言层次的线程库),无需切换内核态,资源消耗少且高效。对 CPU 的竞争是以所属进程的维度参与的,同一进程下的所有用户级线程只能分时复用进程被分配的 CPU 时间片,所以无法很好利用 CPU 多核运算的优势。我们一般情况下说的线程其实是指用户线程;
  • 内核线程由操作系统管理和调度,能够直接操作计算机底层的资源,线程切换的时候 CPU 需要切换到内核态。它能够很好利用多核 CPU 并行计算的优势,开发人员可以通过系统调用使用内核线程。


用户线程是无法被操作系统感知的,用户线程所属的进程或者内核线程才能被操作系统直接调度,分配 CPU 的使用时间。对此衍生出了不同的线程模型,它们之间对 CPU 资源的使用程度各有千秋。

用户级线程模型

用户级线程模型中基本是一个进程对应一个内核线程,如下图所示:
1-1.png

进程内的多线程管理由用户代码完成,这使得线程的创建、切换和同步等工作显得异常轻量级和高效,但是这些复杂的逻辑需要在用户代码中实现,一般依赖于编程语言层次。同时进程内的多线程无法很好利用 CPU 多核进程的优势,只能通过分时复用的方式轮换执行。当进程内的任意进程阻塞,比如线程 A 请求 I/O 操作被阻塞,很可能导致整个进程范围内的阻塞,因为此时进程对应内核线程因为线程 A 的I/O 阻塞而被剥夺 CPU 执行时间,导致整个进程失去了在 CPU 执行代码的权利!

内核级线程模型

内核级线程模型中,进程中的每个线程都会对应一个内核线程,如下图所示:
1-2.png

进程内每创建一个新的线程都会调用操作系统的线程库在内核创建一个新的内核线程与对应,线程的管理和调度有操作系统负责,这将导致每次线程切换上下文时都会从用户态切换到内核态,会有不小的资源消耗,同时创建线程的数量也会受制于操作系统内核创建可创建的内核线程数量。好处是多线程能够充分利用 CPU 的多核并行计算能力,因为每个线程可以独立被操作系统调度分配到 CPU 上执行指令,同时某个线程的阻塞并不会影响到进程内其他线程工作的执行。

两级线程模型

两级线程模型相当于用户级线程模式和内核级线程模型的结合,一个进程将会对应多个内核线程,由进程内的调度器决定进程内的线程如何与申请的内核线程对应,如下图所示:
1-3.png

进程会预先申请一定数量的内核线程,然后将自身创建的线程与内核进程进行对应。线程的调用和管理由进程内的调度器进行,而内核线程的调度和管理由操作系统负责。这种线程模型即能够有效降低线程创建和管理的资源消耗,也能够很好提供线程并行计算的能力,但是给开发人员带来较大的实现难度。

MPG 模型概述

Golang 中的 MPG 线程模型对两级线程模型进行一定程度的改进,使它能够更加灵活地进行线程之间的调度。它由三个主要模块构成,如下图所示:
1-4.png

  • machine,一个 machine 对应一个内核线程,相当于内核线程在 Golang 进程中的映射
  • processor,一个 prcessor 表示执行 Go 代码片段的所必需的上下文环境,可以理解为用户代码逻辑的处理器
  • goroutine,是对 Golang 中代码片段的封装,其实是一种轻量级的用户线程。


为了减轻描述工作,下面的介绍中我们会用 M、P、G 分别指代 machine、processor 和 goroutine。

每一个 M 都会以一个内核线程绑定,M 和 P 之间也是一对一的关系,而 P 和 G 的关系则是一对多。在运行过程中,M 和 内核线程之间对应关系的不会变化,在 M 的生命周期内,它只会与一个内核线程绑定,而 M 和 P 以及 P 和 G 之间的关系都是动态可变的。

在实际的运行过程中,M 和 P 的组合才能够为 G 提供有效的运行环境,而多个可执行 G 将会顺序排成一个队列挂在某个 P 上面,等待调度和执行,如下图所示:
1-5.png

上图中,M 和 P 共同构成了一个基本的运行环境,此时 G0 中的代码片段处于正在运行的状态,而右边的 G 队列处于待执行状态。

M 的创建一般是因为没有足够的 M 来和 P 组合以为 G 提供运行环境,在很多时候 M 的数量可能会比 P 要多。在单个 Golang 进程中,P 的最大数量决定了程序的并发规模,且 P 的最大数量是由程序决定的。可以通过修改环境变量 GOMAXPROCS 和 调用函数 runtime#GOMAXPROCS 来设定 P 的最大值。

M 和 P 会适时的组合和断开,保证 P 中的待执行 G 队列能够得到及时运行。比如说上图中的 G0 此时因为网络 I/O 而阻塞了 M,那么 P 就会携带剩余的 G 投入到其他 M 的怀抱中。这个新的 M1 可能是新创建的,也可能是从调度器空闲 M 列表中获取的,取决于此时的调度器空闲 M 列表中是否存在 M,从而避免 M 的过多创建,如下图所示:
1-6.png

当 M 对应的内核线程被唤醒时,M 将会尝试为 G0 捕获一个 P 上下文,可能是从调度器的空闲 P 列表中获取,如果获取不成功,M 会被 G0 放入到调度器的可执行 G 队列中,等待其他 P 的查找。为了保证 G 的均衡执行,非空闲的 P 会运行完自身的可执行 G 队列中,会周期性从调度器的可执行 G 队列中获取代执行的 G,甚至从其他的 P 的可执行 G 队列中掠夺 G。

本文摘自于《Go语言高并发与微服务实战》,经出版方授权发布。

0 个评论

要回复文章请先登录注册