channel底层原理

函数通信

多个线程之间交换数据无非是两种方式

  • 共享内存加互斥锁或信号量等同步机制防止资源竞争;
  • 先进先出(FIFO)将资源分配给等待时间最长的线程。

Go采用channel通信的原因

  1. 不需要考虑竞争 :向共享内存写入必须用锁保护,否则容易出现数据竞争的问题。channel内部已经处理了同步问题(维护了一个FIFO的队列(sendqrecvq)。
  2. 顺序一致性:共享内存中的多个线程间需要自己解决读取写入的顺序问题。channel保证了收发顺序一致。
  3. 数据交换方式上更明确(数据同步):channel是点对点的,指定了数据流向,从一个goroutine发送到另一个goroutine共享内存需要自己维护数据流向。send/receive两个操作就完成了数据同步,不需要额外的同步措施。向共享内存写入数据后,要通过其他方式通知等待线程。
  4. 不需要手动管理同步工具:共享内存需要显示创建和管理同步工具如互斥锁、信号量等。channel内部已封装了这些细节。

Go 中用于并发协程同步数据的组件

一个是 sync 和sync/atomic包里面的,如sync.Mutex、sync.RWMutex、sync.WaitGroup等,另一个是 channel。只有channel才是Go语言推荐的并发同步的方式,是一等公民,用户使用channel甚至不需要引入包名。

Channel结构

hchan结构体

channel的底层数据结构是hchan,在src/runtime/chan.go 中。

go 复制代码
type hchan struct {
  qcount   uint                 // 队列中所有数据总数
  dataqsiz uint                 // 循环队列大小
  buf      unsafe.Pointer       // 指向循环队列的指针
  elemsize uint16               // 循环队列中元素的大小
  closed   uint32               // chan是否关闭的标识
  elemtype *_type               // 循环队列中元素的类型
  sendx    uint                 // 已发送元素在循环队列中的位置
  recvx    uint                 // 已接收元素在循环队列中的位置
  recvq    waitq                // 等待接收的goroutine的等待队列
  sendq    waitq                // 等待发送的goroutine的等待队列
  lock mutex                    // 控制chan并发访问的互斥锁
}

qcount代表chan中已经接收但还没被读取的元素的个数;

dataqsiz代表循环队列的大小(若是无缓冲通道,则默认为1);

buf 是指向循环队列的指针,循环队列是大小固定的用来存放chan接收的数据的队列;

elemtype 和 elemsiz 表示循环队列中元素的类型和元素的大小;

sendx:待发送的数据在循环队列buffer中的位置索引;

recvx:待接收的数据在循环队列buffer中的位置索引;

recvq 和 sendq 分别表示等待接收数据的 goroutine 与等待发送数据的 goroutine;

阻塞协程队列waitq与sudog结构体

sendqrecvq 存储了当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表,这些等待队列使用双向链表 waitq 表示,链表中所有的元素都是 sudog 结构

go 复制代码
type waitq struct {             //阻塞的协程队列 
    first *sudog              //队列头部 
    last *sudog               //队列尾部 }
rust 复制代码
type sudog struct {		//sudog:包装协程的节点
    g *g				//goroutine,协程;

    next *sudog			//队列中的下一个节点;
    prev *sudog			//队列中的前一个节点;
    elem unsafe.Pointer //读取/写入 channel 的数据的容器;
    
    isSelect bool		//标识当前协程是否处在 select 多路复用的流程中;
    
    c        *hchan 	//标识与当前 sudog 交互的 chan.
}

Channel类型

  1. 同步synchronous)(无缓冲)通道:没有缓冲区,发送端要等待接收端准备好才能发送数据。
  2. 异步asynchronous)(有缓冲)通道:有缓冲的 pointer 型Channel。可以设置缓冲区大小,发送端发送数据不会阻塞,只有缓冲区满了才阻塞。
  3. 异步通道带值元素:有缓冲的 struct 型Channel

Channel构造器函数源码分析

go 复制代码
func makechan(t *chantype, size int) *hchan {
    elem := t.elem	//Channel中元素类型
    
    // 每个元素的内存大小为elem.size,channel的容量为size,计算出总内存mem
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    if overflow || mem > maxAlloc-hchanSize || size < 0 {
        panic(plainError("makechan: size out of range"))
    }

    var c *hchan
    switch {
    case mem == 0:				//无缓冲型Channel
   		 //hchanSize默认为96字节
        c = (*hchan)(mallocgc(hchanSize, nil, true))
        // 竞争检测器使用此位置进行同步。因为无缓冲,buf指向自己,用于竞争检测
        c.buf = c.raceaddr()
    case elem.ptrdata == 0:		//有缓冲, 元素类型是struct的情况
        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))   // mallocgc分配hchan内存加上mem大小(mem可能是0)
        c.buf = add(unsafe.Pointer(c), hchanSize)  // c.buf指向hchan起始地址加上hchanSize,即指向buf字段
    default:					//有缓冲的 pointer 型Channel
        c = new(hchan)
        c.buf = mallocgc(mem, elem, true)      // c.buf指向缓冲区内存起始地址
    }

    // 初始化hchan
    c.elemsize = uint16(elem.size)		//每个元素在内存中占用的字节数
    c.elemtype = elem					//元素类型
    c.dataqsiz = uint(size)				//队列中元素的数量上限
    
    lockInit(&c.lock, lockRankHchan)	//初始化读写保护锁

    return c
}

elem.ptrdata就是检测chan元素类型是否是指针类型:

  • 如果elem.ptrdata == 0,表示chan元素是非指针类型,如struct(连续内存)

  • 如果elem.ptrdata != 0,表示chan元素是指针类型

发送数据

  • 首先判断通道是否为nil即未初始化,若为空则引发死锁

  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若关闭,则引发panic:send on closed channel

  • 通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的读协程

    • 若有阻塞的读协程,此时环形缓冲区内元素个数为0, 则唤醒读协程,直接将要发送的数据传递给它,并完成写入,进行解锁返回

    • 没有阻塞的读协程,则判断环形缓冲区是否有空间

      • 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
      • 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁

更多详细的内容查看Golang Channel 实现原理与源码分析 - 掘金 (juejin.cn)

接受数据

  • 首先判断通道是否为nil即未初始化,若为空则引发死锁

  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若关闭,则判断环形缓冲区是否有元素,若无元素,则返回对应元素的零值。

  • 通道非空未关闭,则正式进入写入流程,首先判断是否有阻塞的写协程

    • 若有阻塞的写协程, 说明环形缓冲区为无缓冲型或已被写满,故判断channel是否为无缓冲型

      • 若 channel为无缓冲型,则直接读取写协程元素,并唤醒写协程;
      • 若 channel 为有缓冲型,则读取环形缓冲区头部元素,并将写协程元素写入缓冲区尾部后唤醒写协程,更新读写索引;
    • 若没有阻塞的写协程,则判断环形缓冲区是否有空间

      • 若环形缓冲区有空间,则直接将当前元素添加到环形缓冲区 sendx的位置,并更新写入位置sendx与通道元素个数qcount,解锁后返回函数。
      • 若环形缓冲区无空间,将当前协程加入阻塞写协程队列中,阻塞协程,等待被读协程唤醒,并完成解锁

关闭channel流程

  • 首先判断通道是否为nil即未初始化,若关闭空channel则引发panic(plainError("close of nil channel"))
  • 若通道非空,由于channel是共享资源,故需要对通道进行lock加锁

  • 继续判断通道是否关闭,若已经关闭,则引发panic(plainError("close of closed channel"))

  • 通道非空未关闭,则正式进入关闭流程:

    • 若有阻塞读协程队列,则将阻塞读协程队列中的协程节点统一添加到 glist,此时一定无阻塞写协程队列,先通知 recvq 的原因在于确保任何等待从通道接收数据的 goroutine 都能够迅速被通知通道已关闭。这样可以防止它们在等待更多数据时无限期地阻塞。而sendq遇到通道关闭时会直接引发panic,而不是等待。因此,先关闭 recvq 可以确保接收者 goroutine 不会阻塞,同时能够及时地了解通道已关闭。

    • 若有阻塞写协程队列,则将阻塞写协程队列中的协程节点统一添加到 glist,此时一定无阻塞读协程队列

    • 唤醒 glist 当中的所有协程.

scss 复制代码
func chansend1(c *hchan, elem unsafe.Pointer) {
    chansend(c, elem, true, getcallerpc())
}

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if c == nil {
        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
        throw("unreachable")
    }

    lock(&c.lock)

    if c.closed != 0 {
        unlock(&c.lock)
        panic(plainError("send on closed channel"))
    }
    
    // ...

不同状态下channel的执行结果

操作 \ 状态 非空、未关闭的chan 关闭状态 nil 通道
Send 结果 阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞 Panic 阻塞(deadlock)
Recv 结果 阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞 接收数据或返回零值 阻塞(deadlock)
close 成功 Panic Panic

for-range 读取 Channel 数据

不管是有缓冲还是无缓冲,都可以使用 for-range 从 channel 中读取数据,并且这个是一直循环读取的。

for-range 中的 range 产生的迭代值为 Channel 中发送的值,如果已经这个 channel 已经 close 了,那么首先还会继续执行,直到所有值被读取完,然后才会跳出 for 循环,因此,通过 for-range 读取 chann 数据会比较方便,因为我们只需要读取数据就行了,不需管他的退出,在 close 之后如果数据读取完了会自动帮我们退出。如果既没有 close 也没有数据可读,那么就会阻塞到 range 这里,除非有数据产生或者 chan 被关闭了。但是如果 channel 是 nil,读取会被阻塞,也就是会一直阻塞在 range 位置。

一个示例如下:

go 复制代码
ch := make(chan int) // 一直循环读取 range 中的迭代值 
for v := range ch { 
// 得到了 v 这个 chann 中的值 
fmt.Println("读取数据:",v) 
}

select 读写 Channel 数据

  • select 的 case 分支里面,可以读数据,也可以写数据。最多只允许有一个 default case,它可以放在 case 列表的任何位置,并且没有任何影响。

  • select 可以同时处理多个 channel,如果有同时多个 case 分支可以去处理,比如同时有多个 channel 可以接收数据,那么 Go 会伪随机(pseudo-random)的选择一个 case 处理。如果没有 case 需要处理,则会选择 default 分支去处理。如果没有 default case,则 select 语句会阻塞,直到某个 case 分支可以处理了。

  • 每次 select 语句的执行,是会扫描完所有的 case 后才确定如何执行,而不是说遇到合适的 case 就直接执行了。

  • 对于 nil channel 上的操作会一直被阻塞,如果没有 default case,只有 nil channel 的 select 会一直被阻塞。

  • select 语句和 switch 语句一样,它不是循环,它只会选择一个 case 来处理,如果想一直处理channel,你可以在外面加一个无限的 for 循环

Channel 的读写超时机制【select + timeout】

我们的一般常见场景就是,当我们从 chann 中进行读取数据,或者写入数据的时候,想要快速返回得到是否成功的结果,如果被 chann 阻塞后,需要指定一定的超时时间,然后如果在超时时间内还没有返回,那么就超时退出,不能一直阻塞在读写 chann 的流程中。

Go 的 time 库里面,提供了 time.NewTimer()、time.After()、time.NewTicker() 等方法,最终都可以通过这些方法来返回或者得到一个 channel,然后向这个 channel 中发送数据,就可以实现定时器的功能。

channel 可以通过 select + timeout 来实现阻塞超时的使用姿势,超时读写的姿势如下:

go 复制代码
// 通过 select 实现读超时,如果读 chann 阻塞 timeout 的时间后就会返回
func ReadWithSelect(ch chan int) (x int, err error) {
	timeout := time.NewTimer(time.Microsecond * 500)

	select {
	case x = <-ch:
		return x, nil
	case <-timeout.C:
		return 0, errors.New("read time out")
	}
}

// 通过 select 实现写超时,如果写 chann 阻塞 timeout 的时间后就会返回
func WriteChWithSelect(ch chan int) error {
	timeout := time.NewTimer(time.Microsecond * 500)

	select {
	case ch <- 1:
		return nil
	case <-timeout.C:
		return errors.New("write time out")
	}
}

TryEnqueue 无阻塞写 Channel 数据

有些场景,我们期望往缓冲队列中写入数据的时候,如果队列已满,那么不要进行写阻塞,而是写完发现队列已满就抛错,那么我们可以通过如下机制的封装来实现,原理是通过一个 select 和 一个 default 语句去实现,有一个 default 就不会阻塞了:

go 复制代码
var jobChan = make(chan int, 3)

func TryEnqueue(job int) bool {
	select {
	case jobChan <- job:
		fmt.Printf("true\n")  // 队列未满
		return true
	default:
		fmt.Printf("false\n") // 队列已满
		return false
	}
}

参考文献

作者:AllenWu

链接:juejin.cn/post/716954...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

作者:Pistachiout

链接:juejin.cn/post/726589...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

作者:腾讯云开发者

链接:juejin.cn/post/717133...

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

相关推荐
Estar.Lee3 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi
2401_857610034 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_5 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞5 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举
一个小坑货5 小时前
Rust 的简介
开发语言·后端·rust
monkey_meng5 小时前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
Estar.Lee6 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
新知图书6 小时前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
盛夏绽放7 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
Ares-Wang7 小时前
Asp.net Core Hosted Service(托管服务) Timer (定时任务)
后端·asp.net