51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

go 并发编程 之 goroutine (go goroutine)

为什么很多人不用PHP ,java 了, 因为go 天生支持 并发。 充分利用现代CPU的多核优势,

比如我们把go程序放到服务器上, 就可以跑满。

java 是os 调用了 一个线程池。

go 比较 轻量级,比如 同样开10万个线程, go 是毫无压力的, java 就有可能崩了。

串行、并发与并行 {#autoid-0-1-0}

串行:我们都是先读小学,小学毕业后再读初中,读完初中再读高中。

并发:同一时间段内执行多个任务(你在用微信和两个女朋友聊天)。

并行:同一时刻执行多个任务(你和你朋友都在用微信和女朋友聊天)。

并发模型 {#autoid-0-1-2}

业界将如何实现并发编程总结归纳为各式各样的并发模型,常见的并发模型有以下几种:

  • 线程&锁模型
  • Actor模型
  • CSP模型
  • Fork&Join模型

Go语言中的并发程序主要是通过基于CSP(communicating sequential processes)的goroutine和channel来实现,当然也支持使用传统的多线程共享内存的并发方式。

goroutine {#autoid-0-1-3}

Goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。区别于操作系统线程由系统内核进行调度, goroutine 是由Go运行时(runtime)负责调度。

例如Go运行时会智能地将 m个goroutine 合理地分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。

Goroutine 是 Go 程序中最基本的并发执行单元。每一个 Go 程序都至少包含一个 goroutine------main goroutine,当 Go 程序启动时它会自动创建。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能------goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了,就是这么简单粗暴。

go关键字 {#autoid-0-2-0}

Go语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上go关键字就可以创建一个 goroutine ,从而让该函数或方法在新创建的 goroutine 中执行

go f()  // 创建一个新的 goroutine 运行函数f

匿名函数也支持使用go关键字创建 goroutine 去执行。

go func(){
  // ...
}()

一个 goroutine 必定对应一个函数/方法,可以创建多个 goroutine 去执行相同的函数/方法。

启动单个goroutine {#autoid-0-2-1}

启动 goroutine 的方式非常简单,只需要在调用函数(普通函数和匿名函数)前加上一个go关键字。

我们先来看一个在 main 函数中执行普通函数调用的示例。

package main

import (
	"fmt"
)

func hello() {
	fmt.Println("hello")
}

func main() {
	hello()
	fmt.Println("你好")
}

将上面的代码编译后执行,得到的结果如下:

hello
你好

接下来我们在调用 hello 函数前面加上关键字go,也就是启动一个 goroutine 去执行 hello 这个函数。

func main() {
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
}

将上述代码重新编译后执行,得到输出结果如下。

你好

这一次的执行结果只在终端打印了"你好",并没有打印 hello。这是为什么呢?

其实在 Go 程序启动时,Go 程序就会为 main 函数创建一个默认的 goroutine 。在上面的代码中我们在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 hello 函数,而此时 main goroutine 还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine。当 main 函数结束时整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出"hello"。

main goroutine 就像是《权利的游戏》中的夜王,其他的 goroutine 都是夜王转化出的异鬼,夜王一死它转化的那些异鬼也就全部GG了。

所以我们要想办法让 main 函数'"等一等"将在另一个 goroutine 中运行的 hello 函数。其中最简单粗暴的方式就是在 main 函数中"time.Sleep"一秒钟了(这里的1秒钟只是我们为了保证新的 goroutine 能够被正常创建和执行而设置的一个值)。

按如下方式修改我们的示例代码。

package main

import (
	"fmt"
	"time"
)

func hello() {
	fmt.Println("hello")
}

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

将我们的程序重新编译后再次执行,程序会在终端输出如下结果,并且会短暂停顿一会儿。

你好
hello

为什么会先打印你好呢?

这是因为在程序中创建 goroutine 执行函数需要一定的开销,而与此同时 main 函数所在的 goroutine 是继续执行的。

在上面的程序中使用time.Sleep让 main goroutine 等待 hello goroutine执行结束是不优雅的,当然也是不准确的。

Go 语言中通过sync包为我们提供了一些常用的并发原语,我们会在后面的小节单独介绍sync包中的内容。在这一小节,我们会先介绍一下 sync 包中的WaitGroup。当你并不关心并发操作的结果或者有其它方式收集并发操作的结果时,WaitGroup是实现等待一组并发操作完成的好方法。

下面的示例代码中我们在 main goroutine 中使用sync.WaitGroup来等待 hello goroutine 完成后再退出。

package main

import (
	"fmt"
	"sync"
)

// 声明全局等待组变量
var wg sync.WaitGroup

func hello() {
	fmt.Println("hello")
	wg.Done() // 告知当前goroutine完成
}

func main() {
	wg.Add(1) // 登记1个goroutine
	go hello()
	fmt.Println("你好")
	wg.Wait() // 阻塞等待登记的goroutine完成
}

将代码编译后再执行,得到的输出结果和之前一致,但是这一次程序不再会有多余的停顿,hello goroutine 执行完毕后程序直接退出。

启动多个goroutine {#autoid-0-2-2}

在 Go 语言中实现并发就是这样简单,我们还可以启动多个 goroutine 。让我们再来看一个新的代码示例。这里同样使用了sync.WaitGroup来实现 goroutine 的同步。

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func hello(i int) {
	defer wg.Done() // goroutine结束就登记-1
	fmt.Println("hello", i)
}
func main() {
	for i := 0; i < 10; i++ {
		wg.Add(1) // 启动一个goroutine就登记+1
		go hello(i)
	}
	wg.Wait() // 等待所有登记的goroutine都结束
}

多次执行上面的代码会发现每次终端上打印数字的顺序都不一致。这是因为10个 goroutine 是并发执行的,而 goroutine 的调度是随机的。

动态栈 {#autoid-0-2-3}

操作系统的线程一般都有固定的栈内存(通常为2MB),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine 的初始栈空间很小(一般为2KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。

goroutine调度 {#autoid-0-2-4}

操作系统内核在调度时会挂起当前正在执行的线程并将寄存器中的内容保存到内存中,然后选出接下来要执行的线程并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。从一个线程切换到另一个线程需要完整的上下文切换。因为可能需要多次内存访问,索引这个切换上下文的操作开销较大,会增加运行的cpu周期。

区别于操作系统内核调度操作系统线程,goroutine 的调度是Go语言运行时(runtime)层面的实现,是完全由 Go 语言本身实现的一套调度系统------go scheduler。它的作用是按照一定的规则将所有的 goroutine 调度到操作系统线程上执行。

GPM调度模型

在经历数个版本的迭代之后,目前 Go 语言的调度器采用的是 GPM 调度模型。

其中:

  • G:表示 goroutine,每执行一次go f()就创建一个 G,包含要执行的函数和上下文信息。
  • 全局队列(Global Queue):存放等待运行的 G。
  • P:表示 goroutine 执行所需的资源,最多有 GOMAXPROCS 个。
  • P 的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G 时,G 优先加入到 P 的本地队列,如果本地队列满了会批量移动部分 G 到全局队列。
  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。(真正干活的线程)
  • Goroutine 调度器和操作系统调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核上执行。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的, goroutine 则是由Go运行时(runtime)自己的调度器调度的,完全是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身 goroutine 的超轻量级,以上种种特性保证了 goroutine 调度方面的性能。

GOMAXPROCS {#autoid-0-2-5}

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。默认值是机器上的 CPU 核心数。例如在一个 8 核心的机器上,GOMAXPROCS 默认为 8。Go语言中可以通过runtime.GOMAXPROCS函数设置当前程序并发时占用的 CPU逻辑核心数。(Go1.5版本之前,默认使用的是单核心执行。Go1.5 版本之后,默认使用全部的CPU 逻辑核心数。)

m:n (把 m个 goroutine 分配给 n个操作系统线程执行)

赞(1)
未经允许不得转载:工具盒子 » go 并发编程 之 goroutine (go goroutine)