channel 底层原理 -自我梳理

283 阅读7分钟

1.channel的底层原理

不要通过共享内存来通信 而是要通过通信实现共享内存

  • channel被重复关闭的话 会panic
  • hchan中有两个指针 sendx recvx 标识环形数组的写入 读出位置 当越过尾部就重新回到头部 buf表示环形数组 这里不使用链表是为了节省存储链表中next指针

  • recvq 和 sendq 分别代表阻塞的读协程队列和阻塞的写协程队列

  • 为了保证并发安全性 channel中内置了一把锁 lock

    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 mutex
    }
    ​
    type waitq struct {
        first *sudog //阻塞队列头部
        last  *sudog //阻塞队列尾部
    }
    ​
    type sudog struct {
        g *g
    ​
        next *sudog
        prev *sudog
        elem unsafe.Pointer // data element (may point to stack)
    ​
        isSelect bool
    ​
        c        *hchan 
    }
    ​
    //对goroutine的再封装
    //从数据结构可以看出 是双向链表 这里的c指向的是该阻塞队列对应的channel
    //特别注意: isSelect bool 判断channel是否在IO多路复用select模式 因为如果在select模式下 case条件未达成的时候 不能直接阻塞挂起 万一之后的case条件达成呢 所以这里是不断循环等待的过程 使用CAS自旋锁//CAS自旋锁的核心思想是通过原子性地比较某个共享变量的当前值与一个期望值,如果相等,则将该共享变量更新为新的值。这一操作是原子的,即在同一时刻只有一个线程可以成功执行更新操作。如果比较失败(当前值与期望值不相等),则说明有其他线程正在访问共享资源,此时线程可以选择等待一段时间后重新尝试,或者继续自旋等待。
    //写一个spinLock //在CAS(Compare-And-Swap)自旋锁中,通常将锁的状态表示为一个整数,而不仅仅是一个布尔值,这是因为整数类型(如int32)在实现中更加灵活,可以表示更多的状态信息。通过使用整数状态,可以在不增加额外的变量的情况下实现更复杂的锁行为。而如果仅使用布尔值,只能表示锁的加锁和解锁两种状态,无法实现更多的控制。其他整数值:可以表示更复杂的状态,例如,可以用一个整数表示锁的加锁次数,允许同一个线程多次获取锁,每次释放锁时减少计数,直到计数为0时才完全释放锁。package main
    ​
    import (
        "runtime"
        "sync/atomic"
    )
    ​
    type spinLock uint32const maxBackoff = 16 //自旋的最大次数func (sl *spinLock) Lock() {
        backoff := 1
        //不能使用原子性操作将状态从0->1 不能得到锁
        for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
            for i := 0; i < backoff; i++ {
                runtime.Gosched() //gourtine主动让渡
            }
            if backoff < maxBackoff {
                backoff <<= 1
            }
        }
    }
    ​
    func (sl *spinLock) Unlock() {
        atomic.StoreUint32((*uint32)(sl), 0)
    }
    

    makechan

    func makechan(t *chantype, size int) *hchan {
        elem := t.elem
        if elem.size >= 1<<16 {
            throw("makechan: invalid channel element type")
        }
        if hchanSize%maxAlign != 0 || elem.align > maxAlign {
            throw("makechan: bad alignment")
        }
    ​
        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:
            // Queue or element size is zero.
            c = (*hchan)(mallocgc(hchanSize, nil, true))
            // Race detector uses this location for synchronization.
            c.buf = c.raceaddr()
        case elem.ptrdata == 0:
            // Elements do not contain pointers.
            // Allocate hchan and buf in one call.
            c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
            c.buf = add(unsafe.Pointer(c), hchanSize)
        default:
            // Elements contain pointers.
            c = new(hchan)
            c.buf = mallocgc(mem, elem, true)
        }
    ​
        c.elemsize = uint16(elem.size)
        c.elemtype = elem
        c.dataqsiz = uint(size)
        lockInit(&c.lock, lockRankHchan)
    ​
        if debugChan {
            print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
        }
        return c
    }
    ​
    /*
        首先估算需要分配的内存大小 mem := math.MulUintptr 然后根据内存大小的不同有三种方式
        1.mem = 0 这里有可能是无缓冲区的channel 也有可能是chan struct{} 单纯用来传递信号的 这里直接分配hchanSize就ok
        //strcut{}{}和struct{}
        //struct{}是一个无元素的结构体类型 通常在没有信息的时候使用 
        //struct {}{}是一个复合字面量,它构造了一个struct{}类型的值,该值也是空。
        //var s struct{}
        //unsafe.Sizeof(a) = 0
        2.非指针类型的有缓冲区的channel 分配内存时分配连续的hchanSize+mem mem是缓冲区的大小 由于是连续的 只需要得到头部指针之后偏移hchanSize的大小就可以
        3.带指针类型的有缓冲区的channel 由于有指针类型 这里使用new 返回的是*Type 而它的缓冲区不是与hchan连续 而是碎片化的缓冲区
    */
    

    写流程

    • 对于一个没有缓冲区或者缓冲区已满的channel 并且此时没有一个其他的goroutine来读取的情况下 写入这个channel 那么当前的goroutine 就会陷入阻塞
    • 异常情况处理主要有两种

    1.对于没有初始化的channel 写入操作会引发死锁 // 这里是goroutine通过gopark函数 使其陷入被动阻塞

    2.对于已经关闭的channel 写入操作会直接panic

    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的缓冲区是空的 要么就是无缓冲区的

      1. 加锁
      2. 取出recv队列中的队首元素 基于memmove方法 将写入元素拷贝给对应的读goroutine
      3. 使用goready()操作唤醒 使得它有被process重新获取的可能
      4. 解锁
    • 正常写

      1. 加锁
      2. 找到环形数组的尾部节点 将数据写入 环形数组的尾节点++ 如果到了超过了头部 那么令其等于0 形成环
      3. 解锁
    • 写时缓冲区满了 也没有读协程去读取

      1. 加锁

      2. 将该写协程放入阻塞的写协程队列中

      3. gopark 将其阻塞挂起

      4. 解锁

        在gopark之后 代码会阻塞在这一行 当有读协程来的时候 继续下面的代码 也就是将其回收 因为此时数据已经被读协程读取了 所以直接回收资源就好

    image-20230922153313944

    读流程

    • 异常的两种情况与写流程类似 唯一不同的是当channel已经被关闭且内部没有元素的时候 此时读取channel会得到数据的零值 如果channel中还有元素 那么会读取到该元素

    • 读的时候有阻塞的写协程

      此时缓冲区一定是满的 或者就没有缓冲区

      1. 加锁
      2. 从阻塞写协程中获取队首元素
      3. 如果channel 没有缓冲区的话 直接读取写协程 并且使用goready将写协程唤醒
      4. 如果channel有缓冲区的话 则读取缓冲区的头部元素 将写协程放入队尾 并且将其唤醒 不直接读取缓冲区队首的写协程是因为要遵循FIFO机制 阻塞队列中的写协程一定是晚于环形数组中的
    • 读的时候缓冲区还有剩余的元素

      那么跟正常写是一样的 就是取出环形数组中recvx的位置 然后放入 recvx++

    • 读的时候缓冲区没有剩余的元素 且 没有对应的写协程

      此时跟上面的写流程是一样的 但是这里读协程被之后的写协程唤醒之后 不会关心数据的流向 在此之前 写协程一定是将数据拷贝给它了

    image-20230922154711823

非阻塞模式

IO多路复用下的模式 有标识block

非阻塞模式下,读/写 channel 方法通过一个 bool 型的响应参数,用以标识是否读取/写入成功.

  • 所有需要使得当前 goroutine 被挂起的操作,在非阻塞模式下都会返回 false;

  • 所有是的当前 goroutine 会进入死锁的操作,在非阻塞模式下都会返回 false;

  • 所有能立即完成读取/写入操作的条件下,非阻塞模式下会返回 true.

    当使用select时 会进入非阻塞模式

    关闭流程

    • 关闭nil channel 或者 已经被关闭的channel时 会panic
    • 关闭channel时会唤醒阻塞读/写协程队列中的所有goroutine(goready()) 写goroutine会陷入panic 读goroutine不会
    if closed {
            if c.closed == 0 {
                throw("chansend: spurious wakeup")
            }
            panic(plainError("send on closed channel"))
    }
    ​
    //这是在chansend方法中的 如果此时因为close操作使得阻塞的写goroutine被唤醒 那么会panic