Go 并行计算的核心-Goroutine

这一篇主要分享的是 Go 中比较核心的概念:协程(Coroutine),在 Go 中被改写之后称之为:Goroutine,它是并发模型的基本执行单元。事实上每一个Go程序至少有一个Goroutine:主Goroutine。当程序启动时,它会自动创建。

线程 和 OS 任务调度模型

为了更好理解 Goroutine,先看一下线程的概念。

线程(Thread):有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程 ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。

线程拥有自己独立的栈和共享的堆,线程的切换一般也由操作系统调度。

上图我们能看到,微信这个进程下启动了不同的线程在独立执行不同的任务。

任务调度模型

线程不能独立存在,它必须依附于某个进程。那么这个进程中的各个线程是如何被调度的呢,调度又是由谁负责?

首先操作系统并行执行的能力有限,多数时候是并发执行任务。大部分操作系统并发执行任务采用的方式是时间片轮转调度。这种调度会由操作系统内核来进行处理。通过硬件的计数器中断处理器,让该线程强制暂停并将该线程的寄存器放入内存中,通过查看线程列表决定接下来执行哪一个线程,并从内存中恢复该线程的寄存器,最后恢复该线程的执行,从而去执行下一个任务。

所以对于线程来说,这里多出来一个上下文切换的过程。调度器要实时去切换时间片以确保当前所有的线程都能照顾到,别饱了大儿子饿了小儿子。

这里衍生出一个很重要的概念:抢占式 任务系统,时间片轮换必然涉及到一个任务可能正执行一半,另一个任务的时间片到了,它此刻不得不悬挂起来。

再说说用户态和内核的的问题。所有用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核态的事情, 例如从硬盘读取数据 或者从键盘获取输入等。而唯一可以做这些事情的就是操作系统, 所以此时程序就需要先操作系统请求以程序的名义来执行这些操作这时需要一个这样的机制:用户态程序切换到内核态, 但是不能控制在内核态中执行的指令,这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)。

虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销。

协程

在高级语言中的 “函数调用” 的概念,在汇编里主要体现为两个寄存器。寄存器是 CPU 内部临时保存数据的区域,相当于高级语言里的变量。但是有一个寄存器是特殊的,它存放了 CPU 当前正在执行的指令的内存地址(Instruction Register)。一个高级语言中的函数一般会被编译成指令存放在一段连续的内存空间中(data segment)。那么所谓函数执行到了第几行这样的信息其实就是保存在这个 Instruction Register 中的。另外一个很特殊的寄存器是 Stack Register,它存放的内存地址指向的内存区域用于函数之间传递参数和返回值,以及存放一个函数内的局部变量。如果不考虑现代计算机 CPU 中各种各样其他存放中间结果的寄存器,理论上保存了 Instruction Register(执行到哪儿了)和 Stack Register(堆栈上的变量)就保存了一个函数的当前执行状态,分别是函数当前执行到了哪,以及这个函数局部变量所代表的当前 state。

以上构成了协程应用的基石,协程就是根据函数的当前执行状态来切换异步执行状态。

协程:协作式任务系统。协作式多任务不需要抢占式多任务的时间片分配,时钟滴答,抢占式上下文切换等一系列开销,根据实现方式的不同,任务切换时甚至可能不需要保存现场。另外协作式多任务中的每项任务都不需要担心被抢占,因而具有更低的通信和同步开销。

我们可以理解协程就是:

​ 协程 = 线程 - 抢占

线程由于共享进程空间,所以就大大减少了进程切换的开销,但依然是一个系统调用,需要从用户空间切换到系统空间然后再切换回用户空间,同时有可能导致 CPU 将时间片切给其它进程。

而协程则是由编程语言所支持的在用户空间内的多任务,协程存在于用户态,大量的协程实际上只占用了内核态的一个线程。简单的说就是同一个线程中,系统维护一个程序列表,某个程序阻塞了如果有其它程序等待执行,则不切线程而直接恢复那个程序的执行上下文。

而协作式多任务的弊端,上文中也提到,是任务必须知道协作的存在,并主动配合协作,而抢占式多任务对任务是透明的。

虽然抢占式多任务的开销更大,但操作系统面对的是一系列未知的应用程序,操作系统不能要求应用程序配合协作,因此在操作系统级别,协作式多任务是不现实的。

但是在应用程序级别,所有的任务都是开发者预先规划好的,因此协作式多任务是可能的。而相比抢占式多任务的透明性协作式多任务反而提供了更精细和可控的调度。

CSP 并发模型

Go 实现了两种并发形式。第一种是大家普遍认知的:多线程共享内存。其实就是 Java 或者 C++ 等语言中的多线程开发。另外一种是 Go 语言特有的,也是 Go 语言推荐的:CSP(communicating sequential processes)并发模型

CSP 并发模型是在 1970 年左右提出的概念,属于比较新的概念,不同于传统的多线程通过共享内存来通信,CSP 讲究的是 “以通信的方式来共享内存”。

请记住下面这句话:
DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING.
“不要以共享内存的方式来通信,相反,要通过通信来共享内存。”

普通的线程并发模型,就是像 Java、C++、或者 Python,它们的线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此在很多时候衍生出一种方便操作的数据结构,叫做 “线程安全的数据结构”。例如 Java 提供的 java.util.concurrent 包中的数据结构,Go 中也实现了传统的线程并发模型。

Go 其实只用到了 CSP 的很小一部分,即理论中的 Process/Channel(对应到 Go 中的 goroutine/channel):这两个并发原语之间相互独立,Process 通过对 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。

在 Go 中任何代码都是运行在 goroutine 里,即便没有显式的 go func() ,默认的 main 函数也是一个 goroutine。Goroutine 开销很小,它有自己的栈,不同于内核线程固定大小的内存块(2MB),goroutine 的栈初始值是 2KB,可以动态增长到 1G(64位1G,32位256M),GC 还会周期性回收不再使用的内存,收缩栈空间)。

channel 是 Go 语言中各个并发结构体(goroutine) 之前的通信机制。 通俗的讲就是各个 goroutine 之间通信的 ”管道“,有点类似于 Linux 中的管道。

生成一个 goroutine 的方式非常的简单:Go 一下就生成:

func main() {
	go func() {
		fmt.Println("hello")
	}()
	fmt.Println("main end")
	time.Sleep(time.Second)
}

通信机制 channel 也很方便,传数据用 channel <- data,取数据用 <-channel。

在通信过程中,传数据 channel <- data 和 取数据 <-channel 必然会成对出现,因为这边传那边取,两个 goroutine 之间才会实现通信。

而且不管传还是取,必阻塞,直到另外的 goroutine 传或者取为止。示例如下:

func main() {
	//初始换一个没有缓冲区的管道
	c := make(chan int)
	go func() {
		c <- 1
	}()
	fmt.Println("main end")
	i := <-c
	fmt.Println(i)
}

注意 main() 本身也是运行了一个 goroutine。

c := make(chan int) 这样就声明了一个阻塞式的无缓冲的通道。chan 是关键字,代表我要创建一个通道。

CSP 并发模型的 Go 实现

线程的实现模型主要有 3 种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),它们之间最大的差异就在于 用户线程与内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系上。

内核级线程模型

用户线程:内核线程 = 1:1,每个用户线程绑定一个内核线程,调度由「内核」完成。

优点:实现简单(直接调用「系统调用」),可以并行(利用多核)
缺点:占用资源多,上下文切换慢

用户级线程模型

用户线程:内核线程 = N:1,多个用户线程(从属于单个进程)绑定一个内核线程,调度由「用户」完成;也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程;许多语言的「协程库」都属于这种方式。

优点:占用资源少,上下文切换快,性能优于「内核级线程模型」
缺点:不支持多核,原生不支持并发(单个用户线程有阻塞调用,则所有线程都阻塞;因为此模式下操作系统的调度最小单位为进程)

可以通过封装阻塞调用为非阻塞调用,来避免所有线程都阻塞。

两级(混合型)线程模型

用户线程:内核线程 = M:N,一个用户进程(包含多个线程)可以「动态绑定」多个内核线程;调度由「内核、用户」完成(用户调度器实现用户线程到内核线程的调度,内核调度器实现内核线程到CPU上的调度);比如:某个内核线程由于其绑定的用户线程阻塞了,这个内核线程关联的其他用户线程可以重新绑定其他内核线程。

优点:支持并发,占用资源少
缺点:调度复杂

Go 采用的是此线程模型。下面来看看 Go 如何实现两级混用线程模型。

Go 内部有三个对象:

  • P (processor) :代表上下文(或者可以认为是 CPU)
  • M (work thread):代表工作线程
  • G:goroutine

正常情况下一个 CPU 对象启一个工作线程对象,线程去检查并执行 goroutine 对象。碰到 goroutine 对象阻塞的时候,会启动一个新的工作线程,以充分利用 CPU 资源。 所有有时候线程对象会比处理器对象多很多.

我们用如下图分别表示 P、M、G:

  • G(Goroutine) :表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

  • M(Machine) :对 Os 内核级线程的封装,数量对应真实的 CPU 数(真正干活的对象)。在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。

    M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

  • P(Processor) :逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P (在 P 的 local runqueue 中)才能被调度。对 M 来说,P 提供了相关的执行环境 (Context),如内存分配状态(mcache),任务队列 (G) 等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。

在单核情况下,所有 Goroutine 运行在同一个线程(M0)中,每一个线程维护一个上下文(P),任何时刻,一个上下文中只有一个 Goroutine,其他 Goroutine 在 runqueue 中等待。

一个 Goroutine 运行完自己的时间片后让出上下文,自己回到 runqueue 中(如下图所示)。当正在运行的 G0 阻塞的时候(可以需要 IO),会再创建一个线程(M1),P 转到新的线程中去运行。

当 M0 返回时,它会尝试从其他线程中 “偷” 一个上下文过来,如果没有偷到,会把 Goroutine 放到 Global runqueue 中去,然后把自己放入线程缓存中。 上下文会定时检查 Global runqueue。

从两级线程模型来看,似乎并不需要 P 的参与,有 G 和 M 就可以了,那为什么要加入 P 呢?

其实 Go 语言运行时系统早期(Go1.0)的实现中并没有 P 的概念,Go 中的调度器直接将 G 分配到合适的 M 上运行。但这样带来了很多问题,例如不同的 G 在不同的 M 上并发运行时可能都需向系统申请资源(如堆内存),由于资源是全局的,将会由于资源竞争造成很多系统性能损耗。

为了解决类似的问题,后面的 Go(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 上的执行与调度。

Go 并发实战

channel

Channel 是 Go 语言中一个非常重要的类型。通过 channel,Go 实现了通过通信来实现内存共享。Channel 是在多个 goroutine 之间传递数据和同步的重要手段。

使用原子函数、读写锁可以保证资源的共享访问安全,但使用 channel 更优雅

channel 的声明:var 变量名 chan 类型(var c chan int ),使用 make 初始化,close 关闭。

无缓存区通道

c := make(chan int)
c <- 1
out := <- c

有缓存的通道

//声明一个缓存区为10 的 int 管道
c := make(chan int, 10)
for i := 0; i < 10; i++ {
  c <- i
}
out := <- c

channel 是一个引用类型,所以在它被初始化之前,它的值是 nil,channel 使用 make 函数进行初始化。可以向它传递一个 int 值,代表 channel 缓冲区的大小(容量),构造出来的是一个缓冲型的 channel;不传或传 0 的,构造的就是一个非缓冲型的 channel。

无缓冲通道发送内容时,如果接受者没有准备好,则发送者会阻塞,反之亦然。有缓冲通道不会阻塞,接受者如果读取成功,则会继续读取,返回值 ok,如果为读取成功,管道未被关闭,则阻塞等待,如果未读取到,并且已经关闭,则返回 !ok。

select 多路复用

select 语句提供了一种处理多通道的方法。跟 switch 语句很像,但是每个分支都是一个通道:

  • 所有通道都会被监听
  • select 会阻塞直到某个通道读取到内容
  • 如果多个通道都可以处理,则会以伪随机的方式处理
  • 如果有默认分支,并且没有通道就绪,则会立即执行

与switch语句相比, select有比较多的限制,其中最大的一条限制就是每个case语句里必须是一个IO操作,大致的结构如下:

select {
    case <-chan1:
    // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
    // 如果成功向chan2写入数据,则进行该case处理语句
    default:
    // 如果上面都没有成功,则进入default处理流程
}

在一个select语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。

如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。

如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有两种可能的情况:

  • 如果给出了default语句,那么就会执行default语句,同时程序的执行会从select语句后的语句中恢复。
  • 如果没有default语句,那么select语句将被阻塞,直到至少有一个通信可以进行下去。

大家来看这个例子:

func main() {
	var ch = make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Println(x)
		case ch <- i:
		}
	}
}

这个例子会输出 0 2 4 6 8i= 1 3 5 7 9 的时候两个 case 都是满足的,这时候 select 就会随机选择一个。

goroutine 阻塞

有时候会出现goroutine阻塞的情况,那么我们如何避免整个程序进入阻塞的情况呢?我们可以利用select来设置超时,通过如下的方式实现:

func main() {

	c := make(chan int)
	o := make(chan bool)

	go func() {
		for {
			select {
			case v := <-c:
				fmt.Println(v)
			case <-time.After(2 * time.Second):
				fmt.Println("timeout")
				o <- true
				break
			}
		}
	}()
	<-o
}

小结

本篇我们开始涉及 Go 核心的多线程通信部分。在 Java 中多个线程之前的数据传输方式是通过共享同一个变量,而 Go 通过 channel 这一中介沟通起了线程之前通信的桥梁。这在编程习惯上是一个很大的改变,大家要熟悉这一使用方式。

讲完线程间通信,我们接下来就进入到并发编程的世界,看看在 Go 中是如何处理并发操作的。

posted @ 2021-01-28 09:46  rickiyang  阅读(1342)  评论(0编辑  收藏  举报