Go常见数据结构的实现原理——channel

一、管道

管道是Go在语言层面提供的协程间的通信方式。

(一)初始化

声明和初始化管道的方式主要有以下几种:

  • 变量声明
  • 使用内置函数 make()。

1、变量声明

	var ch chan int 	//声明管道 

这种方式声明的管道,值为nil。每个管道只能存储一种类型数据。

2、使用内置函数 make()

使用内置函数可以创建为无缓冲管道和带缓冲管道

	ch1 := make(chan string)		//无缓冲管道
	ch2 := make(chan string, 5) 	//带缓冲通道 

3、操作符

操作符 < - 表示数据流向,管道在左表示向管道写入数据,管道在左表示向管道写入数据,管道在右表示从管道读出数据

	ch := make(chan int,10)
	ch <- 1		// 数据流入管道
	d := <-ch	// 数据流出管道
	fmt.Println(d)

默认的管道为双向可读写,管道在函数间传递时可以使用操作符限制管道读写

	func ChanParamRW(ch chan int) {
		// 管道可读写
	}

	func ChanParamR(ch <-chan int) {
		// 只能从管道读取数据
	}

	func ChanParamW(ch chan<- int) {
		// 只能向管道写入数据
	}

4、数据读写

下面以读取数据为例:

  • 管道没有缓冲区时,从管道读取数据会阻塞,直到有协程向管道中写入数据。
  • 管道有缓冲区时,从管道读取数据,如果缓冲区没有数据也会阻塞,直到有协程写入数据。

对于值为nil的通道,无论读写都会阻塞,而且是永久阻塞。使用内置函数close() 可以关闭管道,尝试向关闭的管道写入数据会触发panic,但关闭的管道仍可读。

管道读取表达式最多可以给两个变量赋值:

	v1 := <-ch
	x,ok := <-ch

第一个变量表示读出的数据,第二个变量(bool 类型) 表示是否成功读取了数据(不表示管道的关闭状态)。

一个已关闭的管道有两种情况:

  • 管道缓冲区已经没有数据。(此时ok = false)
  • 管道缓冲区中还有数据。(此时ok = true)

5、小结

内置函数 len() 和 cap() 作用于管道,分别用于查询缓冲区中数据个数及缓冲区大小。管道实现了一种FIFO(先入先出)的队列,数据总是按照写入的顺序流出管道。

协程读取管道时,阻塞的条件有:

  • 管道无缓冲区
  • 管道缓冲区无数据
  • 管道为nil

协程写入管道时,阻塞的条件有:

  • 管道无缓冲区
  • 管道缓冲区已满
  • 管道为nil

(二)实现原理

1、数据结构

管道数据结构源码位于源码包src/runtime/chan.go文件中

type hchan struct {
	qcount   uint           // 当前队列中剩余元素个数
	dataqsiz uint           // 环形队列长度,即可以存放的元素个数
	buf      unsafe.Pointer // 环形队列指针
	elemsize uint16			// 每个元素的大小
	closed   uint32			// 标识关闭状态
	elemtype *_type // 元素类型
	sendx    uint   // 队列下标,指示元素写入时存放到队列中的位置
	recvx    uint   // 队列下标,指示下一个被读取元素在队列中的位置
	recvq    waitq  // 等待读消息的协程队列
	sendq    waitq  // 等待写消息的协程队列
	lock mutex		// 互斥锁,chan不允许并发读写
}
(1)环形队列

chan内部实现了一个环形队列作为其缓冲区,队列的容量是创建chan时指定的。

Go常见数据结构的实现原理——channel_第1张图片

(2)等待队列

从管道读取数据时,如果管道缓冲区为空或者没有缓冲区,则当前协程会被阻塞,并被加入recvq队列。向管道写数据时,如果管道缓冲区为空或者没有缓冲区,则当前协程会被阻塞,并被加入sendq队列。

(3)类型信息

一个管道只能传递一种类型的值,类型信息存储在hchan数据结构中

  • elemtype 代表类型,用于数据传递过程中赋值
  • elemsize 代表类型大小,用于在buf中定位元素位置
(4)互斥锁

一个管道同时仅允许被一个协程读写。

2、 管道操作

(1)创建管道

创建管道的过程实际上时初始化hchan结构,其中类型信息和缓冲区长度由内置函数make()指定,buf的大小由元素大小和缓冲区长度共同决定。

(2)向管道写数据

向一个管道中写数据的过程如下:

  • 如果缓冲区中由空余位置,则将数据写入缓冲区,结束发送过程。
  • 如果缓冲区中没有空余位置,则将当前协程加入sendq队列,进入睡眠并等待被读协程唤醒。
  • 当接收队列recvq不为空时,说明缓冲区没有数据但有协程在等待数据,此时会把数据直接传递给recvq队列中的第一个协程,而不必再写入缓冲区。
(3)从管道读数据

从一个管道读数据的简单过程如下:

  • 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程。
  • 如果缓冲区中没有数据,则当前协程加入recvq队列,进入睡眠并等待被写协程唤醒。
  • 当接收队列sendq不为空,且没有缓冲区,那么此时将直接从sendq队列的第一个协程中获取数据
(4)关闭通道

关闭通道时会把recvq中的协程全部唤醒,这些写成获取的数据都为对应类型的零值。同时还会把sendq队列中的协程全部唤醒,但这些协程会触发painc。

除此之外,其他会触发panic的操作的还有:

  • 关闭值为nil的管道
  • 关闭已经被关闭的管道。
  • 向已经关闭的管道写入数据。

Go常见数据结构的实现原理——channel_第2张图片

3、常见用法

(1)单向管道

由管道的数据结构我们知道,实际上并没有单向管道。单向管道只是对管道的一种使用限制。
Go常见数据结构的实现原理——channel_第3张图片
cha 是一个正常的管道,而ChanParamR()参数限制了传入的管道只能用来读,ChanParamW()参数限制了传入的管道只能用来写。

(2)select

使用select可以监控多个管道,当其中某一个管道可操作时就会触发相应的case分支。select的case语句读挂电脑不会被阻塞,尽管管道中没有数据。这是由于case语句编译后调用管道时会明确传入不阻塞的参数,读不到数据时不会将当前协程加入等待队列,而是直接返回。

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan int, 1)
	ch2 := make(chan int, 1)
	go ChanParamW(ch1)
	go ChanParamW(ch2)
	for {
		select {
		case e := <-ch1:
			fmt.Println("ch1: ", e)
		case e := <-ch2:
			fmt.Println("ch2: ", e)
		default:
			fmt.Println("通道 ch1 和 ch2 中无元素")
			time.Sleep(1 * time.Second)
		}
	}
}

func ChanParamW(ch chan<- int) {
	for {
		ch <- 1
		time.Sleep(1 * time.Second)
	}
}

输出结果如下:
Go常见数据结构的实现原理——channel_第4张图片

(3)for-range

通过for-range可以持续从管道中读出数据,当管道中没有数据时会阻塞当前协程。

func chanRange(chanName chan int) {
	for e := range chanName {
		fmt.Println(e)
	}
}

你可能感兴趣的:(golang)