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,执行其中的与否
      • 否则,阻塞

参考