Golang Goroutine、Channel、Select

12.1 何时使用并发

首先要确保程序确实能从并发中获益。

人们之所以被并发所吸引,是因为他们相信并发程序运行得更快。然而,情况并非总是如此。更多的并发并不会自动让程序变快,反而可能让代码更难理解。关键在于理解:并发 ≠ 并行。并发是一种用来更好地组织你试图解决的问题的工具。

并发代码是否并行(同时)运行取决于硬件以及算法是否允许。1967 年,计算机科学的先驱之一吉恩·阿姆达尔(Gene Amdahl)推导出了阿姆达尔定律,用于计算并预测通过改进系统某一部分性能所能带来的整体性能提升(加速比)上限。程序或系统总有一部分必须串行(按顺序)执行,无法通过增加处理器来加速,因此无论增加多少个处理器(并行度 N 趋向无穷大),系统的最大加速比永远受限于串行部分比例的倒数

系统加速比 S(N) 的计算公式为:(S(N)=\frac{1}{s+\frac{1-s}{N}})

  • s:程序中固有串行部分所占的比例(0 ≤ s ≤ 1)。
  • 1 - s:可并行执行部分所占的比例。
  • N:处理器数量或并行加速倍数。

当 N → ∞ 时,最大加速比的极限为 (\frac{1}{s})。

就我们的目的而言,需要理解的是,更多的并发并不意味着更快的速度

从广义上讲,所有程序都遵循相同的三步流程:接收数据、转换数据、输出结果。程序是否应该使用并发,取决于数据在程序步骤中如何流动。有时两个步骤可以并发进行,因为一个步骤的数据并非另一个步骤继续进行所必需的;而有时两个步骤必须按顺序进行,因为一个步骤依赖于另一个步骤的输出。当开发者要合并来自多个可以独立运行的操作的数据时,就可以使用并发。

并发首先是代码组织手段,不是性能优化手段。

另一个需要注意的重要事项是,如果一个并发运行的进程并不需要花费太多时间,那么就不值得使用并发。并发并非免费的:许多常见的内存中算法如此之快,以至于通过并发传递值的开销会超过通过并行运行并发代码所能节省的时间。这就是为什么并发操作通常用于 I/O,读写磁盘或网络的速度比除最复杂的内存中进程之外的任何进程都要慢数千倍。如果不确定并发是否会有帮助,请首先按顺序编写代码,然后编写一个基准测试以将性能与并发实现进行性能比较。(有关如何对代码进行基准测试,参见“15.6 基准测试(Benchmark)<348页>”)

  • CPU 密集型:单核时并发通常不能让计算本身变快。
  • I/O 密集型:单核时并发依然非常有价值,通过并发可以把等待时间重叠起来。

即使只有单核 CPU,多个 I/O 操作也可以通过多线程并发执行,使各自的 I/O 等待时间相互重叠。进一步地,如果采用异步(或非阻塞 I/O),即使只有单线程,也可以在某个 I/O 等待期间继续处理其他 I/O,从而实现 I/O 并发,而不必让唯一的线程阻塞。

独立且耗时的任务,尤其 I/O 操作,通常最适合并发。

举例说明。假设编写一个调用其他三个 Web 服务的应用程序。该程序需要向其中两个 Web 服务发送数据,然后将这两个调用的结果发送给第三个服务,并返回结果。整个过程的执行时间必须少于 50 毫秒,否则应返回错误。这是一个很好的并发使用场景,因为代码中存在可独立运行的 I/O 操作(互不干扰),需要合并中间结果,并且程序代码有明确的超时限制。在本章末尾(“12.5.11 整合并发工具<270页>”),将展示该案例的代码实现。

12.2 Go 协程(goroutine)

协程是 Go 并发模型的核心概念。为了理解协程,我们先定义几个术语:

  • 进程(Process)

    进程是由计算机操作系统运行的程序的实例。操作系统将一些资源(如内存)与进程关联起来,并确保其他进程无法访问这些资源。进程由一个或多个线程组成。

  • 线程(Thread)

    线程是操作系统分配执行时间的执行单元。进程内的线程可以共享资源访问权限。CPU 可以根据核心数量,同时执行一个或多个线程的指令。操作系统的一项任务就是调度 CPU 上的线程,以确保每个进程(以及进程内的每个线程)都有机会运行。

  • 协程(Goroutine)

    可以将协程想象成一种轻量级线程,由 Go 运行时管理。当 Go 程序启动时,Go 运行时会创建一些线程,并启动一个协程来运行用户程序。用户程序创建的所有协程(包括初始的那个)都会由 Go 运行时调度器自动分配到这些线程上,就像操作系统在 CPU 核心上调度线程一样。这看起来像是额外的工作,因为底层的操作系统已经包含了一个管理线程和进程的调度器,但协程有几个好处:

    • 创建协程比创建线程更快,因为协程实际上并未创建操作系统级别的资源。
    • 协程的初始栈大小比线程的栈大小更小,并且可以根据需要增长。这使得协程的内存使用效率更高。
    • 切换协程比切换线程更快,因为整个过程完全在进程内部完成,避免了相对较慢的操作系统调用。
    • 协程调度器能够优化其决策,因为它本身就是 Go 进程的一部分。调度器与网络轮询器协同工作,当检测到协程因等待 I/O 而阻塞时,可以将其取消调度。协程调度器还与垃圾回收器(GC)集成,确保工作均匀地分配到所有实际运行的操作系统线程上,避免某些线程过载而其他线程空闲,保证 GC 过程对程序性能的影响最小化,并维持整体负载均衡。

这些优势使得 Go 程序能够同时启动数百、数千甚至数万个协程。如果尝试在支持原生线程的语言中启动数千个线程,则程序将会变得极其缓慢。

如果读者对调度器的工作原理感兴趣,可以观看 Kavya Joshi 在 2018 年 GopherCon 大会上所做的名为 GopherCon 2018: The Scheduler Saga - Kavya Joshi - YouTube 的演讲。

启动一个协程的方法是在函数调用前放置 go 关键字。就像调用任何其他函数一样,可以传递参数来初始化其状态。然而,该函数返回的任何值都会被忽略

在 Go 中,任何函数都可以作为协程(goroutine)启动。这与 JavaScript 不同,在 JavaScript 中,只有当函数作者使用 async 关键字声明时,该函数才会异步运行。不过,更常见的做法是使用闭包(closures)(匿名函数)来封装业务逻辑。闭包(closures)可以更灵活地管理协程的上下文和数据。这个闭包(closures)负责处理并发相关的细节(如数据传递、资源管理或错误处理),使协程的调用更清晰和模块化。下面的示例代码会具体展示如何使用闭包启动协程。

func process(val int) int {
	// 这里是具体的业务逻辑,例如计算 val、转换 val 等
}

func processConcurrently(inVals []int) []int { // ❶
	// 创建通道
	in := make(chan int, 5)  // in: 用于接收待处理数据的通道,缓冲区大小为5
	out := make(chan int, 5) // out: 用于发送处理结果的通道,缓冲区大小为5

	// 启动处理协程
	for i := 0; i < 5; i++ { // 创建5个协程,每个协程都监听 'in' 通道
		go func() { // ❷ 每个协程都执行这个闭包函数
			for val := range in { // ❸ 每个协程循环从 'in' 通道读取数据
				out <- process(val) // ❹ 调用 process 函数处理数据,并将结果发送到 'out' 通道
			}
			// 当 'in' 通道关闭时,for-range 循环结束,协程退出
		}()
	}

	// load the data into the in channel in another goroutine
	// read the data from the out channel
	// return the data
}

在此代码中,processConcurrently 函数(❶)创建了一个闭包(closures)(❷),该闭包从通道中读取值(❸),并将它们传递给 process 函数中的业务逻辑(❹)。process 函数完全不知道它是在一个协程(goroutine)中运行的。然后,闭包将 process 函数的结果写入另一个通道(❹)(将在下一节简要概述通道)。这种职责分离使程序更加模块化且易于测试,并将并发逻辑与 API 分离开来。选择使用类似线程的模型来实现并发,意味着 Go 程序避免了 Bob Nystrom 在他的著名博客文章 GopherCon 2018: The Scheduler Saga - Kavya Joshi - YouTube?” 中描述的“函数着色(function Coloring)”问题。

12.3 通道(Channel)

协程(goroutine)通过通道进行通信。与切片(slice)和映射(map)一样,通道(channel)也是一种内置类型,使用 make 函数创建:

ch := make(chan int)
  • chan 是 Go 的关键字,表示 channel。
  • chan int 才是完整的类型,表示“传输 int 的 channel”。

chan<- int:send-only channel,只发送通道

<-chan int:receive-only channel,只接收通道

与映射(map)一样,通道(channel)也是引用类型。当将一个通道传递给函数时,实际上是传递的指向该通道的指针。同样地,与映射和切片一样,通道的零值(Zero Value)也是 nil

12.3.1 读写通道

使用 <- 运算符与通道进行交互。通过将 <- 运算符放在通道变量左侧来从通道读取数据,通过将其放在通道变量右侧来向通道写入数据:

a := <-ch // 从通道 ch 中读取数据,并将其赋值给变量 a
ch <- b   // 将变量 b 的值写入通道 ch

每个写入通道的值只能被读取一次。如果多个协程从同一个通道读取,那么写入通道的一个值只会被其中一个协程读取。

单个协程很少会同时读取和写入同一个通道。当将一个通道赋值给一个变量或字段,或者将其传递给一个函数时,可以在 chan 关键字前使用一个箭头(如 ch <-chan int)来表示该协程仅从通道读取。在 chan 关键字后使用一个箭头(如 ch chan<- int)来表示该协程仅向通道写入。这样做可以让 Go 编译器确保一个通道只被函数读取或写入。

默认情况下,通道是无缓冲的。每次向一个开放的无缓冲通道写入都会导致写入的协程暂停,直到另一个协程从同一个通道读取。同样地,从一个开放的无缓冲通道读取会导致接收的协程暂停,直到另一个协程向同一个通道写入。这意味着如果没有至少两个并发运行的协程,就无法向无缓冲通道写入或从中读取

Go 也支持有缓冲通道(Buffered Channel)。有缓冲通道可以在不阻塞的情况下缓冲一定数量的写入。如果通道的缓冲区在没有任何读取的情况下已满,那么后续对该通道的写入会暂停写入的协程,直到通道被读取。正如向一个已满的缓冲通道写入会阻塞一样,从一个空的缓冲通道读取也会被阻塞

有缓冲通道(Buffered Channel)是通过在创建通道时指定缓冲区的容量来创建的:

ch := make(chan int, 10) // 创建一个有缓冲区的通道,缓冲区大小为10

内置函数 lencap 可返回关于有缓冲通道(Buffered Channel)的信息。使用 len 找出缓冲区中当前有多少个值,使用 cap 找出通道的最大缓冲区大小。缓冲区的容量无法更改

将无缓冲通道(Unbuffered Channel)传递给 lencap 函数时,都将返回 0。这是有道理的,因为根据定义,无缓冲通道(Unbuffered Channel)没有缓冲区来存储值。

多数场景下,都应该使用无缓冲通道(Unbuffered Channel)。在“12.5.5 何时使用缓冲通道与无缓冲通道<263页>”一节,我将介绍有缓冲通道(Buffered Channel)的适用场景。

12.3.2 用 for-range 遍历通道

也可以用 for-range 语法遍历通道。这将持续读取直到通道被关闭(或直到通道中没有值)

for v := range ch { // 遍历通道时,只需声明一个变量即可
	fmt.Println(v)
}

与普通的 for-range 循环(如遍历切片或数组时声明两个变量)不同,遍历通道时只声明一个变量(如 v),它代表从通道读取的值。如果通道是开放的并且存在可用值,该值就会被赋给变量 v,然后执行循环体。如果通道暂时没有值,当前协程(goroutine)会暂停(阻塞),直到通道有新值写入或通道被关闭。当通道被关闭时,循环会自动结束。也可以通过 breakreturn 提前终止循环。

12.3.3 关闭通道

当完成向通道写入数据后,可以使用内置函数 close 来关闭它:

close(ch)

一旦通道被关闭,任何尝试向其写入或再次关闭它的操作都会导致 panic。有趣的是,尝试从已关闭的通道读取总是会成功。如果通道是有缓冲的且还有一些值未被读取,这些值会按顺序返回(确保数据被完全消费)。如果通道是无缓冲的,或者有缓冲通道中没有更多值了,则会返回该通道类型的零值

那么,如何区分从通道读取到的零值是被显式写入的值,还是因为通道已关闭而返回的零值?

如何区分是写入了零值,还是因为通道已关闭而返回了零值?由于 Go 语言力求保持一致性,这里有一个熟悉的答案——Go 提供了一种特殊语法 v, ok := <-ch 来检测通道状态:

v, ok := <-ch

如果 oktrue,则表示通道未关闭,读取的是有效值(可能是零值)。如果 okfalse,则表示通道已关闭,返回的是零值

无论何时从可能已关闭的通道读取数据,都请使用“逗号 ok”惯用语来确保该通道仍处于打开状态。

只有向通道写入数据的协程(goroutine)有权关闭通道。这是为了避免多个协程同时关闭通道导致竞争条件(race condition)。关闭通道并非总是必需的,只有当有协程依赖通道关闭来结束操作时才需要。例如,使用 for-range 循环读取通道的协程会一直阻塞,直到通道关闭,因此必须显式关闭通道。通道本质上是一个变量,如果没有任何协程引用它,Go 的运行时(runtime)会自动将其回收,无需手动管理内存。

Go 的并发模型(如 CSP,Communicating Sequential Processes)通过通道区分于其他语言。通道引导开发者将代码视为“一系列处理阶段”,每个阶段通过通道传递数据。通道使得数据流动的依赖关系明确,例如 A 阶段必须等待 B 阶段的数据(这里说的是 A、B 两个阶段在不同协程执行,否则没必要使用通道,因为单个协程内本来就是顺序的),这种显式依赖让并发逻辑更易理解和维护。许多语言(如 Java、C++)通过共享内存(global shared state)实现线程间通信,但这种方式容易导致竞态条件(race condition)。共享状态是动态变化的,开发者难以追踪数据流向,进而难以判断线程是否真正独立(即是否互相影响)。

通道的关闭逻辑与数据流的关闭逻辑类似:应由上游负责关闭通道,关闭后上游不可再写入;通道关闭后,下游仍可继续读取其中尚未消费的数据,直到全部数据被消费完毕

12.3.4 了解通道的行为方式

通道有多种状态,每种状态在读取、写入或关闭时的行为都不同。请使用表 12.1 来理清这些状态。

表 12.1:通道的行为

非缓冲通道,打开 非缓冲通道,关闭 缓冲通道,打开 缓冲通道,关闭 Nil
Read 暂停,直至写入内容 返回零值(用逗号 ok 查看是否关闭) 若缓冲区为空,则暂停 返回缓冲区中的剩余值;若缓冲区为空,则返回零值(用逗号 OK 查看是否已关闭) 永远挂起
Write 暂停,直至读到内容 导致 panic 若缓冲区已满,则暂停 导致 panic 永远挂起
Close 正常工作 导致 panic 有效,剩余值仍然存在 导致 panic 导致 panic

必须避免导致 Go 程序 panic 的情况。如前所述,标准的编程模式是在没有更多数据需要写入时,让写入的协程负责关闭通道。当有多个协程向同一个通道写入时,这会变得更加复杂,因为对同一个通道调用 close 两次会导致 panic。此外,如果在一个协程关闭了通道,另一个协程向该通道写入也会触发 panic。解决这个问题的方法是使用 sync.WaitGroup(具体示例,详见“12.5.9 使用 WaitGroups<267页>”)。

nil 通道读取数据会永久阻塞,向 nil 通道写入数据同样会永久阻塞,因为不存在可与之完成通信的另一端。如果相关协程没有其他退出路径,就可能造成协程泄漏,甚至最终导致程序死锁。除此之外,nil 通道调用 close 会直接触发 panic。因此,在对通道执行读、写或关闭操作之前,应确保通道已经被正确初始化,而不是处于 nil 状态

nil 通道也可能很危险,但在某些情况下它是有用的。将在“12.5.7 关闭 select 中的 case 分支<265页>”一节中了解更多关于 nil 通道的内容。

12.4 select 语句

select 是 Go 并发模型的关键特征之一(与通道一起),用于协调多个并发操作的选择逻辑。select 语句优雅地解决并发操作的公平性问题——当多个并发操作(如从不同通道读取或写入)可同时执行时,select 提供了一种机制,避免因固定优先级导致某些操作被无限延迟(即“饥饿(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 中,“channel 可读 / 可写”指的是:对应的通信操作此刻能够立即完成,不会阻塞

无缓冲通道

  • 可读 = 有发送方可以立即配对
  • 可写 = 有接收方可以立即配对

有缓冲通道

  • 可读 = 缓冲区有数据 OR 有发送方可立即配对
  • 可写 = 缓冲区有空间 OR 有接收方可立即配对

nil 通道

  • 永远既不可读也不可写,因此它对应的 select case 永远不会被选中

已关闭通道

  • 读取永远是就绪的

  • 写入不会阻塞,因此 case 会被选择,这回导致立即触发 panic

如果多个 case 分支的通道都可以被读取或写入,会发生什么?select 会从所有可以继续执行的 case 分支中随机选择一个来执行,顺序并不重要。这与 switch 语句完全不同,switch 总是选择第一个条件为 truecase 分支。这也很好地解决了饥饿(Starvation)问题,因为没有任何 case 分支会被优先于其他 case,所有 case 都会被同时检查

select 随机选择另一个优势是,它可以防止死锁最常见的成因之一:以不一致的顺序获取锁。如果有两个协程都访问相同的两个通道,那么这两个通道在两个协程中的访问顺序必须相同,否则它们就会死锁。这意味着它们中的任何一个都无法继续执行,因为它们在互相等待。如果 Go 应用程序中的所有协程都死锁了,Go 运行时就会终止整个应用(参见示例 12.1)。

示例 12.1:协程死锁

func main() { // ❶ main 函数在一个由 Go 运行时自动启动的协程中运行
	ch1 := make(chan int)
	ch2 := make(chan int)

	go func() { // ❷ 显式启动的协程
		inGoroutine := 1
		ch1 <- inGoroutine // 等待 ch1 被读取 -> 等待对方
		fromMain := <-ch2
		fmt.Println("goroutine:", inGoroutine, fromMain)
	}()

	inMain := 2
	ch2 <- inMain // 等待 ch2 被读取 -> 等待对方
	fromGoroutine := <-ch1
	fmt.Println("main:", inMain, fromGoroutine)
}

若在 The Go Playground 或本章资源库 sample_code/deadlock 目录运行上述程序,将得到如下报错信息:

fatal error: all goroutines are asleep - deadlock!

请记住,main 函数(❶处)默认运行在由 Go 运行时(runtime)自动创建的主协程中,无需显式启动。显式启动的协程(❷处)必须等待通道 ch1 可读(即有数据写入)后才能继续执行,而 main 协程必须等待通道 ch2 可读后才能继续执行。

如果在主协程中,将通道的读取和写入操作用 select 包裹起来,就可以避免死锁(参见示例 12.2 的❷处)。

示例 12.2:用 select 语句避免死锁

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)

	go func() { // ❶ 显式启动的协程,将值 1 写入通道 ch1,然后从通道 ch2 读取值。
		inGoroutine := 1
		ch1 <- inGoroutine

		fromMain := <-ch2 // 永远等待读取 ch2,因为主协程中没有向 ch2 写入值。
		fmt.Println("goroutine:", inGoroutine, fromMain) // 永远不会执行,一直等待
		// 读取 ch2
	}()

	inMain := 2
	var fromGoroutine int

	select { // ❷ 使用 select 语句避免死锁
	case ch2 <- inMain:
	case fromGoroutine = <-ch1: // ❸ 如果 ch1 可读,则读取值到 fromGoroutine 变量
		// 中。
	}

	fmt.Println("main:", inMain, fromGoroutine)
}

若在 The Go Playground 或本章资源库 sample_code/select 目录运行上述程序,将得到如下输出:

main: 2 1

因为 select 会检查其 case 分支中的任何一个是否可以执行,所以避免了死锁。那个被显式启动的协程(示例 12.2 的❶处)将值 1 写入到通道 ch1,因此主协程中从 ch1fromGoroutine 的读取操作能够成功(示例 12.2 的❸处)。

尽管这个程序不存在死锁问题,但仍然存在逻辑缺陷。显式启动的协程(❶)中的 fmt.Println 语句从未执行,因为它被阻塞在 ch2 的读取操作上。主协程退出时,程序会终止并销毁所有剩余协程,这虽然技术上将暂停的协程杀死,但并非理想解决方案。未正确处理协程的退出可能导致资源泄漏(如未释放的内存或未关闭的通道)。“12.5.3 务必妥善处理协程退出<261页>”一节,将更详细讨论如何确保协程正常退出,避免泄漏。

要让这个程序正常工作,需要用到一些稍后章节才会介绍的技术。可以在 The Go Playground 上找到可行的解决方案。

由于 select 负责与多个通道进行通信,因此它通常被嵌入在 for 循环中:

for {
	select {
	case <-done:
		return
	case v := <-ch:
		fmt.Println(v)
	}
}

for 循环与 select 语句的组合被称为 for-select 循环,这种模式在并发编程中非常常见。在使用 for-select 循环时,必须提供一种退出循环的方式(例如,通过 breakcontext 终止协程),否则可能导致无限循环,进一步可能导致协程泄露。具体实现方法会在“12.5.4 用 Context 终止协程<262页>”一节中介绍。

switch 语句类似,select 可以包含 default 子句。当所有 case 中的通道操作都无法立即执行(即无数据可读或通道未就绪)时,会执行 default。如果希望实现非阻塞的通道读写(即不阻塞等待通道读/写操作完成),可以使用带有 defaultselect。下面的代码在没有可读值时不会等待;它会立即执行 default 分支的代码块:

select { // 即使 ch 没有就绪,也不会阻塞,立即执行 default 分支的代码块。
case v := <-ch:
	fmt.Println("read from ch:", v)
default:
	fmt.Println("no value written to ch")
}

有关 default 的具体用法,详见“12.5.6 用缓冲通道实现背压(Backpressure)<264页>”一节。

for-select 循环中包含 default 案例通常是错误的做法。当 for-select 循环中的所有 case(通道读写操作)都无法立即执行时,default 分支都会被触发执行。这将导致无休止地循环,从而消耗大量的 CPU 资源,导致程序性能下降。

评论