关于后端:03Go语言的设计哲学之三-并发

38次阅读

共计 3374 个字符,预计需要花费 9 分钟才能阅读完成。

本文视频地址

Go 语言原生并发准则

1) Go 语言本身实现层面反对面向多核硬件的并发执行和调度

提到并发执行与调度,咱们首先想到的就是操作系统对过程、线程的调度。操作系统调度器会将零碎中的多个线程依照肯定算法调度到物理 CPU 下来运行。传统的编程语言比方 C、C++ 等的并发实现实际上就是基于操作系统调度的,即程序负责创立线程(个别通过 pthread 等函数库调用实现),操作系统负责调度。这种传统反对并发的形式有诸多有余:

简单

  • 1 创立容易,退出难:应用 C 语言的开发人员都晓得,创立一个 thread(比方利用 pthread)尽管参数也不少,但还能够承受。但一旦波及到 thread 的退出,就要思考 thread 是 detached,还是须要 parent thread 去 join?是否须要在 thread 中设置 cancel point,以保障 join 时能顺利退出?
  • 2 并发单元间通信艰难,易错:多个 thread 之间的通信尽管有多种机制可选,但用起来是相当简单;并且一旦波及到 shared memory,就会用到各种 lock,死锁便成为粗茶淡饭.
  • 3 thread stack size 的设定:是应用默认的,还是设置的大一些,或者小一些呢?

难于扩大

  • 1 一个 thread 的代价曾经比过程小了很多了,但咱们仍然不能大量创立 thread,因为除了每个 thread 占用的资源不小之外,操作系统调度切换 thread 的代价也不小;
  • 2 对于很多网络服务程序,因为不能大量创立 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 例程各司其责,这样的程序在单核处理器上也是失常运行的(在单核上可能解决能力不如非并发计划)。但随着处理器核数的增多,并发计划能够天然地进步解决性能,晋升吞吐。而非并发计划在处理器核数晋升后,也仅仅能应用其中的一个核,无奈天然扩大,这一切都是程序的构造所决定的。这也通知咱们:并发程序的结构设计不要局限于在单核状况下解决能力的高下,而是以在多核状况下可能充沛晋升多核利用率、取得性能的天然晋升为最终目标。

正文完
 0