Go数据结构-channel


Go数据结构-channel

参考: Go语言底层原理剖析 - 第16章 通道于协程间通信



1. channel是干啥的

Go语言之父Rob Pike有句名言:“Don’t communicate by sharing memory, share memory by communicating.”,即“不要通过共享内存来通信,要通过通信来共享内存。”。

Quotes by Rob Pike

通过共享内存来通信比较简单的方法就是,在内存中开辟一块空间,多个线程或者进程可以同时访问这块空间,直觉上会觉得这种方式非常方便,但是也一定会出现数据冲突。为了解决数据冲突,人们又发明了各种锁,而并发性能也会因此下降。

通过通信来实现进程/线程/协程之间的交互给出了一个新的解决方案,其思想主要是:先提供一个或多个高性能队列,线程/进程/协程需要访问别人时,不能直接读写别人的数据,而要通过队列提出请求,然后在对方处理请求时再做相应处理。Go语言给这种方案提供了一个利器:channel!

channel即通道,是Go语言中进行协程间通信的数据结构,是进行并发编程的利器,也是Go语言遵循CSP并发编程模式的结果,这种模型最重要的思想是通过通道来传递消息。




2. channel如何使用

2.1 声明与初始化

2.1.1 声明

var name chan T
// name: channel的名称
// chan T: channel的类型

声明时可以用箭头 <- 限制通道的读写:

  1. chan int 表示通道可以读写int;
  2. chan<- int 表示通道只能写入int;
  3. <-chan int 表示通道只能读取int;

一个还未初始化的通道会被预置为nil初始化的通道会被预置为nil,无法向通道中写入或读取任何数据。要对通道进行操作,需要使用make操作符,make会初始化通道,在内存中分配通道的空间。

2.1.2 初始化

var c = make(chan int) // 无缓冲区
var c = make(chan int, 10) // 有缓冲区

初始化的通道可以设置其缓冲区的长度,默认为无缓冲区。


2.2 读写数据

Go语言设计者将箭头 <- 作为操作符进行通道的读取和写入。

2.2.1 读写无缓冲区通道

对于无缓冲通道,能够向通道写入数据的前提是必须有另一个协程在读取通道。否则,当前的协程会陷入休眠状态,最终陷入死锁。

package main

func main() {
    c := make(chan int)
    c <- 100 // or <- c
}

// fatal error: all goroutines are asleep - deadlock!

有读有写

读取通道有两种返回值的形式,借助编译时将该形式转换为不同的处理函数。第1个返回值仍然为通道读取到的数据,第2个返回值为布尔类型,返回值为false代表当前通道已经关闭。

package main

import (
    "fmt"
    "time"
)

func sender(c chan int) {
    data, ok := <-c
    fmt.Println(data, ok)
}

func main() {
    c := make(chan int)
    go sender(c)
    c <- 100
    time.Sleep(1 * time.Second) // hold住main协程
}

// 100 true

2.2.2 读写有缓冲区通道

对有缓存区的通道读/写数据,如果缓存区为空/缓存区满了,才会发生死锁。

package main

func main() {
    c := make(chan int, 2)
    // <-c
  c <- 100
    c <- 100
    c <- 100
}

2.3 通道关闭

package main

func main() {
    c := make(chan int, 10)
    c <- 100
    close(c)
    c <- 100 // panic: send on closed channel
    <-c // 正常
}

在正常读取的情况下,通道返回的ok为true。通道在关闭时仍然会返回,但是data为其类型的零值,ok也变为了false。和通道读取不同的是,不能向已经关闭的通道中写入数据。

试图重复关闭一个channel将导致panic异常,试图关闭一个nil值的channel也将导致panic异常。

有两个协程正在等待通道中的数据,当main协程关闭通道后,两个协程都会收到通知。

package main

import (
    "fmt"
    "time"
)

func main() {
    c := make(chan int)
    go func() {
        data, ok := <-c
        fmt.Println("goroutine one:", data, ok)
    }()
    go func() {
        data, ok := <-c
        fmt.Println("goroutine two:", data, ok)
    }()
    close(c)
    time.Sleep(1 * time.Second)
}

// goroutine two: 0 false
// goroutine one: 0 false



3. select多路复用

在实践中使用通道时,更多的时候会与select结合,因为时常会出现多个通道与多个协程进行通信的情况,我们当然不希望由于一个通道的读写陷入堵塞,影响其他通道的正常读写。select正是为了解决这一问题诞生的,select赋予了Go语言更加强大的功能。在使用方法上,select的语法类似switch,形式如下:

select {
case <-ch1:
  // ...
case <-ch2:
  // ...
default:
  // ...
}

和switch不同的是,每个case语句都必须对应通道的读写操作。select语句会陷入堵塞,直到一个或多个通道能够正常读写才恢复。

需要注意的是,select在使用时有几个特性:

  1. select随机选择机制
    当多个通道同时准备好执行读写操作时,select会选择哪一个case执行呢?答案是具有一定的随机性。
  2. select堵塞与控制
    如果select中没有任何的通道准备好,那么当前select所在的协程会永远陷入等待,直到有一个case中的通道准备好为止。在实践中,为了避免这种情况发生,有时会加上default分支。default分支的作用是当所有的通道都陷入堵塞时,正常执行default分支。
  3. 循环select
    很多时候,我们不希望select执行完一个分支就退出,而是循环往复执行select中的内容,因此需要将for与select进行组合。
  4. select与nil
    一个为nil的通道,不管是读取还是写入都将陷入堵塞状态。当select语句的case对nil通道进行操作时,case分支将永远得不到执行。



4. channel底层原理

4.1 hchan数据结构

通道在运行时是一个hchan结构体,存储了数据列表,等待读取和发送的协程列表等字段。

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

对于有缓存的通道,存储在buf中的数据虽然是线性的数组,但是用数组和序号recvx、recvq模拟了一个环形队列。recvx可以找到从buf哪个位置获取通道中的元素,而sendx能够找到写入时放入buf的位置,而sendx能够找到写入时放入buf的位置。

buf

当到达循环队列的末尾时,sendx会置为0,以防止其下一次写入0号位置,开始循环利用空间。这同样意味着,当前的通道中只能放入指定大小的数据。当通道中的数据满了后,再次写入数据将陷入等待,直到第0号位置被取出后,才能继续写入。


4.2 初始化步骤

初始化即给通道分配所需的内存:

  1. 当分配大小为0时,只需要在内存中分配hchan结构体的大小即可;
  2. 当通道的元素中不包含指针时,连续分配hchan结构体大小+size元素大小;
  3. 当通道的元素中包含指针时,需要单独分配内存空间,因为当元素中包含指针时,需要单独分配空间才能正常进行垃圾回收。
通道初始化

4.3 通道写入

通道写入

4.4 通道读取

通道读取


文章作者: xuxiangfei
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 xuxiangfei !
  目录