浅谈 Go 的 channel

212 阅读7分钟

文章被同步发布于个人博客,感兴趣的读者可以前往博客阅读。

前言

在Go中,有着这样一种数据类型,它为多个协程之间的通讯提供了一种便捷的通道。它就是Channel类型。Channel 是 Go 中的一个核心类型,你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯。

底层的实现

在Go中,channel的底层实现是一个结构体(源代码位于src/runtime/chan.go):

type hchan struct {
   qcount   uint           // total data in the queue
   dataqsiz uint           // size of the circular queue
   buf      unsafe.Pointer // points to an array of dataqsiz elements
   elemsize uint16
   closed   uint32
   elemtype *_type // element type
   sendx    uint   // send index
   recvx    uint   // receive index
   recvq    waitq  // list of recv waiters
   sendq    waitq  // list of send waiters

   // lock protects all fields in hchan, as well as several
   // fields in sudogs blocked on this channel.
   //
   // Do not change another G's status while holding this lock
   // (in particular, do not ready a G), as this can deadlock
   // with stack shrinking.
   lock mutex
}

hchan的字段解释如下:

  • qcount uint : 当前队列中剩余元素个数
  • dataqsiz uint : 环形队列长度,即缓冲区的大小,即make(chan T,N),N.
  • buf unsafe.Pointer :环形队列指针
  • elemsize uint16 : 每个元素的大小
  • closed uint32 : 表示当前通道是否处于关闭状态。创建通道后,该字段设置为0,即通道打开; 通过调用close将其设置为1,通道关闭。
  • elemtype *_type : 元素类型,用于数据传递过程中的赋值; sendx uint和recvx uint是环形缓冲区的状态字段,它指示缓冲区的当前索引 - 支持数组,它可以从中发送数据和接收数据。
  • recvq waitq : 等待读消息的goroutine队列
  • sendq waitq : 等待写消息的goroutine队列
  • lock mutex : 互斥锁,为每个读写操作锁定通道,因为发送和接收必须是互斥操作。

waitq的定义是一个双向链表:

type waitq struct {
    first *sudog
    last  *sudog
}

这里存储了所有等待中的goroutine队列。

sudog是goroutine的一层封装,它是这样书写的(源代码在src/runtime/runtime2.go):

type sudog struct {
	// The following fields are protected by the hchan.lock of the
	// channel this sudog is blocking on. shrinkstack depends on
	// this for sudogs involved in channel ops.

	g *g

	next *sudog
	prev *sudog
	elem unsafe.Pointer // data element (may point to stack)

	// The following fields are never accessed concurrently.
	// For channels, waitlink is only accessed by g.
	// For semaphores, all fields (including the ones above)
	// are only accessed when holding a semaRoot lock.

	acquiretime int64
	releasetime int64
	ticket      uint32

	// isSelect indicates g is participating in a select, so
	// g.selectDone must be CAS'd to win the wake-up race.
	isSelect bool

	// success indicates whether communication over channel c
	// succeeded. It is true if the goroutine was awoken because a
	// value was delivered over channel c, and false if awoken
	// because c was closed.
	success bool

	parent   *sudog // semaRoot binary tree
	waitlink *sudog // g.waiting list or semaRoot
	waittail *sudog // semaRoot
	c        *hchan // channel
}

此处的g就是goroutine,而elem便是数据存放区域的指针,在这片区域内存储的数据便是channel用来读取或发送的。

而整个Channel的图例大概为这样:(笔者是网上找的图片,下面把hchan拼错了)

img

创建chan

和map、slice一样,chan 在使用前是需要用make函数开辟新的内存空间的:

c := make(chan <type>,[cap])
  • <type> 表示chan的传输类型;
  • [cap]表示分配给chan的缓冲区大小,可以忽略。

而make函数会调用在runtime包内的makeChan函数:

const (
    maxAlign  = 8
    hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))&(maxAlign-1)) 
)

func makechan(t *chantype, size int) *hchan {
    elem := t.elem

    // 略去检查代码
    ... 
    //计算需要分配的buf空间
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
        panic(plainError("makechan: size out of range"))
    }

    var c *hchan
    switch {
    case mem == 0:
        // chan的size或者元素的size是0,不必创建buf
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        // Race detector 
        c.buf = c.raceaddr()
    case elem.ptrdata == 0:
        // 元素不是指针,分配一块连续的内存给hchan数据结构和buf
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        // 表示hchan后面在内存里紧跟着就是buf
        c.buf = add(unsafe.Pointer(c), hchanSize)
    default:
        // 元素包含指针,那么单独分配buf
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)
    }

    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)

    return c
}

缓冲区

前文我们说过,在创建channel时我们可以去给它一块缓冲区。根据缓冲区,我们可以将chan分为无缓冲区的channel和有缓冲区的channel。

  • 无缓冲区的channel:

    对无缓冲区的 channel 发送 / 接收数据时,无缓冲区的 channel 会发送阻塞。当程序中只有对该 channel 发送或接收数据操作时,该程序会发生死锁。因此在程序中使用无缓冲区的 channel 时,必须同时存在发送和接收操作 (可以在 goroutine 中实现), 所以无缓冲区的 channel 又被称为同步 channel。

  • 有缓冲区的chan:

    有缓冲区的 channel 类似于一个队列。当缓冲区未满时,向 channel 中发送数据不会阻塞。当缓冲区满时,发送数据操作将被阻塞,直到有其它 goroutine 从中读取消息。

数据的交互:

在Go中,我们可以使用<-->操作符来向某个channel发送/接受数据。而数据的去向则根据箭头的方向走。

c := make(chan int)

//向chan发送一个int
c <- 5
//从chan内读取一个int
b := <- c

那,如果chan里面没有东西或者塞满了呢?

阻塞

前面我们说过,channel是配合协程去使用的。

当某个协程向channel内发送数据时,若channel满了,则该协程会先挂起,等到channel内部的数据有空位时再执行发送操作;同样的,当某个协程从channel内读取数据时,若channel空了也会触发挂起操作,直到channel的内部有数据了再读取执行后续操作。例如下面的代码:

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan int, 3)

	c <- 1
	c <- 2
	c <- 3

	go func() {
		fmt.Println("我在尝试发送数据")
		c <- 4
		fmt.Println("我发完了")
	}()

	go func() {
		fmt.Println("我在尝试读取数据")
		_ = <-c
		fmt.Println("我读完了")
	}()

	time.Sleep(5 * time.Second)

}

输出结果:

我在尝试发送数据
我在尝试读取数据
我读完了
我发完了

可以观察到,发送的协程会先挂起,直到读取的协程读出一个数字时才会继续执行发送操作。

关闭

go 提供了内置的 close 函数对 channel 进行关闭操作。

ch := make(chan int)
close(ch)

有关 channel 的关闭,有如下注意事项:

  • 关闭未初始化的 channel (nil) 会产生 panic
  • 关闭已关闭的 channel 会产生 panic
  • 向已关闭的 channel 中发送数据会产生 panic
  • 从一个已关闭的 channel 中读取消息不会产生 panic 和阻塞。如果已关闭的 channel 的缓冲区还有数据,则可以正常读取,并返回值为 true 的 ok-idiom. 否则,返回 channel 的默认值和 false 的 ok-idiom.
  • 要将已关闭的 channel 中的数据全部读取出来,可以使用 for-range 方式进行读取。遍历结束后,channel 缓冲区数据为空。此时再进行读取,会返回 channel 的默认值和 false 的 ok-idiom。
import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 1
    close(ch)
    for value := range ch {
        fmt.Println(value)
    }
    val, ok := <-ch
    fmt.Println(val, ok)
}

select

select的用法和switch相似,但它可以用来监听channel的IO操作:

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

在select的使用中注意两个要点:

  1. 如果没有default分支,select会阻塞在多个channel上,对多个channel的读/写事件进行监控。
  2. 如果有一个或多个IO操作可以完成,则Go运行时系统会随机的选择一个执行,否则的话,如果有default分支,则执行default分支语句,如果连default都没有,则select语句会一直阻塞,直到至少有一个IO操作可以进行。   

实例

1. 阻塞主routine

package main

import (
	"fmt"
	"time"
)

func main() {
	c := make(chan int) // Allocate a channel.

	// Start the sort in a goroutine; when it completes, signal on the channel.
	go func() {
		fmt.Println("Please wait for a while......")
		
		for i := 0; i < 5; i++  {
			fmt.Print(".")
			time.Sleep(time.Second)
		}
		c <- 1 // Send a signal; value does not matter.
	}()
	
	<-c

	fmt.Println("Done")

}

由于主routine需要读取来自c的数据,因此主routine会触发阻塞,直到子routine等待5秒后发送一个信号,主routine才会继续运行。

2. 配合select

select 可以同时监听多个 channel 的消息状态.

当其中一个 case 语句非阻塞,则执行对应 case 语句中的内容。若有多个 case 语句非阻塞,则随机挑选一个 case 语句中的内容执行.

若所有 case 语句均处于阻塞状态且定义了 default 语句,则执行 default 语句中的内容。否则,一直阻塞

import (
    "fmt"
    "sync"
)
func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    ach, bch := make(chan int), make(chan int)

    // 消费者 goroutine
    go func(wg *sync.WaitGroup, a, b <-chan int) {
        defer wg.Done()
        var (
            name string
            x    int
            ok   bool
        )
        for {
            select {
            case x, ok = <-a:
                name = "a"
            case x, ok = <-b:
                name = "b"
            }
            if !ok {
                // 如果没有数据发送,则跳出循环
                return
            }
            fmt.Println(name, x)
        }
    }(&wg, ach, bch)

    // 生产者 goroutine
    go func(wg *sync.WaitGroup, a, b chan<- int) {
        defer wg.Done()
        defer close(a)
        defer close(b)
        for i := 0; i < 10; i++ {
            select {
            case a <- i:
            case b <- i * 10:
            }
        }
    }(&wg, ach, bch)

    wg.Wait()
}

上述代码,分别创建了生产者和消费者的 goroutine, 生产者会随机从 a b channel 中随机挑选一个发送消息,而消费者使用一个 for 循环来监控 a b channel, 当 a b 其中一个接收到数据时,则指定对应内容。如果没有数据,则跳出循环,结束 goroutine。