• 有关golang信道的面试笔记


    信道是一个goroutine之间很关键的通信媒介。

    理解golang的信道很重要,这里记录平时易忘记的、易混淆的点。

    1. 基本使用

    刚声明的信道,零值为nil,无法直接使用,需配合make函数进行初始化

       ic :=  make(chan int)
       ic  <-22   // 向无缓冲信道写入数据
       v := <-ic  // 从无缓冲信道读取数据
    
    • 无缓冲信道: 一手交钱,一手交货, sender、receiver必须同时做好动作,才能完成发送->接收;否则,先准备好的一方将会阻塞等待。
    • 有缓冲信道 make(chan int,10):滑轨流水线,因为存在缓冲空间,故并不强制sender、receiver必须同时准备好;当通道空或满时, 一方会阻塞。

    信道存在三种状态: nil, active, closed

    针对这三种状态,sender、receiver有一些行为,我也不知道如何强行记忆这些行为 ☹️:

    动作 nil active closed
    close panic 成功 panic
    ch <- 死锁 阻塞或成功 panic
    <-ch 死锁 阻塞或成功 零值

    2. 从1个例子看chan的实质

    package main
     
    import (
        "fmt"
    )
     
    func SendDataToChannel(ch chan int, value int) {
        fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) // %v 显示struct的值;%T 显示类型
        ch <- value
    }
     
    func main() {
        var v int
        ch := make(chan int)     
        fmt.Printf("ch's value:%v, chan's type: %T \n", ch, ch) 
        go SendDataToChannel(ch, 101)         // 通过信道发送数据
        v = <-ch                              //  从信道接受数据
        fmt.Println(v)       // 101
    }
    

    能正确打印101。

    Q1: 刚学习golang的时候,一直给我们灌输golang函数是值传递,那上例在另外一个协程内部对形参的操作,为什么会影响外部的实参?

    请关注格式化字符的日志输出:

    ch's value:0xc000018180, chan's type: chan int 
    ch's value:0xc000018180, chan's type: chan int 
    101
    

    A: 上面的日志显示传递的ch是一个指针值0xc000018180,类型是chan int( 这并不是说ch是指向chan int类型的指针)。

    chan int本质就是指向hchan结构体的指针。

    内置函数make创建信道: func makechan(t *chantype, size int) *hchan返回了指向hchan结构体的指针:

    type hchan struct {
    	qcount   uint           // 队列中已有的缓存元素的长度
    	dataqsiz uint           // 环形队列的长度
    	buf      unsafe.Pointer // 环形队列的地址
    	elemsize uint16
    	closed   uint32
    	elemtype *_type // 元素类型
    	sendx    uint   // 待发送的元素索引
    	recvx    uint   // 待接受元素索引
    	recvq    waitq  // 阻塞等待的goroutine
    	sendq    waitq  // 阻塞等待的gotoutine
    
    	// 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
    }
    

    Q2: 缓冲信道内部为什么要使用环形队列?

    A:golang是使用数组来实现信道队列,在不移动元素的情况下, 队列会出现“假满”的情况,


    在做成环形队列的情况下, 所有的入队出队操作依旧是 O(1)的时间复杂度,同时元素空间可以重复利用。
    需要使用sendIndex,receIndex来标记实际的待插入/拉取位置,显而易见会出现 sendIndex<=receIndex 的情况。

    recvq,receq是由链表实现的队列,用于存储阻塞等待的goroutine和待发送/待接收值,
    这两个结构也是阻塞goroutine被唤醒的准备条件。

    3. 发送/接收的细节

    不要使用共享内存来通信,而是使用通信来共享内存

    元素值从外界进入信道会被复制,也就是说进入信道的是元素值的副本,并不是元素本身进入信道 (出信道类似)。

    金玉良言落到实处:不同的线程不共享内存、不用锁,线程之间通讯用channel同步也用channel。
    发送/接收数据的两个动作(G1,G2,G3)没有共享的内存,底层通过hchan结构体的buf,使用copy内存的方式进行通信,最后达到了共享内存的目的。

    ② 根据第①点,发送操作包括:复制待发送值,放置到信道内;
    接收操作包括:复制元素值, 放置副本到接收方,删除原值,以上行为在全部完成之前都不会被打断
    所以第①点所说的无锁,其实指的业务代码无锁,信道底层实现还是靠锁。

    以send操作为例,下面代码截取自 https://github.com/golang/go/blob/master/src/runtime/chan.go#L216

    if c.qcount < c.dataqsiz {
      	// Space is available in the channel buffer. Enqueue the element to send.
      	qp := chanbuf(c, c.sendx)         // 计算出buf中待插入位置的地址
      	if raceenabled {
      		racenotify(c, c.sendx, nil)
      	}
      	typedmemmove(c.elemtype, qp, ep)  // 将元素copy进指定的qp地址
      	c.sendx++                         // 重新计算待插入位置的索引
      	if c.sendx == c.dataqsiz {
      		c.sendx = 0                      
      	}
      	c.qcount++
      	unlock(&c.lock)
      	return true
      }
    

    一个常规的send动作:

    • 计算环形队列的待插入位置的地址
    • 将元素copy进指定的qp地址
    • 重新计算待插入位置的索引sendx
    • 如果待插入位置==队列长度,说明插入位置已到尾部,需要插入首部。
    • 以上动作加锁

    进入等待状态的goroutine会进入hchan的sendq/recvq列表

    调度器将G1、G2置为waiting状态,G1、G2进入sendq列表,同时与逻辑处理器分离;

    直到有G3尝试读取信道内recvx元素,之后将唤醒队首G1进入runnable状态,加入调度器的runqueue。

    这里面涉及gopark, goready两个函数。

    如果是无缓冲信道引起的阻塞,将会直接拷贝G1的待发送值到G2的存储位置

    ✍️ https://github.com/golang/go/blob/master/src/runtime/chan.go#L527

    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    func SendDataToChannel(ch chan int, value int) {
    	time.Sleep(time.Millisecond * time.Duration(value))
    	ch <- value
    }
    
    func main() {
    	var v int
    	var ch chan int = make(chan int)
    	go SendDataToChannel(ch, 104) // 通过信道发送数据
    	go SendDataToChannel(ch, 100) // 通过信道发送数据
    	go SendDataToChannel(ch, 95)  // 通过信道发送数据
    	go SendDataToChannel(ch, 120) // 通过信道发送数据
    
    	time.Sleep(time.Second)
    	v = <-ch       //  从信道接受数据
    	fmt.Println(v)  
    
    	time.Sleep(time.Second * 10)
    }
    

    Q3:上述代码大概率稳定输出95

    A:虽然4个goroutine被启动的顺序不定,但是肯定都阻塞了,阻塞的时机不一样,被唤醒的是sendq队首的goroutine,基本可认为第三个goroutine被首先捕获进sendq ,因为是无缓冲信道,将会直接拷贝G3的95给到待接收地址。

    4. 业内总结的信道的常规姿势

    无缓冲、缓冲信道的特征,已经在golang领域形成了特定的套路。

    • 当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,此时的信道称之为无缓冲信道。

    • 当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞,利用这点可以利用信道来做锁。

    • 当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

    Q4: 为什么无缓冲信道不适合做锁?

    A: 我们先思考一下锁的业务实质: 获取独占标识,并能够继续执行; 无缓冲信道虽然可以获取独占标识,但是他阻塞了自身goroutine的执行,所以并不适合实现业务锁。

  • 相关阅读:
    学习笔记:SpringCloud 微服务技术栈_高级篇⑤_可靠消息服务
    2.Python # 代码注释
    QT用户登录注册,数据库实现
    开环控制(自动控制理论)
    MySQL数据库中,在读已提交和可重复读这两个不同事务隔离级别下幻读的区别
    Mac M1安装ROS1或ROS2
    AES和Rijndael的区别
    【Java网络编程】二
    知识运维概述
    运维开发实践 - Docker - 容器实现原理
  • 原文地址:https://www.cnblogs.com/JulianHuang/p/16466093.html