并发编程

Go 中的并发编程

并发编程,Concurrent Programming,指的是编写并发程序。并发程序是指程序中含有多个执行实体(Actor),可以并行(或串行)的执行。

Go 所采用的是 CSP(Communicating Sequential Processes)通信顺序进程并发模式。其理念是:“不要通过共享内存的方式来通信;作为代替,应使用通信来共享内存(Do not communicate by sharing memory; instead, share memory by communicating.)”。这句话就是 Go 语言并发编程模式的 Slogan。

使用 Go 做并发编程,主要需要掌握 goroutine 并发调度,channel 信道通信和 sync 同步机制。

goroutine,go 程

goroutine,一个专用名词,本文采用的翻译为 go 程,是因为与已有术语进程、线程和协程概念不完全相同,是 Go 语言特有的线程实现模式。

go 语句

go 语句可以在一个独立的 goroutine 中执行一个函数,语法为:

// 语法
go 函数或方法调用表达式

// 示例
// 调用具名函数(方法)
go FuncName()
func FuncName() {
    
}

// 调用函数字面量
go func(){
	// 函数主体
}()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注意,下列内建函数的 go 调用不被允许,他们是:

append cap complex imag len make new real
unsafe.Alignof unsafe.Offsetof unsafe.Sizeof
1
2

若强制使用,会导致 go 编译器 go discards result of append(... 错误!究其原因是,以上函数不被视为表达式语句(expression statements),就是不认为处在语句上下文中。

goroutine 具有下列性质:

  • go 语句的执行是非阻塞的,意味着 go 语句不会等待,阻塞后续代码执行。
  • go 语句的执行是并发的,意味着多个 go 语句会同时处于运行状态。
  • main 函数的执行也是在某个独立的 goroutine 中。
  • 各个 goroutine 间是相互独立的,或者是平行的,不存在父子 goroutine 的操作。
  • goroutine 执行的顺序由 goroutine 调度机制内部确定,意味着与语法调用顺序不能保证一致。
  • go 所调用函数运行结束,相应的 goroutine 同时结束。
  • go 所调用函数的返回值会被忽略,没有意义。

参考示例1,concurrenct-1.go,基本语法:

// concurrenct-1.go
package main
import (
    "fmt"
    "time"
)
func main() {
    for i := 0; i < 5; i++ {          
        go func(n int) {
            fmt.Println(n)
        }(i)
    }
    fmt.Println("hello main!")
    time.Sleep(time.Millisecond)
}
// --- run --- 
$ go run concurrence-1.go 
hello main!
2
3
4
0
1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

本例中,利用 for 调用了 5 次 func(n int) 函数,包括 main 函数,共有6个 goroutine 执行,通过输出结果,可发现并不是依据语法调用顺序执行的,而是 goroutine 内部调度执行,具体的执行顺序根据当时 runtime 环境而定。同时 hello main 先被输出,也可见 go 语句在调用时,并没有阻塞执行,后续的代码会继续执行,是因为 main 函数也在独立的 goroutine中执行。

代码中的 time.Sleep(time.Millisecond) 是控制所在的 goroutine ,也就是 main 函数运行的 goroutine 暂停一下(1ms)。是为了给其他的 goroutine 留下执行时间,否则没有该语句,main 所在的主 goroutine 会立即结束,同时导致整个程序结束,也就等不到其他 goroutine 执行结束了。特别注意,除了 time.Sleep() 还有更合理的策略,例如 runtime.Gosched() 或同步,请参考相应章节。

在上例中,还有一个闭包的问题,需要注意。函数字面量会形成一个闭包(closure),若直接使用外部函数变量,会出现闭包现象,请看示例2,concurrence-2.go:

package main
import (
    "fmt"
    "time"
)                             
func main() {
    for i := 0; i < 5; i++ {
        go func() {
            fmt.Println(i)
        }()
    }

    fmt.Println("hello main!")
    time.Sleep(time.Millisecond)
}
// --- run ---
$ go run concurrence-2.go 
hello main!
5
5
5
5
5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

注意示例2,go func() 并没有定义参数,也就是该函数与内部使用的 i 变量(i 变量在函数外定义)形成了闭包,这也导致5次 go func() 函数同时闭包了一个变量 i,而 i 变量随着 for 循环最终被赋值为5,因此5次go func() 中访问的 i 也都是一致的 5。这不是什么错误,就是函数字面量形成的闭包现象的一种结果,大家需要在使用函数字面量时注意这类现象。大多数环境中,我们会使用示例1的参数传递语法。

goroutine 控制

runtime.Gosched()

runtime.Goexit()

channel,信道

同步机制

影响的数据类型

深入 goroutine

并发编程综述

CSP,通信顺序进程

CSP 通信顺序进程是一种典型的并发编程模型,最早见于东尼·霍尔在1978年发表的论文。该模式的典型特征为:

  • 并发程序由独立、并发执行的实体构成。
  • 实体间通过发送消息仅需通信。
  • 发送消息使用的信道(channel)为第一类对象。信道不与任何实体紧耦合,而是可以单独创建和读写,并在多个实体进程间传递。

通信顺序进程模式有实体和信道两大核心模块,在 Go 语言中对应的是 goroutine 调用(go 关键字)和 channel 类型,分别用于实现并发调度和实体间通信 。

并发和并行

多进程

多线程

数据并行

参考