Go 并发

本文将介绍 Go 对并发的支持。

一、进程与线程

具体请看:

操作系统 并发 - 一、进程与线程

二、CSP

传统编程语言 并非面向并发而生,它们对并发的支持往往依赖操作系统,利用的大多是操作系统提供的线程、进程间通信的原语(共享内存、信号、管道、消息队列、套接字等),并且使用更广泛的是结合了 线程同步原语 (锁、原子操作)的 共享内存方式 。这种基于共享内存的并发模型 难用且易错 ,程序员需要精心设计线程间的同步机制,考虑多线程间复杂的内存管理,考虑死锁的预防等。

Go 在并发模型的设计中借鉴了 Tony Hoare 提出的 CSP 并发模型 。CSP,Communicating Sequential Processes,通信顺序进程并发模型。CSP 旨在简化并发程序的编写,它认为输入输出应该是基本的编程原语,Process 只需要调用输入原语获取数据,顺序地处理数据,将结果数据通过输出原语输出即可。因此,一个符合 CSP 模型的并发程序应该是 一组通过输入原语、输出原语连接起来的 Process 的集合 。

Go 始终推荐以 CSP 并发模型风格构建并发程序,并且也提供了对传统的基于共享内存的并发模型的支持。

Don’t communicate by sharing memory, share memory by communicating. – Rob Pike

不要通过共享内存来通信,应该通过通信来共享内存。 – Go语言之父 Rob Pike

三、goroutine - 轻量级线程

1. 什么是轻量级线程?

Go 并没有将操作系统线程作为基本执行单元,而是自己实现了 goroutine,它是由 Go 运行时负责调度的轻量级线程。

相比操作系统线程来说,goroutine 具有以下特点:

  • 资源占用小

    • 操作系统线程栈大小固定,通常为 2M;goroutine 栈大小可变,通常初始大小为 2k
  • 管理开销小,创建、销毁不需要系统调用

  • 调度开销小,由 Go 运行时调度而不是操作系统调度,上下文切换只需要在用户模式下即可完成

2. 基本用法

通过 Go 函数 / 方法 创建 goroutine。

1
go fmt.Println("I am a goroutine")

3. goroutine 是不是协程?

A goroutine is a lightweight thread managed by the Go runtime.

goroutine 并不是协程,其本质上是轻量级线程。

协程最关键的点在于:

  • 协作式调度而非抢占式调度

    • 协作式调度:各个任务之间相互合作,程序主动在需要等待时让出控制权
    • 抢占式调度:各个任务之间不协作,互相争抢,外部调度器会中断运行并进行调度

四、channel - 通道

1. 什么是 channel?

channel 既可以用来实现 goroutine 之间的通信,也可以用来实现 goroutine 之间的同步。

2. channel 是一等公民

在 Go 中,channel 是一等公民,可以在代码中像使用普通变量一样使用它,例如:定义、赋值、传参、作为返回值、将 channel 放到其它 channel 中。

3. 创建 channel

1
ch := make(chan int)

创建一个元素类型为 int 的 channel,它的缓存区大小为 0。channel 的读写都会阻塞。

1
ch := make(chan int, num)

创建一个元素类型为 int 的 channel,它的缓存区大小为 num。缓存区未满时,写不需要阻塞;缓存区非空时,读不需要阻塞;否则,阻塞。

4. channel 的读写

1
2
3
4
5
// 向channel写值
ch <- 13

// 从channel读值
n := <- ch

5. 只读 / 只写的 channel

通过 <- 可以声明只读 / 只写的 channel,如下:

1
2
3
4
5
// 只写
ch1 := make(chan<- int)

// 只读
ch2 := make(<-chan int)

尝试从只写 channel 读值和向只读 channel 写值,都会导致编译错误。

通常而言,只读 / 只写的 channel 会被作为函数的形参,用以限制函数内部对 channel 的操作,例如:

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
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i + 1
time.Sleep(time.Second)
}
close(ch)
}

func consume(ch <-chan int) {
for n := range ch {
println(n)
}
}

func main() {
ch := make(chan int, 5)

go func() {
produce(ch)
}()

go func() {
consume(ch)
}()
}

6. 关闭 channel

可以通过 close() 函数关闭 channel。

channel 关闭后,所有从 channel 读取数据的操作都将返回,具体如下:

1
2
3
4
5
6
7
8
9
10
// 返回零值
n := <- ch

// 返回零值,ok为false
m, ok := <- ch

// 循环结束
for v := range ch {
...
}

7. select

Go 提供了 select 关键字,用于同时在多个 channel 上进行 读值 / 写值操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
select {
// 从ch1读值
case x := <-ch1:
...
// 从ch2读值,并判断是否关闭
case y, ok := <-ch2:
...
// 向ch3写值
case ch3 <- z:
...
// 默认分支
default:
...
}

需要注意的是:

  • 每个 case 都应该是通道操作
  • select 会监听所有 case,
    • 如果有通道就绪,从这些通道中随机选择一个通道执行
    • 否则,
      • 如果有 default,执行其中的与否
      • 否则,阻塞

参考