美文网首页
Go 深入源码 —— select 与 channel

Go 深入源码 —— select 与 channel

作者: 哎呦卧槽_960f | 来源:发表于2020-11-03 10:19 被阅读0次

    Golang 通过 select...case 语句实现了对 channel 的多路复用以及非阻塞收发操作

    本文将着重讲解以下问题:

    强烈建议阅读本文前,先看关于 channel 的源码解析 Go 深入源码 —— Channel

    如果只关心 select 如何实现 channel 的多路复用,以及如何随机选择 case 的话可以直接看 selectgo 实现 channel 的多路复用

    select 使用与编译阶段的转换

    无 case,永久阻塞

    select{}
    

    没有任何 case 的 select 语句会被编译器转换为runtime.block()函数,永久阻塞

    编译阶段转换
    // src/cmd/compile/internal/gc/select.go
    func walkselectcases(caes *Nodes) []*Node {
        // 获取 case 操作的数量
        n := cases.Len()
        if n == 0 {
            return []*Node{mkcall("block", nil, nil)}
        }
        // ...
    }
    
    // src/runtime/select.go
    func block() {
        gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1) // 永久阻塞
    }
    

    单一 channel,阻塞收发

    ch := make(chan struct{})
    select {
    case data <- ch:
        fmt.Printf("ch data: %v\n", data)
    }
    
    // 编译器转换
    if ch == nil {
        block()
    }
    data := <- ch
    

    只有一个 channel 操作,实际会被编译器转换为相应channel 相应的收发操作,其实和实际调用 data := <- ch 并没有什么区别

    编译阶段转换
    // src/cmd/compile/internal/gc/select.go
    func walkselectcases(caes *Nodes) []*Node {
        // 获取 case 操作的数量
        n := cases.Len()
        // ...
        if n == 1 {
            if cas.Left != nil { // not default
                n := cas.Left // n 表示 case 的 channel 操作
                // ...
    
                // 这一段操作便是转换成 if ch == nil {block()}; n;
                a := nod(OIF, nil, nil)
                a.Left = nod(OEQ, ch, nodnil())
                var ln Nodes
                ln.Set(l)
                a.Nbody.Set1(mkcall("block", nil, &ln))
                l = ln.Slice()
                l = append(l, a, n)
            }
            // ...
            return l
        }
    }
    

    单一 channel + default,非阻塞收发

    ch := make(chan int)
    select {
    case ch <- 1:
        // ...
    default:
        fmt.Println("do default")
    }
    
    // 编译器转换
    if selectnbsend(ch, 1) {
        // ...
    } else {
        fmt.Println("do default")
    }
    

    编译器会将 channel 的收发操作转换成 selectnbsend 或者 selectnbrecv/slectnbrecv2函数来完成非阻塞操作

    编译阶段转换
    // src/cmd/compile/internal/gc/select.go
    func walkselectcases(caes *Nodes) []*Node {
        // 获取 case 操作的数量
        n := cases.Len()
        // ...
    
        // 两个 case操作,其中一个还是 default
        if n == 2 && (cases.First().Left == nil || cases.Second().Left == nil {
            n := cas.Left // n 是 case 的 channel 操作
            r := nod(OIF, nil, nil)
            switch n.Op {
            case OSEND:
                ch := n.Left
                // 转换为 if selectnbsend(c, v){ //... } else { // default body ...}
                r.Left = mkcall1(chanfn("selectnbsend", 2, ch.Type), types.Types[TBOOL], &r.Nint, ch, n.Right)
            case OSELRECV:
                // 转换为 if selectnbrecv(&v, c){ //... } else { // default body ...}
                // ...
            case OSELRECV2:
                // 转换为 if selectnbrecv2(&v, &received, c){ //... } else { // default body ...}
                // ...
            }
            return
        }
    }
    

    channel 的非阻塞收发实际都是调用的 chansend 或者 chanrecv

    // src/runtime/chan.go
    
    // 非阻塞发送
    func selectnbsend(c *hchan, elem unsafe.Pointer) (selected bool) {
        // block 参数为 false,非阻塞调用
        return chansend(c,elem, false, getcallerpc())
    }
    
    // 非阻塞接收
    func selectnbrecv(elem unsafe.Pointer, c *hchan) (selected bool) {
        selected, _ = chanrecv(c, elem, false)
        return
    }
    func selectnbrecv2(elem unsafe.Pointer, received *bool, c *hchan) (selected bool) {
        selected, *received = chanrecv(c, elem, false)
        return
    }
    

    而且 channel 会对于非阻塞收发操作有一些优化

    // 非阻塞操作, block 参数为 false
    func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
        // ...
    
        // 快速检测,非阻塞时,有些情况不需要获取锁就可以直接返回
        // 非阻塞,未关闭,非缓冲+没有等待接收的 goroutine 或者 缓冲+缓冲区已满
        if !block && c.closed == 0 &&
            ((c.dataqsiz == 0 && c.recvq.first == nil) ||
            ((c.dataqsiz < 0 && c.qcount == c.dataqsiz)) {
            // 返回 false,表示未发送成功
            return false
        }
        lock(&c.lock)
    }
    
    func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool){
         // ...
    
        // 快速检测,在非阻塞模式下,和发送一样有些条件不需要加锁就可以直接判断返回
        // 非阻塞并且未关闭,非缓冲+没有待发送者或者有缓冲+缓冲为空
        if !block && (c.dataqsiz == 0 && c.sendq.first == nil ||
            c.dataqsiz > 0 && atomic.Loaduint(&c.qcount) == 0) &&
            atomic.Load(&c.closed) == 0 {
            return
        }
        lock(&c.lock)
    }
    

    在加锁前对 channel 进行判断,较少了加锁带来的性能问题
    具体的 channel 收发操作,可以参考 Go 深入源码 —— Channel

    多 channel,实现多路复用

    ch1 := make(chan int)
    ch1 := make(chan int)
    
    select {
    case data, ok := <- ch1:
        fmt.Println("recv from ch1, data: %v, ok: %v", data, ok)
    case ch2 <- 1:
        fmt.Println("send to ch2")
    }
    
    // 编译器转换
    // 第一阶段 生成 scase 对象数组
    selv := [2]scase{}
    order := [2*2]uint16
    for i, case := range cases {
        c := cases{}
        c.kind = ...
        c.c = ...
        c.elem = ...
    }
    
    // 第二阶段 编译器生成调用 selectgo 的逻辑
    chosen, recv := selectgo(selv, order, 2)
    
    // 第三阶段 根据 selectgo 返回值 chosen 来生成 if 语句来执行相应索引的 case
    if chosen == 0 {
        // ...
    }
    if chosen == 1 {
        // ...
    }
    

    多 case 的 select 语句实现了 channel 的多路复用,select 会阻塞,直到有 case 操作完成了收发操作

    如果有多个 case 操作已经可以执行收发操作了,但是 select 会随机选择一个 case 完成收到操作,然后执行 case 下的相应逻辑

    编译器实际会将 select 语句转换成 selectgo 函数,每个 case 操作都会被转换成 scase 结构,然后作为参数传递给 selectgo

    编译阶段转换
    // src/cmd/compile/internal/gc/select.go
    func walkselectcases(cases *Nodes) []*Node {
        // 获取 case 操作的数量
        n := cases.Len()
        if n == 0 { // 无 case
            // ...
        }
        if n == 1 { // 单一 case
            // ...
        }
        if n == 2 && (cases.First().Left == nil || cases.Second().Left == nil) { // 单一 case + defult
            // ...
        }
    
        // 后续逻辑便是多 case 的情况了
    
        // selv scase 数组,scasetype() 返回的便是 scase
        // selv 和 order 会作为 selectgo 的参数
        selv := temp(types.NewArray(scasetype(), int64(n)))
        order := temp(types.NewArray(types.Types[TUINT16], 2*int64(n)))
    
        // 第一阶段
        // 遍历 case 生成 scase 对象存放到 selv 中
        for i, cas := range cases.Slice() {
            var c, elem *Node
            var kind int64 = caseDefault
    
            // cas.Left 如果不为 nil,那么说明是 channel 的收发操作
            if n := cas.Left; n != nil {
                switch n.Op {
                case OSEND:
                    kind = caseSend
                    c = n.Left
                    elem = n.Right
                case OSELRECV, OSELRECV2:
                    kind = caseRecv
                    c = n.Left
                    elem = n.Right
                }
            }
            // 根据 c, elem 构造 scase,并存放到 selv 中
            // ...
        }
    
        // selectgo 会返回的两个值,chosen 表示被选中的 case 的索引,recvOK 表示对于接收操作,是否成功接收
        chosen := temp(types.Types[TINT])
        recvOK := temp(types.Types[TBOOL])
    
        // 第二阶段
        // chose, recvOK := selectgo(selv, order, n)
        r = nod(OAS2, nil, nil)
        r.List.Set2(chose, recvOK)
        fn := syslook("selectgo")
        r.Rlist.Set1(mkcall1(fn, fn.Types, Results(), nil, bytePtrToIndex(selv, 0), bytePtrToIndex(order, 0), nodeintconst(int64(n))))
    
        // 第三阶段
        // 根据 selectgo 返回的 case 索引生成多个 if 语句
        for i, cas := range cases.Slice() {
            // 转换成if chosen == i { // ... case body }
            cond := nod(OEQ, chosen, nodintconst(int64(i)))
            r = node(OIF, cond, nil)
            // ...
            r.Nbody.AppendNodes(&cas.NBody)
        }
    }
    

    在分配 selv 和 order时,使用temp` 方法,应该是为了保证数据会分配到栈中而不是堆中

    多 channel + default

    ch1 := make(chan int)
    ch1 := make(chan int)
    
    select {
        case data, ok := <- ch1:
            fmt.Println("recv from ch1, data: %v, ok: %v", data, ok)
        case ch2 <- 1:
            fmt.Println("send to ch2")
        default:
            fmt.Println("do default")
    }
    

    多 case + default 同样也是调用 selectgo 函数,default 也会封装成 kind 为 caseDefault 的 scase 对象的

    selectgo 并不会进行阻塞,如果所有 case 的 channel 都无法立即完成收发操作,那就会直接执行 default 操作

    selectgo 实现 channel 的多路复用

    对于 select 语句,编译器会进行相应的转换优化操作,而多 channel 操作便是去调用 selectgo 函数来实现

    scase

    select 中 case 操作,无论是 channel 收发还是 default 都会被转换为 scase 结构

    const (
        caseNil = iota  // 表示 channel 为 nil 的情况
        caseRecv
        caseSend
        caseDefault
    )
    
    type scase struct {
        c    *hchan    //进行收发操作的 channel
        elem unsafe.Pointer  // 收发的数据源
        kind uint16    // 上述的四种 kind
        ...
    }
    

    scase 结构很简单,主要记录了 case 操作的类型,channel,以及 channel 收发的数据源

    selectgo

    现在我们开始分析 select 的重头戏 selectgo 函数

    func selectgo(cas0 *scase, order0 *uint16, ncase int)(int, bool)
    
    • cas0 指向一个类型为 [ncases]scase 的数组
    • order0 是一个指向[2*ncases]uint16,数组中的值都是 0
    • selectgo 会返回选中的序号,如果是个接收操作,还会返回是否接收到一个值

    如果看过上文的话就会知道,编译器会将 scases 数组,还有用于排序的数组和 case 数量一起传给 selectgo

    为什么 selectgo 还需要传递一个 order0,而不是直接根据 ncase 直接分配呢
    编译转换会使用 temp 函数来构造生成数组的语句,而这个语句便可以保证数据会分配到栈上,而不是堆上,避免了不必要的堆分配

    而 select 做的第一件事便是将 case0,order0 这些指针转换成相应的 slice 结构

        cas1 := (*[1 << 16]scase)(unsafe.Pointer(cas0))
        order1 := (*[1 << 17]uint16)(unsafe.Pointer(order0))
    
        // [:n:n] 的方式会让slice 的 len 和 cap 相等
        scases := cas1[:ncases:ncases]
        pollorder := order1[:ncases:ncases]
        lockorder := order1[ncases:][:ncases]
    
        // 对于 channel 为 nil 的收发操作,他们的 kind 被修改为 caseNil
        for i := range scases {
            case := &scases[i]
            if cas.c == nil && cas.kind != caseDefault {
                *cas = scase{}
            }
        }
    

    order1 会被分为 pollorderlockorder,而这两个slice将会真正决定 select 的随机选择以及死锁问题

    如何随机选择 case

    selectgo 是通过循环 scases 来挑选可以收发的 channel
    然而循环时并不是按照 scases的顺序,而是 pollorder 中记录的顺序, 这样可以避免 channel 的饥饿问题

    为了保证 select 随机选择 case,所以使用 fastrandn 来生成随机数

        for i := 1; i < ncases; i++ {
            j := fastrandn(uint32(i+1))
            pollorder[i] = pollorder[j]
            pollorder[j] = uint16(i)
        }
    

    pollorder 在开始的时候值都是 0,循环结束后值便是随机顺序的 scases 索引

    避免相同 channel 重复加锁,以及死锁问题

    selectgo 在查找 scases 中已经可以进行收发操作的 channel 前会先对所有的 channel 进行加锁操作

    死锁问题

    如果多个 goroutine 都需要锁定 ch1 ch2,而他们加锁的顺序不固定,那么很可能会出现死锁问题
    这个时候,对加锁的顺序就有要求了,按照同样的顺序的话,没有竞争到 ch1.lock 的 goroutine,会等待加锁 ch1.lcok,而不会直接去加锁 ch2.lock

    加锁前首先会对 lockorder 进行堆排序,生成由 case.c(*hchan) 来排序的 scases 索引顺序

    func selectgo(cas0 *scase, order0 *uint16, ncase int)(int, bool) {
        ...
        // ... 对 looporder 堆排序
    
        // selectgo 在查找 scases 前,先对所有 channel 加锁
        sellock(scases, lockorder)
        ...
    }
    

    sellock 对地址相同的 channel 只会加锁一次

    func sellock(scases []scases, lockorder []int16) {
        var c *hchan
        for _, o := range lockorder {
            c0 := scases[0].c // 根据加锁顺序获取 case
    
            // c 记录了上次加锁的 hchan 地址,如果和当前 *hchan 相同,那么就不会再次加锁
            if c0 != nil && c0 != c {
                c = c0
                lock(&c.lock)
            }
        }
    }
    

    加锁完成后,可以进入 selectgo 主循环逻辑了
    主逻辑会分为三部分:

    1. 首先根据 pollorder 的顺序查找 scases 是否有可以立即收发的 channel
    2. channel 都没有准备好,并且不存在 default,那么就将当前 goroutine 加入到 channel 相应的等待队列,然后等待收其他 goroutine 唤醒
    3. 被唤醒后,再次找到满足条件的 channel
    查找 scases 中是否有准备好的 channel
    loop:
        for i := 0; i < ncases; i++ {
            // 根据 `pollorder` 记录的随机 scases 索引来获取 cas
            casi = int(pollorder[i])
            cas = &scases[casi]
    
            c = cas.c
            switch case.kind {
            case caseNil:
                continue
            case caseRecv:
                // ...
            case caseSend:
                // ...
            case caseDefault:
                // ...
            }
        }
        // ...
    }
    

    根据 pollorder 记录的随机 scases 索引来遍历处理 case,然后根据 case.kind 来查看 channel 是否准备好,然后 goto 跳转到相应逻辑

    case.kindcaseNil,说明 channel 为 nil,那么 continue,不进行任何处理

    caseRecv: channel 接收操作
            switch case.kind {
            case caseRecv:
                sg = c.sendq.dequeue()
                if sg != nil {
                    goto recv
                }
                if c.qcount > 0 {
                    goto bufrecv
                }
                if c.closed != 0 {
                    goto rclose
                }
            ...
            }
    

    如果 channel 中有待发送的 goroutine, 跳转到 recv,调用 recv完成接收操作

    recv:
        // src/runtime/chan.go
        recv(c, sg, cas.elem, func() { selunlock(cases, lockorder) }, 2)
        recvOK = true
        goto retc
    

    如果 channel 中有缓冲数据,那么跳转到 bufrecv,从缓冲区中获取数据

    bufrecv:
        recvOK = true
        gp = chanbuf(c, c.recx)
        if cas.elem != nil {
            typedmemclr(c.elemtype, gp)
        }
        // ... channel 缓冲区调整
        selunlock(scases, lockorder)
        goto retc
    

    如果 channel 已关闭,跳转到 rclose, 将接收值置为空值,recvOK 置为 false

    rclose:
        selunlock(scases, lockorder)
        if cas.elem != nil {
            typedmemclr(c.elemtype, cas.elem)
        }
        goto retc
    

    接收操作与正常接收操作类似,可以参考 channel 接收数据

    caseSend: channel 发送操作
            switch case.kind {
            case caseSend:
                if c.closed != 0 {
                    goto sclose
                }
                sg = c.recvq.dequeue()
                if sg != nil {
                    goto send
                }
                if c.qcount < c.dataqsiz {
                    goto bufsend
                }
            ...
            }
    

    对于发送操作会先判断 channel 是否已经关闭,跳转到 sclose,直接 panic

    sclose:
        selunlock(scases, lockorder)
        panic(plainError("send on closed channel"))
    

    如果 channel 为关闭,并且有待接收队列不为空,说明 channel 的缓冲区为空,跳转到 send , 调用 send 函数,直接发送数据给待接收者

    send:
        send(c, sg, cas.elem, func() { selunlock(scases, lockorder) }, 2)
        goto retc
    

    如果缓冲区不为空的话,跳转到 bufsend,从缓冲区获取数据

    bufsend:
        typedmemove(c.elemtype, chanbuf(c, c.sendx), cas.elem)
        // ... 调整缓冲区索引
        selunlock(scases, lockorder)
        goto retc
    
    caseDefult: default
        var dfli int
        var dfl *scase
        for i := 0; i < ncases; i++{
            // ...
            switch case.kind {
            ...
            case caseDefault:
                dfli = casi
                dfl = cas
            }
        }
        if dfl != nil {
            selunlock(scases, lockorder)
            casi = dfli
            cas = dfl
            return casi, recvOK
        }
    

    dflidfl 记录了 kind 为 caseDefault 的 case

    如果所有 channel 都没有完成收发操作,那么就不会执行 goto 跳转,循环结束后判断 dfl != nil 存在 default,就直接返回 default 的索引 dfli

    加入 channel 等待队列,等待被唤醒

    如果没有 channel 可以执行收发操作,并且没有 default case,那么就将当前 goroutine 加入到 channel 相应的收发队列中,等待被其他 goroutine 唤醒

    func selectgo(cas0 *scase, order0 *uint16, ncase int)(int, bool) {
        // ...
    
        setlock(scases, lockorder)
    
        // ... 查看是否有准备好的 channel,或者存在 default case
    
        gp = getg()
        for _, casi := range lockorder {
            casi = int(casei)
            cas = &scases[casi]
            if cas.kind == caseNil {
                continue // channel 为 nil 直接跳过
            }
            c = cas.c
    
            // 构造 sudog
            sg := acquireSudog()
            sg.g = gp
            sg.isSelect = true
            sg.elem = cas.elem
            sg.c = c
    
            // 加入相应等待队列
            switch cas.kind {
            case caseRecv:
                c.recvq.enqueue(sg)
            case caseSend:
                c.sendq.enqueue(sg)
            }
        }
        // 被唤醒后会根据 param 来判断是否是由 close 操作唤醒的,所以先置为 nil
        gp.param = nil
        // selparkcommit 会解锁所有 channel
        gopark(selparkcommit, nil,waitReasonSelect, traceEvGoBlockSelect, 1)
    

    将当前 goroutine 加入到每一个 channel 等待队列中
    如果所有的 channel 都是 nil 的话就会被永久阻塞,不会被唤醒

    多个channel同时准备进行收发操作,如果解决竞争问题

    selectgo 构建出来的 sudog 会将 isSelect 置为 true,这样时为了避免多个 channel 从等待队列中获取相同 goroutine 封装的 sudog

    现在来看一下 等待队列的出队方法 dequeue

    struct hchan {
        ...
        sendq *waitq
        recvq *waitq
    }
    func (q *waitq) dequeue() *sudog {
        for {
            sgp := q.first
            if sgp == nil {
                return nil
            }
            // ...
    
            // 原子操作,如果 spg.g.selectDone 不为 1,则修改为 0
            // 为 1 说明 spg.g 已经被其他 channel 取出,直接跳过 sgp
            if sgp.isSelect && !atomic.Cas(spg.g.selectDone, 0, 1) {
                continue
            }
        }
        return sgp
    }
    
    被唤醒,选择合适的 case
    func selectgo(cas0 *scase, order0 *uint16, ncase int)(int, bool) {
        // ...
    
        gopark(selparkcommit, nil,waitReasonSelect, traceEvGoBlockSelect, 1)
    
        // 加锁所有的 channel
        sellock(scases, lockorder)
        // param 存放唤醒 goroutine 的 sudog,如果是关闭操作唤醒的,那么就为 nil
        sg = (*sudog)(pg.param)
        gp.param = nil
    
        // waiting 链表按照 lockorder 顺序存放着 sudog
        sglist = gp.waiting
    
        casi = -1
        cas = nil // cas 便是唤醒 goroutine 的 case
        for _, casei := range lockorder {
            k = &scases[casei]
            if k.kind == caseNil {
                continue
            }
            // 如果相等说明,goroutine 是被当前 case 的 channel 收发操作唤醒的
            // 如果是关闭操作,那么 sg 为 nil, 不会对 cas 赋值
            if sg == sglist {
                casi = int(casei)
                cas = k
            } else {
                // goroutine 已经被唤醒,将 sudog 从相应的收发队列中移除
                c = k.c
    
                // func (q *waitq) dequeueSudoG(sgp *sudog)
                // dequeueSudoG 会通过 sudog.prev 和 sudog.next 将 sudog 从等待队列中移除
                if k.kind == caseSend {
                    c.sendq.dequeueSudoG(sglist)
                } else {
                    c.recvq.dequeueSudoG(sglist)
                }
            }
    
            // 释放 sudog,然后准备处理下一个 sudog
            sgnext = sglist.waitlink
            sglist.waitlink = nil
            releaseSudog(sglist)
            sglist = sgnext
        }
        ...
    }
    

    当前goroutine 被唤醒后,将其他 sudog 从相应的 channel 等待队列中移除

    selectgo 会根据变量 cas 的值来判断是收发操作唤醒还是关闭操作唤醒
    关闭操作唤醒的话 gp.param 会被置为 nil,那么就不会赋值 cas 变量

    func selectgo(cas0 *scase, order0 *uint16, ncase int)(int, bool) {
        // ...
    loop:
        // ... 查找 scases 中是否有准备完成的 channel
    
        gopark(selparkcommit, nil,waitReasonSelect, traceEvGoBlockSelect, 1)
    
        // 加锁所有的 channel
        sellock(scases, lockorder)
        // ...
    
        if cas == nil {
            // 由关闭操作唤醒 goroutine,那么再次回到 loop 处
            goto loop
        }
        c = cas.c
        if cas.kind == caseRecv {
            recvOK = true
        }
        selunlock(scases, lockorder)
        return casi, recvOK
    }
    
    关闭操作唤醒 selectgo,并不一定会选择该 case

    对于关闭操作唤醒,逻辑会回到 loop 中再次执行 scases 的检查操作

    关闭操作唤醒 selectgo 后,在完成所有 channel 加锁前又有 channel 准备好收发操作了,那么在 loop 查询时,按照 pollorder 随机顺序,可能会选中刚刚准备好的 channel,而不是唤醒 selectgo 的 case

    收发操作唤醒 selectgo, 必定会选择该 case

    而对于收发操作,已经完成了值的拷贝,必然会选择这个 case,而不会再次去查询

    总结

    使用 单 channel,单 channel + default 时编译器会对 select 进行编译转换,这些转换会带来性能的优化

    多 channel:

    • 首先会对所有的所有的 channel 进行加锁,而加锁过程会按照 channel 进行排序,避免了死锁的出现,并且不会对 channel 重复加锁

    • 加锁完成后,会使用随机数来打乱查询的顺序,随机选择 case 避免 channel 饥饿,保证公平性

    • 如果所有 channel 都没有准备好,并且有 default case,那么就选择 default case

    • 如果没有 default,那么 select 就会阻塞,然后等待被其他 goroutine 唤醒 select

    • 关闭操作唤醒 select 时,最终选择的并不一定是该 channel case,因为会再次使用随机顺序来选择合适的 channel

    • 收发操作唤醒 select 时,必然会选择该 channel case

    参考

    Go 语言设计与实现 - select

    推荐阅读

    Golang 夜读 第 56 期 channel & select 源码分析

    相关文章

      网友评论

          本文标题:Go 深入源码 —— select 与 channel

          本文链接:https://www.haomeiwen.com/subject/zprbvktx.html