设计思想
Go 语言的并发模型是 CSP(Communicating Sequential Processes,通信顺序进程),提倡通过通信共享内存而不是通过共享内存而实现通信。
如果说 goroutine 是 Go 程序并发的执行体,channel 就是它们之间的连接。channel 是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。
channel 所体现出来的 golang 并发思想:
Do not communicate by sharing memory; instead, share memory by communicating.
不要使用内存共享来实现进程间通信,而是让进程间通信基于内存共享。如果直接共享内存,灵活度太高;而进程间通信则是一种有约束的通信。
共享内存加互斥锁是 C++等其他语言采用的并发线程交换数据的方式,在高并发的场景下有时候难以正确的使用,特别是在超大型、巨型的程序中,容易带来难以察觉的隐藏的问题。Go 语言引入 channel 以先进先出(FIFO)将资源分配给等待时间最长的 goroutine,尽量消除数据竞争,让程序以尽可能顺序一致的方式运行。
Golang channel 是一种并发原语,用于在不同 Goroutine 之间进行通信和同步。
本质上,channel 是一种类型安全的 FIFO 队列,它可以实现多个 Goroutine 之间的同步和通信。
channel 是一种引用类型,即使是在不同的 Goroutine 之间传递 channel 时,它们仍然指向相同的底层数据结构。
三个队列
channel 的实现就是锁+三个队列。
channel 本质上是由三个 FIFO 队列组成的用于协程之间传递数据的通道;FIFO 的设计是为了保障公平,让等待时间最长的协程最有资格先获得执行。
三个 FIFO 队列分别是:
- buf,循环队列。golang 限制最多容纳 65536 个元素。
- sendq,发送者队列,生产者队列。用来存放等待发送数据到 channel 的 goroutine 的双向链表。
- recvq,接受者队列,消费者队列。用来存放等待读取数据的 goroutine 的双向链表。
sendq 和 recvq 不限大小。buf 是一个固定大小的循环队列,一旦放满了,在往里面放就会放到 sendq 队列里面;一旦 buf 空了,再想从 channel 里面读取,就会放到 recvq 里面。
channel 的性能跟 sync.Mutex 差不多,golang 官方更推荐使用 channel 进行并发协程之间的数据交互,而不是 sync.Mutex+内存变量的方式。原因是 channel 的设计理念能够让程序变得简单。sync.Mutex+内存变量的方式不容易维护。
当生产者生产消息时,如果 buf 满,则把生产者协程放入 sendq;当消费者消费消息时,如果 buf 空,则把消费者协程放入 recvq。
当 channel 关闭时,recvq 中的协程全部被唤醒,并且读到一个空值;sendq 中的协程因为 channel 的关闭后写入而 panic。
关闭一个已经关闭的 channel 会导致 panic。
channel 的几种操作
创建 channel
ch:=make(chan string ,1)
ch:=make(chan string)
var ch chan string
//读取
<-chan
//写入
chan<-x
// 关闭
close(chan)
// 获取channel的长度
len(chan)
//获取channel的容量
cap(chan)
//基于select的非阻塞访问方式,select是非阻塞的,同时监视多个channel,如果有default,则default会不停地执行。如果channel关闭,则会不停地从channel中得到0值。
select {
case <-x:xxxx
case <-y:yyyyy
}
//使用for循环
for x:=range ch{
}
//使用ok在读channel的同时,判断channel是否关闭。读已经关闭的channel得到0值。
if v, ok := <- ch; ok {
fmt.Println(v)
}
channel 的分类
按照有无缓冲分为有缓冲 channel 和无缓冲 channel
channel 分为无缓冲 channel 和有缓冲 channel。
// 无缓冲 channel,下面两句话是等价的
ch:=make(chan T)
ch:=make(chan T,0)
// 有缓冲channel
ch:=make(chan T,100)
无缓冲 channel,只有当读协程的读操作和写协程的写操作同时进行时,才能够写入和读出,否则会发生读阻塞或者写阻塞。
有缓冲 channel,当缓冲中有数据时,读不会阻塞;当缓冲中数据满时,写阻塞才会发生。
按照读写分为三种
- 可读可写
chan T
- 只读 channel
chan<-T
- 只写 channel
<-chan T
channel 知识点
1.传入 channel 的值是原来的备份,从 channel 中取出来的值也是通道中值的备份
2.如果想通过 channel 传输同一个值,那么可以传递这个值的指针
3.如果关闭 channel 要从发送端关闭,如果从接收端关闭会引发恐慌
4.发送端关闭通道不会影响接收端接收
5.带缓冲区和不带缓冲区的 channel 区别就是长度是否为 0,不带缓冲区的 channel 的长度就是 0
6.操作未被初始化的通道会造成永久阻塞
什么是 channel?
chan 是 Go 中的一种特殊类型,不同的协程可以通过 channel 来进行数据交互。
channel 分为有缓冲区与无缓冲区两种
channel 底层?
channel 是基于环形队列实现的。
有缓冲和无缓冲 channel 的区别
无缓冲的 channel,向 channel 中写入会阻塞,直到有人从 channel 中读数据为止。如下代码,”song goroutine“这句话会在 3 秒之后打印。
//此处使用make 而不是直接var message cha string
message := make(chan string)
go func() {
//此处发生读阻塞
message <- "ping"
fmt.Println("son goroutine")
}()
time.Sleep(time.Second * 3)
msg := <-message //从管道中读取信息
fmt.Println(msg)
使用 make+长度创建有缓冲的 channel,向 channel 中写入数据可以立即完成。
//此处使用make 而不是直接chan
message := make(chan string, 1)
go func() {
//此处不会发生读阻塞,因为message是有缓冲的
message <- "ping"
fmt.Println("son goroutine")
}()
time.Sleep(time.Second * 3)
msg := <-message //从管道中读取信息
fmt.Println(msg)
与 channel 有关的一个线上 bug
for-select 如果所有的 case 的 channel 都关闭了会怎样?
字符串转成 byte 数组,会发生内存拷贝吗?
不会。
拷贝大切片一定比小切片代价大吗?
golang 的 map 底层是什么数据结构?它的扩容机制是什么(扩容的时机,扩容的算法)?
零切片、空切片、nil 切片是什么?
数组和切片有什么区别?
线程安全的 map 怎么实现?
方式一:使用 sync.Map,缺点是类型会变成 interface
方式二:使用 sync.Mutex+map,缺点是写起来麻烦。
能说说 uintptr 和 unsafe.Pointer 的区别吗?
只读通道和只写通道
只读通道的定义: <-chan type
只写通道的定义:chan<-type
如何区分:看 chan 和 <-
的相对位置
time.Ticker 就是持有一个只读通道,其底层还是一个 channel,只不过暴露给开发者的是一个只读通道。
channel 的状态以及读写情况
操作 | nil 的 channel | 正常 channel | 已关闭 channel |
---|---|---|---|
<- ch | 阻塞 | 成功或阻塞 | 读到零值 |
ch <- | 阻塞 | 成功或阻塞 | panic |
close(ch) | panic | 成功 | panic |
golang 的 channel 容易出错的地方:
-
多次 close 导致 panic。解决方式:一是设计上保持只在一处关闭;二是使用 safeClose,sync.Once 保证只执行一次
-
不能向已经关闭的 channel 写入,否则 panic。解决方式:使用 select+全局的 ctx.Done
-
使用无 buffer 的 channel 时,协程写入的时候就会阻塞,如果一直没有人读,则这个协程就会一直挂起。协程太多会导致 Golang 内存不足
Golang 为什么没有提供判断协程是否 close 的函数?
如果提供了这样的函数,可能依旧存在多个协程判为 false,然后去执行 close,多次 close panic。
或者存在判断的时候为 false,所以写入,结果写入的时候已经关闭了。简单来说,判断 close 函数根本不准确。
实现原理
channel 是一个结构体,运行时使用 runtime.hchan(go 1.19 runtime/chan.go)结构体表示。
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
}
字段的具体含义如下:
qcount:表示当前队列中元素的个数。 dataqsiz:表示队列中元素的个数上限,即缓冲区大小。 buf:指向缓存的环形数组,存储实际元素的值。由于 channel 内的元素在堆上分配,因此 buf 是一个 unsafe.Pointer 类型的指针。 elemsize:表示单个元素的大小,以字节为单位。 closed:表示 channel 是否已关闭,0 表示未关闭,1 表示已关闭。 recvx:表示下一个被接收的元素在 buf 中的位置。 sendx:表示下一个被发送的元素在 buf 中的位置。 recvq:接收者的等待队列,用于存储等待从 channel 中读取数据的 goroutine。 sendq:发送者的等待队列,用于存储等待向 channel 中发送数据的 goroutine。 lock:保护 channel 的锁,防止多个 goroutine 同时访问 channel 时发生竞争条件。这里的 lock 是一个 mutex 类型的变量。
需要注意的是,这里的 waitq 和 mutex 分别表示等待队列和互斥锁,是 Golang 内部实现 channel 同步和通信机制所需要的结构体,由 Golang 运行时库提供支持。 原理概述
在 Golang 中,channel 是用于协程之间通信的重要机制,其内部实现涉及到以下几个方面:
数据结构: channel 本质上是一个带有同步功能的队列,Go 语言中的 channel 通过数据结构来实现同步和通信。具体而言,channel 内部包含了一个指向队列数据的指针和两个指向队列头和尾的索引。
内存管理: channel 内部存储的元素是在堆上分配的,这意味着在使用 channel 时不用考虑内存分配和回收的问题,由 Go 运行时自动管理。
同步机制: channel 的本质是一种同步机制,因此其内部实现必须包括同步相关的机制,以确保通信的正确性。Go 语言采用了类似于信号量的方法实现 channel 的同步,即在发送和接收操作时使用锁和条件变量来实现同步。
调度器: Golang 中的调度器负责协程的调度和管理,其在 channel 的实现中起到了重要的作用。调度器通过在不同协程之间切换来实现 channel 的通信和同步。
具体来说,当一个协程试图向 channel 发送数据时,调度器会检查 channel 的缓冲区状态。如果 channel 的缓冲区未满,则将数据写入缓冲区并唤醒等待接收的协程。如果 channel 的缓冲区已满,则当前协程会被阻塞,等待其他协程取走缓冲区中的数据。
当一个协程试图从 channel 中接收数据时,调度器会检查 channel 的缓冲区状态。如果 channel 的缓冲区非空,则将缓冲区中的数据读出并唤醒等待发送的协程。如果 channel 的缓冲区为空,则当前协程会被阻塞,等待其他协程向缓冲区中写入数据。
在 channel 的实现中,Go 语言使用了类似于操作系统中的管道机制,以及用于进程间通信的信号量机制,通过同步、调度等多种机制实现了协程之间的通信和同步。