第四章:并发编程

1 学习 Go 函数:理解 Go 里的函数

1.1 关于函数

函数是基于功能或 逻辑进行封装的可复用的代码结构。将一段功能复杂、很长的一段代码封装成多个代码片段(即函数),有助于提高代码可读性和可维护性。
在 Go 语言中,函数可以分为两种:

  • 带有名字的普通函数
  • 没有名字的匿名函数

由于 Go语言是编译型语言,所以函数编写的顺序是无关紧要的,它不像 Python 那样,函数在位置上需要定义在调用之前。

1.2 函数的声明

函数的声明,使用 func 关键字,后面依次接 函数名,参数列表,返回值列表,用 {} 包裹的代码逻辑体

func 函数名(形式参数列表)(返回值列表){
    函数体
}
  • 形式参数列表描述了函数的参数名以及参数类型,这些参数作为局部变量,其值由函数调用者提供
  • 返回值列表描述了函数返回值的变量名以及类型,如果函数返回一个无名变量或者没有返回值,返回值列表的括号是可以省略的。

举个例子,定义一个 sum 函数,接收两个 int 类型的参数,在运行中,将其值分别赋值给 a,b,并规定必须返回一个int类型的值 。

func sum(a int, b int) (int){
    return a + b
}
func main() {
    fmt.Println(sum(1,2))
}

1.3 函数实现可变参数

上面举的例子,参数个数都是固定的,这很好理解 ,指定什么类型的参数就传入什么类型的变量,数量上,不能多一个,也不能少一个(好像没有可选参数)。
在 Python 中我们可以使用 *args 和 **kw ,还实现可变参数的函数。
可变参数分为几种:

  • 多个类型一致的参数
  • 多个类型不一致的参数

多个类型一致的参数

首先是多个类型一致的参数。
这边定义一个可以对多个数值进行求和的函数,
使用 ...int,表示一个元素为int类型的切片,用来接收调用者传入的参数。

// 使用 ...类型,表示一个元素为int类型的切片
func sum(args ...int) int {
    var sum int
    for _, v := range args {
        sum += v
    }
    return sum
}
func main() {
    fmt.Println(sum(1, 2, 3))
}

// output: 6

其中 ... 是 Go 语言为了方便程序员写代码而实现的语法糖,如果该函数下有多个类型的参数,这个语法糖必须得是最后一个参数。
同时这个语法糖,只能在定义函数时使用。

多个类型不一致的参数

上面那个例子中,我们的参数类型都是 int,如果你希望传多个参数且这些参数的类型都不一样,可以指定类型为 ...interface{} (你可能会问 interface{} 是什么类型,它是空接口,也是一个很重要的知识点,可以点 这篇文章查看相关内容),然后再遍历。
比如下面这段代码,是Go语言标准库中 fmt.Printf() 的函数原型:

import "fmt"
func MyPrintf(args ...interface{}) {
    for _, arg := range args {
        switch arg.(type) {
            case int:
                fmt.Println(arg, "is an int value.")
            case string:
                fmt.Println(arg, "is a string value.")
            case int64:
                fmt.Println(arg, "is an int64 value.")
            default:
                fmt.Println(arg, "is an unknown type.")
        }
    }
}

func main() {
    var v1 int = 1
    var v2 int64 = 234
    var v3 string = "hello"
    var v4 float32 = 1.234
    MyPrintf(v1, v2, v3, v4)
}

1.4 多个可变参数函数传递参数

上面提到了可以使用 ... 来接收多个参数,除此之外,它还有一个用法,就是用来解序列,将函数的可变参数(一个切片)一个一个取出来,传递给另一个可变参数的函数,而不是传递可变参数变量本身。
同样这个用法,也只能在给函数传递参数里使用。
例子如下:

import "fmt"

func sum(args ...int) int {
    var result int
    for _, v := range args {
        result += v
    }
    return result
}

func Sum(args ...int) int {
    // 利用 ... 来解序列
    result := sum(args...)
    return result
}
func main() {
    fmt.Println(Sum(1, 2, 3))
}

1.5 函数的返回值

Go语言中的函数,在你定义的时候,就规定了此函数

  1. 有没有返回值?当没有指明返回值的类型时, 函数体可以用 return 来结束函数的运行,但 return 后不能跟任何一个对象。
  2. 返回几个值?Go 支持一个函数返回多个值
func double(a int) (int, int) {
 b := a * 2
 return a, b
}
func main() {
    // 接收参数用逗号分隔
 a, b := double(2)
 fmt.Println(a, b)
}
  1. 怎么返回值?Go支持返回带有变量名的值
func double(a int) (b int) {
    // 不能使用 := ,因为在返回值哪里已经声明了为int
 b = a * 2
    // 不需要指明写回哪个变量,在返回值类型那里已经指定了
 return
}
func main() {
 fmt.Println(double(2))
}
// output: 4

1.6 方法与函数

方法,在之前的《2.1 面向对象:结构体与继承》里已经介绍过了,它的定义与函数有些不同,你可以点击前面的标题进行交叉学习。
方法和函数有什么区别? 为防会有朋友第一次接触面向对象,这里多嘴一句。
方法,是一种特殊的函数。当你一个函数和对象/结构体进行绑定的时候,我们就称这个函数是一个方法。

1.7 匿名函数的使用

所谓匿名函数,就是没有名字的函数,它只有函数逻辑体,而没有函数名。
定义的格式如下

func(参数列表)(返回参数列表){
    函数体
}

一个名字实际上并没有多大区别,所有使用匿名函数都可以改成普通有名函数,那么什么情况下,会使用匿名函数呢?
定义变量名,是一个不难但是还费脑子的事情,对于那到只使用一次的函数,是没必要拥有姓名的。这才有了匿名函数。
有了这个背景,决定了匿名函数只有拥有短暂的生命,一般都是定义后立即使用。
就像这样,定义后立马执行(这里只是举例,实际代码没有意义)。

func(data int) {
    fmt.Println("hello", data)
}(100)

亦或是做为回调函数使用

// 第二个参数为函数
func visit(list []int, f func(int)) {
    for _, v := range list {
        // 执行回调函数
        f(v)
    }
}
func main() {
    // 使用匿名函数直接做为参数
    visit([]int{1, 2, 3, 4}, func(v int) {
        fmt.Println(v)
    })
}

2 学习 Go 函数:函数类型是什么?

函数类型(function types)是一种很特殊的类型,它表示着所有拥有同样的入参类型和返回值类型的函数集合。
如下这一行代码,定义了一个名叫 Greeting 的函数类型

type Greeting func(name string) string

这种类型有两个特征:

  1. 只接收一个参数 ,并且该参数的类型为 string
  2. 返回值也只有一个参数,其类型为 string

一个函数只要满足这些特征,那么它就可以通过如下方式将该函数转换成 Greeting 类型的函数对象(也即 greet)

func english(name string) string {
    return "Hello, " + name
}

// 转换成 Greeting 类型的函数对象
greet := Greeting(english)
// 或者
var greet Greeting = english

greet 做为 Greeting 类型的对象,也拥有 Greeting 类型的所有方法,比如下面的 say 方法

func (g Greeting) say(n string) {
    fmt.Println(g(n))
}

直接调用试试看,并不会报错

greet.say("World")

将上面的代码整合在一起

package main

import "fmt"

// Greeting function types
type Greeting func(name string) string

func (g Greeting) say(n string) {
    fmt.Println(g(n))
}

func english(name string) string {
    return "Hello, " + name
}

func main() {
    greet := Greeting(english)
    greet.say("World")
}
// output: Hello, World

3 学习 Go 协程:goroutine

说到Go语言,很多没接触过它的人,对它的第一印象,一定是它从语言层面天生支持并发,非常方便,让开发者能快速写出高性能且易于理解的程序。
在 Python (为Py为例,主要是我比较熟悉,其他主流编程语言也类似)中,并发编程的门槛并不低,你要学习多进程,多线程,还要掌握各种支持并发的库 asyncio,aiohttp 等,同时你还要清楚它们之间的区别及优缺点,懂得在不同的场景选择不同的并发模式。
而 Golang 作为一门现代化的编程语言,它不需要你直面这些复杂的问题。在 Golang 里,你不需要学习如何创建进程池/线程池,也不需要知道什么情况下使用多线程,什么时候使用多进程。因为你没得选,也不需要选,它原生提供的 goroutine (也即协程)已经足够优秀,能够自动帮你处理好所有的事情,而你要做的只是执行它,就这么简单。
一个 goroutine 本身就是一个函数,当你直接调用时,它就是一个普通函数,如果你在调用前加一个关键字 go ,那你就开启了一个 goroutine。

// 执行一个函数
func()

// 开启一个协程执行这个函数
go func()

3.1 协程的初步使用

一个 Go 程序的入口通常是 main 函数,程序启动后,main 函数最先运行,我们称之为 main goroutine。
在 main 中或者其下调用的代码中才可以使用 go + func() 的方法来启动协程。
main 的地位相当于主线程,当 main 函数执行完成后,这个线程也就终结了,其下的运行着的所有协程也不管代码是不是还在跑,也得乖乖退出。
因此如下这段代码运行完,只会输出 hello, world ,而不会输出hello, go(因为协程的创建需要时间,当 hello, world打印后,协程还没来得及并执行)

import "fmt"

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    // 启动一个协程
    go mytest()
    fmt.Println("hello, world")
}

对于刚学习Go的协程同学来说,可以使用 time.Sleep 来使 main 阻塞,使其他协程能够有机会运行完全,但你要注意的是,这并不是推荐的方式(后续我们会学习其他更优雅的方式)。
当我在代码中加入一行 time.Sleep 输出就符合预期了。

import (
    "fmt"
    "time"
)

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    go mytest()
    fmt.Println("hello, world")
    time.Sleep(time.Second)
}

输出如下

hello, world
hello, go

3.2 多个协程的效果

为了让你看到并发的效果,这里举个最简单的例子

import (
    "fmt"
    "time"
)

func mygo(name string) {
    for i := 0; i < 10; i++ {
        fmt.Printf("In goroutine %s\n", name)
        // 为了避免第一个协程执行过快,观察不到并发的效果,加个休眠
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    go mygo("协程1号") // 第一个协程
    go mygo("协程2号") // 第二个协程
    time.Sleep(time.Second)
}

输出如下,可以观察到两个协程就如两个线程一样,并发执行

In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号

通过以上简单的例子,是不是折服于Go的这种强大的并发特性,将同步代码转为异步代码,真的只要一个关键字就可以了,也不需要使用其他库,简单方便。
本篇只介绍了协程的简单使用,真正的并发程序还是要结合 信道 (channel)来实现。关于信道的内容,将在下一篇文章中介绍。

4 学习 Go 协程:详解信道/通道

Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。
如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。
信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则。

4.1 信道的定义与使用

每个信道都只能传递一种数据类型的数据,所以在你声明的时候,你得指定数据类型(string int 等等)

var 信道实例 chan 信道类型

声明后的信道,其零值是nil,无法直接使用,必须配合make函进行初始化。

信道实例 = make(chan 信道类型)

亦或者,上面两行可以合并成一句,以下我都使用这样的方式进行信道的声明

信道实例 := make(chan 信道类型)

假如我要创建一个可以传输int类型的信道,可以这样子写。

// 定义信道
pipline := make(chan int)

信道的数据操作,无非就两种:发送数据与读取数据

// 往信道中发送数据
pipline<- 200

// 从信道中取出数据,并赋值给mydata
mydata := <-pipline

信道用完了,可以对其进行关闭,避免有人一直在等待。但是你关闭信道后,接收方仍然可以从信道中取到数据,只是接收到的会永远是 0。

close(pipline)

对一个已关闭的信道再关闭,是会报错的。所以我们还要学会,如何判断一个信道是否被关闭?
当从信道中读取数据时,可以有多个返回值,其中第二个可以表示 信道是否被关闭,如果已经被关闭,ok 为 false,若还没被关闭,ok 为true。

x, ok := <-pipline

4.2 信道的容量与长度

一般创建信道都是使用 make 函数,make 函数接收两个参数

  • 第一个参数:必填,指定信道类型
  • 第二个参数:选填,不填默认为0,指定信道的容量(可缓存多少数据)

对于信道的容量,很重要,这里要多说几点:

  • 当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,否则会报错。此时的信道称之为无缓冲信道
  • 当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞。 利用这点可以利用信道来做锁。
  • 当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

至此我们知道,信道就是一个容器。
若将它比做一个纸箱子

  • 它可以装10本书,代表其容量为10
  • 当前只装了1本书,代表其当前长度为1

信道的容量,可以使用 cap 函数获取 ,而信道的长度,可以使用 len 长度获取。

package main

import "fmt"

func main() {
    pipline := make(chan int, 10)
    fmt.Printf("信道可缓冲 %d 个数据\n", cap(pipline))
    pipline<- 1
    fmt.Printf("信道中当前有 %d 个数据", len(pipline))
}

输出如下

信道可缓冲 10 个数据
信道中当前有 1 个数据

4.3 缓冲信道与无缓冲信道

按照是否可缓冲数据可分为:缓冲信道无缓冲信道
缓冲信道
允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。

pipline := make(chan int, 10)

无缓冲信道
在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。

pipline := make(chan int)

// 或者
pipline := make(chan int, 0)

4.4 双向信道与单向信道

通常情况下,我们定义的信道都是双向通道,可发送数据,也可以接收数据。
但有时候,我们希望对信道的数据流向做一些控制,比如这个信道只能接收数据或者这个信道只能发送数据。
因此,就有了 双向信道单向信道 两种分类。
双向信道
默认情况下你定义的信道都是双向的,比如下面代码

import (
    "fmt"
    "time"
)

func main() {
    pipline := make(chan int)

    go func() {
        fmt.Println("准备发送数据: 100")
        pipline <- 100
    }()

    go func() {
        num := <-pipline
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(time.Second)
}

单向信道
单向信道,可以细分为 只读信道只写信道
定义只读信道

var pipline = make(chan int)
type Receiver = <-chan int // 关键代码:定义别名类型
var receiver Receiver = pipline

定义只写信道

var pipline = make(chan int)
type Sender = chan<- int  // 关键代码:定义别名类型
var sender Sender = pipline

仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。

  • <-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读
  • chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写

有同学可能会问:为什么还要先声明一个双向信道,再定义单向通道呢?比如这样写

type Sender = chan<- int
sender := make(Sender)

代码是没问题,但是你要明白信道的意义是什么?(以下是我个人见解
信道本身就是为了传输数据而存在的,如果只有接收者或者只有发送者,那信道就变成了只入不出或者只出不入了吗,没什么用。所以只读信道和只写信道,唇亡齿寒,缺一不可。
当然了,若你往一个只读信道中写入数据 ,或者从一个只写信道中读取数据 ,都会出错。
完整的示例代码如下,供你参考:

import (
    "fmt"
    "time"
)
 //定义只写信道类型
type Sender = chan<- int

//定义只读信道类型
type Receiver = <-chan int

func main() {
    var pipline = make(chan int)

    go func() {
        var sender Sender = pipline
        fmt.Println("准备发送数据: 100")
        sender <- 100
    }()

    go func() {
        var receiver Receiver = pipline
        num := <-receiver
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(time.Second)
}

4.5 遍历信道

遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。

import "fmt"

func fibonacci(mychan chan int) {
    n := cap(mychan)
    x, y := 1, 1
    for i := 0; i < n; i++ {
        mychan <- x
        x, y = y, x+y
    }
    // 记得 close 信道
    // 不然主函数中遍历完并不会结束,而是会阻塞。
    close(mychan)
}

func main() {
    pipline := make(chan int, 10)

    go fibonacci(pipline)

    for k := range pipline {
        fmt.Println(k)
    }
}

4.6 用信道来做锁

当信道里的数据量已经达到设定的容量时,此时再往里发送数据会阻塞整个程序。
利用这个特性,可以用当他来当程序的锁。
示例如下,详情可以看注释

package main

import (
    "fmt"
    "time"
)

// 由于 x=x+1 不是原子操作
// 所以应避免多个协程对x进行操作
// 使用容量为1的信道可以达到锁的效果
func increment(ch chan bool, x *int) {
    ch <- true
    *x = *x + 1
    <- ch
}

func main() {
    // 注意要设置容量为 1 的缓冲信道
    pipline := make(chan bool, 1)

    var x int
    for i:=0;i<1000;i++{
        go increment(pipline, &x)
    }

    // 确保所有的协程都已完成
    // 以后会介绍一种更合适的方法(Mutex),这里暂时使用sleep
    time.Sleep(time.Second)
    fmt.Println("x 的值:", x)
}

输出如下

x 的值:1000

如果不加锁,输出会小于1000。

4.7 信道传递是深拷贝吗?

数据结构可以分为两种:

  • 值类型 :String,Array,Int,Struct,Float,Bool
  • 引用类型:Slice,Map

这两种不同的类型在拷贝的时候,在拷贝的时候效果是完全不一样的,这对于很多新手可能是一个坑。
对于值类型来说,你的每一次拷贝,Go 都会新申请一块内存空间,来存储它的值,改变其中一个变量,并不会影响另一个变量。

func main() {
    aArr := [3]int{0,1,2}
    fmt.Printf("打印 aArr: %v \n", aArr)
    bArr := aArr
    aArr[0] = 88
    fmt.Println("将 aArr 拷贝给 bArr 后,并修改 aArr[0] = 88")
    fmt.Printf("打印 aArr: %v \n", aArr)
    fmt.Printf("打印 bArr: %v \n", bArr)
}

从输出结果来看,aArr 和 bArr 相互独立,互不干扰

打印 aArr: [0 1 2]
将 aArr 拷贝给 bArr 后,并修改 aArr[0] = 88
打印 aArr: [88 1 2]
打印 bArr: [0 1 2]

对于引用类型来说,你的每一次拷贝,Go 不会申请新的内存空间,而是使用它的指针,两个变量名其实都指向同一块内存空间,改变其中一个变量,会直接影响另一个变量。

func main() {
    aslice := []int{0,1,2}
    fmt.Printf("打印 aslice: %v \n", aslice)
    bslice := aslice
    aslice[0] = 88
    fmt.Println("将 aslice 拷贝给 bslice 后,并修改 aslice[0] = 88")
    fmt.Printf("打印 aslice: %v \n", aslice)
    fmt.Printf("打印 bslice: %v \n", bslice)
}

从输出结果来看,aslice 的更新直接反映到了 bslice 的值。

打印 aslice: [0 1 2]
将 aslice 拷贝给 bslice 后,并修改 aslice[0] = 88
打印 aslice: [88 1 2]
打印 bslice: [88 1 2]

介绍完深拷贝和浅拷贝后,来回来最开始的问题:信道传递是深拷贝吗?
答案是:是否是深拷贝,取决于你传入的值是值类型,还是引用类型?

4.8 几个注意事项

  1. 关闭一个未初始化的 channel 会产生 panic
  2. 重复关闭同一个 channel 会产生 panic
  3. 向一个已关闭的 channel 发送消息会产生 panic
  4. 从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已被读取,则会读取到该类型的零值。
  5. 从已关闭的 channel 读取消息永远不会阻塞,并且会返回一个为 false 的值,用以判断该 channel 是否已关闭(x,ok := <- ch)
  6. 关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息
  7. channel 在 Golang 中是一等公民,它是线程安全的,面对并发问题,应首先想到 channel。

5 学习 Go 协程:WaitGroup

在前两篇文章里,我们学习了 协程 和 信道 的内容,里面有很多例子,当时为了保证 main goroutine 在所有的 goroutine 都执行完毕后再退出,我使用了 time.Sleep 这种简单的方式。
由于写的 demo 都是比较简单的, sleep 个 1 秒,我们主观上认为是够用的。
但在实际开发中,开发人员是无法预知,所有的 goroutine 需要多长的时间才能执行完毕,sleep 多了吧主程序就阻塞了, sleep 少了吧有的子协程的任务就没法完成。
因此,使用time.Sleep 是一种极不推荐的方式,今天主要就要来介绍 一下如何优雅的处理这种情况。

5.1 使用信道来标记完成

“不要通过共享内存来通信,要通过通信来共享内存”
学习了信道后,我们知道,信道可以实现多个协程间的通信,那么我们只要定义一个信道,在任务完成后,往信道中写入true,然后在主协程中获取到true,就认为子协程已经执行完毕。

import "fmt"

func main() {
    done := make(chan bool)
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println(i)
        }
        done <- true
    }()
    <-done
}

输出如下

0
1
2
3
4

5.2 使用 WaitGroup

上面使用信道的方法,在单个协程或者协程数少的时候,并不会有什么问题,但在协程数多的时候,代码就会显得非常复杂,有兴趣可以自己尝试一下。
那么有没有一种更加优雅的方式呢?
有,这就要说到 sync包 提供的 WaitGroup 类型。
WaitGroup 你只要实例化了就能使用

var 实例名 sync.WaitGroup

实例化完成后,就可以使用它的几个方法:

  • Add:初始值为0,你传入的值会往计数器上加,这里直接传入你子协程的数量
  • Done:当某个子协程完成后,可调用此方法,会从计数器上减一,通常可以使用 defer 来调用。
  • Wait:阻塞当前协程,直到实例里的计数器归零。

举一个例子:

import (
    "fmt"
    "sync"
)

func worker(x int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        fmt.Printf("worker %d: %d\n", x, i)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go worker(1, &wg)
    go worker(2, &wg)

    wg.Wait()
}

输出如下

worker 2: 0
worker 2: 1
worker 2: 2
worker 2: 3
worker 2: 4
worker 1: 0
worker 1: 1
worker 1: 2
worker 1: 3
worker 1: 4

以上就是我们在 Go 语言中实现一主多子的协程协作方式,推荐使用 sync.WaitGroup。。

6 学习 Go 协程:互斥锁和读写锁

在 「4.3 学习 Go 协程:详解信道/通道」这一节里我详细地介绍信道的一些用法,要知道的是在 Go 语言中,信道的地位非常高,它是 first class 级别的,面对并发问题,我们始终应该优先考虑使用信道,如果通过信道解决不了的,不得不使用共享内存来实现并发编程的,那 Golang 中的锁机制,就是你绕不过的知识点了。
今天就来讲一讲 Golang 中的锁机制。
在 Golang 里有专门的方法来实现锁,还是上一节里介绍的 sync 包。
这个包有两个很重要的锁类型
一个叫 Mutex, 利用它可以实现互斥锁。
一个叫 RWMutex,利用它可以实现读写锁。

6.1 互斥锁 :Mutex

使用互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确。
举个例子,就像下面这段代码,我开启了三个协程,每个协程分别往 count 这个变量加1000次 1,理论上看,最终的 count 值应试为 3000

package main

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup) {
    for i := 0; i < 1000; i++ {
        *count = *count + 1
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    count := 0
    wg.Add(3)
    go add(&count, &wg)
    go add(&count, &wg)
    go add(&count, &wg)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

可运行多次的结果,都不相同

// 第一次
count 的值为: 2854

// 第二次
count 的值为: 2673

// 第三次
count 的值为: 2840

原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。
解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。
在写代码前,先了解一下 Mutex 锁的两种定义方法

// 第一种
var lock *sync.Mutex
lock = new(sync.Mutex)

// 第二种
lock := &sync.Mutex{}

然后就可以修改你上面的代码,如下所示

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup, lock *sync.Mutex) {
    for i := 0; i < 1000; i++ {
        lock.Lock()
        *count = *count + 1
        lock.Unlock()
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    lock := &sync.Mutex{}
    count := 0
    wg.Add(3)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

此时,不管你执行多少次,输出都只有一个结果

count 的值为: 3000

使用 Mutext 锁虽然很简单,但仍然有几点需要注意:

  • 同一协程里,不要在尚未解锁时再次使加锁
  • 同一协程里,不要对已解锁的锁再次解锁
  • 加了锁后,别忘了解锁,必要时使用 defer 语句

6.2 读写锁:RWMutex

Mutex 是最简单的一种锁类型,他提供了一个傻瓜式的操作,加锁解锁加锁解锁,让你不需要再考虑其他的。
简单同时意味着在某些特殊情况下有可能会造成时间上的浪费,导致程序性能低下。
举个例子,我们平时去图书馆,要嘛是去借书,要嘛去还书,借书的流程繁锁,没有办卡的还要让管理员给你办卡,因此借书通常都要排老长的队,假设图书馆里只有一个管理员,按照 Mutex(互斥锁)的思想, 这个管理员同一时刻只能服务一个人,这就意味着,还书的也要跟借书的一起排队。
可还书的步骤非常简单,可能就把书给管理员扫下码就可以走了。
如果让还书的人,跟借书的人一起排队,那估计有很多人都不乐意了。
因此,图书馆为了提高整个流程的效率,就允许还书的人,不需要排队,可以直接自助还书。
图书管将馆里的人分得更细了,对于读者的不同需求提供了不同的方案。提高了效率。
RWMutex,也是如此,它将程序对资源的访问分为读操作和写操作

  • 为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允计有人更新这个数据(即写锁会阻塞)
  • 为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。

理解了这个后,再来看看,如何使用 RWMutex?
定义一个 RWMuteux 锁,有两种方法

// 第一种
var lock *sync.RWMutex
lock = new(sync.RWMutex)

// 第二种
lock := &sync.RWMutex{}

RWMutex 里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer。

  • 读锁:调用 RLock 方法开启锁,调用 RUnlock 释放锁
  • 写锁:调用 Lock 方法开启锁,调用 Unlock 释放锁(和 Mutex类似)

接下来,直接看一下例子吧

package main

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

func main() {
    lock := &sync.RWMutex{}
    lock.Lock()

    for i := 0; i < 4; i++ {
        go func(i int) {
            fmt.Printf("第 %d 个协程准备开始... \n", i)
            lock.RLock()
            fmt.Printf("第 %d 个协程获得读锁, sleep 1s 后,释放锁\n", i)
            time.Sleep(time.Second)
            lock.RUnlock()
        }(i)
    }

    time.Sleep(time.Second * 2)

    fmt.Println("准备释放写锁,读锁不再阻塞")
    // 写锁一释放,读锁就自由了
    lock.Unlock()

    // 由于会等到读锁全部释放,才能获得写锁
    // 因为这里一定会在上面 4 个协程全部完成才能往下走
    lock.Lock()
    fmt.Println("程序退出...")
    lock.Unlock()
}

输出如下

第 1 个协程准备开始...
第 0 个协程准备开始...
第 3 个协程准备开始...
第 2 个协程准备开始...
准备释放写锁,读锁不再阻塞
第 2 个协程获得读锁, sleep 1s 后,释放锁
第 3 个协程获得读锁, sleep 1s 后,释放锁
第 1 个协程获得读锁, sleep 1s 后,释放锁
第 0 个协程获得读锁, sleep 1s 后,释放锁
程序退出...

7 学习 Go 协程: 信道死锁经典错误案例

刚接触 Go 语言的信道的时候,经常会遇到死锁的错误,而导致这个错误的原因有很多种,这里整理了几种常见的。

fatal error: all goroutines are asleep - deadlock!

错误示例一

看下面这段代码

package main

import "fmt"

func main() {
    pipline := make(chan string)
    pipline <- "hello world"
    fmt.Println(<-pipline)
}

运行会抛出错误,如下

fatal error: all goroutines are asleep - deadlock!

看起来好像没有什么问题?先往信道中存入数据,再从信道中读取数据。
回顾前面的基础,我们知道使用 make 创建信道的时候,若不传递第二个参数,则你定义的是无缓冲信道,而对于无缓冲信道,在接收者未准备好之前,发送操作是阻塞的.
因此,对于解决此问题有两种方法:

  1. 使接收者代码在发送者之前执行
  2. 使用缓冲信道,而不使用无缓冲信道

第一种方法
若要程序正常执行,需要保证接收者程序在发送数据到信道前就进行阻塞状态,修改代码如下

package main

import "fmt"

func main() {
    pipline := make(chan string)
    fmt.Println(<-pipline)
    pipline <- "hello world"
}

运行的时候还是报同样的错误。问题出在哪里呢?
原来我们将发送者和接收者写在了同一协程中,虽然保证了接收者代码在发送者之前执行,但是由于前面接收者一直在等待数据 而处于阻塞状态,所以无法执行到后面的发送数据。还是一样造成了死锁。
有了前面的经验,我们将接收者代码写在另一个协程里,并保证在发送者之前执行,就像这样的代码

package main

func hello(pipline chan string)  {
    <-pipline
}

func main()  {
    pipline := make(chan string)
    go hello(pipline)
    pipline <- "hello world"
}

运行之后 ,一切正常。
第二种方法
接收者代码必须在发送者代码之前 执行,这是针对无缓冲信道才有的约束。
既然这样,我们改使用可缓冲信道不就OK了吗?

package main

import "fmt"

func main() {
    pipline := make(chan string, 1)
    pipline <- "hello world"
    fmt.Println(<-pipline)
}

运行之后,一切正常。

错误示例二

每个缓冲信道,都有容量,当信道里的数据量等于信道的容量后,此时再往信道里发送数据,就失造成阻塞,必须等到有人从信道中消费数据后,程序才会往下进行。
比如这段代码,信道容量为 1,但是往信道中写入两条数据,对于一个协程来说就会造成死锁。

package main

import "fmt"

func main() {
    ch1 := make(chan string, 1)

    ch1 <- "hello world"
    ch1 <- "hello China"

    fmt.Println(<-ch1)
}

错误示例三

当程序一直在等待从信道里读取数据,而此时并没有人会往信道中写入数据。此时程序就会陷入死循环,造成死锁。
比如这段代码,for 循环接收了两次消息(“hello world”和“hello China”)后,再也没有人发送数据了,接收者就会处于一个等待永远接收不到数据的囧境。陷入死循环,造成死锁。

package main

import "fmt"

func main() {
    pipline := make(chan string)
    go func() {
        pipline <- "hello world"
        pipline <- "hello China"
        // close(pipline)
    }()
    for data := range pipline{
        fmt.Println(data)
    }
}

包子铺里的包子已经卖完了,可还有人在排队等着买,如果不再做包子,就要告诉排队的人:不用等了,今天的包子已经卖完了,明日请早呀。
不能让人家死等呀,不跟客人说明一下,人家还以为你们店后面还在蒸包子呢。
所以这个问题,解决方法很简单,只要在发送完数据后,手动关闭信道,告诉 range 信道已经关闭,无需等待就行。

package main

import "fmt"

func main() {
    pipline := make(chan string)
    go func() {
        pipline <- "hello world"
        pipline <- "hello China"
        close(pipline)
    }()
    for data := range pipline{
        fmt.Println(data)
    }
}

8 学习 Go 协程:如何实现一个协程池?

在 Golang 中要创建一个协程是一件无比简单的事情,你只要定义一个函数,并使用 go 关键字去执行它就行了。
如果你接触过其他语言,会发现你在使用使用线程时,为了减少线程频繁创建销毁还来的开销,通常我们会使用线程池来复用线程。
池化技术就是利用复用来提升性能的,那在 Golang 中需要协程池吗?
在 Golang 中,goroutine 是一个轻量级的线程,他的创建、调度都是在用户态进行,并不需要进入内核,这意味着创建销毁协程带来的开销是非常小的。
因此,我认为大多数情况下,开发人员是不太需要使用协程池的。
但也不排除有某些场景下是需要这样做,因为我还没有遇到就不说了。
抛开是否必要这个问题,单纯从技术的角度来看,我们可以怎样实现一个通用的协程池呢?
下面就来一起学习一下我的写法
首先定义一个协程池(Pool)结构体,包含两个属性,都是 chan 类型的。
一个是 work,用于接收 task 任务
一个是 sem,用于设置协程池大小,即可同时执行的协程数量

type Pool struct {
    work chan func()   // 任务
    sem  chan struct{} // 数量
}

然后定义一个 New 函数,用于创建一个协程池对象,有一个细节需要注意
work 是一个无缓冲通道
而 sem 是一个缓冲通道,size 大小即为协程池大小

func New(size int) *Pool {
    return &Pool{
        work: make(chan func()),
        sem:  make(chan struct{}, size),
    }
}

最后给协程池对象绑定两个函数
1、NewTask:往协程池中添加任务
当第一次调用 NewTask 添加任务的时候,由于 work 是无缓冲通道,所以会一定会走第二个 case 的分支:使用 go worker 开启一个协程。

func (p *Pool) NewTask(task func()) {
    select {
        case p.work <- task:
        case p.sem <- struct{}{}:
            go p.worker(task)
    }
}

2、worker:用于执行任务
为了能够实现协程的复用,这个使用了 for 无限循环,使这个协程在执行完任务后,也不退出,而是一直在接收新的任务。

func (p *Pool) worker(task func()) {
    defer func() { <-p.sem }()
    for {
        task()
        task = <-p.work
    }
}

这两个函数是协程池实现的关键函数,里面的逻辑很值得推敲:
1、如果设定的协程池数大于 2,此时第二次传入往 NewTask 传入task,select case 的时候,如果第一个协程还在运行中,就一定会走第二个case,重新创建一个协程执行task
2、如果传入的任务数大于设定的协程池数,并且此时所有的任务都还在运行中,那此时再调用 NewTask 传入 task ,这两个 case 都不会命中,会一直阻塞直到有任务执行完成,worker 函数里的 work 通道才能接收到新的任务,继续执行。
以上便是协程池的实现过程。
使用它也很简单,看下面的代码你就明白了

func main()  {
    pool := New(128)
    pool.NewTask(func(){
        fmt.Println("run task")
    })
}

为了让你看到效果,我设置协程池数为 2,开启四个任务,都是 sleep 2 秒后,打印当前时间。

func main()  {
    pool := New(2)

    for i := 1; i <5; i++{
        pool.NewTask(func(){
            time.Sleep(2 * time.Second)
            fmt.Println(time.Now())
        })
    }

    // 保证所有的协程都执行完毕
    time.Sleep(5 * time.Second)
}

执行结果如下,可以看到总共 4 个任务,由于协程池大小为 2,所以 4 个任务分两批执行(从打印的时间可以看出)

2020-05-24 23:18:02.014487 +0800 CST m=+2.005207182
2020-05-24 23:18:02.014524 +0800 CST m=+2.005243650
2020-05-24 23:18:04.019755 +0800 CST m=+4.010435443
2020-05-24 23:18:04.019819 +0800 CST m=+4.010499440

9 学习 Go 协程:巧妙利用 Context

9.1 什么是 Context?

在 Go 1.7 版本之前,context 还是非编制的,它存在于 golang.org/x/net/context 包中。
后来,Golang 团队发现 context 还挺好用的,就把 context 收编了,在 Go 1.7 版本正式纳入了标准库。
Context,也叫上下文,它的接口定义如下

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

可以看到 Context 接口共有 4 个方法

  • Deadline:返回的第一个值是 截止时间,到了这个时间点,Context 会自动触发 Cancel 动作。返回的第二个值是 一个布尔值,true 表示设置了截止时间,false 表示没有设置截止时间,如果没有设置截止时间,就要手动调用 cancel 函数取消 Context。
  • Done:返回一个只读的通道(只有在被cancel后才会返回),类型为 struct{}。当这个通道可读时,意味着parent context已经发起了取消请求,根据这个信号,开发者就可以做一些清理动作,退出goroutine。
  • Err:返回 context 被 cancel 的原因。
  • Value:返回被绑定到 Context 的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。

9.2 为何需要 Context?

当一个协程(goroutine)开启后,我们是无法强制关闭它的。
常见的关闭协程的原因有如下几种:

  1. goroutine 自己跑完结束退出
  2. 主进程crash退出,goroutine 被迫退出
  3. 通过通道发送信号,引导协程的关闭。

第一种,属于正常关闭,不在今天讨论范围之内。
第二种,属于异常关闭,应当优化代码。
第三种,才是开发者可以手动控制协程的方法,代码示例如下:

func main() {
    stop := make(chan bool)

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("监控退出,停止了...")
                return
            default:
                fmt.Println("goroutine监控中...")
                time.Sleep(2 * time.Second)
            }
        }
    }()

    time.Sleep(10 * time.Second)
    fmt.Println("可以了,通知监控停止")
    stop<- true
    //为了检测监控过是否停止,如果没有监控输出,就表示停止了
    time.Sleep(5 * time.Second)

}

例子中我们定义一个stop的chan,通知他结束后台goroutine。实现也非常简单,在后台goroutine中,使用select判断stop是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果没有接收到,就会执行default里的监控逻辑,继续监控,只到收到stop的通知。
以上是一个 goroutine 的场景,如果是多个 goroutine ,每个goroutine 底下又开启了多个 goroutine 的场景呢?在 飞雪无情的博客 里关于为何要使用 Context,他是这么说的
chan+select的方式,是比较优雅的结束一个goroutine的方式,不过这种方式也有局限性,如果有很多goroutine都需要控制结束怎么办呢?如果这些goroutine又衍生了其他更多的goroutine怎么办呢?如果一层层的无穷尽的goroutine呢?这就非常复杂了,即使我们定义很多chan也很难解决这个问题,因为goroutine的关系链就导致了这种场景非常复杂。
在这里我不是很赞同他说的话,因为我觉得就算只使用一个通道也能达到控制(取消)多个 goroutine 的目的。下面就用例子来验证一下。
该例子的原理是:使用 close 关闭通道后,如果该通道是无缓冲的,则它会从原来的阻塞变成非阻塞,也就是可读的,只不过读到的会一直是零值,因此根据这个特性就可以判断 拥有该通道的 goroutine 是否要关闭。

package main

import (
    "fmt"
    "time"
)

func monitor(ch chan bool, number int)  {
    for {
        select {
        case v := <-ch:
            // 仅当 ch 通道被 close,或者有数据发过来(无论是true还是false)才会走到这个分支
            fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    stopSingal := make(chan bool)

    for i :=1 ; i <= 5; i++ {
        go monitor(stopSingal, i)
    }

    time.Sleep( 1 * time.Second)
    // 关闭所有 goroutine
    close(stopSingal)

    // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
    time.Sleep( 5 * time.Second)

    fmt.Println("主程序退出!!")

}

输出如下

监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器5,正在监控中...
监控器2,接收到通道值为:false,监控结束。
监控器3,接收到通道值为:false,监控结束。
监控器5,接收到通道值为:false,监控结束。
监控器1,接收到通道值为:false,监控结束。
监控器4,接收到通道值为:false,监控结束。
主程序退出!!

上面的例子,说明当我们定义一个无缓冲通道时,如果要对所有的 goroutine 进行关闭,可以使用 close 关闭通道,然后在所有的 goroutine 里不断检查通道是否关闭(前提你得约定好,该通道你只会进行 close 而不会发送其他数据,否则发送一次数据就会关闭一个goroutine,这样会不符合咱们的预期,所以最好你对这个通道再做一层封装做个限制)来决定是否结束 goroutine。
所以你看到这里,我做为初学者还是没有找到使用 Context 的必然理由,我只能说 Context 是个很好用的东西,使用它方便了我们在处理并发时候的一些问题,但是它并不是不可或缺的。
换句话说,它解决的并不是 能不能 的问题,而是解决 更好用 的问题。

9.3 简单使用 Context

如果不使用上面 close 通道的方式,还有没有其他更优雅的方法来实现呢?
有,那就是本文要讲的 Context
我使用 Context 对上面的例子进行了一番改造。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        // 其实可以写成 case <- ctx.Done()
        // 这里仅是为了让你看到 Done 返回的内容
        case v :=<- ctx.Done():
            fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx, i)
    }

    time.Sleep( 1 * time.Second)
    // 关闭所有 goroutine
    cancel()

    // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
    time.Sleep( 5 * time.Second)

    fmt.Println("主程序退出!!")

}

这里面的关键代码,也就三行
第一行:以 context.Background() 为 parent context 定义一个可取消的 context

ctx, cancel := context.WithCancel(context.Background())

第二行:然后你可以在所有的goroutine 里利用 for + select 搭配来不断检查 ctx.Done() 是否可读,可读就说明该 context 已经取消,你可以清理 goroutine 并退出了。

case <- ctx.Done():

第三行:当你想到取消 context 的时候,只要调用一下 cancel 方法即可。这个 cancel 就是我们在创建 ctx 的时候返回的第二个值。

cancel()

运行结果输出如下。可以发现我们实现了和 close 通道一样的效果。

监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

9.4 根Context 是什么?

创建 Context 必须要指定一个 父 Context,当我们要创建第一个Context时该怎么办呢?
不用担心,Go 已经帮我们实现了2个,我们代码中最开始都是以这两个内置的context作为最顶层的parent context,衍生出更多的子Context。

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

一个是Background,主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context,它不能被取消。
一个是TODO,如果我们不知道该使用什么Context的时候,可以使用这个,但是实际应用中,暂时还没有使用过这个TODO。
他们两个本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

9.5 Context 的继承衍生

上面在定义我们自己的 Context 时,我们使用的是 WithCancel 这个方法。
除它之外,context 包还有其他几个 With 系列的函数

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个函数有一个共同的特点,就是第一个参数,都是接收一个 父context。
通过一次继承,就多实现了一个功能,比如使用 WithCancel 函数传入 根context ,就创建出了一个子 context,该子context 相比 父context,就多了一个 cancel context 的功能。
如果此时,我们再以上面的子context(context01)做为父context,并将它做为第一个参数传入WithDeadline函数,获得的子子context(context02),相比子context(context01)而言,又多出了一个超过 deadline 时间后,自动 cancel context 的功能。
接下来我会举例介绍一下这几种 context,其中 WithCancel 在上面已经讲过了,下面就不再举例了

例子 1:WithDeadline

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出如下

监控器5,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器3,监控结束。
监控器4,监控结束。
监控器2,监控结束。
监控器1,监控结束。
监控器5,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

例子 2:WithTimeout

WithTimeout 和 WithDeadline 使用方法及功能基本一致,都是表示超过一定的时间会自动 cancel context。
唯一不同的地方,我们可以从函数的定义看出

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithDeadline 传入的第二个参数是 time.Time 类型,它是一个绝对的时间,意思是在什么时间点超时取消。
而 WithTimeout 传入的第二个参数是 time.Duration 类型,它是一个相对的时间,意思是多长时间后超时取消。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())

    // 相比例子1,仅有这一行改动
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出的结果和上面一样

监控器1,正在监控中...
监控器5,正在监控中...
监控器3,正在监控中...
监控器2,正在监控中...
监控器4,正在监控中...
监控器4,监控结束。
监控器2,监控结束。
监控器5,监控结束。
监控器1,监控结束。
监控器3,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

例子 3:WithValue

通过Context我们也可以传递一些必须的元数据,这些数据会附加在Context上以供使用。
元数据以 Key-Value 的方式传入,Key 必须有可比性,Value 必须是线程安全的。
还是用上面的例子,以 ctx02 为父 context,再创建一个能携带 value 的ctx03,由于他的父context 是 ctx02,所以 ctx03 也具备超时自动取消的功能。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            // 获取 item 的值
            value := ctx.Value("item")
            fmt.Printf("监控器%v,正在监控 %v \n", number, value)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
    ctx03 := context.WithValue(ctx02, "item", "CPU")

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx03, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出如下

监控器4,正在监控 CPU
监控器5,正在监控 CPU
监控器1,正在监控 CPU
监控器3,正在监控 CPU
监控器2,正在监控 CPU
监控器2,监控结束。
监控器5,监控结束。
监控器3,监控结束。
监控器1,监控结束。
监控器4,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

9.6 Context 使用注意事项

  1. 通常 Context 都是做为函数的第一个参数进行传递(规范性做法),并且变量名建议统一叫 ctx
  2. Context 是线程安全的,可以放心地在多个 goroutine 中使用。
  3. 当你把 Context 传递给多个 goroutine 使用时,只要执行一次 cancel 操作,所有的 goroutine 就可以收到 取消的信号
  4. 不要把原本可以由函数参数来传递的变量,交给 Context 的 Value 来传递。
  5. 当一个函数需要接收一个 Context 时,但是此时你还不知道要传递什么 Context 时,可以先用 context.TODO 来代替,而不要选择传递一个 nil。
  6. 当一个 Context 被 cancel 时,继承自该 Context 的所有 子 Context 都会被 cancel。

10 学习 Go 协程:万能的通道模型(公式)

通道,是 Go 语言的一大特色,初次接触 Go 语言时,着实被他给惊艳到了,它让协程之间的通信变得非常的简单。
简单,意味着灵活,按理说,这是好事啊,可你要知道灵活的另一个潜台词,就是不标准,不同的人,使用的方式可能都不一样。
对于 通道 来说,我们要极力避免下面两种情况:

  1. 对一个已关闭的通道,进行关闭
  2. 对一个已关闭的通道,写入数据

因此,这两种操作,都会异常程序触发 panic。
然而,Go 语言并没有提供一个内置的函数来判断一个通道是否关闭,这肯定不是技术上的限制,应该另有原因。
本篇文章是我基于《如何优雅地关闭 channel?》这篇文章,再进行总结而得出来一套万能的通道编程模型,非常建议大家全文精读后收为已用。

10.1 有隐患且不优雅的做法

对此,可能有的人会自己写个函数来判断函数是否关闭
比如,针对第一种情况,可以包装个 SafeClose 函数,用 defer recover 关闭通道可能引起的 panic

func SafeClose(ch chan T) (justClosed bool) {
    defer func() {
        if recover() != nil {
            // 一个函数的返回结果可以在defer调用中修改。
            justClosed = false
        }
    }()

    // 假设ch != nil。
    close(ch)   // 如果ch已关闭,则产生一个恐慌。
    return true // <=> justClosed = true; return
}

而对于第二种情况,同样可以包装个 SafeSend 函数,用 defer recover 给通道发送数据可能引起的 panic

func SafeSend(ch chan T, value T) (closed bool) {
    defer func() {
        if recover() != nil {
            closed = true
        }
    }()

    ch <- value  // 如果ch已关闭,则产生一个恐慌。
    return false // <=> closed = false; return
}

上面这两种方法,虽然可以在一定程度上,可以解决通道操作的 panic 问题,但仍然存在隐患,比如下面这个例子,

package main

func SafeSend(ch chan bool, value bool) (closed bool) {
    defer func() {
        if recover() != nil {
            closed = true
        }
    }()

    ch <- value  // 如果ch已关闭,则产生一个恐慌。
    return false // <=> closed = false; return
}

func SafeClose(ch chan bool) (justClosed bool) {
    defer func() {
        if recover() != nil {
            // 一个函数的返回结果可以在defer调用中修改。
            justClosed = false
        }
    }()

    // 假设ch != nil。
    close(ch)   // 如果ch已关闭,则产生一个恐慌。
    return true // <=> justClosed = true; return
}

func main() {
    c := make(chan bool)
    go func() {
        for i := 0; i < 25; i++ {
            SafeSend(c, true)
        }
    }()
    SafeClose(c)
}

直接 go run 是没有问题的,但你要是加个 -race ,Go 编译器就会替你排查数据竞争的隐患
image.png

10.2 万能的通道编程模型

重新梳理一下思路,可以发现:

  1. (发送者)对一个已关闭的通道,写入数据 ❌
  2. (接收者)对一个已关闭的通道,读取数据 ✅

那么如果能保证发送者本身知道通道是关闭的,它就不会再傻傻地往一个已关闭的通道发送数据了。

一个发送者,N 个接收者

关键问题是,发送者如何知道呢?
Go 语言本身没有提供类似的函数,同时上一节,咱们也探讨了,使用 recover panic 的方式封装函数,同样会有数据竞争的问题。
语言层面不可行,那么就由开发者约定协议。

  • 通道应当由唯一发送者关闭
  • 若没有唯一发送者,则需要加“管理角色”的通道

第一点很好理解:当只有一个发送者时,他自己本身肯定是知道通道是否关闭,就不用再判断是否关闭了,自己想关闭就关闭,完全没事。
可要是没有唯一发送者呢?
这又要分两种情况了。

  1. 多个发送者,一个接收者
  2. 多个发送者,多个接收者

无论哪种场景,都会有数据竞争的问题。
上面我也说了,对于没有唯一发送者的方案就是加一个 “管理角色” 的通道。
为了方便解释,我将通道分为两种:

  1. 业务通道:承载数据,用于多个协程间共享数据
  2. 管理通道:仅为了标记业务通道是否关闭而存在

因此管理通道需要满足两个条件:
第一个条件:具备广播功能
那只能是无缓冲通道(关闭后,所有 read 该通道的所有协程,都能明确的知道该通道已关闭)。

  • 当该管理通道关闭了,说明业务通道也关闭了。
  • 当该管理通道阻塞了,说明业务通道还没关闭。

第二个条件:有唯一发送者
这个开发者非常容易实现:

  • 对于多个发送者,一个接收者的场景,业务通道的这个接收者,就可以充当管理通道的 唯一发送者
  • 对于多个发送者,多个接收者的场景,就需要再单独开启一个媒介协程做 唯一发送者

针对这两个场景,这边分别举个例子

N个发送者,一个接收者

首先是多个发送者,一个接收者

package main

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

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 100000
    const NumSenders = 1000

    wg := sync.WaitGroup{}
    wg.Add(1)

    // 业务通道
    dataCh := make(chan int)

    // 管理通道:必须是无缓冲通道
    // 其发送者是 业务通道的接收者。
    // 其接收者是 业务通道的发送者。
    stopCh := make(chan struct{})

    // 业务通道的发送者
    for i := 0; i < NumSenders; i++ {
        go func() {
            for {
                // 提前检查管理通道是否关闭
                // 让业务通道发送者早尽量退出
                select {
                case <- stopCh:
                    return
                default:
                }

                select {
                case <- stopCh:
                    return
                case dataCh <- rand.Intn(Max):
                }
            }
        }()
    }

    // 业务通道的接收者,亦充当管理通道的发送者
    go func() {
        defer wg.Done()

        for value := range dataCh {
            if value == 6666 {
                // 当达到某个条件时
                // 通过关闭管理通道来广播给所有业务通道的发送者
                close(stopCh)
                return
            }
        }
    }()

    wg.Wait()
}

N个发送者,N个接收者

然后是多个发送者,多个接收者,这个场景需要另外开启一个媒介协程。
媒介协程的作用,很明显啊,就是充当媒介,媒介要有自己的一个媒介通道:

  • 其发送者是:业务通道的所有发送者和接收者
  • 其接收者是:媒介协程(是唯一的)

既然媒介协程只有一个,那自然而然地,媒介协程做为管理通道的 唯一发送者,再合适不过了。
还有一个非常重要的点是,媒介协程要是媒介通道的接收者,因此它要先于业务通道的所有发送者、接收者启动。
这就要求,媒介通道,必须是缓冲通道,长度可以取 1 即可。
完整的示例代码如下

package main

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

func main() {
    rand.Seed(time.Now().UnixNano())

    const Max = 100000
    const NumReceivers = 10
    const NumSenders = 1000

    wg := sync.WaitGroup{}
    wg.Add(NumReceivers)

    // 1. 业务通道
    dataCh := make(chan int)

    // 2. 管理通道:必须是无缓冲通道
    // 其发送者是:额外启动的管理协程
    // 其接收者是:所有业务通道的发送者。
    stopCh := make(chan struct{})

    // 3. 媒介通道:必须是缓冲通道
    // 其发送者是:业务通道的所有发送者和接收者
    // 其接收者是:媒介协程(唯一)
    toStop := make(chan string, 1)

    var stoppedBy string

    // 媒介协程
    go func() {
        stoppedBy = <-toStop
        close(stopCh)
    }()

    // 业务通道发送者
    for i := 0; i < NumSenders; i++ {
        go func(id string) {
            for {
                // 提前检查管理通道是否关闭
                // 让业务通道发送者早尽量退出
                select {
                case <- stopCh:
                    return
                default:
                }

                value := rand.Intn(Max)
                select {
                case <-stopCh:
                    return
                case dataCh <- value:
                }
            }
        }(strconv.Itoa(i))
    }

    // 业务通道的接收者
    for i := 0; i < NumReceivers; i++ {
        go func(id string) {
            defer wg.Done()

            for {
                // 提前检查管理通道是否关闭
                // 让业务通道接收者早尽量退出
                select {
                case <- stopCh:
                    return
                default:
                }

                select {
                case <- stopCh:
                    return
                case value := <-dataCh:
                    // 一旦满足某个条件,就通过媒介通道发消息给媒介协程
                    // 以关闭管理通道的形式,广播给所有业务通道的协程退出
                    if value == 6666 {
                        // 务必使用 select,两个目的:
                        // 1、防止协程阻塞
                        // 2、防止向已关闭的通道发送数据导致panic
                        select {
                        case toStop <- "接收者#" + id:
                        default:
                        }
                        return
                    }

                }
            }
        }(strconv.Itoa(i))
    }

    wg.Wait()
    fmt.Println("被" + stoppedBy + "终止了")
}

无论是直接运行,还是加 -race 都没有问题了。
image.png
可能会有的人会有疑问,为什么最后一个例子里,业务通道没有关闭呢?
我觉得有必要讲一下这个,不然有的朋友可能会绕不出来。
我们的最终目的其实不是关闭业务通道,而是让业务通道相关的协程能够正常退出。
业务通道其实并不都要去关闭它,多关闭一个就多一分风险,何必呢?
一旦所有的协程正常退出了,Go 的垃圾回收自然会清理掉,这样是不是更省事呢?

10.3 总结一下

通道是 Go 语言的一等公民,用 Go 写应用几乎都绕不开通道,但在多协程并发的面前,人的逻辑会显得非常脆弱,当你正得意于自己写的代码跑得非常顺利的时候,通道 经常会给你带来意料不到的惊喜,而这种问题如果在一开始程序设计时没有注意到,等到问题出现后,可能会面临代码结构的重新设计。
本文是我基于 《如何优雅地关闭 channel?》这篇文章,经过提炼总结出一套认为比较好理解的通道编程模型。
文中我造了一些新词,诸如 业务通道管理通道媒介通道媒介协程,仅是为个人理解方便而造出的新概念,这里需要说明一下,希望不会对你给你的学习造成困扰。
这里再次总结一下,这个 万能的通道编程模型

  1. 当只有一个发送者时,无论有多少接收者,业务通道都应由唯一发送者关闭。
  2. 当有多个发送者,一个接收者时,应借助管理通道,由业务通道唯一接收者充当管理通道的发送者,其他业务通道的发送者充当接收者
  3. 当有多个发送者,多个接收者时,这是最复杂的,不仅要管理通道,还要另起一个专门的媒介协程,新增一个媒介通道,但核心逻辑都是一样。

11 学习 Go 协程:常见的并发模型

本篇内容主要是了解下并发编程中的一些概念,及讲述一些常用的并发模型都是什么样的,从而理解 Golang 中的 协程在这些众多模型中是一种什么样的存在及地位。可能和本系列的初衷(零基础学Go)有所出入,因此你读不读本篇都不会对你学习Go有影响,尽管我个人觉得这是有必要了解的。
你可以自行选择,若你只想学习 Golang 有关的内容,完全可以跳过本篇。

11.1 并发与并行

讲到并发,那不防先了解下什么是并发,与之相对的并行有什么区别?
这里我用两个例子来形象描述:

  • 并发:当你在跑步时,发现鞋带松,要停下来系鞋带,这时候跑步和系鞋带就是并发状态。
  • 并行:你跑步时,可以同时听歌,那么跑步和听歌就是并行状态,谁也不影响谁。

在计算机的世界中,一个CPU核严格来说同一时刻只能做一件事,但由于CPU的频率实在太快了,人们根本感知不到其切换的过程,所以我们在编码的时候,实际上是可以在单核机器上写多进程的程序(但你要知道这是假象),这是相对意义上的并行。
而当你的机器有多个 CPU 核时,多个进程之间才能真正的实现并行,这是绝对意义上的并行。
接着来说并发,所谓的并发,就是多个任务之间可以在同一时间段里一起执行。
但是在单核CPU里,他同一时刻只能做一件事情 ,怎么办?
谁都不能偏坦,我就先做一会 A 的活,再做一会B 的活,接着去做一会 C 的活,然后再去做一会 A 的活,就这样不断的切换着,大家都很开心,其乐融融。

1. 并发编程的模型

在计算机的世界里,实现并发通常有几种方式:

  1. 多进程模型:创建新的进程处理请求
  2. 多线程模型:创建新的线程处理请求
  3. 使用线程池:线程/进程创建销毁开销大
  4. I/O 多路复用+单/多线程

11.2 多进程与多线程

对于普通的用户来说,进程是最熟悉的存在,比如一个 QQ ,一个微信,它们都是一个进程。
进程是计算机资源分配的最小单位,而线程是比进程更小的执行单元,它不能脱离于进程单独存在。
在一个进程里,至少有一个线程,那个线程叫主线程,同时你也可以创建多个线程,多个线程之间是可以并发执行的。
线程是调度的基本单位,在多线程里,在调度过程中,需要由 CPU 和 内核层参与上下文的切换。如果你跑了A线程,然后切到B线程,内核调用开始,CPU需要对A线程的上下文保留,然后切到B线程,然后把控制权交给你的应用层调度。
而进程的切换,相比线程来说,会更加麻烦。
因为进程有自己的独立地址空间,多个进程之间的地址空间是相互隔离的,这和线程有很大的不同,单个进程内的多个线程 共享进程中的数据的,使用相同的地址空间,所以CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
此外,由于同一进程下的线程共享全局变量、静态变量等数据,使得线程间的通信非常方便,相比之下,进程间的通信(IPC,InterProcess Communication)就略显复杂,通常的进程间的通信方式有:管道,消息队列,信号量,Socket,Streams 等
说了这么多,好像都在说线程优于进程,也不尽然。
比如多线程更多用于有IO密集型的业务场景,而对于计算密集型的场景,应该优先选择多进程。
同时,多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

11.3 I/O多路复用

I/O多路复用 ,英文全称为 I/O multiplexing,这个中文翻译和把 socket 翻译成 套接字一样,影响了我对其概念的理解。
在互联网早期,为了实现一个服务器可以处理多个客户端的连接,程序猿是这样做的。服务器得知来了一个请求后,就去创建一个线程处理这个请求,假如有10个客户请求,就创建10个线程,这在当时联网设备还比较匮乏的时代,是没有任何问题的。
但随着科技的发展,人们越来越富裕,都买得起电脑了,网民也越来越多了,由于一台机器的能开启的线程数是有限制的,当请求非常集中量大到一定量时,服务器的压力就巨大无比。
终于到了 1983年,人们意识到这种问题,提出了一种最早的 I/O 多路复用的模型(select实现),这种模型,对比之前最大的不同就是,处理请求的线程不再是根据请求来定,后端请求的进程只有一个。虽然这种模型在现在看来还是不行,但在当时已经大大减小了服务器系统的开销,可以解决服务器压力太大的问题,毕竟当时的电脑都是很珍贵的。
再后来,家家都有了电脑,手机互联网的时代也要开始来了,联网设备爆炸式增长,之前的 select ,早已不能支撑用户请求了。
由于使用 select 最多只能接收 1024 个连接,后来程序猿们又改进了 select 发明了 pool,pool 使用的链表存储,没有最大连接数的限制。
select 和 pool ,除了解决了连接数的限制 ,其他似乎没有本质的区别。
都是服务器知道了有一个连接来了,由于并不知道是哪那几个流(可能有一个,多个,甚至全部),所以只能一个一个查过去(轮循),假如服务器上有几万个文件描述符(下称fd,file descriptor),而你要处理一个请求,却要遍历几万个fd,这样是不是很浪费时间和资源。
由此程序员不得不持续改进 I/O多路复用的策略,这才有了后来的 epoll 方法。
epoll 解决了前期 select 和 poll 出现的一系列的尴尬问题,比如:

  • select 和 poll 无差别轮循fd,浪费资源,epoll 使用通知回调机制,有流发生 IO事件时就会主动触发回调函数
  • select 和 poll 线程不安全,epoll 线程安全
  • select 请求连接数的限制,epoll 能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
  • select 和 pool 需要频繁地将fd复制到内核空间,开销大,epoll通过内核和用户空间共享一块内存来减少这方面的开销。

虽然 I/O 多路复用经历了三种实现:select -> pool -> epoll,这也不是就说 epoll 出现了, select 就会被淘汰掉。
epoll 关注的是活跃的连接数,当连接数非常多但活跃连接少的情况下(比如长连接数较多),epoll 的性能最好。
而 select 关注的是连接总数,当连接数多而且大部分的连接都很活跃的情况下,选择 select 会更好,因为 epoll 的通知回调机制需要很多的函数回调。
另外还有一点是,select 是 POSIX 规定的,一般操作系统均有实现,而 epoll 是 Linux 所有的,其他平台上没有。
IO多路复用除了以上三种不同的具体实现的区别外,还可以根据线程数的多少来分类

  • 一个线程的IO多路复用,比如 Redis
  • 多个线程的IO多路复用,比如 goroutine

IO多路复用 + 单进(线)程有个好处,就是不会有并发编程的各种坑问题,比如在nginx里,redis里,编程实现都会很简单很多。编程中处理并发冲突和一致性,原子性问题真的是很难,极易出错。

11.4 三种线程模型?

实际上,goroutine 并非传统意义上的协程。
现在主流的线程模型分三种:

  • 内核级线程模型
  • 用户级线程模型
  • 两级线程模型(也称混合型线程模型)

传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler 在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把 goroutine 类比成协程,但心里一定要有个清晰的认知 — goroutine并不等同于协程。
关于这块,想详细了解的,可以前往:https://studygolang.com/articles/13344

11.5 协程的优势在哪?

协程,可以认为是轻量级的“线程”。
对比线程,有如下几个明显的优势。

  1. 协程的调度由 Go 的 runtime 管理,协程切换不需要经由操作系统内核,开销较小。
  2. 单个协程的堆栈只有几个kb,可创建协程的数量远超线程数。

同时,在 Golang 里,我还体会到了这种现代化编程语言带来的优势,它考虑得面面俱到,让编码变得更加的傻瓜式,goroutine的定义不需要在定义时区分是否异步函数(相对Python的 async def 而言),运行时只需要一个关键字 go,就可以轻松创建一个协程。
使用 -race 来检测数据 访问的冲突
协程什么时候会切换

  1. I/O,select
  2. channel
  3. 等待锁
  4. 函数调用
  5. runtime.Gosched()
posted @ 2024-03-14 23:22  liuyang9643  阅读(7)  评论(0编辑  收藏  举报