一、介绍
Goroutine 是 Go 语言支持并发的核心,在一个 Go 程序中同时创建成百上千个 goroutine 是非常普遍的,一个 goroutine 会以一个很小的栈开始其生命周期,一般只需要2 KB。
区别于操作系统线程由系统内核进行调度, goroutine 是由 Go 运行时(runtime)负责调度。
例如 Go 运行时会智能地将 m 个 goroutine 合理地分配给 n 个操作系统线程,实现类似 m:n 的调度机制,不再需要 Go 开发者自行在代码层面维护一个线程池。
Goroutine 是 Go 程序中最基本的并发执行单元。
每一个 Go 程序都至少包含一个 goroutine——main goroutine,当 Go 程序启动时它会自动创建。
在 Go 语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能——goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了,就是这么简单粗暴。
goroutine 的特点:
goroutine 具有可增长的分段堆栈。这意味着它们只在需要时才会使用更多内存
goroutine 的启动时间比线程快
goroutine 原生支持利用 channel 安全地进行通信
goroutine 共享数据结构时无需使用互斥锁
二、go 关键字
Go 语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上 go 关键字就可以创建一个 goroutine ,从而让该函数或方法在新创建的 goroutine 中执行。
go f() // 创建一个新的 goroutine 运行函数f
匿名函数也支持使用 go 关键字创建 goroutine 去执行。
go func(){
// ...
}()
一个 goroutine 必定对应一个函数/方法,可以创建多个 goroutine 去执行相同的函数/方法。
三、启动单个 goroutine
启动 goroutine 的方式非常简单,只需要在调用函数(普通函数和匿名函数)前加上一个 go 关键字。
我们先来看一个在 main 函数中执行普通函数调用的示例。
package main
import (
"fmt"
)
func hello() {
fmt.Println("hello")
}
func main() {
hello()
fmt.Println("你好")
}
将上面的代码编译后执行,得到的结果如下:
hello
你好
代码中 hello 函数和其后面的打印语句是串行的。

接下来我们在调用 hello 函数前面加上关键字go,也就是启动一个 goroutine 去执行 hello 这个函数。
func main() {
go hello() // 启动另外一个goroutine去执行hello函数
fmt.Println("你好")
}
将上述代码重新编译后执行,得到输出结果如下。
你好
这一次的执行结果只在终端打印了"你好",并没有打印 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 秒钟了(这里的 1 秒钟是我们根据经验而设置的一个值,在这个示例中 1 秒钟足够创建新的 goroutine 执行完 hello 函数了)。
按如下方式修改我们的示例代码。
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
在 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 的调度是随机的。
①、动态栈
操作系统的线程一般都有固定的栈内存(通常为 2 MB),而 Go 语言中的 goroutine 非常轻量级,一个 goroutine 的初始栈空间很小(一般为 2 KB),所以在 Go 语言中一次创建数万个 goroutine 也是可能的。
并且 goroutine 的栈不是固定的,可以根据需要动态地增大或缩小, Go 的 runtime 会自动为 goroutine 分配合适的栈空间。
②、goroutine 调度
操作系统内核在调度时会挂起当前正在执行的线程并将寄存器中的内容保存到内存中,然后选出接下来要执行的线程并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。
从一个线程切换到另一个线程需要完整的上下文切换。
因为可能需要多次内存访问,索引这个切换上下文的操作开销较大,会增加运行的 cpu 周期。
区别于操作系统内核调度操作系统线程,goroutine 的调度是 Go 语言运行时(runtime)层面的实现,是完全由 Go 语言本身实现的一套调度系统——go scheduler。它的作用是按照一定的规则将所有的 goroutine 调度到操作系统线程上执行。
在经历数个版本的迭代之后,目前 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
Go 运行时的调度器使用 GOMAXPROCS 参数来确定需要使用多少个 OS 线程来同时执行 Go 代码。
默认值是机器上的 CPU 核心数。
例如在一个 8 核心的机器上,GOMAXPROCS 默认为 8。
Go 语言中可以通过 runtime.GOMAXPROCS 函数设置当前程序并发时占用的 CPU 逻辑核心数。
(Go1.5 版本之前,默认使用的是单核心执行。Go1.5 版本之后,默认使用全部的 CPU 逻辑核心数。)
五、练习题
请写出下面程序的执行结果。
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i)
}()
}
Go基础 第16.2章 并发-goroutine协程