第三条原则: 原生并发,轻量高效

依靠主频的提高带来性能的提升已无法实现,人们开始把研究重点转向通过把多个执行内核放进一个处理器,每个内核在较低的频率下工作来降低功耗同时提高性能。

Go 语言原生并发原则的落地是映射到几个层面上的:

1. Go 语言自身实现层面支持面向多核硬件的并发执行和调度

传统支持并发的方式有诸多不足:

  • 复杂
    • 创建容易,退出难
    • 并发单元间通信困难,易错
    • thread stack size 的设定
  • 难于扩展
    • 除了每个 thread 占用的资源不小之外,操作系统调度切换 thread 的代价也不小
    • 对于很多网络服务程序, 给程序员带来不小的心智负担

**
Go 采用了用户层轻量级 thread或者说是类 coroutine的概念来解决这些问题,Go 将之称为”goroutine”。

  • goroutine 占用的资源非常小,每个 goroutine stack 的 size 默认设置是 2k,
  • goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。所有的 Go 代码都在 goroutine 中执行,哪怕是 go 的 runtime 也不例外。将这些 goroutines 按照一定算法放到“CPU”上执行的程序就称为goroutine 调度器或goroutine scheduler。

goroutine 的调度全要靠 Go 自己完成,实现 Go 程序内 goroutine 之间“公平”的竞争“CPU”资源,这个任务就落到了 Go runtime 头上。

Go 语言实现了G-P-M 调度模型和 work stealing 算法,这个模型一直沿用至今,如下图所示:

image.png

  1. G:表示 goroutine,存储了 goroutine 的执行 stack 信息、goroutine 状态以及 goroutine 的任务函数等;另外 G 对象是可以重用的。
  2. P:表示逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量(前提:系统的物理 cpu 核数>=P 的数量);P 的最大作用还是其拥有的各种 G 对象队列、链表、一些 cache 和状态。每个 G 要想真正运行起来,首先需要被分配一个 P(进入到 P 的 local runq 中)。对于 G 来说,P 就是运行它的“CPU”,可以说:G 的眼里只有 P。
  3. M:M 代表着真正的执行计算资源,一般对应的是操作系统的线程。从 Goroutine 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定才能让 P 的 runq 中 G 得以真实运行起来。这样的 P 与 M 的关系,就好比 Linux 操作系统调度层面用户线程(user thread)与核心线程(kernel thread)的对应关系那样(N x M)。M 在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从各种队列、p 的本地队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 m,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

2. Go 语言为开发者提供的支持并发的语法元素和机制

先来看看那些设计并诞生于单核年代的编程语言,诸如:C、C++、Java 在语法元素和机制层面是如何支持并发的。

  • 执行单元:线程;
  • 创建和销毁的方式:调用库函数或调用对象方法;
  • 并发线程间的通信:多基于操作系统提供的 IPC 机制,比如:共享内存、Socket、Pipe 等,当然也会使用有并发保护的全局变量。

Go 为开发人员提供了语言层面内置的并发语法元素和机制:

  • 执行单元:goroutine;
  • 创建和销毁方式:go+函数调用;函数退出即 goroutine 退出;
  • 并发 goroutine 的通信:通过语言内置的 channel 传递消息或实现同步,并通过 select 实现多路 channel 的并发控制。

3. 并发原则对 Go 开发者在程序结构设计层面的影响

Rob Pike 曾有过一次关于“并发不是并行”1的主题分享,在那次分享中,这位 Go 语言之父图文并茂地讲解了并发(Concurrency)和并行(Parallelism)的区别。Rob Pike 认为:

  • 并发是有关结构的,它是一种将一个程序分解成小片段并且每个小片段都可以独立执行的程序设计方法; 并发程序的小片段之间一般存在通信联系并且通过通信相互协作;
  • 并行是有关执行的,它表示同时进行一些计算任务 。

Rob Pike 分享中的那个“搬运书问题”来重新诠释一下并发的含义:

最简单的方案莫过于下图:

image.png

这个方案显然不是并发设计方案,它没有对问题进行任何分解,所有事情都是由一个 gopher 从头到尾按顺序完成的。但即便这样一个并非并发的方案,我们也可以将其放到多核的硬件上并行的执行,只是需要多建立几个 gopher 例程(procedure)的实例罢了:

image.png

但和并发方案相比,这种方案是缺乏自动扩展为并行的能力的。Rob Pike 在分享中给出了两种并发方案,也就是该问题的两种分解方案,两种方案都是正确的,只是分解粒度的细致程度不同

  • 方案1

image.png

并发方案 1 将原来单一的 gopher 例程执行拆分为 4 个执行不同任务的 gopher 例程,每个例程更简单:

  1. 将书搬运到车上(loadBooksToCart);
  2. 推车到垃圾焚化地点(moveCartToIncinerator);
  3. 将书从车上搬下送入焚化炉(unloadBookIntoIncinerator);
  4. 将空车送返(returnEmptyCart)。

理论上并发方案 1 的处理性能能达到初始方案的四倍,并且不同 gopher 例程可以在不同的处理器核上并行执行,而无需像最初方案那样需要建立新实例实现并行

  • 方案2

image.png

和并发方案 1 相比,并发方案 2 增加了“暂存区域”,分解的粒度更细:

  1. 每个部分的 gopher 例程各司其责,这样的程序在单核处理器上也是正常运行的(在单核上可能处理能力不如非并发方案)。但随着处理器核数的增多,并发方案可以自然地提高处理性能,提升吞吐。
  2. 而非并发方案在处理器核数提升后,也仅仅能使用其中的一个核,无法自然扩展,这一切都是程序的结构所决定的。这也告诉我们:并发程序的结构设计不要局限于在单核情况下处理能力的高低,而是以在多核情况下能够充分提升多核利用率、获得性能的自然提升为最终目的。

小结

除此之外,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计的层面对程序进行拆解组合,再映射到程序执行层面上:goroutines 各自执行特定的工作,通过 channel+select 将 goroutines 组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让 Go 语言更适应现代计算环境。