Golang 高并发解读(一)

470 阅读6分钟

1. 协程 (Coroutine)

  协程是一种用户态的轻量级线程,又称微线程,协程的调度完全由用户控制。人们通常将协程和子程序(函数)比较着理解。子程序调用总是一个入口,一次返回,一旦退出即完成了子程序的执行。

  协程与多线程相比,其优势体现在:协程的执行效率极高。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

2. 线程模型

  Go语言内置了 goroutine 机制,可以快速地开发并发程序, 更好的利用多核处理器资源。

  Go并发编程模型在底层是由操作系统所提供的线程库支撑。

  线程可以视为进程中的控制流。一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程称为该进程的主线程。当然,一个进程也可以包含多个线程。这些线程都是由当前进程中已存在的线程创建出来的,创建的方法就是调用系统调用,更确切地说是调用 pthread create 函数。拥有多个线程的进程可以并发执行多个任务,并且即使某个或某些任务被阻塞,也不会影响其他任务正常执行,这可以大大改善程序的响应时间和吞吐量。另一方面,线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。

  线程的实现模型主要有3个:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异就在于线程与内核调度实体( Kernel Scheduling Entity,简称 KSE )之间的对应关系上。内核调度实体就是可以被内核的调度器调度的对象,也称为内核级线程,是操作系统内核的最小调度单元。

  • 内核态线程模型 (内核线程: 用户态线程 = 1: 1)
  • 用户态线程模型 (内核线程: 用户态线程 = 1: N)
  • 混合态线程模型 (内核线程: 用户态线程 = N: M)。混合型线程模型,即用户调度器实现用户线程到KSE的“调度”,内核调度器实现KSE到CPU上的调度。

2.1. G-P-M模型

  在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。goroutine机制实现混合态线程模型,goroutine机制是协程(coroutine)的一种实现,golang内置的调度器,可以让多核CPU中每个CPU执行一个协程。

// 用go关键字加上一个函数(这里用了匿名函数)
// 调用就做到了在一个新的“线程”并发执行任务
go func() { 
    // do something in one new goroutine
}()
// 等价上方
new java.lang.Thread(() -> { 
    // do something in one new thread
}).start();

  Go语言中支撑整个 scheduler 实现的主要有4个重要结构,分别是M、G、P、Sched

  M、G、P定义在runtime.h中,都是Go语言运行时系统(其中包括内存分配器,并发调度器,垃圾收集器等组件,可以想象为Java中的JVM)抽象出来概念和数据结构对象。

  Sched定义在proc.c中。

  • M (Machine) :系统线程,它由操作系统管理的,goroutine运行在Machine之上;Machine里维护小对象内存cache (mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。

  在linux平台上是用clone系统调用创建的,其与用linux pthread库创建出来的线程本质上是一样的,都是利用系统调用创建出来的OS线程实体。M的作用就是执行G中包装的并发任务。Go运行时系统中的调度器的主要职责就是将G公平合理的安排到多个M上去执行。其属于OS资源,可创建的数量上也受限了OS,通常情况下G的数量都多于活跃的M的。

  • P (Processor) :处理器,主要用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让我们从N:1调度到M:N调度的重要部分。

  主要作用是管理G对象(每个P都有一个G队列),并为G在M上的运行提供本地化资源。

  • G (Goroutine) :包含了栈,指令指针,以及其他对调度goroutine的重要信息,例如其阻塞的channel。

  上面用go关键字加函数调用的代码就是创建了一个G对象,是对一个要并发执行的任务的封装,也可以称作用户态线程。属于用户级资源,对OS透明,具备轻量级,可以大量创建,上下文切换成本低等特点。

  • Sched:调度器,它维护有存储Machine和goroutine的队列以及调度器的一些状态信息等。

  Processor 数量是在启动时被设置为环境变量 GOMAXPROCS 的值,或者通过运行时调用函数 GOMAXPROCS() 进行设置。数量固定意味着任意时刻只有 GOMAXPROCS 个线程在运行go代码。

2.2. 运行模型

  • 单核

单核.png

  • 多核阻塞

多核阻塞.png

  • run queue 完成。

对半分.png

  当其中一个Processor的runqueue为空,没有goroutine可以调度。它会从另外一个上下文偷取一半的goroutine。

3. 总结

  Go1.0 的实现中并没有P的概念,Go中的调度器直接将G分配到合适的M上运行。但这样带来了很多问题,例如,不同的G在不同的M上并发运行时可能都需向系统申请资源(如堆内存),由于资源是全局的,将会由于资源竞争造成很多系统性能损耗,为了解决类似的问题,Go1.1运行时系统加入了P,让P去管理G对象,M要想运行G必须先与一个P绑定,然后才能运行该P管理的G。这样可以在P对象中预先申请一些系统资源(本地资源),G需要的时候先向自己的本地P申请(无需锁保护),如果不够用或没有再向全局申请,而且从全局拿的时候会多拿一部分,以供后面高效的使用。

  而且由于P解耦了G和M对象,这样即使M由于被其上正在运行的G阻塞住,其余与该M关联的G也可以随着P一起迁移到别的活跃的M上继续运行,从而让G总能及时找到M并运行自己,从而提高系统的并发能力。

  Go运行时系统通过构造G-P-M对象模型实现了一套用户态的并发调度系统,可以自己管理和调度自己的并发任务,所以可以说Go语言原生支持并发。自己实现的调度器负责将并发任务分配到不同的内核线程上运行,然后内核调度器接管内核线程在CPU上的执行与调度。

Goroutine原理.png