Go 语言入门指南:channel | 青训营

194 阅读8分钟

Golang——channel

Go语言中的channel是一种用于协程之间通信的机制。在并发编程中,协程之间的通信是非常重要的,因为它可以使得不同的协程之间协同工作,从而实现更高效的程序执行。本文将详细介绍Go语言中的channel,包括其用法、特性、以及一些实践等。

一、不同的Goroutine之间如何通讯

1.1 全局变量加锁同步

package main

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

var (
	// make(Type, len, cap)
	// len:数据类型实际占用的内存空间长度,map、 channel 是可选参数,slice 是必要参数
	myMap = make(map[int]int, 10)
	// 声明一个全局的互斥锁
	lock sync.Mutex
)

// 编写一个函数来计算各个数的阶乘并放入到map中
// 启动多个协程,map应该做成全局的
func getGactorial(n int) {
	res := 1
	for i := 1; i <= n; i++ {
		res *= i
	}

	// 加锁访问map
	lock.Lock()
	myMap[n] = res
	// 解锁
	lock.Unlock()

}

func main() {

	// 开启多个协程完成这个任务
	for i := 1; i <= 10; i++ {
		go getGactorial(i)
	}
	
	// 休眠5秒等协程都运行完
	time.Sleep(time.Second * 5)

	// 输出map中的计算结果
	lock.Lock()
	for i, v := range myMap {
		fmt.Printf("map[%d]=%d\n", i, v)
	}
	lock.Unlock()
}

1.2 使用channel进行同步(更具有go特色的做法)

  1. channel本质类似于一个队列
  2. 数据是先进先出
  3. 线程安全,多goroutine访问时,不需要加锁,就是说channel本身就是线程安全的
  4. channel是有类型的,一个string的channel只能存放string类型的数据
  5. channel是引用类型
  6. channel必须初始化才能写入数据,即make后才能使用
  7. 向管道中写入数据时不能超过其容量,容量在make的时候会指定
  8. 在没有使用协程的情况下,如果管道数据已经全部取出,再取就会报错deadlock
  9. 在默认情况下,管道是双向的,但也可以声明为只读或只写
// 声明为只写
var chan1 chan<- int
chan1 = make(chan int, 3)
chan1 <- 20
// num := <- chan1 //Error
fmt.Println("chan1 = ", chan1)
// 声明为只读
var chan2 <-chan int
num := <-chan2
fmt.Println("num = ", num)

二、带缓存的channel

2.1 channel的声明:

var 变量名 chan 数据类型

// 管道的使用
package main

import "fmt"

func main() {
	// 创建一个可以存放3个int类型的管道
	var intChan chan int        // 声明管道
	intChan = make(chan int, 3) // 初始化管道(必须),cap是3

	// 向管道写入数据,最多写入三个数据
	intChan <- 10
	num := 216
	intChan <- num
	intChan <- 50

	// 查看管道的长度和容量(cap)
	fmt.Printf("channel len = %d, cap = %d\n", len(intChan), cap(intChan)) // 3, 3

	// 从管道中取数据
	// 先进先出,取出一个数据则len变小,但容量cap不变
	var num1 int
	num1 = <-intChan
	fmt.Println("num1 = ", num1)
	fmt.Printf("channel len = %d, cap = %d\n", len(intChan), cap(intChan)) // 2, 3

	num2 := <-intChan
	num3 := <-intChan
	num4 := <-intChan                                              // 这个会报错
	fmt.Println("num2 = ", num2, "num3 = ", num3, "num4 = ", num4) // deadlock
}

存放任意数据类型的管道(空接口实现,注意类型断言的使用)

// 管道的使用
package main

import "fmt"

type Cat struct {
	Name string
	Age  int
}

func main() {

	// 创建一个可以存放3个任意数据类型的管道(空接口来实现)
	allChan := make(chan interface{}, 3)

	allChan <- 10
	allChan <- "tom"
	cat := Cat{"mimi", 4}
	allChan <- cat

	// 如果我们希望获得管道中的第三个元素,则先将前2个推出
	<-allChan
	<-allChan

	// 从管道中取出猫猫
	newCat := <-allChan

	fmt.Printf("newCat=%T, newCat=%v\n", newCat, newCat) // newcat=main.Cat, newcat={mimi 4}
	// fmt.Printf("newCat.Name=%v", newCat.Name) 编译不通过,因为newcat实际是空接口类型,不能访问属性,需要类型断言!!!
	a := newCat.(Cat)
	fmt.Printf("newCat.Name=%v", a.Name)
}

2.2 channel的关闭

使用内置函数close可以关闭channel,当channel关闭后,就不能再向channel写数据,但是仍然可以从该channel读取数据

2.3 channel的遍历

channel支持for-range的方式进行遍历

  1. 在遍历时,如果channel没有关闭,则会出现deadlock错误
  2. 在遍历时,如果channel已经关闭,则会正常遍历数据,遍历完后,就会退出遍历
	intChan := make(chan int, 3)
	intChan <- 1
	intChan <- 2
	intChan <- 3
	close(intChan) // 关闭channel,之后不能再往其中写,但可以读取

	// 遍历管道用for-range
	for v := range intChan {
		fmt.Println("v = ", v)
    }

三、不带缓存的channel

一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作。

ch = make(chan int)    // unbuffered channel
ch = make(chan int, 0) // unbuffered channel

基于无缓存Channels的发送和接收操作将导致两个goroutine做一次同步操作。因为这个原因,无缓存Channels有时候也被称为同步Channels

四、串联的channels(pipeline)

Channels也可以用于将多个goroutine连接在一起,一个Channel的输出作为下一个Channel的输入。这种串联的Channels就是所谓的管道(pipeline)

例子:

img

第一个goroutine是一个计数器,用于生成0、1、2、……形式的整数序列,然后通过channel将该整数序列发送给第二个goroutine;第二个goroutine是一个求平方的程序,对收到的每个整数求平方,然后将平方后的结果通过第二个channel发送给第三个goroutine;第三个goroutine是一个打印程序,打印收到的每个整数。

当一个channel被关闭后,再向该channel发送数据将导致panic异常。当一个被关闭的channel中已经发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。

没有办法直接测试一个channel是否被关闭,但是接收操作有一个变体形式:它多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从channels接收到值,false表示channels已经被关闭并且里面没有值可接收

// Squarer
go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break // channel was closed and drained
        }
        squares <- x * x
    }
    close(squares)
}()

但是上面这样写很笨,推荐使用for-range循环,它依次从channel接收数据,当channel被关闭并且没有值可接收时跳出循环。

func main() {
    naturals := make(chan int)
    squares := make(chan int)

    // Counter
    go func() {
        for x := 0; x < 100; x++ {
            naturals <- x
        }
        close(naturals)
    }()

    // Squarer
    go func() {
        for x := range naturals {
            squares <- x * x
        }
        close(squares)
    }()

    // Printer (in main goroutine)
    for x := range squares {
        fmt.Println(x)
    }
}

其实并不需要关闭每一个channel。**只有当需要告诉接收者goroutine,所有的数据已经全部发送时才需要关闭channel。**不管一个channel是否被关闭,当它没有被引用时将会被Go语言的垃圾自动回收器回收。(不要将关闭一个打开文件的操作和关闭一个channel操作混淆。对于每个打开的文件,都需要在不使用的时候调用对应的Close方法来关闭文件。)

五、使用select解决从管道取数据的阻塞问题

传统方法在遍历管道时,如果不关闭会阻塞而导致deadlock,当不确定什么时候关闭管道时,可以使用select方法解决

package main

import (
	"fmt"
	"time"
)

func main() {
	intChan := make(chan int, 10)
	for i := 0; i < 10; i++ {
		intChan <- i
	}

	stringChan := make(chan string, 5)
	for i := 0; i < 5; i++ {
		stringChan <- "hello" + fmt.Sprintf("%d", i)
	}

	// label:
	for {
		select {
		case v := <-intChan: // 如果intChan一直没有关闭,不会一直阻塞而deadlock,会自动到下一个case匹配
			fmt.Printf("从intChan读取的数据%d\n", v)
			time.Sleep(time.Second)
		case v := <-stringChan:
			fmt.Printf("从stringChan读取的数据%s\n", v)
			time.Sleep(time.Second)
		default:
			fmt.Printf("都取不到了,不玩了,程序员可以加入逻辑\n")
			time.Sleep(time.Second)
			return
			// break label
		}
	}
}

六、goroutine和channel结合使用案例

利用goroutine和channel协同工作实现下面的要求:

  1. 开启一个writeData协程,向管道intChan中写入50个整数。
  2. 开启一个readData协程,从管道intChan中读取writeData写入的数据。
  3. 注意:writeDatareadData操作的是同一个管道。
  4. 主线程需要等待writeDatareadData协程都完成工作才能退出管道。
package main

import "fmt"

func writeData(intChan chan int) {
	for i := 1; i <= 50; i++ {
		intChan <- i
		fmt.Println("writeData ", i)
	}
	close(intChan) // 写完了关闭管道并不影响读
}

func readData(intChan chan int, exitChan chan bool) {
	for {
		v, ok := <-intChan
		if !ok { // intChan读完才会退出
			break
		}
		fmt.Printf("readData读到数据 = %v\n", v)
	}

	// 读取完数据后,关闭exitChan
	close(exitChan)
}

func main() {

	// 创建两个管道
	intChan := make(chan int, 50)
	exitChan := make(chan bool, 1) // 退出管道,用于主线程检测退出协程完成标志

	go writeData(intChan)
	go readData(intChan, exitChan)

	// 检查exitChan中的协程完成标志来确保协程任务完成后主线程才结束
	for {
		_, ok := <-exitChan
		if !ok {
			break
		}
	}
}
  • 一个channel有发送接受两个主要操作,都是通信行为。

  • 一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine。

  • 发送和接收两个操作都使用<-运算符。

  • 在发送语句中,<-运算符分割channel和要发送的值。

  • 在接收语句中,<-运算符写在channel对象之前。一个不使用接收结果的接收操作也是合法的。