Go指南-Channel使用总结

1,984 阅读6分钟

前言

Goroutine和Channel是Go语言实现并发编程的两大利器,本文是Channel介绍的第一篇,主要介绍:

  • 1.什么是Channel
  • 2.Channel的基本使用
  • 3.Channel使用的注意事项
  • 4.为什么会有单向Channel
  • 5.Channel的使用场景

什么是Channel

Channel是Go语言中比较重要的数据结构,你可以将其看做一条管道,可以往里面写入数据,也可以从里面读取数据。

Channel也是Goroutine之间通信的桥梁,并且是线程安全的,通过和Goroutine搭配,实现了比较著名的CSP模型

Channel遵循先进先出的原则,这依赖其内部的互斥锁,Channel在读取和写入数据前,都会进行加锁,这个会在后面Channel的实现原理中讲到。

Channel的基本使用

// 初始化一条管道,这条管道只能传送 int 类型的数据
ch := make(chan int)

// 初始化一条管道,且带有缓冲区
ch := make(chan int, 3)

// 往channel中写入数据的语法
ch <- v

// 从channel中读取数据的语法:从channel中读取值,并赋给v
v := <-ch

// 遍历channel中的数据
for i := range ch {
	fmt.Println(i)
}

Channel的基本语法非常简单,但是非常容易出现死锁,或者panic,这个会在下面的注意事项中进行详细描述。

Channel的注意事项

前面提到,Channel本身的语法并不难,难的是一不小心产生的死锁,以及panic。这是我对不同状态下的Channel进行读取、写入和关闭之后的一些总结:

场景读取写入关闭
未初始化的channel死锁死锁panic
已初始化的channel(无缓冲)看具体代码看具体代码正常
已初始化的channel(有缓冲)看具体代码看具体代码正常
已关闭的channel正常panicpanic

注:死锁 和 panic 并不太一样,大家可以自行查询。

1.对未初始化的channel进行读写关

  • 1.当channel未初始化,往channel写入数据,会造成死锁(deadlock!)
  • 2.当channel未初始化,往channel读取数据,会造成死锁(deadlock!)
  • 3.当channel为初始化,关闭该channel,会触发panic
// 测试用例
// 往一个未初始化的channel写数据
func unInitTest1()  {
	var ch chan int
	ch <- 1
}

// 从一个未初始化的channel读数据
func unInitTest2()  {
	var ch chan int
	<- ch
}

// 关闭一个未初始化的channel
func unInitTest3()  {
	var ch chan int
	close(ch)
}

2.对已初始化的channel进行读写关

对已初始化的channel进行读写,有可能导致死锁,需要看具体的代码,

1.当channel已初始化,但没带缓冲

// channel容量/缓冲为0,只有写入没有读取,死锁
// fatal error: all goroutines are asleep - deadlock!
func initTest1() {
	c := make(chan int)
	c <- 10
}

// channel容量/缓冲为0,只有读取没有写入,死锁
func initTest2() {
	c := make(chan int)
	data, ok := <-c
	if ok {
		fmt.Println(data)
	}
}

// channel容量/缓冲为0,看似有读取,也有写入,实际第一步的时候已经死锁
func initTest3() {
	c := make(chan int)
	// 第一步
	data, ok := <-c
	if ok {
		fmt.Println(data)
	}
	// 第二步
	c <- 10
}

// 正常,此时的channel相当于一条管道,将写入和读取两端直接连接起来
// 技巧:对于不带缓冲的channel,读取要比写入前执行,且不能阻塞
func initTest4() {
	c := make(chan int)
	go func() {
		// 第一步
		data, ok := <-c
		if ok {
			fmt.Println(data)
		}
	}()
	// 第二步
	c <- 10
}

2.当channel已初始化,但带了缓冲(容量不为0)

// 正常
func initTest5()  {
	c := make(chan int, 1)
	c <- 10
}

// 死锁,因为此时检测到没有"写入的goroutine",所以读取会一直阻塞,所以造成死锁
func initTest6() {
	c := make(chan int, 1)
	data, ok := <-c
	if ok {
		fmt.Println(data)
	}
}

// 正常,此时的channel相当于一条管道,将写入和读取两端直接连接起来
// 技巧:对于带缓冲的channel,写入要比读取先执行,且不能溢出(阻塞)
func initTest7() {
	c := make(chan int, 1)
	c <- 10
	data, ok := <-c
	if ok {
		fmt.Println(data)
	}
}

3.对已经关闭的channel进行读写

  • 1.当channel已经关闭,往channel写入数据,会造成panic (panic: send on closed channel)
  • 2.当channel已经关闭,从channel读取数据,不会造成panic。如果channel中还有未读取的消息,仍然能继续读出;如果没有未读的消息,则会读取到该类型的零值(默认值)。
  • 3.当channel已经关闭,再次关闭channel(重复关闭channel),会造成panic (panic: close of closed channel)。

单向Channel

双向Channel指这个管道既可以发送数据,也可以读取数据;单向Channel指这个管道只能读,或者只能写。

可能有人会疑惑为什么会有单向Channel,毕竟只读、或者只写channel并没有意义。

我一开始也有这种疑惑,后面发现,单向channel的主要作用是避免channel滥用,并且能在编译前发现我们的逻辑错误。

我们以生产者消费者的例子来说明,对于生产者而言,从逻辑上看,或者从它的职责上看,它只会发送数据;而对于消费者而言,它只会接收数据。对于这种场景,我们就可以定义两个Channel,一个是只读,一个是只写,如果只读的Channel被写入数据,或者只写的Channel被用于读取,就可以提前检测出来!

package main

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

// 生产者与消费者
var wg sync.WaitGroup

func main() {
	ch := make(chan int, 100)
	
	go consumer(ch)
	producer(ch)
	wg.Wait()
	fmt.Println("finished!")
}

// 定义一个只写的channel
func producer(sender chan <- int) {
	for i := 1; i < 10; i++ {
		sender <- i
		wg.Add(1)
		time.Sleep(1 * time.Second)
	}
}

// 定义一个只读的channel
func consumer(reader <-chan int) {
	fmt.Println("consumer")
	for {
		data, ok := <-reader
		if !ok {
			fmt.Println("no data")
		}
		fmt.Printf("consumer %d\n", data)
		wg.Done()
	}
}

上面的例子利用了Channel的两个特性:

  • 1.Channel的传参是引用传递的。所以对单向Channel的修改,都会反映到原始的双向Channel上。
  • 2.双向Channel是可以自动转换为单向Channel的。所以一般都是在函数外层声明一个双向Channel,然后在函数参数声明的是一个单向Channel。

Channel的一些使用场景

1.搭配Goroutine实现并发编程

package main

import "fmt"

func sum(s []int, c chan int) {
	sum := 0
	for _, v := range s {
		sum += v
	}
	c <- sum // send sum to c
}

func main() {
	s := []int{7, 2, 8, -9, 4, 0}

	c := make(chan int)
	go sum(s[:len(s)/2], c)
	go sum(s[len(s)/2:], c)
	x, y := <-c, <-c // receive from c

	fmt.Println(x, y, x+y)
}

这是Go Tour中的一个例子,利用Goroutine和Channel实现数组的分批求和

2.搭配Goroutine实现异步、解耦

package main

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

// 生产者与消费者
var wg sync.WaitGroup

var ch = make(chan int, 100)

func main() {
	go consumer()
	producer()
	wg.Wait()
	fmt.Println("finished!")
}

func producer() {
	for i := 1; i < 10; i++ {
		ch <- i
		wg.Add(1)
		time.Sleep(1 * time.Second)
	}
}

func consumer() {
	fmt.Println("consumer")
	for {
		data, ok := <-ch
		if !ok {
			fmt.Println("no data")
		}
		fmt.Printf("consumer %d\n", data)
		wg.Done()
	}
}

上面的例子是一个典型的生产者消费者的例子,通过Channel实现生产者和消费者的解耦,这种思想也常用于日志的写入。

3.使用Channel做锁
使用Channel做锁,主要利用了Channel的缓冲区已满,继续写入数据会阻塞;以及缓冲区为空,继续读取数据会阻塞的特性。