写点什么

Golang channel 底层是如何实现的?(深度好文)

  • 2024-07-05
    广东
  • 本文字数:10487 字

    阅读完需:约 34 分钟

Golang channel底层是如何实现的?(深度好文)

Hi 你好,我是 k 哥。大厂搬砖 6 年的后端程序员。


我们知道,Go 语言为了方便使用者,提供了简单、安全的协程数据同步和通信机制,channel。那我们知道 channel 底层是如何实现的吗?今天 k 哥就来聊聊 channel 的底层实现原理。同时,为了验证我们是否掌握了 channel 的实现原理,本文也收集了 channel 的高频面试题,理解了原理,面试题自然不在话下。

1 原理

默认情况下,读写未就绪的 channel(读没有数据的 channel,或者写缓冲区已满的 channel)时,协程会被阻塞。


但是当读写 channel 操作和 select 搭配使用时,即使 channel 未就绪,也可以执行其它分支,当前协程不会被阻塞。


ch := make(chan int)select{  case <- ch:  default:}
复制代码


本文主要介绍 channel 的阻塞模式,和 select 搭配使用的非阻塞模式,后续会另起一篇介绍。

1.1 数据结构


channel 涉及到的核心数据结构包含 3 个。

hchan

// channeltype hchan struct {    // 循环队列    qcount   uint           // 通道中数据个数    dataqsiz uint           // buf长度    buf      unsafe.Pointer // 数组指针    sendx    uint   // send index    recvx    uint   // receive index    elemsize uint16 // 元素大小    elemtype *_type // 元素类型        closed   uint32 // 通道关闭标志        recvq    waitq  // 由双向链表实现的recv waiters队列    sendq    waitq  // 由双向链表实现的send waiters队列    lock mutex}
复制代码


hchan 是 channel 底层的数据结构,其核心是由数组实现的一个环形缓冲区:


  1. qcount 通道中数据个数

  2. dataqsiz 数组长度

  3. buf 指向数组的指针,数组中存储往 channel 发送的数据

  4. sendx 发送元素到数组的 index

  5. recvx 从数组中接收元素的 index

  6. elemsize channel 中元素类型的大小

  7. elemtype channel 中的元素类型

  8. closed 通道关闭标志

  9. recvq 因读取 channel 而陷入阻塞的协程等待队列

  10. sendq 因发送 channel 而陷入阻塞的协程等待队列

  11. lock 锁

waitq

// 等待队列(双向链表)type waitq struct {    first *sudog    last  *sudog}
复制代码


waitq 是因读写 channel 而陷入阻塞的协程等待队列。


  1. first 队列头部

  2. last 队列尾部

sudog

// sudog represents a g in a wait list, such as for sending/receiving// on a channel.type sudog struct {    g *g // 等待send或recv的协程g    next *sudog // 等待队列下一个结点next    prev *sudog // 等待队列前一个结点prev    elem unsafe.Pointer // data element (may point to stack)        success bool // 标记协程g被唤醒是因为数据传递(true)还是channel被关闭(false)    c        *hchan // channel}
复制代码


sudog 是协程等待队列的节点:


  1. g 因读写而陷入阻塞的协程

  2. next 等待队列下一个节点

  3. prev 等待队列前一个节点

  4. elem 对于写 channel,表示需要发送到 channel 的数据指针;对于读 channel,表示需要被赋值的数据指针。

  5. success 标记协程被唤醒是因为数据传递(true)还是 channel 被关闭(false)

  6. c 指向 channel 的指针

1.2 通道创建

func makechan(t *chantype, size int) *hchan {    elem := t.elem    // buf数组所需分配内存大小    mem := elem.size*uintptr(size)    var c *hchan    switch {    case mem == 0:// Unbuffered channels,buf无需内存分配        c = (*hchan)(mallocgc(hchanSize, nil, true))        // Race detector uses this location for synchronization.        c.buf = c.raceaddr()    case elem.ptrdata == 0: // Buffered channels,通道元素类型非指针        c = (*hchan)(mallocgc(hchanSize+mem, nil, true))        c.buf = add(unsafe.Pointer(c), hchanSize)    default:        // Buffered channels,通道元素类型是指针        c = new(hchan)        c.buf = mallocgc(mem, elem, true)    }
c.elemsize = uint16(elem.size) c.elemtype = elem c.dataqsiz = uint(size) return c}
复制代码


通道创建主要是分配内存并构建 hchan 对象。

1.3 通道写入

3 种异常情况处理

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {    // 1.channel为nil    if c == nil {        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)        throw("unreachable")    }        lock(&c.lock) //加锁        // 2.如果channel已关闭,直接panic    if c.closed != 0 {        unlock(&c.lock)        panic(plainError("send on closed channel"))    }       // Block on the channel.     mysg := acquireSudog()    c.sendq.enqueue(mysg) // 入sendq等待队列    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)            closed := !mysg.success // 协程被唤醒的原因是因为数据传递还是通道被关闭    // 3.因channel被关闭导致阻塞写协程被唤醒并panic    if closed {        panic(plainError("send on closed channel"))    }}
复制代码


  1. 对 nil channel 写入,会死锁

  2. 对被关闭的 channel 写入,会 panic

  3. 对因写入而陷入阻塞的协程,如果 channel 被关闭,阻塞协程会被唤醒并 panic

写时有阻塞读协程

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {    lock(&c.lock) //加锁    // 1、当存在等待接收的Goroutine    if sg := c.recvq.dequeue(); sg != nil {        // Found a waiting receiver. We pass the value we want to send        // directly to the receiver, bypassing the channel buffer (if any).        send(c, sg, ep, func() { unlock(&c.lock) }, 3) // 直接把正在发送的值发送给等待接收的Goroutine,并将此接收协程放入可调度队列等待调度        return true    }}
// send processes a send operation on an empty channel c.// The value ep sent by the sender is copied to the receiver sg.// The receiver is then woken up to go on its merry way.// Channel c must be empty and locked. send unlocks c with unlockf.// sg must already be dequeued from c.// ep must be non-nil and point to the heap or the caller's stack.func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { // 将ep写入sg中的elem if sg.elem != nil { t:=c.elemtype dst := sg.elem // memmove copies n bytes from "from" to "to". memmove(dst, ep, t.size) sg.elem = nil // 数据已经被写入到<- c变量,因此sg.elem指针可以置空了 } gp := sg.g unlockf() gp.param = unsafe.Pointer(sg) sg.success = true // 唤醒receiver协程gp goready(gp, skip+1)}
// 唤醒receiver协程gp,将其放入可运行队列中等待调度执行func goready(gp *g, traceskip int) { systemstack(func() { ready(gp, traceskip, true) })}// Mark gp ready to run.func ready(gp *g, traceskip int, next bool) { status := readgstatus(gp) // Mark runnable. _g_ := getg() mp := acquirem() // disable preemption because it can be holding p in a local var // status is Gwaiting or Gscanwaiting, make Grunnable and put on runq casgstatus(gp, _Gwaiting, _Grunnable) runqput(_g_.m.p.ptr(), gp, next) wakep() releasem(mp)}
复制代码


  1. 加锁

  2. 从阻塞读协程队列取出 sudog 节点

  3. 在 send 方法中,调用 memmove 方法将数据拷贝给 sudog.elem 指向的变量。

  4. goready 方法唤醒接收到数据的阻塞读协程 g,将其放入协程可运行队列中等待调度

  5. 解锁

写时无阻塞读协程但环形缓冲区仍有空间

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {    lock(&c.lock) //加锁    // 当缓冲区未满时    if c.qcount < c.dataqsiz {        // Space is available in the channel buffer. Enqueue the element to send.        qp := chanbuf(c, c.sendx) // 获取指向缓冲区数组中位于sendx位置的元素的指针        typedmemmove(c.elemtype, qp, ep) // 将当前发送的值拷贝到缓冲区        c.sendx++         if c.sendx == c.dataqsiz {            c.sendx = 0 // 因为是循环队列,sendx等于队列长度时置为0        }        c.qcount++        unlock(&c.lock)        return true    }}
复制代码


  1. 加锁

  2. 将数据放入环形缓冲区

  3. 解锁

写时无阻塞读协程且环形缓冲区无空间

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {    lock(&c.lock) //加锁        // Block on the channel.     // 将当前的Goroutine打包成一个sudog节点,并加入到阻塞写队列sendq里    gp := getg()    mysg := acquireSudog()    mysg.elem = ep    mysg.g = gp    mysg.c = c    gp.waiting = mysg    c.sendq.enqueue(mysg) // 入sendq等待队列           // 调用gopark将当前Goroutine设置为等待状态并解锁,进入休眠等待被唤醒,触发协程调度    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)        // 被唤醒之后执行清理工作并释放sudog结构体    gp.waiting = nil    gp.activeStackChans = false    closed := !mysg.success // gp被唤醒的原因是因为数据传递还是通道被关闭    gp.param = nil      mysg.c = nil    releaseSudog(mysg)    // 因关闭被唤醒则panic    if closed {        panic(plainError("send on closed channel"))    }    // 数据成功传递    return true}
复制代码


  1. 加锁。

  2. 将当前协程 gp 封装成 sudog 节点,并加入 channel 的阻塞写队列 sendq。

  3. 调用 gopark 将当前协程设置为等待状态并解锁,触发调度其它协程运行。

  4. 因数据被读或者 channel 被关闭,协程从 park 中被唤醒,清理 sudog 结构。

  5. 因 channel 被关闭导致协程唤醒,panic

  6. 返回

整体写流程


func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {    // 1.channel为nil    if c == nil {        // 当前Goroutine阻塞挂起        gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)        throw("unreachable")    }    // 2.加锁    lock(&c.lock)         // 3.如果channel已关闭,直接panic    if c.closed != 0 {        unlock(&c.lock)        panic(plainError("send on closed channel"))    }    // 4、存在阻塞读协程    if sg := c.recvq.dequeue(); sg != nil {        // Found a waiting receiver. We pass the value we want to send        // directly to the receiver, bypassing the channel buffer (if any).        send(c, sg, ep, func() { unlock(&c.lock) }, 3) // 直接把正在发送的值发送给等待接收的Goroutine,并将此接收协程放入可调度队列等待调度        return true    }    // 5、缓冲区未满时    if c.qcount < c.dataqsiz {        // Space is available in the channel buffer. Enqueue the element to send.        qp := chanbuf(c, c.sendx) // 获取指向缓冲区数组中位于sendx位置的元素的指针        typedmemmove(c.elemtype, qp, ep) // 将当前发送的值拷贝到缓冲区        c.sendx++         if c.sendx == c.dataqsiz {            c.sendx = 0 // 因为是循环队列,sendx等于队列长度时置为0        }        c.qcount++        unlock(&c.lock)        return true    }    // Block on the channel.     // 6、将当前协程打包成一个sudog结构体,并加入到channel的阻塞写队列sendq    gp := getg()    mysg := acquireSudog()    mysg.elem = ep    mysg.waitlink = nil    mysg.g = gp    mysg.c = c    gp.waiting = mysg    gp.param = nil    c.sendq.enqueue(mysg) // 入sendq等待队列        atomic.Store8(&gp.parkingOnChan, 1)        // 7.调用gopark将当前协程设置为等待状态并解锁,进入休眠,等待被唤醒,并触发协程调度    gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)        // 8. 被唤醒之后执行清理工作并释放sudog结构体    gp.waiting = nil    gp.activeStackChans = false    closed := !mysg.success // g被唤醒的原因是因为数据传递还是通道被关闭    gp.param = nil    mysg.c = nil    releaseSudog(mysg)    // 9.因关闭被唤醒则panic    if closed {        panic(plainError("send on closed channel"))    }    // 10.数据成功传递    return true}
复制代码


  1. channel 为 nil 检查。为空则死锁。

  2. 加锁

  3. 如果 channel 已关闭,直接 panic。

  4. 当存在阻塞读协程,直接把数据发送给读协程,唤醒并将其放入协程可运行队列中等待调度运行。

  5. 当缓冲区未满时,将当前发送的数据拷贝到缓冲区。

  6. 当既没有阻塞读协程,缓冲区也没有剩余空间时,将协程加入阻塞写队列 sendq。

  7. 调用 gopark 将当前协程设置为等待状态,进入休眠等待被唤醒,触发协程调度。

  8. 被唤醒之后执行清理工作并释放 sudog 结构体

  9. 唤醒之后检查,因 channel 被关闭导致协程唤醒则 panic。

  10. 返回。

1.4 通道读

2 种异常情况处理

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {    // 1.channel为nil    if c == nil {        // 否则,当前Goroutine阻塞挂起        gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)        throw("unreachable")    }
lock(&c.lock) // 2.如果channel已关闭,并且缓冲区无元素,返回(true,false) if c.closed != 0 { if c.qcount == 0 { unlock(&c.lock) if ep != nil { //根据channel元素的类型清理ep对应地址的内存,即ep接收了channel元素类型的零值 typedmemclr(c.elemtype, ep) } return true, false } }}
复制代码


  1. channel 未初始化,读操作会死锁

  2. channel 已关闭且缓冲区无数据,给读变量赋零值。

读时有阻塞写协程

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {    lock(&c.lock)        // Just found waiting sender with not closed.    // 等待发送的队列sendq里存在Goroutine    if sg := c.sendq.dequeue(); sg != nil {        // Found a waiting sender. If buffer is size 0, receive value        // directly from sender. Otherwise, receive from head of queue        // and add sender's value to the tail of the queue (both map to        // the same buffer slot because the queue is full).        // 如果无缓冲区,那么直接从sender接收数据;否则,从buf队列的头部接收数据,并把sender的数据加到buf队列的尾部        recv(c, sg, ep, func() { unlock(&c.lock) }, 3)        return true, true // 接收成功    }        }
// recv processes a receive operation on a full channel c.func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { // channel无缓冲区,直接从sender读 if c.dataqsiz == 0 { if ep != nil { // copy data from sender t := c.elemtype src := sg.elem typeBitsBulkBarrier(t, uintptr(ep), uintptr(src), t.size) memmove(dst, src, t.size) } } else { // 从队列读,sender再写入队列 qp := chanbuf(c, c.recvx) // copy data from queue to receiver if ep != nil { typedmemmove(c.elemtype, ep, qp) } // copy data from sender to queue typedmemmove(c.elemtype, qp, sg.elem) c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 } c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz } // 唤醒sender队列协程sg sg.elem = nil gp := sg.g unlockf() gp.param = unsafe.Pointer(sg) sg.success = true // 唤醒协程 goready(gp, skip+1)}
复制代码


  1. 加锁

  2. 从阻塞写队列取出 sudog 节点

  3. 假如 channel 为无缓冲区通道,则直接读取 sudog 对应写协程数据,唤醒写协程。

  4. 假如 channel 为缓冲区通道,从 channel 缓冲区头部(recvx)读数据,将 sudog 对应写协程数据,写入缓冲区尾部(sendx),唤醒写协程。

  5. 解锁

读时无阻塞写协程且缓冲区有数据

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {    lock(&c.lock)    // 缓冲区buf中有元素,直接从buf拷贝元素到当前协程(在已关闭的情况下,队列有数据依然会读)    if c.qcount > 0 {        // Receive directly from queue        qp := chanbuf(c, c.recvx)        if ep != nil {            typedmemmove(c.elemtype, ep, qp)// 将从buf中取出的元素拷贝到当前协程        }        typedmemclr(c.elemtype, qp) // 同时将取出的数据所在的内存清空        c.recvx++        if c.recvx == c.dataqsiz {            c.recvx = 0        }        c.qcount--        unlock(&c.lock)        return true, true // 接收成功    }}
复制代码


  1. 加锁

  2. 从环形缓冲区读数据。在 channel 已关闭的情况下,缓冲区有数据依然可以被读。

  3. 解锁

读时无阻塞写协程且缓冲区无数据

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {    lock(&c.lock)
// no sender available: block on this channel. // 阻塞模式,获取当前Goroutine,打包一个sudog,并加入到channel的接收队列recvq里 gp := getg() mysg := acquireSudog() mysg.elem = ep gp.waiting = mysg mysg.g = gp mysg.c = c gp.param = nil c.recvq.enqueue(mysg) // 入接收队列recvq // 挂起当前Goroutine,设置为_Gwaiting状态,进入休眠等待被唤醒 gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// 因通道关闭或者读到数据被唤醒 gp.waiting = nil success := mysg.success gp.param = nil mysg.c = nil releaseSudog(mysg) return true, success // 10.返回成功}
复制代码


  1. 加锁。

  2. 将当前协程 gp 封装成 sudog 节点,加入 channel 的阻塞读队列 recvq。

  3. 调用 gopark 将当前协程设置为等待状态并解锁,触发调度其它协程运行。

  4. 因读到数据或者 channel 被关闭,协程从 park 中被唤醒,清理 sudog 结构。

  5. 返回

整体读流程


// chanrecv receives on channel c and writes the received data to ep.// ep may be nil, in which case received data is ignored.// If block == false and no elements are available, returns (false, false).// Otherwise, if c is closed, zeros *ep and returns (true, false).// Otherwise, fills in *ep with an element and returns (true, true).// A non-nil ep must point to the heap or the caller's stack.func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {    // 1.channel为nil    if c == nil {        // 否则,当前Goroutine阻塞挂起        gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)        throw("unreachable")    }    // 2.加锁    lock(&c.lock)    // 3.如果channel已关闭,并且缓冲区无元素,返回(true,false)    if c.closed != 0 {        if c.qcount == 0 {            unlock(&c.lock)            if ep != nil {                //根据channel元素的类型清理ep对应地址的内存,即ep接收了channel元素类型的零值                typedmemclr(c.elemtype, ep)            }            return true, false        }        // The channel has been closed, but the channel's buffer have data.    } else {        // Just found waiting sender with not closed.        // 4.存在阻塞写协程        if sg := c.sendq.dequeue(); sg != nil {            // Found a waiting sender. If buffer is size 0, receive value            // directly from sender. Otherwise, receive from head of queue            // and add sender's value to the tail of the queue (both map to            // the same buffer slot because the queue is full).            // 如果无缓冲区,那么直接从sender接收数据;否则,从buf队列的头部接收数据,并把sender的数据加到buf队列的尾部            recv(c, sg, ep, func() { unlock(&c.lock) }, 3)            return true, true // 接收成功        }    }    // 5.缓冲区buf中有元素,直接从buf拷贝元素到当前协程(在已关闭的情况下,队列有数据依然会读)    if c.qcount > 0 {        // Receive directly from queue        qp := chanbuf(c, c.recvx)        if ep != nil {            typedmemmove(c.elemtype, ep, qp)// 将从buf中取出的元素拷贝到当前协程        }        typedmemclr(c.elemtype, qp) // 同时将取出的数据所在的内存清空        c.recvx++        if c.recvx == c.dataqsiz {            c.recvx = 0        }        c.qcount--        unlock(&c.lock)        return true, true // 接收成功    }
// no sender available: block on this channel. // 6.获取当前Goroutine,封装成sudog节点,加入channel阻塞读队列recvq gp := getg() mysg := acquireSudog() mysg.elem = ep mysg.waitlink = nil gp.waiting = mysg mysg.g = gp mysg.c = c gp.param = nil c.recvq.enqueue(mysg) // 入接收队列recvq atomic.Store8(&gp.parkingOnChan, 1) // 7.挂起当前Goroutine,设置为_Gwaiting状态,进入休眠等待被唤醒 gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// 8.因通道关闭或者可读被唤醒 gp.waiting = nil gp.activeStackChans = false success := mysg.success gp.param = nil mysg.c = nil releaseSudog(mysg) // 9.返回 return true, success }
复制代码


通道读流程如下:


  1. channel 为 nil 检查。空则死锁。

  2. 加锁。

  3. 如果 channel 已关闭,并且缓冲区无数据,读变量赋零值,返回。

  4. 当存在阻塞写协程,如果缓冲区已满,则直接从 sender 接收数据;否则,从环形缓冲区头部接收数据,并把 sender 的数据加到环形缓冲区尾部。唤醒 sender,将其放入协程可运行队列中等待调度运行,返回。

  5. 如果缓冲区中有数据,直接从缓冲区拷贝数据到当前协程,返回。

  6. 当既没有阻塞写协程,缓冲区也没有数据时,将协程加入阻塞读队列 recvq。

  7. 调用 gopark 将当前协程设置为等待状态,进入休眠等待被唤醒,触发协程调度。

  8. 因通道关闭或者可读被唤醒。

  9. 返回。

1.5 通道关闭

func closechan(c *hchan) {    // // 1.channel为nil则panic    if c == nil {        panic(plainError("close of nil channel"))    }    lock(&c.lock)    // 2.已关闭的channel再次关闭则panic    if c.closed != 0 {        unlock(&c.lock)        panic(plainError("close of closed channel"))    }    // 设置关闭标记    c.closed = 1
var glist gList // 遍历recvq和sendq中的协程放入glist // release all readers for { sg := c.recvq.dequeue() if sg == nil { break } if sg.elem != nil { typedmemclr(c.elemtype, sg.elem) sg.elem = nil } if sg.releasetime != 0 { sg.releasetime = cputicks() } gp := sg.g gp.param = unsafe.Pointer(sg) sg.success = false glist.push(gp) }
// release all writers (they will panic) for { sg := c.sendq.dequeue() if sg == nil { break } sg.elem = nil if sg.releasetime != 0 { sg.releasetime = cputicks() } gp := sg.g gp.param = unsafe.Pointer(sg) sg.success = false glist.push(gp) } unlock(&c.lock)
// 3.将glist中所有Goroutine的状态置为_Grunnable,等待调度器进行调度 for !glist.empty() { gp := glist.pop() gp.schedlink = 0 goready(gp, 3) }}
复制代码


  1. channel 为 nil 检查。为空则 panic

  2. 已关闭 channel 再次被关闭,panic

  3. 将 sendq 和 recvq 所有 Goroutine 的状态置为_Grunnable,放入协程调度队列等待调度器调度

2 高频面试题

  1. channel 的底层实现原理 (数据结构)

  2. nil、关闭的 channel、有数据的 channel,再进行读、写、关闭会怎么样?(各类变种题型)

  3. 有缓冲 channel 和无缓冲 channel 的区别


https://mp.weixin.qq.com/s?__biz=MzkwODYxNDk0OA==&mid=2247483769&idx=1&sn=4a25c2bfae111dca1930f801b8c9e7b7&chksm=c0c60d32f7b18424d3b1e82c4fdb03f1dfea652fc918d7d5991541a1df51554d8cdb2fdab529&token=1552198173&lang=zh_CN#rd

发布于: 刚刚阅读数: 6
用户头像

腾讯等大厂6年golang架构师 2018-11-17 加入

我是k哥。大厂6年golang架构师,亿万dau、百万qps服务owner。 公粽号【golang架构师k哥】每周分享golang和架构师技能。

评论

发布
暂无评论
Golang channel底层是如何实现的?(深度好文)_golang_golang架构师k哥_InfoQ写作社区