Go 并发机制:Goroutine

Go 是当前一门热门的编程语言,其优秀的并发特性吸引了无数程序员的目光。

Go 的并发特性是一个比较大的话题,笔者计划从以下三个方面讨论:

  • Go goroutine 并发机制
  • Go channel 通道机制
  • Go select 机制

本文讨论 Go 的 goroutine 并发机制。

并发与并行

在讨论 goroutine 之前,我们先来看下并发与并行的区别。

多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行。

以生活中慢跑例子来说明并发与并行的区别。

一个人在进行慢跑,途中鞋带松了。这时,他停止跑步,系好鞋带,然后再继续跑步。这是并发的经典示例,即这个人能够处理跑步和系鞋带,可以理解为这个人可以“同时”(at onece)处理多个事情。

同样以慢跑来例子来说明并行的含义。假设一个人正在戴着 airpods 听音乐慢跑。在这种情况下,这个人同时(at the same time)进行慢跑和听音乐,这就是所谓的并行。

Go 使用 goroutine 和 channel 实现并发特性。

什么是 Goroutine

Goroutine 是一个轻量级的可独立运行的工作单元,可以看作是轻量级的线程。与线程相比,创建 goroutine 的成本很小,因此 go 应用程序通常会同时运行数千个 goroutine。

Goroutine 的优势

  • 创建 goroutine 的成本远小于线程。Goroutine 的栈大小只有几 KB,且可以根据应用程的需要增长和收缩。而对于线程,栈大小必须指定和固定下来
  • 多个 goroutine 可以复用一个操作系统的线程。在一个有数千个 goroutine 的程序中可能只有一个线程。如果一个线程由于 goroutine 等待用户输入而阻塞,则会创建一个新的线程,其余的 goroutine 也会移动到这个新的线程运行。这些对于程序员来说都是透明的
  • Goroutine 使用通道(channel)来通信。通道的使用可以防止访问共享内存时出现资源竞争的问题。有关通道的机制,我们在下一节进行描述

如何启动一个 Goroutine

为了启动一个 goroutine,只需要在函数或方法前面添加上 go 关键字,这样一来,我们就启动了一个 goroutine ,这个 goroutine 会并发地运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
)

func hello() {
fmt.Println("Hello world goroutine")
}
func main() {
go hello()
fmt.Println("main function")
}

上面的源程序中,我们使用 go hello() 启动一个 goroutine,这样 hello() 函数就会在一个 goroutine 中运行。同时, main() 函数会在另一个被称为 main goroutine 的 goroutine 中运行,hello() 函数与 main() 函数实现了并发运行。

运行程序,得到输出:

1
main function

输出结果与我们预期不一致,我们本以为会同时输出 Hello world goroutinemain function,但实际上只输出main function。为什么会这样呢?

原因如下。

  • 当启动一个新的 goroutine 后会立即返回。跟普通的函数或方法不同,主函数并不会等待新启动的 goroutine 返回,而是会继续执行下一行主函数的语句
  • 当主函数所在的 main goroutine 执行结束后,程序也运行结束,这样其他的 goroutine 也不再执行

由上面的分析可知,当主函数执行完 go hello() 后,会马上执行下一行打印语句,然后程序结束了运行。这样 hello goroutine 也就没有机会执行。

为解决hello goroutine 未执行的问题,可以在主函数中使用 Sleep 语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"time"
)

func hello() {
fmt.Println("Hello world goroutine")
}
func main() {
go hello()
time.Sleep(1 * time.Second)
fmt.Println("main function")
}

在上面的程序中,我们使用了语句 time.Sleep(1 * time.Second) ,这样主函数会等待 1 秒钟,在这 1 秒内,hello goroutine 会输出 Hello world goroutine ,然后主函数会再输出 main function

上面的程序仅作示例使用,实际中,为了让 main goroutine 等待其他的 goroutine 执行完毕,可以使用 channel(通道)。我们将在下一篇文章中对 channel 进行描述。

启动多个 Goroutine

接下来我们编写一个更复杂的程序,在这个程序中,会同时启动多个 goroutine。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"time"
)

func numbers() {
for i := 1; i <= 5; i++ {
time.Sleep(250 * time.Millisecond)
fmt.Printf("%d ", i)
}
}
func alphabets() {
for i := 'a'; i <= 'e'; i++ {
time.Sleep(400 * time.Millisecond)
fmt.Printf("%c ", i)
}
}
func main() {
go numbers()
go alphabets()
time.Sleep(3000 * time.Millisecond)
fmt.Println("main terminated")
}

在上面的程序中,分别启动了 numbersalphabets 两个 goroutine,这个两个 goroutine 会并发执行。

numbers goroutine 会睡眠 250 毫秒,然后打印数字 1,接着睡眠 250 毫秒,然后打印数字 2,直至打印 数字5

同样的,alphabets goroutine 睡眠 400 毫秒,然后打印 字母 a,直到打印字母 e

main goroutine 启动两个 goroutine 后,睡眠 3000 毫秒,然后结束运行。

运行程序,得到输出:

1 a 2 3 b 4 c 5 d e main terminated

可以使用下图来表示几个 goroutine 执行情况。

第一个蓝色的长条图表示 numbers goroutine 的执行情况,每隔 250 毫秒打印一个数字。第二个红色的长条图表示alphabets goroutine 的执行情况,每隔 400 毫秒打印一个字母。第三个绿色的长条图表示 main goroutine 的执行情况,睡眠 3000 毫秒,然后结束执行。最后一个长条图表示程序的实际输出。通过这个图,可以清晰得到程序的执行和输出结果。

参考资料