关于Go中channel的一些小结

995 阅读3分钟

为啥要在go中使用通道

Go语言提倡通过通信共享内存而不是通过共享内存而实现通信;

channel可以让一个goroutine发送值到另一个goroutine

通道像一个队列,遵循先入先出的原则,保证收发数据的顺序。

通道是指定元素类型的,因此声明时也要带上指定的元素类型。

Channel类型

像map一样,通道是一个用make创建的数据结构引用。复制和参数传递到一个函数里的是一个引用,调用者和被调用者都引用同一份数据结构。

channel的零值是nil。

var c chan int	// 声明一个传递整形的通道
fmt.Println(c)	// nil

类型比较

两个同类型的通道可以用==比较, 通道也可以和nil进行比较

var c2 chan int
fmt.Println(c2==c)	// true
fmt.Println(c2==nil)	// true

Channel操作

两种主要的操作:发送(send),接收(receive),统称为通信。send语句从一个goroutine传输一个值到另外一个goroutine。

<-操作符可以代替发送和接受操作。

ch <- 1 // 发送操作
v := <- ch // 带赋值语句的接收操作
<- ch // 丢弃结果的接收操作
v,ok := <- ch // ok参数表示是否已经关闭通道,为true时未关闭, 为false时关闭

还有一种操作是close关闭操作

close(ch)

关闭的通道:

  1. 对一个关闭的通道再发送数据,会直接PANIC。
  2. 对一个关闭但还有值的通道接收数据会一直获取值直到通道为空。
  3. 对一个已经关闭但没有值的通道接收数据会对应类型的零值。
  4. 对一个已经关闭的通道的接收数据操作返回的第二个参数为bool值,为false,未关闭时为true
  5. 重复关闭一个通道会PAINC

关闭一个通道的操作不是必须的,只有在通知接收端所有数据都发送完毕之后才需要关闭通道。

无缓冲通道

无缓冲通道是一个阻塞通道,发送和接收goroutine都会同步化,也叫同步通道。

当在无缓冲通道中发送数据,但接收端还未执行接收操作的话,其他向通道发送数据的操作都会被阻塞,直到从通道接收值。

相反, 如果接收操作先执行,那在还未有发送操作之前, 接收端会被阻塞,直到有发送操作(另一个goroutine)

下面这个程序会在5秒之后结束运行,因为main程序在等待协程对通道ch的发送操作

import (
	"fmt"
	"time"
)

var ch chan struct{}

func send() {
	time.Sleep(5*time.Second)
	ch <- struct{}{}
}

func main() {
	start := time.Now()
	ch = make(chan struct{})
	go send() // 5秒之后会发送一个空结构体

	<-ch
	fmt.Println("持续了:", time.Since(start))
}

假如不开协程去运行会发生什么?

// go send() // 5秒后会发送一个空结构体
send() // 不使用协程运行

出现了死锁错误

fatal error: all goroutines are asleep - deadlock!

单向通道

在程序的发展中,总是(大致上)把大方法拆分成小方法,每个小方法都应尽可能的完成自己任务,因此通道作为函数的形参时也出现了两种不同的单向通道:只读单向通道、只写单向通道。

在通道作为参数传递时,双向通道可以作为单向通道的实参,但反之则不可以。

只读单向通道:

  1. <-chan struct{}, 一个只能从里面接收值的通道,对其进行写操作的话,编译无法通过。

image-20210531165350933

  1. 此外,由于关闭操作close需要确保没有值可以发送时才能关闭,因此关闭一个只读通道也是无法通过编译的。

    func read(readc <-chan int) {
    	fmt.Println(<-readc)
    	close(readc)
    }
    
    // # command-line-arguments
    // .\main.go:7:7: invalid operation: close(readc) (cannot close receive-only channel)
    

只写单向通道:chan<- struct{},一个只能写值到里面,但不能读取值的通道,同样的,若对其进行读操作,编译无法通过。

image-20210531165534041

示例:

func read(readc <-chan int) {
   fmt.Println(<-readc)
}

func write(writec chan<- int) {
   writec <- 1
}

func main() {
   c := make(chan int)
   go write(c)
   
   read(c)
}

缓冲通道

缓冲通道中有一个元素队列,队列的最大长度在创建通道的时候通过make的容量参数设置。

下面将创建一个可以放3个元素的缓冲通道。

c := make(chan struct{}, 3)

缓冲通道与无缓冲通道的区别就是,缓冲通道会带有一定长度的队列作为缓冲区,如上面的3。在发送前3个值时,通道并不会阻塞,而这时再发送第4个值就会阻塞掉,除非有消费端消费任意一个值。假如通道处于非满的状态(有0~2个值)是不会阻塞的,可以任意发送和读取值。

可以使用len()函数获取当前通道有多少个元素。

比较少用的,可以用cap()函数知道当前通道的容量是多少。

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

	c <- struct{}{}
	c <- struct{}{}
	
	fmt.Println(len(c)) // 2
	fmt.Println(cap(c)) // 3
}

循环从通道中取值

当for range一个通道时,这个通道被关闭的话,for循环会直接退出。

func main() {
	c := make(chan int, 5)
	
	for i := 0; i < 6; i++ {
		go func(i int) {
			c <- i
			if i >= 5 {
				close(c)
			}
		}(i)
	}
	
	for i := range c {
		fmt.Println(i)
	}
	
	fmt.Println("for ends")
	for {}
}

Select多道复用

Selectswitch语句类似,有一系列情况和一个默认的可选的分支。

select {
    case <- ch1:
    //...
    case x := <- ch2:
    //...use x...
    case ch3 <- y
    // ...
	default:
    // ...
}

每个分支指定一次通信(在通道上的接收或者发送操作)和一个代码块。

select一直等待,直到一次通信来告知有一些情况可以执行。然后它会进行这次通信并执行相应的语句;其他的通信将不会发生。

如下面的程序,每次循环都会先阻塞在select语句,知道tick事件发生或者abort事件发生

func abortAction(a chan<- struct{}) {
	time.Sleep(5 * time.Second)
	a <- struct{}{}
}

func main() {
	t := time.Tick(time.Second)
	abort := make(chan struct{})
	go abortAction(abort)
	
	for {
		select {
		case v := <-t:
			fmt.Println("tick...:", v)
		case <-abort:
			fmt.Println("abort...:")
			return
		}
	}
}

对于没有对应情况的select(没有case),它将永远等待。

select{}

如果多种情况(case)同时发生,会随机选择一个去执行,以此保证每一个通道有相同的机会被选中。

有时候,我们在通道还未初始化完,但又不想因此而去阻塞它——非阻塞通信。可以用select做到。select有一种默认情况,可以在指定在没有其他的通信发生时进行相应的处理。

像下面的程序,每过一秒会打印一次default,直到abort事件发生。

func abortAction(a chan<- struct{}) {
	time.Sleep(5 * time.Second)
	a <- struct{}{}
}

func main() {
	abort := make(chan struct{})
	go abortAction(abort)
	
	for {
		select {
		case <-abort:
			fmt.Println("abort")
			return
		default:
			fmt.Println("default")
			time.Sleep(1 * time.Second)
		}
	}
}

一些特点(技巧)

  1. 一个已经关闭的通道,可以一直接收值(nil),也就是可以一直触发某个case,可以用来作为一个全局关闭的广播。

  2. nil通道无论是发送操作还是接收操作都是阻塞的,可以放在case分支里做判断,使用场景是指定某些参数例如--verbose为false时创建Nil通道,否则创建普通通道

    var tick <-chan time.Time
    if verbose {
        tick = time.Tick(500*time.Millisecond)
    }
    
    // .......
    select {
    	case <-tick:
            print("something")
        default:
            print("default")
    }
    

本文正在参加技术专题18期-聊聊Go语言框架