Golang-并发9

http://c.biancheng.net/golang/concurrent/

Go语言并发简述(并发的优势)

Go语言的并发机制运用起来非常简便,在启动并发的方式上直接添加了语言级的关键字就可以实现,和其他编程语言相比更加轻量。

下面来介绍几个概念:

进程/线程

进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。

线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。

并发/并行

多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。

并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。

协程/线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

线程:一个线程上可以跑多个协程,协程是轻量级的线程。

优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。使用Go语言开发服务器程序时,就需要对它的并发机制有深入的了解。

Goroutine 介绍

goroutine 是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。

说到底 goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。

使用 go 关键字就可以创建 goroutine,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在Go语言中则被称为 goroutine。

goroutine 的用法如下:

  1. //go 关键字放在方法调用前新建一个 goroutine 并执行方法体
  2. go GetThingDone(param1, param2);
  3. //新建一个匿名方法并执行
  4. go func(param1, param2) {
  5. }(val1, val2)
  6. //直接新建一个 goroutine 并在 goroutine 中执行代码块
  7. go {
  8. //do someting...
  9. }

因为 goroutine 在多核 cpu 环境下是并行的,如果代码块在多个 goroutine 中执行,那么我们就实现了代码的并行。

如果需要了解程序的执行情况,怎么拿到并行的结果呢?需要配合使用channel进行。

channel

channel 是Go语言在语言级别提供的 goroutine 间的通信方式。我们可以使用 channel 在两个或多个 goroutine 之间传递消息。

channel 是进程内的通信方式,因此通过 channel 传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。如果需要跨进程通信,我们建议用分布式系统的方法来解决,比如使用 Socket 或者 HTTP 等通信协议。Go语言对于网络方面也有非常完善的支持。

channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。如果对 Unix 管道有所了解的话,就不难理解 channel,可以将其认为是一种类型安全的管道。

定义一个 channel 时,也需要定义发送到 channel 的值的类型,注意,必须使用 make 创建 channel,代码如下所示:

  1. ci := make(chan int)
  2. cs := make(chan string)
  3. cf := make(chan interface{})

回到在 Windows 和 Linux 出现之前的古老年代,在开发程序时并没有并发的概念,因为命令式程序设计语言是以串行为基础的,程序会顺序执行每一条指令,整个程序只有一个执行上下文,即一个调用栈,一个堆。

并发则意味着程序在运行时有多个执行上下文,对应着多个调用栈。我们知道每一个进程在运行时,都有自己的调用栈和堆,有一个完整的上下文,而操作系统在调度进程的时候,会保存被调度进程的上下文环境,等该进程获得时间片后,再恢复该进程的上下文到系统中。

从整个操作系统层面来说,多个进程是可以并发的,那么并发的价值何在?下面我们先看以下几种场景。

1) 一方面我们需要灵敏响应的图形用户界面,一方面程序还需要执行大量的运算或者 IO 密集操作,而我们需要让界面响应与运算同时执行。

2) 当我们的 Web 服务器面对大量用户请求时,需要有更多的“Web 服务器工作单元”来分别响应用户。

3) 我们的事务处于分布式环境上,相同的工作单元在不同的计算机上处理着被分片的数据,计算机的 CPU 从单内核(core)向多内核发展,而我们的程序都是串行的,计算机硬件的能力没有得到发挥。

4) 我们的程序因为 IO 操作被阻塞,整个程序处于停滞状态,其他 IO 无关的任务无法执行。

从以上几个例子可以看到,串行程序在很多场景下无法满足我们的要求。下面我们归纳了并发程序的几条优点,让大家认识到并发势在必行:

    • 并发能更客观地表现问题模型;
    • 并发可以充分利用 CPU 核心的优势,提高程序的执行效率;
    • 并发能充分利用 CPU 与其他硬件设备固有的异步性。

Go语言goroutine(轻量级线程)

在编写 Socket 网络程序时,需要提前准备一个线程池为每一个 Socket 的收发包分配一个线程。开发人员需要在线程数量和 CPU 数量间建立一个对应关系,以保证每个任务能及时地被分配到 CPU 上进行处理,同时避免多个任务频繁地在线程间切换执行而损失效率。

虽然,线程池为逻辑编写者提供了线程分配的抽象机制。但是,如果面对随时随地可能发生的并发和线程处理需求,线程池就不是非常直观和方便了。能否有一种机制:使用者分配足够多的任务,系统能自动帮助使用者把任务分配到 CPU 上,让这些任务尽量并发运作。这种机制在 Go语言中被称为 goroutine

goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。

Go 程序从 main 包的 main() 函数开始,在程序启动时,Go 程序就会为 main() 函数创建一个默认的 goroutine。

使用普通函数创建 goroutine

Go 程序中使用 go 关键字为一个函数创建一个 goroutine。一个函数可以被创建多个 goroutine,一个 goroutine 必定对应一个函数。

1) 格式

为一个普通函数创建 goroutine 的写法如下:

go 函数名( 参数列表 )

  • 函数名:要调用的函数名。
  • 参数列表:调用函数需要传入的参数。


使用 go 关键字创建 goroutine 时,被调用函数的返回值会被忽略。

如果需要在 goroutine 中返回数据,请使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出。

2) 例子

使用 go 关键字,将 running() 函数并发执行,每隔一秒打印一次计数器,而 main 的 goroutine 则等待用户输入,两个行为可以同时进行。请参考下面代码:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func running() {
  7. var times int
  8. // 构建一个无限循环
  9. for {
  10. times++
  11. fmt.Println("tick", times)
  12. // 延时1秒
  13. time.Sleep(time.Second)
  14. }
  15. }
  16. func main() {
  17. // 并发执行程序
  18. go running()
  19. // 接受命令行输入, 不做任何事情
  20. var input string
  21. fmt.Scanln(&input)
  22. }

命令行输出如下:

tick 1
tick 2
tick 3
tick 4
tick 5

代码执行后,命令行会不断地输出 tick,同时可以使用 fmt.Scanln() 接受用户输入。两个环节可以同时进行。

代码说明如下:
第 12 行,使用 for 形成一个无限循环。
第 13 行,times 变量在循环中不断自增。
第 14 行,输出 times 变量的值。
第 17 行,使用 time.Sleep 暂停 1 秒后继续循环。
第 25 行,使用 go 关键字让 running() 函数并发运行。
第 29 行,接受用户输入,直到按 Enter 键时将输入的内容写入 input 变量中并返回,整个程序终止。

这段代码的执行顺序如下图所示。


图:并发运行图


这个例子中,Go 程序在启动时,运行时(runtime)会默认为 main() 函数创建一个 goroutine。在 main() 函数的 goroutine 中执行到 go running 语句时,归属于 running() 函数的 goroutine 被创建,running() 函数开始在自己的 goroutine 中执行。此时,main() 继续执行,两个 goroutine 通过 Go 程序的调度机制同时运作。

使用匿名函数创建goroutine

go 关键字后也可以为匿名函数或闭包启动 goroutine。

1) 使用匿名函数创建goroutine的格式

使用匿名函数或闭包创建 goroutine 时,除了将函数定义部分写在 go 的后面之外,还需要加上匿名函数的调用参数,格式如下:

go func( 参数列表 ){
    函数体
}( 调用参数列表 )

其中:

  • 参数列表:函数体内的参数变量列表。
  • 函数体:匿名函数的代码。
  • 调用参数列表:启动 goroutine 时,需要向匿名函数传递的调用参数。

2) 使用匿名函数创建goroutine的例子

在 main() 函数中创建一个匿名函数并为匿名函数启动 goroutine。匿名函数没有参数。代码将并行执行定时打印计数的效果。参见下面的代码:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. go func() {
  8. var times int
  9. for {
  10. times++
  11. fmt.Println("tick", times)
  12. time.Sleep(time.Second)
  13. }
  14. }()
  15. var input string
  16. fmt.Scanln(&input)
  17. }

代码说明如下:

  • 第 10 行,go 后面接匿名函数启动 goroutine。
  • 第 12~19 行的逻辑与前面程序的 running() 函数一致。
  • 第 21 行的括号的功能是调用匿名函数的参数列表。由于第 10 行的匿名函数没有参数,因此第 21 行的参数列表也是空的。

提示

所有 goroutine 在 main() 函数结束时会一同结束。
goroutine 虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的 goroutine 调度器的实现和运行环境。
终止 goroutine 的最好方法就是自然返回 goroutine 对应的函数。虽然可以用 golang.org/x/net/context 包进行 goroutine 生命期深度控制,但这种方法仍然处于内部试验阶段,并不是官方推荐的特性。
截止 Go 1.9 版本,暂时没有标准接口获取 goroutine 的 ID。

Go语言并发通信

在工程上,有两种最常见的并发通信模型:共享数据和消息。

共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了,也就是常说的共享内存。

现在我们尝试将这段C语言代码直接翻译为Go语言代码,代码如下所示。

  1. package main
  2. import (
  3.     "fmt"
  4.     "runtime"
  5.     "sync"
  6. )
  7. var counter int = 0
  8. func Count(lock *sync.Mutex) {
  9. lock.Lock()
  10. counter++
  11. fmt.Println(counter)
  12. lock.Unlock()
  13. }
  14. func main() {
  15. lock := &sync.Mutex{}
  16. for i := 0; i < 10; i++ {
  17. go Count(lock)
  18. }
  19. for {
  20. lock.Lock()
  21. c := counter
  22. lock.Unlock()
  23. runtime.Gosched()
  24. if c >= 10 {
  25. break
  26. }
  27. }
  28. }

在上面的例子中,我们在 10 个 goroutine 中共享了变量 counter。每个 goroutine 执行完成后,会将 counter 的值加 1。因为 10 个 goroutine 是并发执行的,所以我们还引入了锁,也就是代码中的 lock 变量。每次对 n 的操作,都要先将锁锁住,操作完成后,再将锁打开。

在 main 函数中,使用 for 循环来不断检查 counter 的值(同样需要加锁)。当其值达到 10 时,说明所有 goroutine 都执行完毕了,这时主函数返回,程序退出。

事情好像开始变得糟糕了。实现一个如此简单的功能,却写出如此臃肿而且难以理解的代码。想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多 C/C++ 开发者正在经历的,其实 Java 和 C# 开发者也好不到哪里去。

Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。

消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存。

Go语言竞争状态简述

有并发,就有资源竞争,如果两个或者多个 goroutine 在没有相互同步的情况下,访问某个共享的资源,比如同时对该资源进行读写时,就会处于相互竞争的状态,这就是并发中的资源竞争。

并发本身并不复杂,但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。

下面的代码中就会出现竞争状态:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. "sync"
  6. )
  7. var (
  8. count int32
  9. wg sync.WaitGroup
  10. )
  11. func main() {
  12. wg.Add(2)
  13. go incCount()
  14. go incCount()
  15. wg.Wait()
  16. fmt.Println(count)
  17. }
  18. func incCount() {
  19. defer wg.Done()
  20. for i := 0; i < 2; i++ {
  21. value := count
  22. runtime.Gosched()
  23. value++
  24. count = value
  25. }
  26. }

这是一个资源竞争的例子,大家可以将程序多运行几次,会发现结果可能是 2,也可以是 3,还可能是 4。这是因为 count 变量没有任何同步保护,所以两个 goroutine 都会对其进行读写,会导致对已经计算好的结果被覆盖,以至于产生错误结果。

代码中的 runtime.Gosched() 是让当前 goroutine 暂停的意思,退回执行队列,让其他等待的 goroutine 运行,目的是为了使资源竞争的结果更明显。

下面我们来分析一下程序的运行过程,将两个 goroutine 分别假设为 g1 和 g2:

  • g1 读取到 count 的值为 0;
  • 然后 g1 暂停了,切换到 g2 运行,g2 读取到 count 的值也为 0;
  • g2 暂停,切换到 g1,g1 对 count+1,count 的值变为 1;
  • g1 暂停,切换到 g2,g2 刚刚已经获取到值 0,对其 +1,最后赋值给 count,其结果还是 1;
  • 可以看出 g1 对 count+1 的结果被 g2 给覆盖了,两个 goroutine 都 +1 而结果还是 1。


通过上面的分析可以看出,之所以出现上面的问题,是因为两个 goroutine 相互覆盖结果。

所以我们对于同一个资源的读写必须是原子化的,也就是说,同一时间只能允许有一个 goroutine 对共享资源进行读写操作。

共享资源竞争的问题,非常复杂,并且难以察觉,好在 Go 为我们提供了一个工具帮助我们检查,这个就是go build -race 命令。在项目目录下执行这个命令,生成一个可以执行文件,然后再运行这个可执行文件,就可以看到打印出的检测信息。

go build命令中多加了一个-race 标志,这样生成的可执行程序就自带了检测资源竞争的功能,运行生成的可执行文件,效果如下所示:

==================
WARNING: DATA RACE
Read at 0x000000619cbc by goroutine 8:
  main.incCount()
      D:/code/src/main.go:25 +0x80

Previous write at 0x000000619cbc by goroutine 7:
  main.incCount()
      D:/code/src/main.go:28 +0x9f

Goroutine 8 (running) created at:
  main.main()
      D:/code/src/main.go:17 +0x7e

Goroutine 7 (finished) created at:
  main.main()
      D:/code/src/main.go:16 +0x66
==================
4
Found 1 data race(s)

通过运行结果可以看出 goroutine 8 在代码 25 行读取共享资源value := count,而这时 goroutine 7 在代码 28 行修改共享资源count = value,而这两个 goroutine 都是从 main 函数的 16、17 行通过 go 关键字启动的。

锁住共享资源

Go语言提供了传统的同步 goroutine 的机制,就是对共享资源加锁。atomic 和 sync 包里的一些函数就可以对共享的资源进行加锁操作。

原子函数

原子函数能够以很底层的加锁机制来同步访问整型变量和指针,示例代码如下所示:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. "sync"
  6. "sync/atomic"
  7. )
  8. var (
  9. counter int64
  10. wg sync.WaitGroup
  11. )
  12. func main() {
  13. wg.Add(2)
  14. go incCounter(1)
  15. go incCounter(2)
  16. wg.Wait() //等待goroutine结束
  17. fmt.Println(counter)
  18. }
  19. func incCounter(id int) {
  20. defer wg.Done()
  21. for count := 0; count < 2; count++ {
  22. atomic.AddInt64(&counter, 1) //安全的对counter加1
  23. runtime.Gosched()
  24. }
  25. }

上述代码中使用了 atmoic 包的 AddInt64 函数,这个函数会同步整型值的加法,方法是强制同一时刻只能有一个 gorountie 运行并完成这个加法操作。当 goroutine 试图去调用任何原子函数时,这些 goroutine 都会自动根据所引用的变量做同步处理。

另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。下面是代码就使用了 LoadInt64 和 StoreInt64 函数来创建一个同步标志,这个标志可以向程序里多个 goroutine 通知某个特殊状态。

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "sync/atomic"
  6. "time"
  7. )
  8. var (
  9. shutdown int64
  10. wg sync.WaitGroup
  11. )
  12. func main() {
  13. wg.Add(2)
  14. go doWork("A")
  15. go doWork("B")
  16. time.Sleep(1 * time.Second)
  17. fmt.Println("Shutdown Now")
  18. atomic.StoreInt64(&shutdown, 1)
  19. wg.Wait()
  20. }
  21. func doWork(name string) {
  22. defer wg.Done()
  23. for {
  24. fmt.Printf("Doing %s Work\n", name)
  25. time.Sleep(250 * time.Millisecond)
  26. if atomic.LoadInt64(&shutdown) == 1 {
  27. fmt.Printf("Shutting %s Down\n", name)
  28. break
  29. }
  30. }
  31. }

上面代码中 main 函数使用 StoreInt64 函数来安全地修改 shutdown 变量的值。如果哪个 doWork goroutine 试图在 main 函数调用 StoreInt64 的同时调用 LoadInt64 函数,那么原子函数会将这些调用互相同步,保证这些操作都是安全的,不会进入竞争状态。

互斥锁

另一种同步访问共享资源的方式是使用互斥锁,互斥锁这个名字来自互斥的概念。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界代码。

示例代码如下所示:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. "sync"
  6. )
  7. var (
  8. counter int64
  9. wg sync.WaitGroup
  10. mutex sync.Mutex
  11. )
  12. func main() {
  13. wg.Add(2)
  14. go incCounter(1)
  15. go incCounter(2)
  16. wg.Wait()
  17. fmt.Println(counter)
  18. }
  19. func incCounter(id int) {
  20. defer wg.Done()
  21. for count := 0; count < 2; count++ {
  22. //同一时刻只允许一个goroutine进入这个临界区
  23. mutex.Lock()
  24. {
  25. value := counter
  26. runtime.Gosched()
  27. value++
  28. counter = value
  29. }
  30. mutex.Unlock() //释放锁,允许其他正在等待的goroutine进入临界区
  31. }
  32. }

同一时刻只有一个 goroutine 可以进入临界区。之后直到调用 Unlock 函数之后,其他 goroutine 才能进去临界区。当调用 runtime.Gosched 函数强制将当前 goroutine 退出当前线程后,调度器会再次分配这个 goroutine 继续运行。

Go语言GOMAXPROCS(调整并发的运行性能)

在 Go语言程序运行时(runtime)实现了一个小型的任务调度器。这套调度器的工作原理类似于操作系统调度线程,Go 程序调度器可以高效地将 CPU 资源分配给每一个任务。传统逻辑中,开发者需要维护线程池中线程与 CPU 核心数量的对应关系。同样的,Go 地中也可以通过 runtime.GOMAXPROCS() 函数做到,格式为:

runtime.GOMAXPROCS(逻辑CPU数量)

这里的逻辑CPU数量可以有如下几种数值:

  • <1:不修改任何数值。
  • =1:单核心执行。
  • >1:多核并发执行。

一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置,例如:

  1. runtime.GOMAXPROCS(runtime.NumCPU())

Go 1.5 版本之前,默认使用的是单核心执行。从 Go 1.5 版本开始,默认执行上面语句以便让代码并发执行,最大效率地利用 CPU。

GOMAXPROCS 同时也是一个环境变量,在应用程序启动前设置环境变量也可以起到相同的作用。

在讲解并发概念时,总会涉及另外一个概念并行。下面让我们来了解并发和并行之间的区别。

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。


并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。

在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。这种“使用较少的资源做更多的事情”的哲学,也是指导 Go语言设计的哲学。

如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。否则,哪怕 Go语言运行时使用多个线程,goroutine 依然会在同一个物理处理器上并发运行,达不到并行的效果。

下图展示了在一个逻辑处理器上并发运行 goroutine 和在两个逻辑处理器上并行运行两个并发的 goroutine 之间的区别。调度器包含一些聪明的算法,这些算法会随着 Go语言的发布被更新和改进,所以不推荐盲目修改语言运行时对逻辑处理器的默认设置。如果真的认为修改逻辑处理器的数量可以改进性能,也可以对语言运行时的参数进行细微调整。

goroutine和coroutine的区别

C#、Lua、Python 语言都支持 coroutine 特性。coroutine 与 goroutine 在名字上类似,都可以将函数或者语句在独立的环境中运行,但是它们之间有两点不同:

  • goroutine 可能发生并行执行;
  • 但 coroutine 始终顺序执行。


goroutines 意味着并行(或者可以以并行的方式部署),coroutines 一般来说不是这样的,goroutines 通过通道来通信;coroutines 通过让出和恢复操作来通信,goroutines 比 coroutines 更强大,也很容易从 coroutines 的逻辑复用到 goroutines。

狭义地说,goroutine 可能发生在多线程环境下,goroutine 无法控制自己获取高优先度支持;coroutine 始终发生在单线程,coroutine 程序需要主动交出控制权,宿主才能获得控制权并将控制权交给其他 coroutine。

goroutine 间使用 channel 通信,coroutine 使用 yield 和 resume 操作。

goroutine 和 coroutine 的概念和运行机制都是脱胎于早期的操作系统。

coroutine 的运行机制属于协作式任务处理,早期的操作系统要求每一个应用必须遵守操作系统的任务处理规则,应用程序在不需要使用 CPU 时,会主动交出 CPU 使用权。如果开发者无意间或者故意让应用程序长时间占用 CPU,操作系统也无能为力,表现出来的效果就是计算机很容易失去响应或者死机。

goroutine 属于抢占式任务处理,已经和现有的多线程和多进程任务处理非常类似。应用程序对 CPU 的控制最终还需要由操作系统来管理,操作系统如果发现一个应用程序长时间大量地占用 CPU,那么用户有权终止这个任务。

Go语言通道(chan)——goroutine之间通信的管道

如果说 goroutine 是 Go语言程序的并发体的话,那么 channels 就是它们之间的通信机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channels 可发送数据的类型。一个可以发送 int 类型数据的 channel 一般写为 chan int。

Go语言提倡使用通信的方法代替共享内存,当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。声明通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。

这里通信的方法就是使用通道(channel),如下图所示。


图:goroutine 与 channel 的通信


在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。

通道的特性

Go语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。

通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

声明通道类型

通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明如下:

var 通道变量 chan 通道类型

  • 通道类型:通道内的数据类型。
  • 通道变量:保存通道的变量。


chan 类型的空值是 nil,声明后需要配合 make 后才能使用。

创建通道

通道是引用类型,需要使用 make 进行创建,格式如下:

通道实例 := make(chan 数据类型)

  • 数据类型:通道内传输的元素类型。
  • 通道实例:通过make创建的通道句柄。


请看下面的例子:

  1. ch1 := make(chan int) // 创建一个整型类型的通道
  2. ch2 := make(chan interface{}) // 创建一个空接口类型的通道, 可以存放任意格式
  3. type Equip struct{ /* 一些字段 */ }
  4. ch2 := make(chan *Equip) // 创建Equip指针类型的通道, 可以存放*Equip

使用通道发送数据

通道创建后,就可以使用通道进行发送和接收操作。

1) 通道发送数据的格式

通道的发送使用特殊的操作符<-,将数据通过通道发送的格式为:

通道变量 <- 值

  • 通道变量:通过make创建好的通道实例。
  • 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致。

2) 通过通道发送数据的例子

使用 make 创建一个通道后,就可以使用<-向通道发送数据,代码如下:

  1. // 创建一个空接口通道
  2. ch := make(chan interface{})
  3. // 将0放入通道中
  4. ch <- 0
  5. // 将hello字符串放入通道中
  6. ch <- "hello"

3) 发送将持续阻塞直到数据被接收

把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示,代码如下:

  1. package main
  2. func main() {
  3. // 创建一个整型通道
  4. ch := make(chan int)
  5. // 尝试将0通过通道发送
  6. ch <- 0
  7. }

运行代码,报错:

fatal error: all goroutines are asleep - deadlock!

报错的意思是:运行时发现所有的 goroutine(包括main)都处于等待 goroutine。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。

使用通道接收数据

通道接收同样使用<-操作符,通道接收有如下特性:
① 通道的收发操作在不同的两个 goroutine 间进行。

由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。

② 接收将持续阻塞直到发送方发送数据。

如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。

③ 每次接收一个元素。
通道一次只能接收一个数据元素。

通道的数据接收一共有以下 4 种写法。

1) 阻塞接收数据

阻塞模式接收数据时,将接收变量作为<-操作符的左值,格式如下:

data := <-ch

执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。

2) 非阻塞接收数据

使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:

data, ok := <-ch

  • data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。
  • ok:表示是否接收到数据。


非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。如果需要实现接收超时检测,可以配合 select 和计时器 channel 进行,可以参见后面的内容。

3) 接收任意数据,忽略接收的数据

阻塞接收数据后,忽略从通道返回的数据,格式如下:

<-ch

执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。

使用通道做并发同步的写法,可以参考下面的例子:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. // 构建一个通道
  7. ch := make(chan int)
  8. // 开启一个并发匿名函数
  9. go func() {
  10. fmt.Println("start goroutine")
  11. // 通过通道通知main的goroutine
  12. ch <- 0
  13. fmt.Println("exit goroutine")
  14. }()
  15. fmt.Println("wait goroutine")
  16. // 等待匿名goroutine
  17. <-ch
  18. fmt.Println("all done")
  19. }

执行代码,输出如下:

wait goroutine
start goroutine
exit goroutine
all done

代码说明如下:

  • 第 10 行,构建一个同步用的通道。
  • 第 13 行,开启一个匿名函数的并发。
  • 第 18 行,匿名 goroutine 即将结束时,通过通道通知 main 的 goroutine,这一句会一直阻塞直到 main 的 goroutine 接收为止。
  • 第 27 行,开启 goroutine 后,马上通过通道等待匿名 goroutine 结束。

4) 循环接收

通道的数据接收可以借用 for range 语句进行多个元素的接收操作,格式如下:

  1. for data := range ch {
  2. }

通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面例子中的 data。

遍历通道数据的例子请参考下面的代码。

使用 for 从通道中接收数据:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. // 构建一个通道
  8. ch := make(chan int)
  9. // 开启一个并发匿名函数
  10. go func() {
  11. // 从3循环到0
  12. for i := 3; i >= 0; i-- {
  13. // 发送3到0之间的数值
  14. ch <- i
  15. // 每次发送完时等待
  16. time.Sleep(time.Second)
  17. }
  18. }()
  19. // 遍历接收通道数据
  20. for data := range ch {
  21. // 打印通道数据
  22. fmt.Println(data)
  23. // 当遇到数据0时, 退出接收循环
  24. if data == 0 {
  25. break
  26. }
  27. }
  28. }

执行代码,输出如下:

3
2
1
0

代码说明如下:

    • 第 12 行,通过 make 生成一个整型元素的通道。
    • 第 15 行,将匿名函数并发执行。
    • 第 18 行,用循环生成 3 到 0 之间的数值。
    • 第 21 行,将 3 到 0 之间的数值依次发送到通道 ch 中。
    • 第 24 行,每次发送后暂停 1 秒。
    • 第 30 行,使用 for 从通道中接收数据。
    • 第 33 行,将接收到的数据打印出来。
    • 第 36 行,当接收到数值 0 时,停止接收。如果继续发送,由于接收 goroutine 已经退出,没有 goroutine 发送到通道,因此运行时将会触发宕机报错。

Go语言单向通道——通道中的单行道

Go语言的类型系统提供了单方向的 channel 类型,顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。当然 channel 本身必然是同时支持读写的,否则根本没法用。

假如一个 channel 真的只能读取数据,那么它肯定只会是空的,因为你没机会往里面写数据。同理,如果一个 channel 只允许写入数据,即使写进去了,也没有丝毫意义,因为没有办法读取到里面的数据。所谓的单向 channel 概念,其实只是对 channel 的一种使用限制。

单向通道的声明格式

我们在将一个 channel 变量传递到一个函数时,可以通过将其指定为单向 channel 变量,从而限制该函数中可以对此 channel 的操作,比如只能往这个 channel 中写入数据,或者只能从这个 channel 读取数据。

单向 channel 变量的声明非常简单,只能写入数据的通道类型为chan<-,只能读取数据的通道类型为<-chan,格式如下:

var 通道实例 chan<- 元素类型    // 只能写入数据的通道
var 通道实例 <-chan 元素类型    // 只能读取数据的通道

  • 元素类型:通道包含的元素类型。
  • 通道实例:声明的通道变量。

单向通道的使用例子

示例代码如下:

  1. ch := make(chan int)
  2. // 声明一个只能写入数据的通道类型, 并赋值为ch
  3. var chSendOnly chan<- int = ch
  4. //声明一个只能读取数据的通道类型, 并赋值为ch
  5. var chRecvOnly <-chan int = ch

上面的例子中,chSendOnly 只能写入数据,如果尝试读取数据,将会出现如下报错:

invalid operation: <-chSendOnly (receive from send-only type chan<- int)

同理,chRecvOnly 也是不能写入数据的。

当然,使用 make 创建通道时,也可以创建一个只写入或只读取的通道:

  1. ch := make(<-chan int)
  2. var chReadOnly <-chan int = ch
  3. <-chReadOnly

上面代码编译正常,运行也是正确的。但是,一个不能写入数据只能读取的通道是毫无意义的。

time包中的单向通道

time 包中的计时器会返回一个 timer 实例,代码如下:

  1. timer := time.NewTimer(time.Second)

timer的Timer类型定义如下:

  1. type Timer struct {
  2. C <-chan Time
  3. r runtimeTimer
  4. }

第 2 行中 C 通道的类型就是一种只能读取的单向通道。如果此处不进行通道方向约束,一旦外部向通道写入数据,将会造成其他使用到计时器的地方逻辑产生混乱。

因此,单向通道有利于代码接口的严谨性。

关闭 channel

关闭 channel 非常简单,直接使用Go语言内置的 close() 函数即可:

close(ch)

在介绍了如何关闭 channel 之后,我们就多了一个问题:如何判断一个 channel 是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:

x, ok := <-ch

这个用法与 map 中的按键获取 value 的过程比较类似,只需要看第二个 bool 返回值即可,如果返回值是 false 则表示 ch 已经被关闭。

Go语言无缓冲的通道

Go语言中无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。

如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

阻塞指的是由于某种原因数据没有到达,当前协程(线程)持续处于等待状态,直到条件满足才解除阻塞。

同步指的是在两个或多个协程(线程)之间,保持数据内容一致性的机制。

下图展示两个 goroutine 如何利用无缓冲的通道来共享一个值。

使用无缓冲的通道在 goroutine 之间同步
图:使用无缓冲的通道在 goroutine 之间同步


在第 1 步,两个 goroutine 都到达通道,但哪个都没有开始执行发送或者接收。在第 2 步,左侧的 goroutine 将它的手伸进了通道,这模拟了向通道发送数据的行为。这时,这个 goroutine 会在通道中被锁住,直到交换完成。

在第 3 步,右侧的 goroutine 将它的手放入通道,这模拟了从通道里接收数据。这个 goroutine 一样也会在通道中被锁住,直到交换完成。在第 4 步和第 5 步,进行交换,并最终在第 6 步,两个 goroutine 都将它们的手从通道里拿出来,这模拟了被锁住的 goroutine 得到释放。两个 goroutine 现在都可以去做别的事情了。

为了讲得更清楚,让我们来看两个完整的例子。这两个例子都会使用无缓冲的通道在两个 goroutine 之间同步交换数据。

【示例 1】在网球比赛中,两位选手会把球在两个人之间来回传递。选手总是处在以下两种状态之一,要么在等待接球,要么将球打向对方。可以使用两个 goroutine 来模拟网球比赛,并使用无缓冲的通道来模拟球的来回,代码如下所示。

  1. // 这个示例程序展示如何用无缓冲的通道来模拟
  2. // 2 个goroutine 间的网球比赛
  3. package main
  4. import (
  5. "fmt"
  6. "math/rand"
  7. "sync"
  8. "time"
  9. )
  10. // wg 用来等待程序结束
  11. var wg sync.WaitGroup
  12. func init() {
  13. rand.Seed(time.Now().UnixNano())
  14. }
  15. // main 是所有Go 程序的入口
  16. func main() {
  17. // 创建一个无缓冲的通道
  18. court := make(chan int)
  19. // 计数加 2,表示要等待两个goroutine
  20. wg.Add(2)
  21. // 启动两个选手
  22. go player("Nadal", court)
  23. go player("Djokovic", court)
  24. // 发球
  25. court <- 1
  26. // 等待游戏结束
  27. wg.Wait()
  28. }
  29. // player 模拟一个选手在打网球
  30. func player(name string, court chan int) {
  31. // 在函数退出时调用Done 来通知main 函数工作已经完成
  32. defer wg.Done()
  33. for {
  34. // 等待球被击打过来
  35. ball, ok := <-court
  36. if !ok {
  37. // 如果通道被关闭,我们就赢了
  38. fmt.Printf("Player %s Won\n", name)
  39. return
  40. }
  41. // 选随机数,然后用这个数来判断我们是否丢球
  42. n := rand.Intn(100)
  43. if n%13 == 0 {
  44. fmt.Printf("Player %s Missed\n", name)
  45. // 关闭通道,表示我们输了
  46. close(court)
  47. return
  48. }
  49. // 显示击球数,并将击球数加1
  50. fmt.Printf("Player %s Hit %d\n", name, ball)
  51. ball++
  52. // 将球打向对手
  53. court <- ball
  54. }
  55. }

运行这个程序,输出结果如下所示。

Player Nadal Hit 1
Player Djokovic Hit 2
Player Nadal Hit 3
Player Djokovic Missed
Player Nadal Won

代码说明如下:

  • 第 22 行,创建了一个 int 类型的无缓冲的通道,让两个 goroutine 在击球时能够互相同步。
  • 第 28 行和第 29 行,创建了参与比赛的两个 goroutine。在这个时候,两个 goroutine 都阻塞住等待击球。
  • 第 32 行,将球发到通道里,程序开始执行这个比赛,直到某个 goroutine 输掉比赛。
  • 第 43 行可以找到一个无限循环的 for 语句。在这个循环里,是玩游戏的过程。
  • 第 45 行,goroutine 从通道接收数据,用来表示等待接球。这个接收动作会锁住 goroutine,直到有数据发送到通道里。通道的接收动作返回时。
  • 第 46 行会检测 ok 标志是否为 false。如果这个值是 false,表示通道已经被关闭,游戏结束。
  • 第 53 行到第 60 行,会产生一个随机数,用来决定 goroutine 是否击中了球。
  • 第 58 行如果某个 goroutine 没有打中球,关闭通道。之后两个 goroutine 都会返回,通过 defer 声明的 Done 会被执行,程序终止。
  • 第 64 行,如果击中了球 ball 的值会递增 1,并在第 67 行,将 ball 作为球重新放入通道,发送给另一位选手。在这个时刻,两个 goroutine 都会被锁住,直到交换完成。


【示例 2】用不同的模式,使用无缓冲的通道,在 goroutine 之间同步数据,来模拟接力比赛。在接力比赛里,4 个跑步者围绕赛道轮流跑。第二个、第三个和第四个跑步者要接到前一位跑步者的接力棒后才能起跑。比赛中最重要的部分是要传递接力棒,要求同步传递。在同步接力棒的时候,参与接力的两个跑步者必须在同一时刻准备好交接。代码如下所示。

  1. // 这个示例程序展示如何用无缓冲的通道来模拟
  2. // 4 个goroutine 间的接力比赛
  3. package main
  4. import (
  5. "fmt"
  6. "sync"
  7. "time"
  8. )
  9. // wg 用来等待程序结束
  10. var wg sync.WaitGroup
  11. // main 是所有Go 程序的入口
  12. func main() {
  13. // 创建一个无缓冲的通道
  14. baton := make(chan int)
  15. // 为最后一位跑步者将计数加1
  16. wg.Add(1)
  17. // 第一位跑步者持有接力棒
  18. go Runner(baton)
  19. // 开始比赛
  20. baton <- 1
  21. // 等待比赛结束
  22. wg.Wait()
  23. }
  24. // Runner 模拟接力比赛中的一位跑步者
  25. func Runner(baton chan int) {
  26. var newRunner int
  27. // 等待接力棒
  28. runner := <-baton
  29. // 开始绕着跑道跑步
  30. fmt.Printf("Runner %d Running With Baton\n", runner)
  31. // 创建下一位跑步者
  32. if runner != 4 {
  33. newRunner = runner + 1
  34. fmt.Printf("Runner %d To The Line\n", newRunner)
  35. go Runner(baton)
  36. }
  37. // 围绕跑道跑
  38. time.Sleep(100 * time.Millisecond)
  39. // 比赛结束了吗?
  40. if runner == 4 {
  41. fmt.Printf("Runner %d Finished, Race Over\n", runner)
  42. wg.Done()
  43. return
  44. }
  45. // 将接力棒交给下一位跑步者
  46. fmt.Printf("Runner %d Exchange With Runner %d\n",
  47. runner,
  48. newRunner)
  49. baton <- newRunner
  50. }

运行这个程序,输出结果如下所示。

Runner 1 Running With Baton
Runner 1 To The Line
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 2 To The Line
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 3 To The Line
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over

代码说明如下:

  • 第 17 行,创建了一个无缓冲的 int 类型的通道 baton,用来同步传递接力棒。
  • 第 20 行,我们给 WaitGroup 加 1,这样 main 函数就会等最后一位跑步者跑步结束。
  • 第 23 行创建了一个 goroutine,用来表示第一位跑步者来到跑道。
  • 第 26 行,将接力棒交给这个跑步者,比赛开始。
  • 第 29 行,main 函数阻塞在 WaitGroup,等候最后一位跑步者完成比赛。
  • 第 37 行,goroutine 对 baton 通道执行接收操作,表示等候接力棒。
  • 第 46 行,一旦接力棒传了进来,就会创建一位新跑步者,准备接力下一棒,直到 goroutine 是第四个跑步者。
  • 第 50 行,跑步者围绕跑道跑 100 ms。
  • 第 55 行,如果第四个跑步者完成了比赛,就调用 Done,将 WaitGroup 减 1,之后 goroutine 返回。
  • 第 64 行,如果这个 goroutine 不是第四个跑步者,接力棒会交到下一个已经在等待的跑步者手上。在这个时候,goroutine 会被锁住,直到交接完成。


在这两个例子里,我们使用无缓冲的通道同步 goroutine,模拟了网球和接力赛。代码的流程与这两个活动在真实世界中的流程完全一样,这样的代码很容易读懂。

现在知道了无缓冲的通道是如何工作的,下一节我们将为大家介绍带缓冲的通道。

Go语言带缓冲的通道

Go语言中有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。

无缓冲通道保证收发过程同步。无缓冲收发过程类似于快递员给你电话让你下楼取快递,整个递交快递的过程是同步发生的,你和快递员不见不散。但这样做快递员就必须等待所有人下楼完成操作后才能完成所有投递工作。如果快递员将快递放入快递柜中,并通知用户来取,快递员和用户就成了异步收发过程,效率可以有明显的提升。带缓冲的通道就是这样的一个“快递柜”。

创建带缓冲通道

如何创建带缓冲的通道呢?参见如下代码:

通道实例 := make(chan 通道类型, 缓冲大小)

  • 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型。
  • 缓冲大小:决定通道最多可以保存的元素数量。
  • 通道实例:被创建出的通道实例。


下面通过一个例子中来理解带缓冲通道的用法,参见下面的代码:

  1. package main
  2. import "fmt"
  3. func main() {
  4. // 创建一个3个元素缓冲大小的整型通道
  5. ch := make(chan int, 3)
  6. // 查看当前通道的大小
  7. fmt.Println(len(ch))
  8. // 发送3个整型元素到通道
  9. ch <- 1
  10. ch <- 2
  11. ch <- 3
  12. // 查看当前通道的大小
  13. fmt.Println(len(ch))
  14. }

代码输出如下:

0
3

代码说明如下:

  • 第 8 行,创建一个带有 3 个元素缓冲大小的整型类型的通道。
  • 第 11 行,查看当前通道的大小。带缓冲的通道在创建完成时,内部的元素是空的,因此使用 len() 获取到的返回值为 0。
  • 第 14~16 行,发送 3 个整型元素到通道。因为使用了缓冲通道。即便没有 goroutine 接收,发送者也不会发生阻塞。
  • 第 19 行,由于填充了 3 个通道,此时的通道长度变为 3。

阻塞条件

带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作是长度永远为 0 的带缓冲通道。因此根据这个特性,带缓冲通道在下面列举的情况下依然会发生阻塞:

  • 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
  • 带缓冲通道为空时,尝试接收数据时发生阻塞。

为什么Go语言对通道要限制长度而不提供无限长度的通道?

我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。

Go语言channel超时机制

Go语言没有提供直接的超时处理机制,所谓超时可以理解为当我们上网浏览一些网站时,如果一段时间之后不作操作,就需要重新登录。

那么我们应该如何实现这一功能呢,这时就可以使用 select 来设置超时。

虽然 select 机制不是专门为超时而设计的,却能很方便的解决超时问题,因为 select 的特点是只要其中有一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。

超时机制本身虽然也会带来一些问题,比如在运行比较快的机器或者高速的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。

select 的用法与 switch 语言非常类似,由 select 开始一个新的选择块,每个选择条件由 case 语句来描述。

与 switch 语句相比,select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作,大致的结构如下:

select {
    case <-chan1:
    // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
    // 如果成功向chan2写入数据,则进行该case处理语句
    default:
    // 如果上面都没有成功,则进入default处理流程
}

在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。

如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。

如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:

  • 如果给出了 default 语句,那么就会执行 default 语句,同时程序的执行会从 select 语句后的语句中恢复;
  • 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去。


示例代码如下所示:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. ch := make(chan int)
  8. quit := make(chan bool)
  9. //新开一个协程
  10. go func() {
  11. for {
  12. select {
  13. case num := <-ch:
  14. fmt.Println("num = ", num)
  15. case <-time.After(3 * time.Second):
  16. fmt.Println("超时")
  17. quit <- true
  18. }
  19. }
  20. }() //别忘了()
  21. for i := 0; i < 5; i++ {
  22. ch <- i
  23. time.Sleep(time.Second)
  24. }
  25. <-quit
  26. fmt.Println("程序结束")
  27. }

运行结果如下:

num =  0
num =  1
num =  2
num =  3
num =  4
超时
程序结束

Go语言多核并行化

Go语言具有支持高并发的特性,可以很方便地实现多线程运算,充分利用多核心 cpu 的性能。

众所周知服务器的处理器大都是单核频率较低而核心数较多,对于支持高并发的程序语言,可以充分利用服务器的多核优势,从而降低单核压力,减少性能浪费。

Go语言实现多核多线程并发运行是非常方便的,下面举个例子:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. for i := 0; i < 5; i++ {
  7. go AsyncFunc(i)
  8. }
  9. }
  10. func AsyncFunc(index int) {
  11. sum := 0
  12. for i := 0; i < 10000; i++ {
  13. sum += 1
  14. }
  15. fmt.Printf("线程%d, sum为:%d\n", index, sum)
  16. }

运行结果如下:

线程0, sum为:10000
线程2, sum为:10000
线程3, sum为:10000
线程1, sum为:10000
线程4, sum为:10000

在执行一些昂贵的计算任务时,我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。此时我们需要了解 CPU 核心的数量,并针对性地分解计算任务到多个 goroutine 中去并行运行。

下面我们来模拟一个完全可以并行的计算任务:计算 N 个整型数的总和。我们可以将所有整型数分成 M 份,M 即 CPU 的个数。让每个 CPU 开始计算分给它的那份计算任务,最后将每个 CPU 的计算结果再做一次累加,这样就可以得到所有 N 个整型数的总和:

  1. type Vector []float64
  2. // 分配给每个CPU的计算任务
  3. func (v Vector) DoSome(i, n int, u Vector, c chan int) {
  4. for ; i < n; i++ {
  5. v[i] += u.Op(v[i])
  6. }
  7. c <- 1 // 发信号告诉任务管理者我已经计算完成了
  8. }
  9. const NCPU = 16 // 假设总共有16核
  10. func (v Vector) DoAll(u Vector) {
  11. c := make(chan int, NCPU) // 用于接收每个CPU的任务完成信号
  12. for i := 0; i < NCPU; i++ {
  13. go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
  14. }
  15. // 等待所有CPU的任务完成
  16. for i := 0; i < NCPU; i++ {
  17. <-c // 获取到一个数据,表示一个CPU计算完成了
  18. }
  19. // 到这里表示所有计算已经结束
  20. }

这两个函数看起来设计非常合理,其中 DoAll() 会根据 CPU 核心的数目对任务进行分割,然后开辟多个 goroutine 来并行执行这些计算任务。

是否可以将总的计算时间降到接近原来的 1/N 呢?答案是不一定。如果掐秒表,会发现总的执行时间没有明显缩短。再去观察 CPU 运行状态,你会发现尽管我们有 16 个 CPU 核心,但在计算过程中其实只有一个 CPU 核心处于繁忙状态,这是会让很多Go语言初学者迷惑的问题。

官方给出的答案是,这是当前版本的 Go 编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个 goroutine,并且从运行状态看这些 goroutine 也都在并行运行,但实际上所有这些 goroutine 都运行在同一个 CPU 核心上,在一个 goroutine 得到时间片执行的时候,其他 goroutine 都会处于等待状态。从这一点可以看出,虽然 goroutine 简化了我们写并行代码的过程,但实际上整体运行效率并不真正高于单线程程序。

虽然Go语言还不能很好的利用多核心的优势,我们可以先通过设置环境变量 GOMAXPROCS 的值来控制使用多少个 CPU 核心。具体操作方法是通过直接设置环境变量 GOMAXPROCS 的值,或者在代码中启动 goroutine 之前先调用以下这个语句以设置使用 16 个 CPU 核心:

runtime.GOMAXPROCS(16)

到底应该设置多少个 CPU 核心呢,其实 runtime 包中还提供了另外一个 NumCPU() 函数来获取核心数,示例代码如下:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. )
  6. func main() {
  7. cpuNum := runtime.NumCPU() //获得当前设备的cpu核心数
  8. fmt.Println("cpu核心数:", cpuNum)
  9. runtime.GOMAXPROCS(cpuNum) //设置需要用到的cpu数量
  10. }

运行结果如下:

cpu核心数: 4

Go语言互斥锁(sync.Mutex)和读写互斥锁(sync.RWMutex)

Go语言包中的 sync 包提供了两种锁类型:sync.Mutex 和 sync.RWMutex。

Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。从 RWMutex 的实现看,RWMutex 类型其实组合了 Mutex:

type RWMutex struct {
    w Mutex
    writerSem uint32
    readerSem uint32
    readerCount int32
    readerWait int32
}

对于这两种锁类型,任何一个 Lock() 或 RLock() 均需要保证对应有 Unlock() 或 RUnlock() 调用与之对应,否则可能导致等待该锁的所有 goroutine 处于饥饿状态,甚至可能导致死锁。锁的典型使用模式如下:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. var (
  7. // 逻辑中使用的某个变量
  8. count int
  9. // 与变量对应的使用互斥锁
  10. countGuard sync.Mutex
  11. )
  12. func GetCount() int {
  13. // 锁定
  14. countGuard.Lock()
  15. // 在函数退出时解除锁定
  16. defer countGuard.Unlock()
  17. return count
  18. }
  19. func SetCount(c int) {
  20. countGuard.Lock()
  21. count = c
  22. countGuard.Unlock()
  23. }
  24. func main() {
  25. // 可以进行并发安全的设置
  26. SetCount(1)
  27. // 可以进行并发安全的获取
  28. fmt.Println(GetCount())
  29. }

代码说明如下:

  • 第 10 行是某个逻辑步骤中使用到的变量,无论是包级的变量还是结构体成员字段,都可以。
  • 第 13 行,一般情况下,建议将互斥锁的粒度设置得越小越好,降低因为共享访问时等待的时间。这里笔者习惯性地将互斥锁的变量命名为以下格式:

    变量名+Guard

    以表示这个互斥锁用于保护这个变量。
  • 第 16 行是一个获取 count 值的函数封装,通过这个函数可以并发安全的访问变量 count。
  • 第 19 行,尝试对 countGuard 互斥量进行加锁。一旦 countGuard 发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 countGuard 被解锁。
  • 第 22 行使用 defer 将 countGuard 的解锁进行延迟调用,解锁操作将会发生在 GetCount() 函数返回时。
  • 第 27 行在设置 count 值时,同样使用 countGuard 进行加锁、解锁操作,保证修改 count 值的过程是一个原子过程,不会发生并发访问冲突。


在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更加高效。sync 包中的 RWMutex 提供了读写互斥锁的封装。

我们将互斥锁例子中的一部分代码修改为读写互斥锁,参见下面代码:

  1. var (
  2. // 逻辑中使用的某个变量
  3. count int
  4. // 与变量对应的使用互斥锁
  5. countGuard sync.RWMutex
  6. )
  7. func GetCount() int {
  8. // 锁定
  9. countGuard.RLock()
  10. // 在函数退出时解除锁定
  11. defer countGuard.RUnlock()
  12. return count
  13. }

代码说明如下:

    • 第 6 行,在声明 countGuard 时,从 sync.Mutex 互斥锁改为 sync.RWMutex 读写互斥锁。
    • 第 12 行,获取 count 的过程是一个读取 count 数据的过程,适用于读写互斥锁。在这一行,把 countGuard.Lock() 换做 countGuard.RLock(),将读写互斥锁标记为读状态。如果此时另外一个 goroutine 并发访问了 countGuard,同时也调用了 countGuard.RLock() 时,并不会发生阻塞。
    • 第 15 行,与读模式加锁对应的,使用读模式解锁。

Go语言等待组(sync.WaitGroup)

Go语言中除了可以使用通道(channel)和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境中完成指定数量的任务

在 sync.WaitGroup(等待组)类型中,每个 sync.WaitGroup 值在内部维护着一个计数,此计数的初始默认值为零。

等待组有下面几个方法可用,如下表所示。

等待组的方法
方法名功能
(wg * WaitGroup) Add(delta int) 等待组的计数器 +1
(wg * WaitGroup) Done() 等待组的计数器 -1
(wg * WaitGroup) Wait() 当等待组计数器不等于 0 时阻塞直到变 0。


对于一个可寻址的 sync.WaitGroup 值 wg:

  • 我们可以使用方法调用 wg.Add(delta) 来改变值 wg 维护的计数。
  • 方法调用 wg.Done() 和 wg.Add(-1) 是完全等价的。
  • 如果一个 wg.Add(delta) 或者 wg.Done() 调用将 wg 维护的计数更改成一个负数,一个恐慌将产生。
  • 当一个协程调用了 wg.Wait() 时,
    • 如果此时 wg 维护的计数为零,则此 wg.Wait() 此操作为一个空操作(noop);
    • 否则(计数为一个正整数),此协程将进入阻塞状态。当以后其它某个协程将此计数更改至 0 时(一般通过调用 wg.Done()),此协程将重新进入运行状态(即 wg.Wait() 将返回)。


等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加了 N 个并发任务进行工作时,就将等待组的计数器值增加 N。每个任务完成时,这个值减 1。同时,在另外一个 goroutine 中等待这个等待组的计数器值为 0 时,表示所有任务已经完成。

下面的代码演示了这一过程:

  1. package main
  2. import (
  3. "fmt"
  4. "net/http"
  5. "sync"
  6. )
  7. func main() {
  8. // 声明一个等待组
  9. var wg sync.WaitGroup
  10. // 准备一系列的网站地址
  11. var urls = []string{
  12. "http://www.github.com/",
  13. "https://www.qiniu.com/",
  14. "https://www.golangtc.com/",
  15. }
  16. // 遍历这些地址
  17. for _, url := range urls {
  18. // 每一个任务开始时, 将等待组增加1
  19. wg.Add(1)
  20. // 开启一个并发
  21. go func(url string) {
  22. // 使用defer, 表示函数完成时将等待组值减1
  23. defer wg.Done()
  24. // 使用http访问提供的地址
  25. _, err := http.Get(url)
  26. // 访问完成后, 打印地址和可能发生的错误
  27. fmt.Println(url, err)
  28. // 通过参数传递url地址
  29. }(url)
  30. }
  31. // 等待所有的任务完成
  32. wg.Wait()
  33. fmt.Println("over")
  34. }

代码说明如下:

    • 第 12 行,声明一个等待组,对一组等待任务只需要一个等待组,而不需要每一个任务都使用一个等待组。
    • 第 15 行,准备一系列可访问的网站地址的字符串切片。
    • 第 22 行,遍历这些字符串切片。
    • 第 25 行,将等待组的计数器加1,也就是每一个任务加 1。
    • 第 28 行,将一个匿名函数开启并发。
    • 第 31 行,在匿名函数结束时会执行这一句以表示任务完成。wg.Done() 方法等效于执行 wg.Add(-1)。
    • 第 34 行,使用 http 包提供的 Get() 函数对 url 进行访问,Get() 函数会一直阻塞直到网站响应或者超时。
    • 第 37 行,在网站响应和超时后,打印这个网站的地址和可能发生的错误。
    • 第 40 行,这里将 url 通过 goroutine 的参数进行传递,是为了避免 url 变量通过闭包放入匿名函数后又被修改的问题。
    • 第 44 行,等待所有的网站都响应或者超时后,任务完成,Wait 就会停止阻塞。

Go语言死锁、活锁和饥饿概述

死锁

死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁发生的条件有如下几种:

1) 互斥条件

线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。

2) 请求和保持条件

线程 T1 至少已经保持了一个资源 R1 占用,但又提出使用另一个资源 R2 请求,而此时,资源 R2 被其他线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。

3) 不剥夺条件

线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。

4) 环路等待条件

在死锁发生时,必然存在一个“进程 - 资源环形链”,即:{p0,p1,p2,...pn},进程 p0(或线程)等待 p1 占用的资源,p1 等待 p2 占用的资源,pn 等待 p0 占用的资源。

最直观的理解是,p0 等待 p1 占用的资源,而 p1 而在等待 p0 占用的资源,于是两个进程就相互等待。

死锁解决办法:

  • 如果并发查询多个表,约定访问顺序;
  • 在同一个事务中,尽可能做到一次锁定获取所需要的资源;
  • 对于容易产生死锁的业务场景,尝试升级锁颗粒度,使用表级锁;
  • 采用分布式事务锁或者使用乐观锁。


死锁程序是所有并发进程彼此等待的程序,在这种情况下,如果没有外界的干预,这个程序将永远无法恢复。

为了便于大家理解死锁是什么,我们先来看一个例子(忽略代码中任何不知道的类型,函数,方法或是包,只理解什么是死锁即可),代码如下所示:

  1. package main
  2. import (
  3.     "fmt"
  4.     "runtime"
  5.     "sync"
  6.     "time"
  7. )
  8. type value struct {
  9.     memAccess sync.Mutex
  10.     value     int
  11. }
  12. func main() {
  13.     runtime.GOMAXPROCS(3)
  14.     var wg sync.WaitGroup
  15.     sum := func(v1, v2 *value) {
  16.         defer wg.Done()
  17.         v1.memAccess.Lock()
  18.         time.Sleep(2 * time.Second)
  19.         v2.memAccess.Lock()
  20.         fmt.Printf("sum = %d\n", v1.value+v2.value)
  21.         v2.memAccess.Unlock()
  22.         v1.memAccess.Unlock()
  23.     }
  24.     product := func(v1, v2 *value) {
  25.         defer wg.Done()
  26.         v2.memAccess.Lock()
  27.         time.Sleep(2 * time.Second)
  28.         v1.memAccess.Lock()
  29.         fmt.Printf("product = %d\n", v1.value*v2.value)
  30.         v1.memAccess.Unlock()
  31.         v2.memAccess.Unlock()
  32.     }
  33.     var v1, v2 value
  34.     v1.value = 1
  35.     v2.value = 1
  36.     wg.Add(2)
  37.     go sum(&v1, &v2)
  38.     go product(&v1, &v2)
  39.     wg.Wait()
  40. }

运行上面的代码,可能会看到:

fatal error: all goroutines are asleep - deadlock!

为什么呢?如果仔细观察,就可以在此代码中看到时机问题,以下是运行时的图形表示。

一个因时间问题导致死锁的演示
图 :一个因时间问题导致死锁的演示

活锁

活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复同样的操作,而且总会失败。

例如线程 1 可以使用资源,但它很礼貌,让其他线程先使用资源,线程 2 也可以使用资源,但它同样很绅士,也让其他线程先使用资源。就这样你让我,我让你,最后两个线程都无法使用资源。

活锁通常发生在处理事务消息中,如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行,这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。

当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。这就像两个过于礼貌的人在路上相遇,他们彼此让路,然后在另一条路上相遇,然后他们就一直这样避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。例如在网络上发送数据包,如果检测到冲突,都要停止并在一段时间后重发,如果都在 1 秒后重发,还是会冲突,所以引入随机性可以解决该类问题。

下面通过示例来演示一下活锁:

  1. package main
  2. import (
  3.     "bytes"
  4.     "fmt"
  5.     "runtime"
  6.     "sync"
  7.     "sync/atomic"
  8.     "time"
  9. )
  10. func main() {
  11.     runtime.GOMAXPROCS(3)
  12.     cv := sync.NewCond(&sync.Mutex{})
  13.     go func() {
  14.         for range time.Tick(1 * time.Second) { // 通过tick控制两个人的步调
  15.             cv.Broadcast()
  16.         }
  17.     }()
  18.     takeStep := func() {
  19.         cv.L.Lock()
  20.         cv.Wait()
  21.         cv.L.Unlock()
  22.     }
  23.     tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool {
  24.         fmt.Fprintf(out, " %+v", dirName)
  25.         atomic.AddInt32(dir, 1)
  26.         takeStep()                      //走上一步
  27.         if atomic.LoadInt32(dir) == 1 { //走成功就返回
  28.             fmt.Fprint(out, ". Success!")
  29.             return true
  30.         }
  31.         takeStep() // 没走成功,再走回来
  32.         atomic.AddInt32(dir, -1)
  33.         return false
  34.     }
  35.     var left, right int32
  36.     tryLeft := func(out *bytes.Buffer) bool {
  37.         return tryDir("向左走", &left, out)
  38.     }
  39.     tryRight := func(out *bytes.Buffer) bool {
  40.         return tryDir("向右走", &right, out)
  41.     }
  42.     walk := func(walking *sync.WaitGroup, name string) {
  43.         var out bytes.Buffer
  44.         defer walking.Done()
  45.         defer func() { fmt.Println(out.String()) }()
  46.         fmt.Fprintf(&out, "%v is trying to scoot:", name)
  47.         for i := 0; i < 5; i++ {
  48.             if tryLeft(&out) || tryRight(&out) {
  49.                 return
  50.             }
  51.         }
  52.         fmt.Fprintf(&out, "\n%v is tried!", name)
  53.     }
  54.     var trail sync.WaitGroup
  55.     trail.Add(2)
  56.     go walk(&trail, "男人") // 男人在路上走
  57.     go walk(&trail, "女人") // 女人在路上走
  58.     trail.Wait()
  59. }

输出结果如下:

go run main.go
女人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
女人 is tried!
男人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
男人 is tried!

这个例子演示了使用活锁的一个十分常见的原因,两个或两个以上的并发进程试图在没有协调的情况下防止死锁。这就好比,如果走廊里的人都同意,只有一个人会移动,那就不会有活锁;一个人会站着不动,另一个人会移到另一边,他们就会继续移动。

活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待,活锁有可能自行解开,死锁则不能。

饥饿

饥饿是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。

与死锁不同的是,饥饿锁在一段时间内,优先级低的线程最终还是会执行的,比如高优先级的线程执行完之后释放了资源。

活锁与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。

下面的示例程序中包含了一个贪婪的 goroutine 和一个平和的 goroutine:

  1. package main
  2. import (
  3.     "fmt"
  4.     "runtime"
  5.     "sync"
  6.     "time"
  7. )
  8. func main() {
  9.     runtime.GOMAXPROCS(3)
  10.     var wg sync.WaitGroup
  11.     const runtime = 1 * time.Second
  12.     var sharedLock sync.Mutex
  13.     greedyWorker := func() {
  14.         defer wg.Done()
  15.         var count int
  16.         for begin := time.Now(); time.Since(begin) <= runtime; {
  17.             sharedLock.Lock()
  18.             time.Sleep(3 * time.Nanosecond)
  19.             sharedLock.Unlock()
  20.             count++
  21.         }
  22.         fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
  23.     }
  24.     politeWorker := func() {
  25.         defer wg.Done()
  26.         var count int
  27.         for begin := time.Now(); time.Since(begin) <= runtime; {
  28.             sharedLock.Lock()
  29.             time.Sleep(1 * time.Nanosecond)
  30.             sharedLock.Unlock()
  31.             sharedLock.Lock()
  32.             time.Sleep(1 * time.Nanosecond)
  33.             sharedLock.Unlock()
  34.             sharedLock.Lock()
  35.             time.Sleep(1 * time.Nanosecond)
  36.             sharedLock.Unlock()
  37.             count++
  38.         }
  39.         fmt.Printf("Polite worker was able to execute %v work loops\n", count)
  40.     }
  41.     wg.Add(2)
  42.     go greedyWorker()
  43.     go politeWorker()
  44.     wg.Wait()
  45. }

输出如下:

Greedy worker was able to execute 276 work loops
Polite worker was able to execute 92 work loops

贪婪的 worker 会贪婪地抢占共享锁,以完成整个工作循环,而平和的 worker 则试图只在需要时锁定。两种 worker 都做同样多的模拟工作(sleeping 时间为 3ns),可以看到,在同样的时间里,贪婪的 worker 工作量几乎是平和的 worker 工作量的两倍!

假设两种 worker 都有同样大小的临界区,而不是认为贪婪的 worker 的算法更有效(或调用 Lock 和 Unlock 的时候,它们也不是缓慢的),我们得出这样的结论,贪婪的 worker 不必要地扩大其持有共享锁上的临界区,井阻止(通过饥饿)平和的 worker 的 goroutine 高效工作。

总结

不适用锁肯定会出问题。如果用了,虽然解了前面的问题,但是又出现了更多的新问题。

    • 死锁:是因为错误的使用了锁,导致异常;
    • 活锁:是饥饿的一种特殊情况,逻辑上感觉对,程序也一直在正常的跑,但就是效率低,逻辑上进行不下去;
    • 饥饿:与锁使用的粒度有关,通过计数取样,可以判断进程的工作效率。

Go语言CSP:通信顺序进程简述

Go实现了两种并发形式,第一种是大家普遍认知的多线程共享内存,其实就是 Java 或 C++ 等语言中的多线程开发;另外一种是Go语言特有的,也是Go语言推荐的 CSP(communicating sequential processes)并发模型。

CSP 并发模型是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享 channel(管道)进行通信的并发模型。

Go语言就是借用 CSP 并发模型的一些概念为之实现并发的,但是Go语言并没有完全实现了 CSP 并发模型的所有理论,仅仅是实现了 process 和 channel 这两个概念。

process 就是Go语言中的 goroutine,每个 goroutine 之间是通过 channel 通讯来实现数据共享。

这里我们要明确的是“并发不是并行”。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核 CPU 上才可能真正地同时运行;并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如 GPU 中对图像处理都会有大量的并行运算。

为了更好地编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让开发人员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力。

在并发编程中,对共享资源的正确访问需要精确地控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过通道传递(实际上多个独立执行的线程很少主动共享资源)。

并发编程的核心概念是同步通信,但是同步的方式却有多种。先以大家熟悉的互斥量 sync.Mutex 来实现同步通信,示例代码如下所示:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var mu sync.Mutex
  8. go func() {
  9. fmt.Println("C语言中文网")
  10. mu.Lock()
  11. }()
  12. mu.Unlock()
  13. }

由于 mu.Lock() 和 mu.Unlock() 并不在同一个 Goroutine 中,所以也就不满足顺序一致性内存模型。同时它们也没有其他的同步事件可以参考,也就是说这两件事是可以并发的。

因为可能是并发的事件,所以 main() 函数中的 mu.Unlock() 很有可能先发生,而这个时刻 mu 互斥对象还处于未加锁的状态,因而会导致运行时异常。

下面是修复后的代码:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var mu sync.Mutex
  8. mu.Lock()
  9. go func() {
  10. fmt.Println("C语言中文网")
  11. mu.Unlock()
  12. }()
  13. mu.Lock()
  14. }

修复的方式是在 main() 函数所在线程中执行两次 mu.Lock(),当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,main() 函数的阻塞状态驱动后台线程继续向前执行。

当后台线程执行到 mu.Unlock() 时解锁,此时打印工作已经完成了,解锁会导致 main() 函数中的第二个 mu.Lock() 阻塞状态取消,此时后台线程和主线程再没有其他的同步事件参考,它们退出的事件将是并发的,在 main() 函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的。

使用 sync.Mutex 互斥锁同步是比较低级的做法,我们现在改用无缓存通道来实现同步:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. done := make(chan int)
  7. go func() {
  8. fmt.Println("C语言中文网")
  9. <-done
  10. }()
  11. done <- 1
  12. }

根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前。因此,后台线程<-done 接收操作完成之后,main 线程的done <- 1 发送操作才可能完成(从而退出 main、退出程序),而此时打印工作已经完成了。

上面的代码虽然可以正确同步,但是对通道的缓存大小太敏感,如果通道有缓存,就无法保证 main() 函数退出之前后台线程能正常打印了,更好的做法是将通道的发送和接收方向调换一下,这样可以避免同步事件受通道缓存大小的影响:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. done := make(chan int, 1) // 带缓存通道
  7. go func() {
  8. fmt.Println("C语言中文网")
  9. done <- 1
  10. }()
  11. <-done
  12. }

对于带缓存的通道,对通道的第 K 个接收完成操作发生在第 K+C 个发送操作完成之前,其中 C 是通道的缓存大小。虽然通道是带缓存的,但是 main 线程接收完成是在后台线程发送开始但还未完成的时刻,此时打印工作也是已经完成的。

基于带缓存通道,我们可以很容易将打印线程扩展到 N 个,下面的示例是开启 10 个后台线程分别打印:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func main() {
  6. done := make(chan int, 10) // 带10个缓存
  7. // 开N个后台打印线程
  8. for i := 0; i < cap(done); i++ {
  9. go func() {
  10. fmt.Println("C语言中文网")
  11. done <- 1
  12. }()
  13. }
  14. // 等待N个后台线程完成
  15. for i := 0; i < cap(done); i++ {
  16. <-done
  17. }
  18. }

对于这种要等待 N 个线程完成后再进行下一步的同步操作有一个简单的做法,就是使用 sync.WaitGroup 来等待一组事件:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. )
  6. func main() {
  7. var wg sync.WaitGroup
  8. // 开N个后台打印线程
  9. for i := 0; i < 10; i++ {
  10. wg.Add(1)
  11. go func() {
  12. fmt.Println("C语言中文网")
  13. wg.Done()
  14. }()
  15. }
  16. // 等待N个后台线程完成
  17. wg.Wait()
  18. }

其中 wg.Add(1) 用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行则不能保证被正常执行到)。当后台线程完成打印工作之后,调用 wg.Done() 表示完成一个事件,main() 函数的 wg.Wait() 是等待全部的事件完成。

 

posted @ 2022-04-07 11:02  hanease  阅读(97)  评论(0编辑  收藏  举报