解密 Go 语言的 channel,协程是如何基于 channel 进行同步的

楔子

上一篇文章我们介绍了 goroutine,了解了 Go 是如何实现并发的。虽然实现了并发的效果,但是我们拿不到函数的返回值,因为它是通过 goroutine 的方式启动的,我们不能像下面这样做:

  • res := go f()
  • fmt.Println(go f())

那么问题来了,多个 goroutine 之间如何进行通信呢?答案是通过 channel,我们称之为管道、或者通道。

什么是 channel

goroutine 和 channel 是 Go 语言并发编程的两大基石,goroutine 用于执行并发任务,channel 用于 goroutine 之间的同步、通信。

channel 在 gouroutine 之间架起了一条管道,通过往管道里传输数据,实现 gouroutine 之间的通信。由于它是线程安全的,所以用起来非常方便。channel 还提供 “先进先出” 的特性,并影响 goroutine 的阻塞和唤醒。

而在 Go 里面有一句很经典的话:

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而是要通过通信来共享内存。这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。

package main

import (
    "fmt"
    "time"
)

func main() {
    // channel 可以使用 make 函数创建
    // 其中 chan 是一个关键字, 表示这是一个通道, int 表示通道里面存放数据的类型
    ch := make(chan int)
    
    go func() {
        // <-ch, 表示从通道 ch 中将数据取出来
        // 当然也可以用变量接收取出来的数据, data := <-ch
        time.Sleep(time.Second)
        fmt.Println("子goroutine")
        <-ch
    }()
    
    // ch <- data, 表示将数据 data 写到通过通道 ch 里
    ch <- 1
    fmt.Println("主goroutine")
    
    /*
       子goroutine
       主goroutine
    */
}

首先默认情况下使用 make 创建的通道是无缓冲的,无缓冲通道表示通道里能存放的数据个数为 0,往里面放数据放不进去,必须同时有人在通道的另一方接数据;而有缓冲的通道则可以存放数据,在将数据放进通道之后就可以离开了。

分析一下代码,首先 ch <- 1 表示主 goroutine 要将整数 1 放进通道 ch 中。但由于这里的通道是无缓冲的,或者说通道的容量为 0,导致主 goroutine 放不进去只能阻塞在这里。如果主 goroutine 想解除阻塞,那就必须有人同时往通道取数据,这样数据就会由通道的一方传递到另一方。

所以只有当子 goroutine 执行到 <-ch 的时候,主 goroutine 才会解除阻塞,这个时候数据会被子 goroutine 取走。如果我们将代码改一下:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int)
    
    go func() {
        time.Sleep(time.Second)
        fmt.Println("子goroutine")
        //<-ch
    }()
    
    ch <- 1
    fmt.Println("主goroutine")
    
    /*
       子goroutine
       fatal error: all goroutines are asleep - deadlock!
    */
}

我们看到发生死锁了,只要程序中处于阻塞状态,并且只剩下一个主 goroutine,就会出现死锁。

有缓冲 channel 和无缓冲 channel

对 channel 的发送和接收操作,都会在编译期间转换成底层的发送函数和接收函数。

channel 分为两种:带缓冲、不带缓冲。对不带缓冲的 channel 进行的操作实际上可以看作是 "同步模式",带缓冲的则可以看作是 "异步模式"。

  • 同步模式:发送方和接收方要同步就绪,只有在两者都 ready 的情况下,数据才能在两者间传输(实际上就是内存拷贝)。否则,任意一方先进行发送或接收操作,都会被挂起,等待另一方的出现才能被唤醒。
  • 异步模式:在缓冲区可用的情况下(有剩余容量),发送和接收操作都可以顺利进行。如果容量为 0(缓冲区满了),操作的一方在写入的时候同样会被挂起,直到出现接收操作才会被唤醒。

一句话总结:同步模式下,必须要使发送方和接收方配对,操作才会成功,否则会被阻塞;异步模式下,缓冲区要有剩余容量,操作才会成功,否则也会被阻塞。

那么我们来看看如何创建一个有缓冲的通道:

package main

import (
    "fmt"
    "time"
)

func main() {
    // 创建一个容量为 3 的有缓冲通道
    ch := make(chan int, 3)
    
    // 这里都不会阻塞
    ch <- 1
    ch <- 2
    ch <- 3
    
    // 启动五个 goroutine
    for i := 1; i < 5; i++ {
        go func(i int) {
            time.Sleep(time.Second * time.Duration(i))
            fmt.Println("子goroutine", i)
            <-ch
        }(i)
    }
    
    // 缓冲区满了, 此时会阻塞
    ch <- 4
    ch <- 5
    // 如果想要执行到这里, 那么必须要有子 goroutine 从通道中取走两个数据
    fmt.Println("主goroutine")
    
    /*
       子goroutine 1
       子goroutine 2
       主goroutine
    */
}

主 goroutine 不会等待所有的子 goroutine,它只是在等待有人从通道取走两个数据罢了,因此只有两个子 goroutine 打印了。

往通道放入数据,那么要求通道必须有空闲位置(缓冲区不能满)才可以;同理取数据也是如此,必须要求通道里面有数据才行。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch := make(chan int, 3)
    for i := 1; i < 5; i++ {
        go func(i int) {
            time.Sleep(time.Second * time.Duration(i))
            fmt.Println("子goroutine", i)
            ch <- i
        }(i)
    }
    
    // 缓冲区没有数据, 所以此时会阻塞
    <- ch
    <- ch
    // 如果想要执行到这里, 那么必须要有子 goroutine 从通道中放入数据
    fmt.Println("主goroutine")
    
    /*
       子goroutine 1
       子goroutine 2
       主goroutine
    */
}

因为一开始通道没有数据,所以主 goroutine 会在 <- ch 处出现阻塞,直到有子 goroutine 往通道里面放入数据。

单向 channel

目前的 channel 我们既可以放入数据,也可以取出数据,但还可以让其变成单向的只能放入数据 或 只能取出数据的 channel。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // 从箭头的指向我们可以看出, 这是一个只能放入数据的 channel, 并且放入的是整型数据
    // 可以 ch1 <- 1, 但是 <- ch报错
    var ch1 = make(chan<- int, 3)
    
    // 从箭头的指向我们可以看出, 这是一个只能取出数据的 channel, 并且取出的是整型数据
    // 可以 <- ch, 但是 ch <- 1 报错
    var ch2 = make(<-chan int, 3)
    
    // 另外 channel 本质上是一个指针,大小固定 8 字节
    fmt.Println(ch1, ch2)  // 0xc0000d0000 0xc0000d0080
    fmt.Println(unsafe.Sizeof(ch1))  // 8
    
    // 像切片、map、interface{}、channel, 只声明不赋值的话, 值默认为 nil
    var ch3 chan int
    fmt.Println(ch3 == nil)  // true
}

如果你希望 channel 在某个方向上受到限制,那么可以使用单向 channel。

channel 的关闭

channel 是可以关闭的,我们看看如何关闭一个 channel:

package main

import "fmt"

func main() {
    var ch = make(chan int, 5)
    // 此时通道里面有 5 个元素,分别是 0 1 2 3 4
    for i:=0; i<5; i++{
        ch <- i
    }
    fmt.Println(<-ch)  // 0
    fmt.Println(<-ch)  // 1
    fmt.Println(<-ch)  // 2
    fmt.Println(<-ch)  // 3
    // 调用 close 函数可以关闭一个 channel
    // 只能关闭一次, 对一个已经 close 的 channel 不能再 close
    close(ch)
    // 那么问题来了, 从一个关闭的 channel 里面获取值会发生什么呢?
    fmt.Println(<-ch)  // 4
    fmt.Println(<-ch)  // 0
    fmt.Println(<-ch)  // 0
    fmt.Println(<-ch)  // 0
    // 首先即使通道关闭, 也不影响读取, 但当通道里面的数据读取完毕之后, 再读就会得到零值
    // 所以问题来了, 当读取的数据是 0 时, 我要如何判断它是往通道里面写入的 0、还是因数据被读取完毕而产生的零值呢
    // 还记得 map 是怎么做的吗? 没错, 使用两个变量接收即可
    val, flag := <-ch
    fmt.Println(val, flag)  // 0 false
    // 如果读取的是往通道里面写入的数据, 那么 flag 是 true
    // 如果读取的是因数据被读取完毕而产生的零值, 那么 flag 是 false
    // 注意:向一个关闭的通道写入值是会报错的
}

此外我们还可以使用 range 关键字去遍历获取 channel 中的值:

package main

import "fmt"

func main() {
    var ch = make(chan int)
    go func() {
        for i:=0; i<5;i++{
            ch <- i
        }
        close(ch)
    }()
    
    for val := range ch{
        fmt.Println(val)
        /*
           0
           1
           2
           3
           4
        */
    }
    // 我们看到当通道关闭之后, range ch 的遍历就结束了
}

使用 range 遍历 channel 时,终止条件是 channel 被关闭。如果通道不关闭,那么 for range 会一直尝试获取。假设我们上面将 close(ch) 注释掉了,那么子 goroutine 结束之后,主 goroutine 还是会尝试从通道中获取值。但是此时程序中只剩下主 goroutine了,因此会出现死锁,所以当 range 遍历一个 channel 时,循环默认的结束条件是通道被关闭。

当然啦,不是说通道关闭了,for range 就立刻结束。等到它把 channel 里面的数据都取走之后,又发现 channel 已关闭,那么才会停止遍历。假设有一个存放任务的管道,生产者往里面写,消费者通过 for range 遍历进行消费。但生产者速度很快,往里面写 100 个之后就将通道关闭了,而消费者才刚遍历到第 20 个。那么 range 不会立即结束,而是将通道剩余的 80 个数据消费完之后才会结束。

因为已关闭的通道无法再写入数据,所以 range 会等到通道关闭并且数据都被取走之后才会结束。

package main

import "fmt"

func main() {
    var ch = make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
    // 关闭通道,此时通道内还有三个元素
    close(ch)
    
    v, ok := <- ch
    fmt.Println(v, ok)  // 1 true
    
    for val := range ch{
        fmt.Println(val)
        /*
           2
           3
        */
    }
}

注意:range 如果遍历的是 channel,那么只会返回一个值,就是通道里面的数据。因为当通道没数据、并且关闭的时候,循环就结束了。

channel 搭配 select 关键字使用

如果有多个 channel,我们可以使用 select 进行监听,那什么是 select 呢?

  • select 可以监听多个 channel 的写入或读取
  • 执行 select, 若有 case 通过(不阻塞), 则执行这个 case 块
  • 若有多个 case 通过, 则随机挑选一个 case 执行
  • 若所有 case 均阻塞, 且定义了 default 块, 则执行 default 块; 没有定义 default 块, 那么 select 语句阻塞, 直到有 case 被唤醒
  • 使用 break 可以跳出 select
package main

import (
    "fmt"
)

func main() {
    var ch1 = make(chan int)
    var ch2 = make(chan string)
    
    go func() {
        for {
            // select 类似于 switch, 一次只会执行一个分支
            select {
            case t := <-ch1:
                fmt.Println(t)
            case t := <-ch2:
                fmt.Println(t)
            }
        }
    }()
    
    
    ch1 <- 123
    ch2 <- "xxx"
    for{}
    /*
       123
       xxx
    */
}

select 语句里面必须监视 channel,当某个分支监视的通道可写或可读时,执行此分支。

我们可以通过 select 的方式,实现一个斐波那契数列。

package main

import (
    "fmt"
)

func main() {
    var valueChan = make(chan int)
    var quitChan = make(chan struct{})
    
    go func() {
        for {
            select {
            case t := <-valueChan:
                fmt.Println(t)
            case <- quitChan:
            
            }
        }
    }()
    
    // 当 valueChan 里面有值时,select 里面的第一个 case 就会将值取出来
    for a, b, n := 1, 1, 1; n < 10; n++{
        a, b = a + b, a
        valueChan <- a
    }
    // 主 goroutine 和子 goroutine 之间是解耦的
    // 可能子 goroutine 没打印完,主 goroutine 就退出了
    // 所以再使用一个 channel 用于退出,当子 goroutine 打印完所有的 value 之后
    // select 的第二个 case 就会满足条件
    quitChan <- struct{}{}
    /*
       2
       3
       5
       8
       13
       21
       34
       55
       89
    */
}

因此当你需要处理多个 channel,哪个 channel 有数据就处理哪个,面对这样的需求可以使用 select 实现。

channel 实现定时器

我们可以用 channel 实现一个定时器的功能,不过需要搭配 time 模块使用:

package main

import (
    "fmt"
    "time"
)

func main() {
    // 两秒钟之后, time.C 里面就会有值
    timer := time.NewTimer(time.Second * 2)
    // 如果没到 2 秒, 那么这里会卡住, 因为 timer.C 是一个 channel, 要 2s 后才会往里面写入值
    // 但需要注意的是: 并不是执行了 <-timer.C 之后才开始写入, 而是当调用 NewTimer 的时候就已经开始了
    // 如果 sleep 两秒后再调用 <-timer.C, 那么会瞬间打印出值, 因为 2s 已经过去了, timer.C 里面已经有值了
    // 这里会打印当前时间
    fmt.Println(<-timer.C) // 2019-08-28 23:20:51.9973729 +0800 CST m=+2.004240601
    
    // 定时器的重置
    // 这里写成 10 s,那么要等到 10 秒之后,time.C 里面才会有值
    timer1 := time.NewTimer(time.Second * 10)
    timer1.Reset(time.Second) // 这里改成一秒
    // 那么 1s 后这里就会打印出值
    fmt.Println(<-timer1.C) // 2019-08-28 23:20:53.0093997 +0800 CST m=+3.016267401
    
    // 定时器的停止
    timer2 := time.NewTimer(time.Second * 10)
    timer2.Stop()
    // fmt.Println(<-timer2.C) 
    // 一旦定时器停止,time2.C 这个通道也就关闭了,如果再试图从 timer2.C 里面读取数据,就会造成死锁。
}

除了 NewTimer 之外,还有一个 NewTicker,只不过前者是只获取一次,而后者可以循环获取。

package main

import (
    "fmt"
    "time"
)

func main() {
    // 除了 Timer,还有一个 Ticker
    // 和 Timer 用法一样,只不过 Ticker 是循环获取,Timer 只获取一次
    ticker := time.NewTicker(time.Second)
    for i := 0; i < 5; i++ {
        fmt.Printf("第%d次循环 %v\n", i+1, (<-ticker.C).Format("2006-1-2 15:4:5"))
        /*
        	第1次循环 2019-8-28 23:26:14
        	第2次循环 2019-8-28 23:26:15
        	第3次循环 2019-8-28 23:26:16
        	第4次循环 2019-8-28 23:26:17
        	第5次循环 2019-8-28 23:26:18
        */
    }
    // 当然定时器也可以停止,但是无法重置,这里不再演示
}

然后 time 里面还有一个 After,它相当于对 NewTimer 进行了一个封装,直接返回一个 channel。

所以基于 time.After 我们很容易实现超时控制,比如某个 channel 如果 30 秒没有数据,那么任务就结束。

func taskDeal(taskChan chan int) {
    select {
    case val := <- taskChan:
        // 执行相关逻辑
        fmt.Println(val)
    // 30 秒没有从 taskChan 收到数据,那么退出  
    case <- time.After(30):
        return
    }
}

所以说 Go 是专注于高并发的语言,不仅仅是它提供了 goroutine,就连并发编程中的一系列痛点,也提供了简便的解决方案。

控制并发数量

有时候我们需要定时执行几百个任务,但是并发数量又不能太高,假设是 3 吧,这个时候就可以通过 channel 控制并发数量。

var limit = make(chan int, 3)

func main() {
    // …………
    for _, w := range work {
        go func() {
            limit <- 1
            w()
            <-limit
        }()
    }
    // …………
}

构建一个缓冲型的 channel,容量为 3。接着遍历任务列表,每个任务启动一个 goroutine 去完成。真正执行任务,访问第三方的动作在 w() 中完成,在执行 w() 之前,先要从 limit 中拿 "许可证",拿到许可证之后,才能执行 w(),并且在执行完任务,要将 "许可证" 归还,这样就可以控制同时运行的 goroutine 数。

注意:这里的limit <- 1 放在 func 内部而不是外部,原因如下:

  • 如果在外层, 就是控制系统 goroutine 的数量, 可能会阻塞 for 循环, 影响业务逻辑
  • limit 其实和逻辑无关, 只是性能调优, 放在内层和外层的语义不太一样

还有一点要注意的是,如果 w() 发生 panic,那 "许可证" 可能就还不回去了,因此需要使用 defer 来保证。

其实在其它语言中,我们一般都会搞一个"池"出来,比如线程池。但在 Go 里面,大可不必搞一个协程池,需要执行任务的时候创建一个协程,任务执行完毕再将协程销毁即可,只需要保证创建的协程数量是可控的即可。因为协程是需要绑定在线程上的,而 Go 的线程已经具备池化的功能了。

channel 的底层结构

上面算是介绍了一下 channel 的相关用法,那么 channel 具体的底层结构是怎样的呢?来看一下。

type hchan struct {
    // channel 里的元素数量
    qcount   uint           
    // channel 的容量
    dataqsiz uint           
    // channel 本质上也是通过数组来存储元素的
    // 而 buf 便指向该数组,当然只针对有缓冲的 channel
    // 注意:这个数组是循环数组,因为元素可以添加、可以取出,因此数组必须要循环使用
    buf      unsafe.Pointer 
    // channel 里面每个元素的大小
    elemsize uint16
    // channel 是否被关闭
    closed   uint32
    // channel 里面元素的类型
    elemtype *_type 
    // 已发送元素在循环数组中的索引
    sendx    uint   
    // 已接收元素在循环数组中的索引
    recvx    uint   
    // 等待接收的 goroutine 组成的链表
    // 当 goroutine 接收元素陷入阻塞时,该 goroutine 就会被添加到该链表中
    recvq    waitq  
    // 等待发送的 goroutine 组成的链表
    sendq    waitq  
    // 互斥锁,保护 hchan 中的所有字段
    // 比如往 channel 里塞数据、从 channel 里读数据是需要加锁的
    lock mutex
}

// 然后是 waitq 这个结构体需要说一下
// 当 goroutine 在发送和接收数据陷入阻塞时,它会被包装成 sudog
// 名字很直观,要等待处理的 g,这些 sudog 会组成一个链表
// waitq 里面记录了链表的第一个元素和最后一个元素
type waitq struct {
    first *sudog
    last  *sudog
}

channel 的结构应该不难理解,我们画一张图。

而使用 make 函数创建一个 channel 时,底层会返回 hchan 结构体的指针,所以我们在传递的时候直接传递 channel 即可。

channel 发送数据的底层原理

如果想往 channel 里面发送数据,直接通过 ch <- val 即可,但这只是一个语法糖,在编译阶段会转成对 runtime.chansend1() 函数的调用。

func chansend1(c *hchan, elem unsafe.Pointer) {
    chansend(c, elem, true, getcallerpc())
}

最终会调用 chansend 方法,该方法比较长,但可以分为三种情况:

  • 直接发送
  • 放入缓存
  • 休眠等待

直接发送

直接发送就是已经有协程处于休眠等待状态,等待从 channel 里面取数据,只是当前没有数据(channel 为空)。而当把数据发送至队列的那一刻,就会立即被取走。

事实上,这种情况下数据不会发往队列,因为已经有协程在等待了,所以会直接将数据交给等待的协程。

  • 从队列中取出一个等待的 G
  • 将数据拷贝过去
  • 唤醒 G

放入缓存

如果在放入数据的时候发现有等待的 G,那么会将数据直接交给它。但如果没有等待的 G,并且此时有缓存空间,那么会直接将数组放入缓存。具体实现也很简单:

  • 获取要将数据放入环形数组中的索引
  • 存入数据
  • 维护索引

休眠等待

如果在放入数据的时候发现有等待的 G,那么会将数据直接交给它。但如果没有等待的 G,并且此时也没有缓存空间,那么该 G 自己(被包装成 sudog 之后)会进入发送队列,休眠等待。

  • 将自己包装成 sudog
  • sudog 进入 sendq 队列
  • 休眠并解锁

channel 接收数据的底层原理

和发送数据不同,从 channel 里面取数据有两种情况:

  • val := <- ch,在编译阶段会转成对 runtime.chanrecv1() 函数的调用。
  • val, ok := <- ch,在编译阶段会转成对 runtime.chanrecv2() 函数的调用。

最终会调用 chanrecv 方法,整个过程可以分为四种情况:

  • 有等待的 G,从 G 接收
  • 有等待的 G,从缓存接收
  • 接收缓存
  • 阻塞接收

有等待的 G,从 G 接收

接收数据前,已经有 G 在休眠等待发送。

  • 判断有 G 在发送队列等待,进入 recv()
  • 判断 channel 是否无缓存,只有当发送队列存在等待的 G 并且 channel 无缓存时,才会从等待的 G 中接收

有等待的 G,从缓存接收

接收数据前,有等待的 G,但是 channel 里面有缓存数据(一般是缓存区已满),那么会从缓存读取。因为一定是缓存区先满,然后 G 发送数据阻塞,所以缓存区的数据有更高的优先级。

  • 判断有 G 在发送队列等待,进入recv()
  • 判断此 channel 有缓存
  • 从缓存取走一个数据
  • 唤醒其它正在阻塞的 G(被唤醒的 G 会发现队列有空闲位置了,于是插入数据并解除阻塞)

接收缓存

没有 G 在休眠等待发送,但是缓存有内容,此时比较简单,直接从缓存取走一个数据即可。

  • 判断没有 G 在发送队列等待
  • 判断此 channel 有缓存
  • 从缓存中取走一个数据

阻塞接收

没有 G 在等待发送,而且没有缓存或缓存为空,此时接收数据的协程会阻塞。

  • 判断没有 G 在发送队列等待
  • 判断此 channel 没有缓存或缓存为空
  • 将自己包装成 sudog 放入等待接收队列,休眠

如果后续有 G 准备发送数据,那么由于当前的 G 已经在准备接收数据了,那么发送数据的 G 会将数据拷贝到当前等待接收数据的 G 中并唤醒,该 G 醒来之后就可以通过 <- 拿到数据了。

所以唤醒是由 G 来做的,某个 G 在接收或发送数据阻塞了,那么需要由其它的 G 来唤醒。

总结一下:

  • 编译阶段,<- 会转成 chanrecv()
  • 有等待的 G 且无缓存时,从 G 接收
  • 有等待的 G 且有缓存时,从缓存接收
  • 无等待的 G 且缓存有数据时,从缓存接收
  • 无等待的 G 且缓存无数据时,阻塞等待(等待别的协程发送数据喂给自己、并把自己唤醒)

小结

  • channel 是一个引用类型,所以在它被初始化之前,它的值是 nil,channel 使用 make 函数进行初始化。不初始化的话,为 nil 的 channel 无论收数据还是发数据都会阻塞。
  • 向已经关闭的 channel 中写入数据会导致 panic,但是可以读取数据。如果关闭的通道中还有数据(在内部的数据还没有全部被取走的情况下,通道被关闭),那么会读取到通道内部的数据,并且返回值的第二个元素为 true。如果关闭的通道中没有数据了,那么会读取到零值,并且返回值的第二个元素为 false。
  • 重复关闭 channel 或者关闭为 nil 的 channel 都会引发 panic。
  • 同时监听多个 channel 或者避免阻塞在 channel 上,可以搭配 select 使用,select 会自动监听多个 channel。
  • channel 还可以搭配定时器使用,实现定时任务的功能。

使用场景大致分为以下五种:

  • 数据交流:当做并发地 buffer 或 queue,解决成产者-消费者问题,多个 goroutine 可以并发地生产和消费数据
  • 数据传递:一个 goroutine 将数据交给另一个 goroutine,相当于把数据的所有权托付出去(即通过通信来共享内存)
  • 信号通知:一个 goroutine 可以将信号(closing、closed、data ready)传递给另一个或者另一组 goroutine
  • 任务编排:可以让一组 goroutine 按照一定顺序并发或者串行的执行,这就是编排的功能
  • 锁:利用 channel 也可以实现锁的机制,通过共享内存来通信便是借助于对共享数据进行加锁实现的,这也是传统的并发编程处理方式。所以从这里可以看出,channel 和 Lock、Cond 等基本的并发原语是有竞争关系的
posted @ 2019-11-20 00:10  古明地盆  阅读(2662)  评论(2编辑  收藏  举报