原创分享 03.Go 语言的设计哲学之三: 并发

happy_brother · 2021年01月21日 · 57 次阅读

Go 语言原生并发原则 1) Go 语言自身实现层面支持面向多核硬件的并发执行和调度 提到并发执行与调度,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。传统的编程语言比如 C、C++ 等的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过 pthread 等函数库调用实现),操作系统负责调度。这种传统支持并发的方式有诸多不足:

  • 复杂
    • 创建容易,退出难:使用 C 语言的开发人员都知道,创建一个 thread(比如利用 pthread)虽然参数也不少,但还可以接受。但一旦涉及到 thread 的退出,就要考虑 thread 是 detached,还是需要 parent thread 去 join?是否需要在 thread 中设置 cancel point,以保证 join 时能顺利退出?
    • 并发单元间通信困难,易错:多个 thread 之间的通信虽然有多种机制可选,但用起来是相当复杂;并且一旦涉及到 shared memory,就会用到各种 lock,死锁便成为家常便饭;
    • thread stack size 的设定:是使用默认的,还是设置的大一些,或者小一些呢?
  • 难于扩展
    • 一个 thread 的代价已经比进程小了很多了,但我们依然不能大量创建 thread,因为除了每个 thread 占用的资源不小之外,操作系统调度切换 thread 的代价也不小;
    • 对于很多网络服务程序,由于不能大量创建 thread,就要在少量 thread 里做网络多路复用,即:使用 epoll/kqueue/IoCompletionPort 这套机制,即便有 libevent、libev 这样的第三方库帮忙,写起这样的程序也是很不易的,存在大量 callback,给程序员带来不小的心智负担。 为此,Go 采用了用户层轻量级 thread 或者说是类 coroutine 的概念来解决这些问题,Go 将之称为"goroutine"。goroutine 占用的资源非常小,每个 goroutine stack 的 size 默认设置是 2k,goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 goroutine。所有的 Go 代码都在 goroutine 中执行,哪怕是 go 的 runtime 也不例外。将这些 goroutines 按照一定算法放到 “CPU” 上执行的程序就称为 goroutine 调度器或 goroutine scheduler。

不过,一个 Go 程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有 thread,它甚至不知道有什么叫 Goroutine 的东西的存在。goroutine 的调度全要靠 Go 自己完成,实现 Go 程序内 goroutine 之间 “公平” 的竞争 “CPU” 资源,这个任务就落到了 Go runtime 头上。 Go 语言实现了 G-P-M 调度模型和 work stealing 算法,这个模型一直沿用至今,如下图所示:

  • G:表示 goroutine,存储了 goroutine 的执行 stack 信息、goroutine 状态以及 goroutine 的任务函数等;另外 G 对象是可以重用的。
  • P:表示逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量(前提:系统的物理 cpu 核数>=P 的数量);P 的最大作用还是其拥有的各种 G 对象队列、链表、一些 cache 和状态。每个 G 要想真正运行起来,首先需要被分配一个 P(进入到 P 的 local runq 中)。对于 G 来说,P 就是运行它的 “CPU”,可以说:G 的眼里只有 P。
  • 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 的并发控制。 对比来看,Go 对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。 3) 并发原则对 Go 开发者在程序结构设计层面的影响

由于 goroutine 的开销很小(相对线程),Go 官方是鼓励大家使用 goroutine 来充分利用多核资源的。但并不是有了 goroutine 就一定能充分的利用多核资源,或者说即便使用 Go 也不一定能设计编写出一个好的并发程序。 为此 Rob Pike 曾有过一次关于 “并发不是并行” 1 的主题分享,在那次分享中,这位 Go 语言之父图文并茂地讲解了并发(Concurrency)和并行(Parallelism)的区别。Rob Pike 认为:

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

重点:并发是一种程序结构设计的方法,它使得并行成为可能。不过这依然很抽象,我们这里也借用 Rob Pike 分享中的那个 “搬运书问题” 来重新诠释一下并发的含义。搬运书问题要求设计一个方案,使得 gopher 能更快地将一堆废弃的语言手册搬到垃圾回收场烧掉。

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

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

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

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

  • 将书搬运到车上(loadBooksToCart);
  • 推车到垃圾焚化地点(moveCartToIncinerator);
  • 将书从车上搬下送入焚化炉(unloadBookIntoIncinerator);
  • 将空车送返(returnEmptyCart)。 理论上并发方案 1 的处理性能能达到初始方案的四倍,并且不同 gopher 例程可以在不同的处理器核上并行执行,而无需像最初方案那样需要建立新实例实现并行。

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

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册