Go语言精进之路,摘要——并发编程

65 阅读4分钟

并发编程

优先考虑并发设计

并发不是并行,并发关乎结构,并行关乎执行。----Rob Pike

并发方案

重新做应用结构设计,即将应用分解成多个在基本只想单元中执行的有一定关联关系的代码片段。我们看到与并行方案中应用自身结构无须调整有所不同,并发方案中应用自身结构做出了较大调整,应用内部拆分为多个可独立运行的模块。这样虽然应用仍然以单实例的方式运行,但其中的每个内部模块都运行于一个单独的操作系统线程中,多核资源得以充分利用。

image.png

Go语言的设计哲学之一是“原生并发,轻量高效”。Go并未使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine这一由Go运行时负责调度的用户层轻量级线程为并发程序设计提供原生支持。goroutine相比传统操作系统线程而言具有如下优势。

  1. 资源占用小,每个协程的初始栈大小仅为2KB
  2. 由Go运行时而不是操作系统调用,协程上下文切换代价较小
  3. 语言原生支持:协程由go关键字接函数或方法创建,函数或方法返回即表示协程退出,开发体验更佳
  4. 语言内置channel作为协程建通信原语,为并发设计提供强大支撑

和传统编程语言不同的是,Go语言是面向并发而生的。因此,在应用的结构设计阶段,Go的惯例是优先考虑并发设计。这样做更多是考虑到随着外界环境的变化,经过并发设计的Go应用可以更好、更自然地适应规模化。

了解协程的调度原理

协程调度器

由于一个goroutine占用资源很少,一个Go程序中可以创建成千上万个并发的goroutine。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)。一个Go程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,goroutine的调度全要靠Go自己完成。

协程调度模型与演进过程

G-M-P模型

image.png P是一个“逻辑处理器”,每个G要想真正运行起来,首先需要被分配一个P,即进入P的本地队列(local runq)中,这里暂时忽略全局运行队列(global runq)。对于G来说,P就是运行它的“CPU”,可以说在G的眼里只有P。但从协程调度器的角度来讲,真正的CPU应该是M,只有将M和P绑定,才能让P的本地运行队列中的G真正动起来。这样的P与M的关系就好比Linux操作系统调度层面用户线程与内核线程的关系(N:N)

抢占式调度

G-P-M模型的实现是goroutine调度器的一大进步,但调度器仍然有一个头疼的问题,那就是不支持抢占式调度,这导致一旦某个G中出现死循环的代码逻辑,那么G将永久占用分配给它的P和M,而位于同一个P中的其他G将得不到调度,出现“饿死”的情况。更为严重的是,当只有一个P(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”。于是Dmitry Vyukov又提出了“Go抢占式调度器设计”(Go Preemptive Scheduler Design),并在Go 1.2版本中实现了抢占式调度。这个抢占式调度的原理是在每个函数或方法的入口加上一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种协作式抢占调度的解决方案只是局部解决了“饿死”问题,对于没有函数调用而是纯算法循环计算的G,goroutine调度器依然无法抢占。

进一步理解

  • G:代表goroutine,存储了goroutine的执行栈信息、goroutine状态及goroutine的任务函数等。另外G对象是可以重用的。
  • P:代表逻辑processor,P的数量决定了系统内最大可并行的G的数量(前提:系统的物理CPU核数>=P的数量)。P中最有用的是其拥有的各种G对象队列、链表、一些缓存和状态。
  • M:M代表着真正的执行计算资源。在绑定有效的P后,进入一个调度循环;而调度循环的机制大致是从各种队列、P的本地运行队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M。如此反复。M并不保留G状态,这是G可以跨M调度的基础。