本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
并发是一个计算机科学用语,将一个进程分割成独立组件并指明这些组件如何安全共享数据。大部分语言通过库提供并发,使用的是尝试通过获取锁操作执行系统级共享数据的线程。Go独树一帜。它的主要并发模块,很多认认为是Go的最著名的特性,基于CSP(通讯顺序过程)。它依据快速排序算法的发明人Tony Hoare在1978年的论文所描述的并发风格。根据CSP实现的模式和标准并发同样强大,但容易理解多了。
本章中,我们将快速复习支持Go并发的核心特性:协程、通道以及select
关键字。然后我们会学习一些常见的Go并发模式,接着我们会学习一些底层技术是更好方法的场景。
我们先给出一些告诫。并发肯定让程序受益。Go新手在尝试使用并发时,通常会经历一系列阶段:
人们使用并发是因为相信并发的程序运行速度更快。可惜有时却事与愿违。更多的并发并不会自动让程序变快,而且会让程序更难理解。核心点在于明白并发不是并行。并发是一种更好地组织待解决问题的框架。并发代码是否并行(同时执行)取决于硬件以及算法是否允许。1967年,计科的先驱之一Gene Amdahl,提出了阿姆达尔定律。这是一个在给定了多少任务必须顺序执行的情况下,并行处理能在多大程度上提升性能的公式。如果想深入了解阿姆达尔定律,可以学习Clay Breshears所著的并发的艺术一书。这里我们只需要知道更多的并发并不表示更快的速度。
广义来说,所有程序都遵循三步流程:接收数据、处理数据、输出结果。是否该在程序中使用并发取决于数据在程序中如何流动。有时两个步骤可以并发执行,因为相互之间不依赖另一步骤的数据做进一步处理,而其中一步依赖于另一步的输出时就需要顺序执行。在合并多个可独立运行的操作生成的数据时可使用并发。
另外要重点提一下所运行的任务耗时很短并不值得使用并发。并发是有开销的,很多常见内存中的算法非常快,通过并发传递数据所带来的开销远大于并行运行并发代码所节省的时间。这也是为什么并发操作常用于I/O,对磁盘或网络进行数千次读写非常缓慢,而它们又是最复杂的内存进程。如果不确定并发是否有益,先编写顺序执行代码,然后编写基准测试来与并发实现进行性能比较。(参见编写测试一章中如何编写benchmark代码的内容。)
考虑这样一个例子。假设在编写一个调用其它三个web服务的服务。向其中两个服务发送数据,接收这两次调用的结果发送给第三个服务,再返回结果。整个过程必须在50毫秒以内,否则会返回错误。这就是使用并发的一个好场景,因为存在彼此不进行交互的I/O操作,以及合并结果的代码,同时对于代码时长还有限制。在本章结尾,我们就能明白如何实现这段代码。
协程是Go并发模型的核心概念。要理解协程,我们先做几个名词解释。第一个是进程。进程是操作系统中运行中的一个程序实例。操作系统将进程关联一些资源,比如内存,来确保其它进程不会占用它们。进程由多个线程组成。线程是由操作系统分配了一定时间的执行单元。进程中的线程共享资源。CPU根据核数可同时执行一个或多个线程的指令。操作系统的一项任务是调度CPU上的线程来保障进程(以及进程中的每个线程)有运行的机会。
协程是由Go运行时所管理的轻量进程。在Go程序开始运行时,Go运行时会创建一些线程并启动一个协程来运行程序。程序所创建的所有协程,包括初始协程,会自动由Go运行时调度器分配给这些线程,这就和操作系统在多核CPU间调度线程是一样的。看上去可能是画蛇添足,因为底层操作系统已经有管理线程和进程的调度器了,但这么做其实有如下好处:
这些优点可以让Go程序产生成百上千甚至是几万个同步的协程。如果尝试使用原生线程在语言中启动几千线程,程序会像蜗牛一样慢。
小贴士:如果深入了解调度器的原理,可以听听Kavya Joshi在GopherCon 2018上主题为The Scheduler Saga的演讲。
通过在函数调用前添加go
关键字来开启协助。和其它函数一样,可以向其传递参数来初始化状态。但是函数的返回值会被忽略。
所有函数都能以协程启动。这和JavaScript不同,JS 中必须由作者使用async
关键字来声明函数才能异步运行。但是Go中习惯上使用包裹着业务逻辑的闭包来启动协程。闭包负责并发的登记。比如,闭包从通道中读取值并传递给业务逻辑,业务完全不知道运行于协程之中。然后函数的结果会写回到其它通道。(我们会在下一节中简单地概览通道。)责任的分享使得代码模块化、可测试,并将并发维护在你的API之外:
func process(val int) int {
// do something with val
}
func runThingConcurrently(in <-chan int, out chan<- int) {
go func() {
for val := range in {
result := process(val)
out <- result
}
}()
}
协程使用通道进行通讯。与切片和字典一样,通道也是使用make
函数创建的内置类型:
ch := make(chan int)
和字典一样,通道也是引用类型。在将通道传递给函数时,实际是向通道传递一个指针。还是与字典和切片一样,通道的零值是nil
。
使用<-
运算符来与通道通信。把<-
运算符放到通道变量的左边来读取通道,而写入通道时则放到右边:
a := <-ch // 读取ch中的值并赋值给a
ch <- b // 将b中的值写入ch
写入通道的每个值只能进行一次读取。如果多个协程从同一个通道中读取,写入到通道的值只会被其中的一个读取。
协程很少读取并写入同一通道。在将通道赋值给一个变量或字段,或是传递给函数时,将箭头放到chan
关键字前(ch <-chan int
) 来表示协程仅从通道中进行读取。将箭头放到chan
关键字之后(ch chan<- int
) 来表示协程仅向通道写入。这样Go编译器就能保障通道仅由函数读取或写入。
默认通道是无缓冲的。每次对打开的无缓冲通道写入都会导致写协程暂停,直到另一个协程从同一个通道读取。类似地,每次对打开的无缓冲通道读取都会导致读协程暂停,直到另一个协程对同一个通道写入。这表示至少要有两个并发运行的协程才能对无缓冲通道写入或读取。
Go还有带缓冲通道。这些通道会在不阻塞的情况下缓冲一定数量的写入。如果缓冲满了又没有对通道的读取,随后对通道的写入会暂停写协程直到有对通道的读取。就像向满缓冲通道写入一样,读取空缓冲通道也会阻塞住。
在创建通道时通过指定缓冲容量来创建有缓冲通道:
ch := make(chan int, 10)
内置函数len
和cap
返回缓冲通道的相关信息。使用len
找出缓冲中当前有多少值,使用cap
来找出最大缓冲尺寸。缓冲的容量无法修改。
注:对len
和cap
传递无缓冲通道会返回0。这可以理解,因为从定义上来说,无缓冲通道没有用于存储值的缓冲。
大部分时候都应当使用无缓冲通道。在何时使用缓冲和无缓冲通道一节中,我们会讨论使用缓冲通道的场景。
也可以使用for-range
循环来读取通道:
for v := range ch {
fmt.Println(v)
}
与其它for-range
循环不同的是,通道中只声明了一个变量,也就是其值。循环会一直持续,直至通道关闭或是出现了break
或return
语句。
在完成对通道写入后,可以使用内置的close
函数关闭通道:
close(ch)
通道在关闭后,写入通道或再次关闭通道都会panic。有趣的是,对关闭的通道读取却总是成功的。如果是有缓冲通道且值尚未被读取,会按顺序进行返回。如果是无缓冲通道或是有缓冲通道中没有值,会返回通道类型的零值。
这就出现和字典相同的问题:在读取通道时,怎么区分写入的就是零值还是因为通道关闭而返回了零值?因为Go致力于语言的一致性,答案也类似:我们使用逗号ok语法来检测通道是否关闭了:
v, ok := <-ch
如果ok
设为了true
,那么通道是打开的。如若设为了false
,通道就是关闭的。
小贴士:在读取有可能关闭的通道时,使用逗号ok语句来确保通道仍是开启的。
关闭通道是写入通道的协程的职责。注意只在协程等待通道关闭时才需要关闭通道(比如使用for-range
循环来读取通道)。因为通道只是一种变量,Go运行时可以检测到其不再使用而进行垃圾回收。
通道是让Go并发模型独树一帜的两大特性之一。它引导我们把代码看成一系列阶段,并让数据依赖清晰,也就更容易对并发进行推理。其它语言依靠全局共享状态来在线程间通讯。可变共享状态不利于理解程序中的数据流动,也让我们了解线程是否是独立的变得很难。
通道有多种状态,每个状态的读取、写入或关闭的行为都不同。通过表10-1来辅助理解。
图10-1 通道的行为
无缓冲,打开 | 无缓冲,关闭 | 有缓冲,打开 | 有缓冲,关闭 | Nil | |
---|---|---|---|---|---|
Read | 在写入前暂停 | 返回零值(使用逗号ok确定是否关闭) | 在缓冲为空时暂停 | 返回缓冲中的剩余值。如果缓冲为空,返回零值(使用逗号ok确定是否关闭) | 永远挂起 |
Write | 在读取前暂停 | PANIC | 在缓冲满了之后暂停 | PANIC | 永远挂起 |
Close | 正常 | PANIC | Works, remaining values still there | PANIC | PANIC |
必须避免导致Go程序panic的场景。前面提到,标准模式是让写协程在没内容再写时负责关闭通道。在有多个协程向同一个通道写入时,问题就变复杂了,因为向同一个通道反复调用close
会panic。此外,如果在一个协程中关闭通道,另一个协程在向其写入时也会panic。解决这一问题的方法是使用sync.WaitGroup
。我们会在使用WaitGroup一节中通过案例学习。
nil
通道也会很危险,但它也有使用场景。我们会在关闭select 中的case一节中讲到。
select
语句是另一个让Go并发模型别具一格的功能。它是Go中并发的控制结构,可优雅解决一个常见问题:如果可以执行两个并发操作,先执行哪一个呢?不能优先其中一个操作,否则可能一些情况永远不会得到处理。这称之为饥饿(starvation)。
select
关键字允许协程对一组多个通道读取或写入。它很像是一个空switch
语句:
select {
case v := <-ch:
fmt.Println(v)
case v := <-ch2:
fmt.Println(v)
case ch3 <- x:
fmt.Println("wrote", x)
case <-ch4:
fmt.Println("got value on ch4, but ignored it")
}
select
中的每个case
为对通道的读取或写入。如果某一case
可进行读取或写入,那么case
的内容体就会执行。和switch
一样,select
中的每个case
有其独立代码块。
如果有多条分支存在通道可读取或写入会怎么样呢?select
算法很简单:它随机可执行的分支,顺序并不重要。这与switch
语言截然不同,后者总是选择第一个解析为true
的分支。它还利落地解决了饥饿问题,没有哪个case
有优先级,全部同时进行检测。
select
随机选择的另一个好处是防止了最常见的死锁归因:以不一致的顺序获取锁。如果有两个协程都访问同样的两个通道,必须在两个协程中以同样的顺序进行访问,否则会造成死锁。这意味着两者都不能继续执行,因为都在等待另一个。如果Go应用中的每个协程都出现了死锁,Go运行时会杀死程序(见例10-1)。
例10-1 死锁协程
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
v := 1
ch1 <- v
v2 := <-ch2
fmt.Println(v, v2)
}()
v := 2
ch2 <- v
v2 := <-ch1
fmt.Println(v, v2)
}
在The Go Playground中运行这段程序,会得到如下错误:
fatal error: all goroutines are asleep - deadlock!
别忘了我们的main
运行于启动时Go运行时所开启的协程。我们开启协程必须在读取到ch1
之后才能继续,而主协程必须在读取到ch2
之后才能继续。
如果将主协程访问的通道放到select
中,就可以避免死锁(见例10-2)。
例10-2 使用select
来避免死锁
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
v := 1
ch1 <- v
v2 := <-ch2
fmt.Println(v, v2)
}()
v := 2
var v2 int
select {
case ch2 <- v:
case v2 = <-ch1:
}
fmt.Println(v, v2)
}
在The Go Playground 中运行程序得到的输出如下:
2 1
因为select
会检测有没有分支可以继续,这就避免了死锁。我们所开启的协程将值1写入ch1
,因此主协程中从ch1
中将值读入v2
可以执行。
因为常常配合使用,这种组合通常被称为for-select
循环。在使用for-select
循环时,必须包含退出循环的方式。我们会在done通道模式一节中学习一种方法。
和switch
语句一样,select
语句可以加default
分支。还是和switch
一样,在没有分支的通道可读取或写入时会使用default
分支。如果希望对通道实现非阻塞读或写,对select
使用default
。以下代码在ch
中无值可以读取时不会等待,它会立即执行default
内容体:
select {
case v := <-ch:
fmt.Println("read from ch:", v)
default:
fmt.Println("no value written to ch")
}
我们会在背压(backpressure) 一节使用到default
。
注:在for-select
循环中添加default
分支通常都是有问题的。每次循环各分支中没有内容可以读写时就会触发该分支。这会让for
循环持续运行,耗费大量的CPU。