Go 系列教程-6 多线程 并发

Go 系列教程 —— 20. 并发入门

Go 是并发式语言,而不是并行式语言。在讨论 Go 如何处理并发之前,我们必须理解何为并发,以及并发与并行的区别。

并发是什么?

并发是指立即处理多个任务的能力。一个例子就能很好地说明这一点。

我们可以想象一个人正在跑步。假如在他晨跑时,鞋带突然松了。于是他停下来,系一下鞋带,接下来继续跑。这个例子就是典型的并发。这个人能够一下搞定跑步和系鞋带两件事,即立即处理多个任务。

并行是什么?并行和并发有何区别?

并行是指同时处理多个任务。这听起来和并发差不多,但其实完全不同。

我们同样用这个跑步的例子来帮助理解。假如这个人在慢跑时,还在用他的 iPod 听着音乐。在这里,他是在跑步的同时听音乐,也就是同时处理多个任务。这称之为并行。

从技术上看并发和并行

通过现实中的例子,我们已经明白了什么是并发,以及并发与并行的区别。作为一名极客,我们接下来从技术的角度来考察并发和并行。:)

假如我们正在编写一个 web 浏览器。这个 web 浏览器有各种组件。其中两个分别是 web 页面的渲染区和从网上下载文件的下载器。假设我们已经构建好了浏览器代码,各个组件也都可以相互独立地运行(通过像 Java 里的线程,或者通过即将介绍的 Go 语言中的 Go 协程来实现)。当浏览器在单核处理器中运行时,处理器会在浏览器的两个组件间进行上下文切换。它可能在一段时间内下载文件,转而又对用户请求的 web 页面进行渲染。这就是并发。并发的进程从不同的时间点开始,分别交替运行。在这里,就是在不同的时间点开始进行下载和渲染,并相互交替运行的。

如果该浏览器在一个多核处理器上运行,此时下载文件的组件和渲染 HTML 的组件可能会在不同的核上同时运行。这称之为并行。

image

并行不一定会加快运行速度,因为并行运行的组件之间可能需要相互通信。在我们浏览器的例子里,当文件下载完成后,应当对用户进行提醒,比如弹出一个窗口。于是,在负责下载的组件和负责渲染用户界面的组件之间,就产生了通信。在并发系统上,这种通信开销很小。但在多核的并行系统上,组件间的通信开销就很高了。所以,并行不一定会加快运行速度!

Go 对并发的支持

Go 编程语言原生支持并发。Go 使用 Go 协程(Goroutine) 和信道(Channel)来处理并发。

Go 系列教程 —— 21. Go 协程

在前面的教程里,我们探讨了并发,以及并发与并行的区别。本教程则会介绍在 Go 语言里,如何使用 Go 协程(Goroutine)来实现并发。

Go 协程是什么?

Go 协程是与其他函数或方法一起并发运行的函数或方法。Go 协程可以看作是轻量级线程。与线程相比,创建一个 Go 协程的成本很小。因此在 Go 应用中,常常会看到有数以千计的 Go 协程并发地运行。

Go 协程相比于线程的优势

  • 相比线程而言,Go 协程的成本极低。堆栈大小只有若干 kb,并且可以根据应用的需求进行增减。而线程必须指定堆栈的大小,其堆栈是固定不变的。
  • Go 协程会复用(Multiplex)数量更少的 OS 线程。即使程序有数以千计的 Go 协程,也可能只有一个线程。如果该线程中的某一 Go 协程发生了阻塞(比如说等待用户输入),那么系统会再创建一个 OS 线程,并把其余 Go 协程都移动到这个新的 OS 线程。所有这一切都在运行时进行,作为程序员,我们没有直接面临这些复杂的细节,而是有一个简洁的 API 来处理并发。
  • Go 协程使用信道(Channel)来进行通信。信道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。信道可以看作是 Go 协程之间通信的管道。我们会在下一教程详细讨论信道。

如何启动一个 Go 协程?

调用函数或者方法时,在前面加上关键字 go,可以让一个新的 Go 协程并发地运行。

让我们创建一个 Go 协程吧。

package main

import (
    "fmt"
)

func hello() {
    fmt.Println("Hello world goroutine")
}
func main() {
    go hello()
    fmt.Println("main function")
}

在线运行程序

在第 11 行,go hello() 启动了一个新的 Go 协程。现在 hello() 函数与 main() 函数会并发地执行。主函数会运行在一个特有的 Go 协程上,它称为 Go 主协程(Main Goroutine)。

运行一下程序,你会很惊讶!

该程序只会输出文本 main function。我们启动的 Go 协程究竟出现了什么问题?要理解这一切,我们需要理解两个 Go 协程的主要性质。

  • 启动一个新的协程时,协程的调用会立即返回。与函数不同,程序控制不会去等待 Go 协程执行完毕。在调用 Go 协程之后,程序控制会立即返回到代码的下一行,忽略该协程的任何返回值。
  • 如果希望运行其他 Go 协程,Go 主协程必须继续运行着。如果 Go 主协程终止,则程序终止,于是其他 Go 协程也不会继续运行。

现在你应该能够理解,为何我们的 Go 协程没有运行了吧。在第 11 行调用了 go hello() 之后,程序控制没有等待 hello 协程结束,立即返回到了代码下一行,打印 main function。接着由于没有其他可执行的代码,Go 主协程终止,于是 hello 协程就没有机会运行了。

我们现在修复这个问题。

package main

import (  
    "fmt"
    "time"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}
func main() {  
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

在线运行程序

在上面程序的第 13 行,我们调用了 time 包里的函数 Sleep,该函数会休眠执行它的 Go 协程。在这里,我们使 Go 主协程休眠了 1 秒。因此在主协程终止之前,调用 go hello() 就有足够的时间来执行了。该程序首先打印 Hello world goroutine,等待 1 秒钟之后,接着打印 main function

在 Go 主协程中使用休眠,以便等待其他协程执行完毕,这种方法只是用于理解 Go 协程如何工作的技巧。信道可用于在其他协程结束执行之前,阻塞 Go 主协程。我们会在下一教程中讨论信道。

启动多个 Go 协程

为了更好地理解 Go 协程,我们再编写一个程序,启动多个 Go 协程。

package main

import (  
    "fmt"
    "time"
)

func numbers() {  
    for i := 1; i <= 5; i++ {
        time.Sleep(250 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}
func alphabets() {  
    for i := 'a'; i <= 'e'; i++ {
        time.Sleep(400 * time.Millisecond)
        fmt.Printf("%c ", i)
    }
}
func main() {  
    go numbers()
    go alphabets()
    time.Sleep(3000 * time.Millisecond)
    fmt.Println("main terminated")
}

在线运行程序

在上面程序中的第 21 行和第 22 行,启动了两个 Go 协程。现在,这两个协程并发地运行。numbers 协程首先休眠 250 微秒,接着打印 1,然后再次休眠,打印 2,依此类推,一直到打印 5 结束。alphabete 协程同样打印从 a 到 e 的字母,并且每次有 400 微秒的休眠时间。 Go 主协程启动了 numbers 和 alphabete 两个 Go 协程,休眠了 3000 微秒后终止程序。

该程序会输出:

1 a 2 3 b 4 c 5 d e main terminated

程序的运作如下图所示。为了更好地观看图片,请在新标签页中打开。

image

第一张蓝色的图表示 numbers 协程,第二张褐红色的图表示 alphabets 协程,第三张绿色的图表示 Go 主协程,而最后一张黑色的图把以上三种协程合并了,表明程序是如何运行的。在每个方框顶部,诸如 0 ms 和 250 ms 这样的字符串表示时间(以微秒为单位)。在每个方框的底部,123 等表示输出。蓝色方框表示:250 ms 打印出 1500 ms 打印出 2,依此类推。最后黑色方框的底部的值会是 1 a 2 3 b 4 c 5 d e main terminated,这同样也是整个程序的输出。以上图片非常直观,你可以用它来理解程序是如何运作的。

Go 系列教程 —— 22. 信道(channel)

上一教程里,我们探讨了如何使用 Go 协程(Goroutine)来实现并发。我们接着在本教程里学习信道(Channel),学习如何通过信道来实现 Go 协程间的通信。

什么是信道?

信道可以想像成 Go 协程之间通信的管道。如同管道中的水会从一端流到另一端,通过使用信道,数据也可以从一端发送,在另一端接收。

信道的声明

所有信道都关联了一个类型。信道只能运输这种类型的数据,而运输其他类型的数据都是非法的。

chan T 表示 T 类型的信道。

信道的零值为 nil。信道的零值没有什么用,应该像对 map 和切片所做的那样,用 make 来定义信道。

下面编写代码,声明一个信道。

package main

import "fmt"

func main() {  
    var a chan int
    if a == nil {
        fmt.Println("channel a is nil, going to define it")
        a = make(chan int)
        fmt.Printf("Type of a is %T", a)
    }
}

在线运行程序

由于信道的零值为 nil,在第 6 行,信道 a 的值就是 nil。于是,程序执行了 if 语句内的语句,定义了信道 a。程序中 a 是一个 int 类型的信道。该程序会输出:

channel a is nil, going to define it  
Type of a is chan int

简短声明通常也是一种定义信道的简洁有效的方法。

a := make(chan int)

这一行代码同样定义了一个 int 类型的信道 a

通过信道进行发送和接收

如下所示,该语法通过信道发送和接收数据。

data := <- a // 读取信道 a  
a <- data // 写入信道 a

信道旁的箭头方向指定了是发送数据还是接收数据。

在第一行,箭头对于 a 来说是向外指的,因此我们读取了信道 a 的值,并把该值存储到变量 data

在第二行,箭头指向了 a,因此我们在把数据写入信道 a

发送与接收默认是阻塞的

发送与接收默认是阻塞的。这是什么意思?当把数据发送到信道时,程序控制会在发送数据的语句处发生阻塞,直到有其它 Go 协程从信道读取到数据,才会解除阻塞。与此类似,当读取信道的数据时,如果没有其它的协程把数据写入到这个信道,那么读取过程就会一直阻塞着。

信道的这种特性能够帮助 Go 协程之间进行高效的通信,不需要用到其他编程语言常见的显式锁或条件变量。

信道的代码示例

理论已经够了:)。接下来写点代码,看看协程之间通过信道是怎么通信的吧。

我们其实可以重写上章学习 Go 协程 时写的程序,现在我们在这里用上信道。

首先引用前面教程里的程序。

package main

import (  
    "fmt"
    "time"
)

func hello() {  
    fmt.Println("Hello world goroutine")
}
func main() {  
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

在线运行程序

这是上一篇的代码。我们使用到了休眠,使 Go 主协程等待 hello 协程结束。如果你看不懂,建议你阅读上一教程 Go 协程

我们接下来使用信道来重写上面代码。

package main

import (  
    "fmt"
)

func hello(done chan bool) {  
    fmt.Println("Hello world goroutine")
    done <- true
}
func main() {  
    done := make(chan bool)
    go hello(done)
    <-done
    fmt.Println("main function")
}

在线运行程序

在上述程序里,我们在第 12 行创建了一个 bool 类型的信道 done,并把 done 作为参数传递给了 hello 协程。在第 14 行,我们通过信道 done 接收数据。这一行代码发生了阻塞,除非有协程向 done 写入数据,否则程序不会跳到下一行代码。于是,这就不需要用以前的 time.Sleep 来阻止 Go 主协程退出了。

<-done 这行代码通过协程(译注:原文笔误,信道)done 接收数据,但并没有使用数据或者把数据存储到变量中。这完全是合法的。

现在我们的 Go 主协程发生了阻塞,等待信道 done 发送的数据。该信道作为参数传递给了协程 hellohello 打印出 Hello world goroutine,接下来向 done 写入数据。当完成写入时,Go 主协程会通过信道 done 接收数据,于是它解除阻塞状态,打印出文本 main function

该程序输出如下:

Hello world goroutine  
main function

我们稍微修改一下程序,在 hello 协程里加入休眠函数,以便更好地理解阻塞的概念。

package main

import (  
    "fmt"
    "time"
)

func hello(done chan bool) {  
    fmt.Println("hello go routine is going to sleep")
    time.Sleep(4 * time.Second)
    fmt.Println("hello go routine awake and going to write to done")
    done <- true
}
func main() {  
    done := make(chan bool)
    fmt.Println("Main going to call hello go goroutine")
    go hello(done)
    <-done
    fmt.Println("Main received data")
}

在线运行程序

在上面程序里,我们向 hello 函数里添加了 4 秒的休眠(第 10 行)。

程序首先会打印 Main going to call hello go goroutine。接着会开启 hello 协程,打印 hello go routine is going to sleep。打印完之后,hello 协程会休眠 4 秒钟,而在这期间,主协程会在 <-done 这一行发生阻塞,等待来自信道 done 的数据。4 秒钟之后,打印 hello go routine awake and going to write to done,接着再打印 Main received data

信道的另一个示例

我们再编写一个程序来更好地理解信道。该程序会计算一个数中每一位的平方和与立方和,然后把平方和与立方和相加并打印出来。

例如,如果输出是 123,该程序会如下计算输出:

squares = (1 * 1) + (2 * 2) + (3 * 3) 
cubes = (1 * 1 * 1) + (2 * 2 * 2) + (3 * 3 * 3) 
output = squares + cubes = 50

我们会这样去构建程序:在一个单独的 Go 协程计算平方和,而在另一个协程计算立方和,最后在 Go 主协程把平方和与立方和相加。

package main

import (  
    "fmt"
)

func calcSquares(number int, squareop chan int) {  
    sum := 0
    for number != 0 {
        digit := number % 10
        sum += digit * digit
        number /= 10
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {  
    sum := 0 
    for number != 0 {
        digit := number % 10
        sum += digit * digit * digit
        number /= 10
    }
    cubeop <- sum
} 

func main() {  
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares + cubes)
}

在线运行程序

在第 7 行,函数 calcSquares 计算一个数每位的平方和,并把结果发送给信道 squareop。与此类似,在第 17 行函数 calcCubes 计算一个数每位的立方和,并把结果发送给信道 cubop

这两个函数分别在单独的协程里运行(第 31 行和第 32 行),每个函数都有传递信道的参数,以便写入数据。Go 主协程会在第 33 行等待两个信道传来的数据。一旦从两个信道接收完数据,数据就会存储在变量 squares 和 cubes 里,然后计算并打印出最后结果。该程序会输出:

Final output 1536

死锁

使用信道需要考虑的一个重点是死锁。当 Go 协程给一个信道发送数据时,照理说会有其他 Go 协程来接收数据。如果没有的话,程序就会在运行时触发 panic,形成死锁。

同理,当有 Go 协程等着从一个信道接收数据时,我们期望其他的 Go 协程会向该信道写入数据,要不然程序就会触发 panic。

package main

func main() {  
    ch := make(chan int)
    ch <- 5
}

在线运行程序

在上述程序中,我们创建了一个信道 ch,接着在下一行 ch <- 5,我们把 5 发送到这个信道。对于本程序,没有其他的协程从 ch接收数据。于是程序触发 panic,出现如下运行时错误。

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:  
main.main()  
    /tmp/sandbox249677995/main.go:6 +0x80

单向信道

我们目前讨论的信道都是双向信道,即通过信道既能发送数据,又能接收数据。其实也可以创建单向信道,这种信道只能发送或者接收数据。

package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    sendch := make(chan<- int)
    go sendData(sendch)
    fmt.Println(<-sendch)
}

在线运行程序

上面程序的第 10 行,我们创建了唯送(Send Only)信道 sendchchan<- int 定义了唯送信道,因为箭头指向了 chan。在第 12 行,我们试图通过唯送信道接收数据,于是编译器报错:

main.go:11: invalid operation: <-sendch (receive from send-only type chan<- int)

一切都很顺利,只不过一个不能读取数据的唯送信道究竟有什么意义呢?

这就需要用到信道转换(Channel Conversion)了。把一个双向信道转换成唯送信道或者唯收(Receive Only)信道都是行得通的,但是反过来就不行。

package main

import "fmt"

func sendData(sendch chan<- int) {  
    sendch <- 10
}

func main() {  
    cha1 := make(chan int)
    go sendData(cha1)
    fmt.Println(<-cha1)
}

在线运行程序

在上述程序的第 10 行,我们创建了一个双向信道 cha1。在第 11 行 cha1 作为参数传递给了 sendData 协程。在第 5 行,函数 sendData 里的参数 sendch chan<- int 把 cha1 转换为一个唯送信道。于是该信道在 sendData 协程里是一个唯送信道,而在 Go 主协程里是一个双向信道。该程序最终打印输出 10

关闭信道和使用 for range 遍历信道

数据发送方可以关闭信道,通知接收方这个信道不再有数据发送过来。

当从信道接收数据时,接收方可以多用一个变量来检查信道是否已经关闭。

v, ok := <- ch

上面的语句里,如果成功接收信道所发送的数据,那么 ok 等于 true。而如果 ok 等于 false,说明我们试图读取一个关闭的通道。从关闭的信道读取到的值会是该信道类型的零值。例如,当信道是一个 int 类型的信道时,那么从关闭的信道读取的值将会是 0

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for {
        v, ok := <-ch
        if ok == false {
            break
        }
        fmt.Println("Received ", v, ok)
    }
}

在线运行程序

在上述的程序中,producer 协程会从 0 到 9 写入信道 chn1,然后关闭该信道。主函数有一个无限的 for 循环(第 16 行),使用变量 ok(第 18 行)检查信道是否已经关闭。如果 ok 等于 false,说明信道已经关闭,于是退出 for 循环。如果 ok 等于 true,会打印出接收到的值和 ok 的值。

Received  0 true  
Received  1 true  
Received  2 true  
Received  3 true  
Received  4 true  
Received  5 true  
Received  6 true  
Received  7 true  
Received  8 true  
Received  9 true

for range 循环用于在一个信道关闭之前,从信道接收数据。

接下来我们使用 for range 循环重写上面的代码。

package main

import (  
    "fmt"
)

func producer(chnl chan int) {  
    for i := 0; i < 10; i++ {
        chnl <- i
    }
    close(chnl)
}
func main() {  
    ch := make(chan int)
    go producer(ch)
    for v := range ch {
        fmt.Println("Received ",v)
    }
}

在线运行程序

在第 16 行,for range 循环从信道 ch 接收数据,直到该信道关闭。一旦关闭了 ch,循环会自动结束。该程序会输出:

Received  0  
Received  1  
Received  2  
Received  3  
Received  4  
Received  5  
Received  6  
Received  7  
Received  8  
Received  9

我们可以使用 for range 循环,重写信道的另一个示例这一节里面的代码,提高代码的可重用性。

如果你仔细观察这段代码,会发现获得一个数里的每位数的代码在 calcSquares 和 calcCubes 两个函数内重复了。我们将把这段代码抽离出来,放在一个单独的函数里,然后并发地调用它。

package main

import (  
    "fmt"
)

func digits(number int, dchnl chan int) {  
    for number != 0 {
        digit := number % 10
        dchnl <- digit
        number /= 10
    }
    close(dchnl)
}
func calcSquares(number int, squareop chan int) {  
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit
    }
    squareop <- sum
}

func calcCubes(number int, cubeop chan int) {  
    sum := 0
    dch := make(chan int)
    go digits(number, dch)
    for digit := range dch {
        sum += digit * digit * digit
    }
    cubeop <- sum
}

func main() {  
    number := 589
    sqrch := make(chan int)
    cubech := make(chan int)
    go calcSquares(number, sqrch)
    go calcCubes(number, cubech)
    squares, cubes := <-sqrch, <-cubech
    fmt.Println("Final output", squares+cubes)
}

在线运行程序

上述程序里的 digits 函数,包含了获取一个数的每位数的逻辑,并且 calcSquares 和 calcCubes 两个函数并发地调用了 digits。当计算完数字里面的每一位数时,第 13 行就会关闭信道。calcSquares 和 calcCubes 两个协程使用 for range 循环分别监听了它们的信道,直到该信道关闭。程序的其他地方不变,该程序同样会输出:

Final output 1536

本教程的内容到此结束。关于信道还有一些其他的概念,比如缓冲信道(Buffered Channel)、工作池(Worker Pool)和 select。我们会在接下来的教程里专门介绍它们。

Go 系列教程 —— 23. 缓冲信道和工作池(Buffered Channels and Worker Pools)

什么是缓冲信道?

上一教程里,我们讨论的主要是无缓冲信道。我们在信道的教程里详细讨论了,无缓冲信道的发送和接收过程是阻塞的。

我们还可以创建一个有缓冲(Buffer)的信道。只在缓冲已满的情况,才会阻塞向缓冲信道(Buffered Channel)发送数据。同样,只有在缓冲为空的时候,才会阻塞从缓冲信道接收数据。

通过向 make 函数再传递一个表示容量的参数(指定缓冲的大小),可以创建缓冲信道。

ch := make(chan type, capacity)

要让一个信道有缓冲,上面语法中的 capacity 应该大于 0。无缓冲信道的容量默认为 0,因此我们在上一教程创建信道时,省略了容量参数。

我们开始编写代码,创建一个缓冲信道。

示例一

package main

import (  
    "fmt"
)


func main() {  
    ch := make(chan string, 2)
    ch <- "naveen"
    ch <- "paul"
    fmt.Println(<- ch)
    fmt.Println(<- ch)
}

在线运行程序

在上面程序里的第 9 行,我们创建了一个缓冲信道,其容量为 2。由于该信道的容量为 2,因此可向它写入两个字符串,而且不会发生阻塞。在第 10 行和第 11 行,我们向信道写入两个字符串,该信道并没有发生阻塞。我们又在第 12 行和第 13 行分别读取了这两个字符串。该程序输出:

naveen  
paul

示例二

我们再看一个缓冲信道的示例,其中有一个并发的 Go 协程来向信道写入数据,而 Go 主协程负责读取数据。该示例帮助我们进一步理解,在向缓冲信道写入数据时,什么时候会发生阻塞。

package main

import (  
    "fmt"
    "time"
)

func write(ch chan int) {  
    for i := 0; i < 5; i++ {
        ch <- i
        fmt.Println("successfully wrote", i, "to ch")
    }
    close(ch)
}
func main() {  
    ch := make(chan int, 2)
    go write(ch)
    time.Sleep(2 * time.Second)
    for v := range ch {
        fmt.Println("read value", v,"from ch")
        time.Sleep(2 * time.Second)

    }
}

在线运行程序

在上面的程序中,第 16 行在 Go 主协程中创建了容量为 2 的缓冲信道 ch,而第 17 行把 ch 传递给了 write 协程。接下来 Go 主协程休眠了两秒。在这期间,write 协程在并发地运行。write 协程有一个 for 循环,依次向信道 ch 写入 0~4。而缓冲信道的容量为 2,因此 write 协程里立即会向 ch 写入 0 和 1,接下来发生阻塞,直到 ch 内的值被读取。因此,该程序立即打印出下面两行:

successfully wrote 0 to ch  
successfully wrote 1 to ch

打印上面两行之后,write 协程中向 ch 的写入发生了阻塞,直到 ch 有值被读取到。而 Go 主协程休眠了两秒后,才开始读取该信道,因此在休眠期间程序不会打印任何结果。主协程结束休眠后,在第 19 行使用 for range 循环,开始读取信道 ch,打印出了读取到的值后又休眠两秒,这个循环一直到 ch 关闭才结束。所以该程序在两秒后会打印下面两行:

read value 0 from ch  
successfully wrote 2 to ch

该过程会一直进行,直到信道读取完所有的值,并在 write 协程中关闭信道。最终输出如下:

successfully wrote 0 to ch  
successfully wrote 1 to ch  
read value 0 from ch  
successfully wrote 2 to ch  
read value 1 from ch  
successfully wrote 3 to ch  
read value 2 from ch  
successfully wrote 4 to ch  
read value 3 from ch  
read value 4 from ch

死锁

package main

import (  
    "fmt"
)

func main() {  
    ch := make(chan string, 2)
    ch <- "naveen"
    ch <- "paul"
    ch <- "steve"
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

在线运行程序

在上面程序里,我们向容量为 2 的缓冲信道写入 3 个字符串。当在程序控制到达第 3 次写入时(第 11 行),由于它超出了信道的容量,因此这次写入发生了阻塞。现在想要这次写操作能够进行下去,必须要有其它协程来读取这个信道的数据。但在本例中,并没有并发协程来读取这个信道,因此这里会发生死锁(deadlock)。程序会在运行时触发 panic,信息如下:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:  
main.main()  
    /tmp/sandbox274756028/main.go:11 +0x100

长度 vs 容量

缓冲信道的容量是指信道可以存储的值的数量。我们在使用 make 函数创建缓冲信道的时候会指定容量大小。

缓冲信道的长度是指信道中当前排队的元素个数。

代码可以把一切解释得很清楚。:)

package main

import (  
    "fmt"
)

func main() {  
    ch := make(chan string, 3)
    ch <- "naveen"
    ch <- "paul"
    fmt.Println("capacity is", cap(ch))
    fmt.Println("length is", len(ch))
    fmt.Println("read value", <-ch)
    fmt.Println("new length is", len(ch))
}

在线运行程序

在上面的程序里,我们创建了一个容量为 3 的信道,于是它可以保存 3 个字符串。接下来,我们分别在第 9 行和第 10 行向信道写入了两个字符串。于是信道有两个字符串排队,因此其长度为 2。在第 13 行,我们又从信道读取了一个字符串。现在该信道内只有一个字符串,因此其长度变为 1。该程序会输出:

capacity is 3  
length is 2  
read value naveen  
new length is 1

WaitGroup

在本教程的下一节里,我们会讲到工作池(Worker Pools)。而 WaitGroup 用于实现工作池,因此要理解工作池,我们首先需要学习 WaitGroup

WaitGroup 用于等待一批 Go 协程执行结束。程序控制会一直阻塞,直到这些协程全部执行完毕。假设我们有 3 个并发执行的 Go 协程(由 Go 主协程生成)。Go 主协程需要等待这 3 个协程执行结束后,才会终止。这就可以用 WaitGroup 来实现。

理论说完了,我们编写点儿代码吧。:)

package main

import (  
    "fmt"
    "sync"
    "time"
)

func process(i int, wg *sync.WaitGroup) {  
    fmt.Println("started Goroutine ", i)
    time.Sleep(2 * time.Second)
    fmt.Printf("Goroutine %d ended\n", i)
    wg.Done()
}

func main() {  
    no := 3
    var wg sync.WaitGroup
    for i := 0; i < no; i++ {
        wg.Add(1)
        go process(i, &wg)
    }
    wg.Wait()
    fmt.Println("All go routines finished executing")
}

在线运行程序

WaitGroup 是一个结构体类型,我们在第 18 行创建了 WaitGroup 类型的变量,其初始值为零值。WaitGroup 使用计数器来工作。当我们调用 WaitGroup 的 Add 并传递一个 int 时,WaitGroup 的计数器会加上 Add 的传参。要减少计数器,可以调用 WaitGroup 的 Done() 方法。Wait() 方法会阻塞调用它的 Go 协程,直到计数器变为 0 后才会停止阻塞。

上述程序里,for 循环迭代了 3 次,我们在循环内调用了 wg.Add(1)(第 20 行)。因此计数器变为 3。for 循环同样创建了 3 个 process 协程,然后在第 23 行调用了 wg.Wait(),确保 Go 主协程等待计数器变为 0。在第 13 行,process 协程内调用了 wg.Done,可以让计数器递减。一旦 3 个子协程都执行完毕(即 wg.Done() 调用了 3 次),那么计数器就变为 0,于是主协程会解除阻塞。

在第 21 行里,传递 wg 的地址是很重要的。如果没有传递 wg 的地址,那么每个 Go 协程将会得到一个 WaitGroup 值的拷贝,因而当它们执行结束时,main 函数并不会知道

该程序输出:

started Goroutine  2  
started Goroutine  0  
started Goroutine  1  
Goroutine 0 ended  
Goroutine 2 ended  
Goroutine 1 ended  
All go routines finished executing

由于 Go 协程的执行顺序不一定,因此你的输出可能和我不一样。:)

工作池的实现

缓冲信道的重要应用之一就是实现工作池

一般而言,工作池就是一组等待任务分配的线程。一旦完成了所分配的任务,这些线程可继续等待任务的分配。

我们会使用缓冲信道来实现工作池。我们工作池的任务是计算所输入数字的每一位的和。例如,如果输入 234,结果会是 9(即 2 + 3 + 4)。向工作池输入的是一列伪随机数。

我们工作池的核心功能如下:

  • 创建一个 Go 协程池,监听一个等待作业分配的输入型缓冲信道。
  • 将作业添加到该输入型缓冲信道中。
  • 作业完成后,再将结果写入一个输出型缓冲信道。
  • 从输出型缓冲信道读取并打印结果。

我们会逐步编写这个程序,让代码易于理解。

第一步就是创建一个结构体,表示作业和结果。

type Job struct {  
    id       int
    randomno int
}
type Result struct {  
    job         Job
    sumofdigits int
}

所有 Job 结构体变量都会有 id 和 randomno 两个字段,randomno 用于计算其每位数之和。

而 Result 结构体有一个 job 字段,表示所对应的作业,还有一个 sumofdigits 字段,表示计算的结果(每位数字之和)。

第二步是分别创建用于接收作业和写入结果的缓冲信道。

var jobs = make(chan Job, 10)  
var results = make(chan Result, 10)

工作协程(Worker Goroutine)会监听缓冲信道 jobs 里更新的作业。一旦工作协程完成了作业,其结果会写入缓冲信道 results

如下所示,digits 函数的任务实际上就是计算整数的每一位之和,最后返回该结果。为了模拟出 digits 在计算过程中花费了一段时间,我们在函数内添加了两秒的休眠时间。

func digits(number int) int {  
    sum := 0
    no := number
    for no != 0 {
        digit := no % 10
        sum += digit
        no /= 10
    }
    time.Sleep(2 * time.Second)
    return sum
}

然后,我们写一个创建工作协程的函数。

func worker(wg *sync.WaitGroup) {  
    for job := range jobs {
        output := Result{job, digits(job.randomno)}
        results <- output
    }
    wg.Done()
}

上面的函数创建了一个工作者(Worker),读取 jobs 信道的数据,根据当前的 job 和 digits 函数的返回值,创建了一个 Result结构体变量,然后将结果写入 results 缓冲信道。worker 函数接收了一个 WaitGroup 类型的 wg 作为参数,当所有的 jobs 完成的时候,调用了 Done() 方法。

createWorkerPool 函数创建了一个 Go 协程的工作池。

func createWorkerPool(noOfWorkers int) {  
    var wg sync.WaitGroup
    for i := 0; i < noOfWorkers; i++ {
        wg.Add(1)
        go worker(&wg)
    }
    wg.Wait()
    close(results)
}

上面函数的参数是需要创建的工作协程的数量。在创建 Go 协程之前,它调用了 wg.Add(1) 方法,于是 WaitGroup 计数器递增。接下来,我们创建工作协程,并向 worker 函数传递 wg 的地址。创建了需要的工作协程后,函数调用 wg.Wait(),等待所有的 Go 协程执行完毕。所有协程完成执行之后,函数会关闭 results 信道。因为所有协程都已经执行完毕,于是不再需要向 results 信道写入数据了。

现在我们已经有了工作池,我们继续编写一个函数,把作业分配给工作者。

func allocate(noOfJobs int) {  
    for i := 0; i < noOfJobs; i++ {
        randomno := rand.Intn(999)
        job := Job{i, randomno}
        jobs <- job
    }
    close(jobs)
}

上面的 allocate 函数接收所需创建的作业数量作为输入参数,生成了最大值为 998 的伪随机数,并使用该随机数创建了 Job 结构体变量。这个函数把 for 循环的计数器 i 作为 id,最后把创建的结构体变量写入 jobs 信道。当写入所有的 job 时,它关闭了 jobs 信道。

下一步是创建一个读取 results 信道和打印输出的函数。

func result(done chan bool) {  
    for result := range results {
        fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)
    }
    done <- true
}

result 函数读取 results 信道,并打印出 job 的 id、输入的随机数、该随机数的每位数之和。result 函数也接受 done 信道作为参数,当打印所有结果时,done 会被写入 true。

现在一切准备充分了。我们继续完成最后一步,在 main() 函数中调用上面所有的函数。

func main() {  
    startTime := time.Now()
    noOfJobs := 100
    go allocate(noOfJobs)
    done := make(chan bool)
    go result(done)
    noOfWorkers := 10
    createWorkerPool(noOfWorkers)
    <-done
    endTime := time.Now()
    diff := endTime.Sub(startTime)
    fmt.Println("total time taken ", diff.Seconds(), "seconds")
}

我们首先在 main 函数的第 2 行,保存了程序的起始时间,并在最后一行(第 12 行)计算了 endTime 和 startTime 的差值,显示出程序运行的总时间。由于我们想要通过改变协程数量,来做一点基准指标(Benchmark),所以需要这么做。

我们把 noOfJobs 设置为 100,接下来调用了 allocate,向 jobs 信道添加作业。

我们创建了 done 信道,并将其传递给 result 协程。于是该协程会开始打印结果,并在完成打印时发出通知。

通过调用 createWorkerPool 函数,我们最终创建了一个有 10 个协程的工作池。main 函数会监听 done 信道的通知,等待所有结果打印结束。

为了便于参考,下面是整个程序。我还引用了必要的包。

package main

import (  
    "fmt"
    "math/rand"
    "sync"
    "time"
)

type Job struct {  
    id       int
    randomno int
}
type Result struct {  
    job         Job
    sumofdigits int
}

var jobs = make(chan Job, 10)  
var results = make(chan Result, 10)

func digits(number int) int {  
    sum := 0
    no := number
    for no != 0 {
        digit := no % 10
        sum += digit
        no /= 10
    }
    time.Sleep(2 * time.Second)
    return sum
}
func worker(wg *sync.WaitGroup) {  
    for job := range jobs {
        output := Result{job, digits(job.randomno)}
        results <- output
    }
    wg.Done()
}
func createWorkerPool(noOfWorkers int) {  
    var wg sync.WaitGroup
    for i := 0; i < noOfWorkers; i++ {
        wg.Add(1)
        go worker(&wg)
    }
    wg.Wait()
    close(results)
}
func allocate(noOfJobs int) {  
    for i := 0; i < noOfJobs; i++ {
        randomno := rand.Intn(999)
        job := Job{i, randomno}
        jobs <- job
    }
    close(jobs)
}
func result(done chan bool) {  
    for result := range results {
        fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)
    }
    done <- true
}
func main() {  
    startTime := time.Now()
    noOfJobs := 100
    go allocate(noOfJobs)
    done := make(chan bool)
    go result(done)
    noOfWorkers := 10
    createWorkerPool(noOfWorkers)
    <-done
    endTime := time.Now()
    diff := endTime.Sub(startTime)
    fmt.Println("total time taken ", diff.Seconds(), "seconds")
}

在线运行程序

为了更精确地计算总时间,请在你的本地机器上运行该程序。

该程序输出:

Job id 1, input random no 636, sum of digits 15  
Job id 0, input random no 878, sum of digits 23  
Job id 9, input random no 150, sum of digits 6  
...
total time taken  20.01081009 seconds

程序总共会打印 100 行,对应着 100 项作业,然后最后会打印一行程序消耗的总时间。你的输出会和我的不同,因为 Go 协程的运行顺序不一定,同样总时间也会因为硬件而不同。在我的例子中,运行程序大约花费了 20 秒。

现在我们把 main 函数里的 noOfWorkers 增加到 20。我们把工作者的数量加倍了。由于工作协程增加了(准确说来是两倍),因此程序花费的总时间会减少(准确说来是一半)。在我的例子里,程序会打印出 10.004364685 秒。

...
total time taken  10.004364685 seconds

现在我们可以理解了,随着工作协程数量增加,完成作业的总时间会减少。你们可以练习一下:在 main 函数里修改 noOfJobs 和 noOfWorkers 的值,并试着去分析一下结果。

Go 系列教程 —— 24. Select

什么是 select?

select 语句用于在多个发送/接收信道操作中进行选择。select 语句会一直阻塞,直到发送/接收操作准备就绪。如果有多个信道操作准备完毕,select 会随机地选取其中之一执行。该语法与 switch 类似,所不同的是,这里的每个 case 语句都是信道操作。我们好好看一些代码来加深理解吧。

示例

package main

import (  
    "fmt"
    "time"
)

func server1(ch chan string) {  
    time.Sleep(6 * time.Second)
    ch <- "from server1"
}
func server2(ch chan string) {  
    time.Sleep(3 * time.Second)
    ch <- "from server2"

}
func main() {  
    output1 := make(chan string)
    output2 := make(chan string)
    go server1(output1)
    go server2(output2)
    select {
    case s1 := <-output1:
        fmt.Println(s1)
    case s2 := <-output2:
        fmt.Println(s2)
    }
}

在线运行程序

在上面程序里,server1 函数(第 8 行)休眠了 6 秒,接着将文本 from server1 写入信道 ch。而 server2 函数(第 12 行)休眠了 3 秒,然后把 from server2 写入了信道 ch

而 main 函数在第 20 行和第 21 行,分别调用了 server1 和 server2 两个 Go 协程。

在第 22 行,程序运行到了 select 语句。select 会一直发生阻塞,除非其中有 case 准备就绪。在上述程序里,server1 协程会在 6 秒之后写入 output1 信道,而server2 协程在 3 秒之后就写入了 output2 信道。因此 select 语句会阻塞 3 秒钟,等着 server2 向 output2 信道写入数据。3 秒钟过后,程序会输出:

from server2

然后程序终止。

select 的应用

在上面程序中,函数之所以取名为 server1 和 server2,是为了展示 select 的实际应用。

假设我们有一个关键性应用,需要尽快地把输出返回给用户。这个应用的数据库复制并且存储在世界各地的服务器上。假设函数 server1 和 server2 与这样不同区域的两台服务器进行通信。每台服务器的负载和网络时延决定了它的响应时间。我们向两台服务器发送请求,并使用 select 语句等待相应的信道发出响应。select 会选择首先响应的服务器,而忽略其它的响应。使用这种方法,我们可以向多个服务器发送请求,并给用户返回最快的响应了。:)

默认情况

在没有 case 准备就绪时,可以执行 select 语句中的默认情况(Default Case)。这通常用于防止 select 语句一直阻塞。

package main

import (  
    "fmt"
    "time"
)

func process(ch chan string) {  
    time.Sleep(10500 * time.Millisecond)
    ch <- "process successful"
}

func main() {  
    ch := make(chan string)
    go process(ch)
    for {
        time.Sleep(1000 * time.Millisecond)
        select {
        case v := <-ch:
            fmt.Println("received value: ", v)
            return
        default:
            fmt.Println("no value received")
        }
    }

}

在线运行程序

上述程序中,第 8 行的 process 函数休眠了 10500 毫秒(10.5 秒),接着把 process successful 写入 ch 信道。在程序中的第 15 行,并发地调用了这个函数。

在并发地调用了 process 协程之后,主协程启动了一个无限循环。这个无限循环在每一次迭代开始时,都会先休眠 1000 毫秒(1 秒),然后执行一个 select 操作。在最开始的 10500 毫秒中,由于 process 协程在 10500 毫秒后才会向 ch 信道写入数据,因此 select 语句的第一个 case(即 case v := <-ch:)并未就绪。所以在这期间,程序会执行默认情况,该程序会打印 10 次 no value received

在 10.5 秒之后,process 协程会在第 10 行向 ch 写入 process successful。现在,就可以执行 select 语句的第一个 case 了,程序会打印 received value: process successful,然后程序终止。该程序会输出:

no value received  
no value received  
no value received  
no value received  
no value received  
no value received  
no value received  
no value received  
no value received  
no value received  
received value:  process successful

死锁与默认情况

package main

func main() {  
    ch := make(chan string)
    select {
    case <-ch:
    }
}

在线运行程序

上面的程序中,我们在第 4 行创建了一个信道 ch。我们在 select 内部(第 6 行),试图读取信道 ch。由于没有 Go 协程向该信道写入数据,因此 select 语句会一直阻塞,导致死锁。该程序会触发运行时 panic,报错信息如下:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:  
main.main()  
    /tmp/sandbox416567824/main.go:6 +0x80

如果存在默认情况,就不会发生死锁,因为在没有其他 case 准备就绪时,会执行默认情况。我们用默认情况重写后,程序如下:

package main

import "fmt"

func main() {  
    ch := make(chan string)
    select {
    case <-ch:
    default:
        fmt.Println("default case executed")
    }
}

在线运行程序

以上程序会输出:

default case executed

如果 select 只含有值为 nil 的信道,也同样会执行默认情况。

package main

import "fmt"

func main() {  
    var ch chan string
    select {
    case v := <-ch:
        fmt.Println("received value", v)
    default:
        fmt.Println("default case executed")

    }
}

在线运行程序

在上面程序中,ch 等于 nil,而我们试图在 select 中读取 ch(第 8 行)。如果没有默认情况,select 会一直阻塞,导致死锁。由于我们在 select 内部加入了默认情况,程序会执行它,并输出:

default case executed

随机选取

当 select 由多个 case 准备就绪时,将会随机地选取其中之一去执行。

package main

import (  
    "fmt"
    "time"
)

func server1(ch chan string) {  
    ch <- "from server1"
}
func server2(ch chan string) {  
    ch <- "from server2"

}
func main() {  
    output1 := make(chan string)
    output2 := make(chan string)
    go server1(output1)
    go server2(output2)
    time.Sleep(1 * time.Second)
    select {
    case s1 := <-output1:
        fmt.Println(s1)
    case s2 := <-output2:
        fmt.Println(s2)
    }
}

在线运行程序

在上面程序里,我们在第 18 行和第 19 行分别调用了 server1 和 server2 两个 Go 协程。接下来,主程序休眠了 1 秒钟(第 20 行)。当程序控制到达第 21 行的 select 语句时,server1 已经把 from server1 写到了 output1 信道上,而 server2 也同样把 from server2 写到了 output2 信道上。因此这个 select 语句中的两种情况都准备好执行了。如果你运行这个程序很多次的话,输出会是 from server1 或者 from server2,这会根据随机选取的结果而变化。

请在你的本地系统上运行这个程序,获得程序的随机结果。因为如果你在 playground 上在线运行的话,它的输出总是一样的,这是由于 playground 不具有随机性所造成的。

这下我懂了:空 select

package main

func main() {  
    select {}
}

在线运行程序

你认为上面代码会输出什么?

我们已经知道,除非有 case 执行,select 语句就会一直阻塞着。在这里,select 语句没有任何 case,因此它会一直阻塞,导致死锁。该程序会触发 panic,输出如下:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:  
main.main()  
    /tmp/sandbox299546399/main.go:4 +0x20

Go 系列教程 —— 25. Mutex

本教程我们学习 Mutex。我们还会学习怎样通过 Mutex 和信道来处理竞态条件(Race Condition)。

临界区

在学习 Mutex 之前,我们需要理解并发编程中临界区(Critical Section)的概念。当程序并发地运行时,多个 Go 协程不应该同时访问那些修改共享资源的代码。这些修改共享资源的代码称为临界区。例如,假设我们有一段代码,将一个变量 x 自增 1。

x = x + 1

如果只有一个 Go 协程访问上面的代码段,那都没有任何问题。

但当有多个协程并发运行时,代码却会出错,让我们看看究竟是为什么吧。简单起见,假设在一行代码的前面,我们已经运行了两个 Go 协程。

在上一行代码的内部,系统执行程序时分为如下几个步骤(这里其实还有很多包括寄存器的技术细节,以及加法的工作原理等,但对于我们的系列教程,只需认为只有三个步骤就好了):

  1. 获得 x 的当前值
  2. 计算 x + 1
  3. 将步骤 2 计算得到的值赋值给 x

如果只有一个协程执行上面的三个步骤,不会有问题。

我们讨论一下当有两个并发的协程执行该代码时,会发生什么。下图描述了当两个协程并发地访问代码行 x = x + 1 时,可能出现的一种情况。

one-scenario

我们假设 x 的初始值为 0。而协程 1 获取 x 的初始值,并计算 x + 1。而在协程 1 将计算值赋值给 x 之前,系统上下文切换到了协程 2。于是,协程 2 获取了 x 的初始值(依然为 0),并计算 x + 1。接着系统上下文又切换回了协程 1。现在,协程 1 将计算值 1 赋值给 x,因此 x 等于 1。然后,协程 2 继续开始执行,把计算值(依然是 1)复制给了 x,因此在所有协程执行完毕之后,x 都等于 1。

现在我们考虑另外一种可能发生的情况。

another-scenario

在上面的情形里,协程 1 开始执行,完成了三个步骤后结束,因此 x 的值等于 1。接着,开始执行协程 2。目前 x 的值等于 1。而当协程 2 执行完毕时,x 的值等于 2。

所以,从这两个例子你可以发现,根据上下文切换的不同情形,x 的最终值是 1 或者 2。这种不太理想的情况称为竞态条件(Race Condition),其程序的输出是由协程的执行顺序决定的。

在上例中,如果在任意时刻只允许一个 Go 协程访问临界区,那么就可以避免竞态条件。而使用 Mutex 可以达到这个目的

Mutex

Mutex 用于提供一种加锁机制(Locking Mechanism),可确保在某时刻只有一个协程在临界区运行,以防止出现竞态条件。

Mutex 可以在 sync 包内找到。Mutex 定义了两个方法:Lock 和 Unlock。所有在 Lock 和 Unlock 之间的代码,都只能由一个 Go 协程执行,于是就可以避免竞态条件。

mutex.Lock()  
x = x + 1  
mutex.Unlock()

在上面的代码中,x = x + 1 只能由一个 Go 协程执行,因此避免了竞态条件。

如果有一个 Go 协程已经持有了锁(Lock),当其他协程试图获得该锁时,这些协程会被阻塞,直到 Mutex 解除锁定为止。

含有竞态条件的程序

在本节里,我们会编写一个含有竞态条件的程序,而在接下来一节,我们再修复竞态条件的问题。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup) {  
    x = x + 1
    wg.Done()
}
func main() {  
    var w sync.WaitGroup
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在上述程序里,第 7 行的 increment 函数把 x 的值加 1,并调用 WaitGroup 的 Done(),通知该函数已结束。

在上述程序的第 15 行,我们生成了 1000 个 increment 协程。每个 Go 协程并发地运行,由于第 8 行试图增加 x 的值,因此多个并发的协程试图访问 x 的值,这时就会发生竞态条件。

由于 playground 具有确定性,竞态条件不会在 playground 发生,请在你的本地运行该程序。请在你的本地机器上多运行几次,可以发现由于竞态条件,每一次输出都不同。我其中遇到的几次输出有 final value of x 941final value of x 928final value of x 922 等。

使用 Mutex

在前面的程序里,我们创建了 1000 个 Go 协程。如果每个协程对 x 加 1,最终 x 期望的值应该是 1000。在本节,我们会在程序里使用 Mutex,修复竞态条件的问题。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, m *sync.Mutex) {  
    m.Lock()
    x = x + 1
    m.Unlock()
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    var m sync.Mutex
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, &m)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在 playground 中运行

Mutex 是一个结构体类型,我们在第 15 行创建了 Mutex 类型的变量 m,其值为零值。在上述程序里,我们修改了 increment 函数,将增加 x 的代码(x = x + 1)放置在 m.Lock() 和 m.Unlock()之间。现在这段代码不存在竞态条件了,因为任何时刻都只允许一个协程执行这段代码。

于是如果运行该程序,会输出:

final value of x 1000

在第 18 行,传递 Mutex 的地址很重要。如果传递的是 Mutex 的值,而非地址,那么每个协程都会得到 Mutex 的一份拷贝,竞态条件还是会发生。

使用信道处理竞态条件

我们还能用信道来处理竞态条件。看看是怎么做的。

package main  
import (  
    "fmt"
    "sync"
    )
var x  = 0  
func increment(wg *sync.WaitGroup, ch chan bool) {  
    ch <- true
    x = x + 1
    <- ch
    wg.Done()   
}
func main() {  
    var w sync.WaitGroup
    ch := make(chan bool, 1)
    for i := 0; i < 1000; i++ {
        w.Add(1)        
        go increment(&w, ch)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}

在 playground 中 运行

在上述程序中,我们创建了容量为 1 的缓冲信道,并在第 18 行将它传入 increment 协程。该缓冲信道用于保证只有一个协程访问增加 x 的临界区。具体的实现方法是在 x 增加之前(第 8 行),传入 true 给缓冲信道。由于缓冲信道的容量为 1,所以任何其他协程试图写入该信道时,都会发生阻塞,直到 x 增加后,信道的值才会被读取(第 10 行)。实际上这就保证了只允许一个协程访问临界区。

该程序也输出:

final value of x 1000

Mutex vs 信道

通过使用 Mutex 和信道,我们已经解决了竞态条件的问题。那么我们该选择使用哪一个?答案取决于你想要解决的问题。如果你想要解决的问题更适用于 Mutex,那么就用 Mutex。如果需要使用 Mutex,无须犹豫。而如果该问题更适用于信道,那就使用信道。:)

由于信道是 Go 语言很酷的特性,大多数 Go 新手处理每个并发问题时,使用的都是信道。这是不对的。Go 给了你选择 Mutex 和信道的余地,选择其中之一都可以是正确的。

总体说来,当 Go 协程需要与其他协程通信时,可以使用信道。而当只允许一个协程访问临界区时,可以使用 Mutex。

就我们上面解决的问题而言,我更倾向于使用 Mutex,因为该问题并不需要协程间的通信。所以 Mutex 是很自然的选择。

我的建议是去选择针对问题的工具,而别让问题去将就工具。:)

本教程到此结束。祝你愉快。

猜你喜欢

转载自blog.csdn.net/ytd7777/article/details/85004371