深入解读Golang信道

news/2024/7/24 10:47:23 标签: 队列, 指针, 列表, go, java

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

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

1. 基本使用

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

go">ic :=  make(chan int)
   ic  <-22   // 向无缓冲信道写入数据
   v := <-ic  // 从无缓冲信道读取数据
  • 无缓冲信道:一手交钱,一手交货, sender、receiver必须同时做好动作,才能完成发送->接收;否则,先准备好的一方将会阻塞等待。

  • 有缓冲信道 make(chan int,10):滑轨流水线,因为存在缓冲空间,故并不强制sender、receiver必须同时准备好;当通道空或满时, 有一方会阻塞。

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

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

动作nilactiveclosed
closepanic成功panic
ch <-死锁阻塞或成功panic
<-ch死锁阻塞或成功零值

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

go">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函数是值传递,那上例在另外一个协程内部对形参的操作,为什么会影响外部的实参?

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

go">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[1]创建信道:func makechan(t *chantype, size int) *hchan返回了指向hchan结构体指针

go">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
}
614f7381a5eb670aec48660d178b2139.png

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

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

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

ab501645bcf344ef0e63917f90cf32f6.png

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

go">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`元素[2],之后将唤醒[3]队首G1[4]进入runnable状态,加入调度器的runqueue。

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

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

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

go">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, 50)  // 通过信道发送数据
 go SendDataToChannel(ch, 120) // 通过信道发送数据

 time.Sleep(time.Second)
 v = <-ch       //  从信道接受数据
 fmt.Println(v)  

 time.Sleep(time.Second * 10)
}

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

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

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

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

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

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

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

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

A:我们先思考一下锁的业务实质:

线程自行获取锁标识,执行临界区代码,释放锁标识。

无缓冲信道: 协程(从信道投递或者接收)都能起到获取锁的效果,但当没有其他协程配合时会阻塞自身执行;有其他协程配合时,获取的锁是瞬间的,根本锁不住临界区代码。

缓冲长度为1的信道: 协程(向信道投递/接收一个值)就能起到锁的效果,还可以继续执行临界区代码,之后可以(向信道接收/投递一个值)解锁,整个过程这个协程可以自行完成,完美契合业务互斥锁的行为。

 5.  该选择信道 还是并发原语 

go的信道在golang中是first class, 但是并发原语只是在sync包,另外经典名言“不要使用共享内存来通信,而是使用通信来共享内存”也强调了信道在golang并发中的作用。
 

[那什么时候该使用信道? 什么时候该使用并发原语?]


信道的核心是数据流动,关注到并发问题中的数据流动,把流动的数据放在信道中,就能使用信道解决这个并发问题。
DataFlow -> Drawing -> Pipieline -> Exiting  

mutex的能力是数据不动,某段时间只给一个协程访问数据的权限, 擅长数据位置固定的场景。

参考资料

[1]

内置函数make: https://github.com/golang/go/blob/master/src/runtime/chan.go#L7

[2]

直到有G3尝试读取信道内recvx元素: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L629

[3] 唤醒: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L654
[4]

队首G1: https://github.com/golang/go/blob/1ebc983000ed411a1c06f6b8a61770be1392e707/src/runtime/chan.go#L527

[5]

如果是无缓冲信道引起的阻塞,将会直接拷贝G1的待发送值到G2的存储位置: https://github.com/golang/go/blob/master/src/runtime/chan.go#L616


http://www.niftyadmin.cn/n/1864417.html

相关文章

使用curl指令发起websocket请求

昨日的文章没指出websocket请求协商切换的精髓&#xff0c;删除重发。前文相关&#xff1a;• .NET WebSockets 核心原理初体验[1]• SignalR 从开发到生产部署避坑指南[2]tag&#xff1a;浏览器--->nginx--> server其中提到nginx默认不会为客户端转发Upgrade、Connectio…

Go语言反向代理的姿势

先重温一下什么叫反向代理&#xff0c;正向代理。鹅厂二面&#xff0c;nginx回忆录 所谓正向&#xff0c;反向代理取决于代理的是出站请求&#xff0c;还是入站请求。 正向代理&#xff1a; 代理的出站请求&#xff0c; 客户端能感知到代理程序&#xff0c;架构上距离客户端更近…

ConcurrentDictionary字典操作竟然不全是线程安全的?

好久不见&#xff0c;马甲哥封闭居家半个月&#xff0c;记录之前遇到的一件小事。 标题不准确&#xff0c;实际上ConcurrentDictionary<TKey,TValue>绝大部分api都是线程安全且原子性的[1]&#xff0c; 唯二的例外是接收工厂函数的api&#xff1a;AddOrUpdate、GetOrAdd&…

面向对象五大设计原则

最近在看七牛云许式伟的架构课, 重温了面向对象五大设计原则(SOLID)&#xff0c;扣理论文字找出处。&#xff08;当然许老板是不可能深聊这么低级的内容&#xff0c;&#x1f921;&#xff09; 注意区分设计原则和设计模式。 设计原则更为抽象和泛化&#xff1b; 设计模式也是抽…

两将军问题和TCP三次握手

两将军问题&#xff0c;又被称为两将军悖论、两军问题&#xff0c; 是一个经典的计算机思想实验。 首先&#xff0c; 为避免混淆&#xff0c;我们需要认识到两将军问题虽然与拜占庭将军问题相关&#xff0c;但两者不是一个东西。拜占庭将军问题是一个更通用的两将军问题版本&am…

Go语言正/反向代理的姿势

先重温一下什么叫反向代理&#xff0c;正向代理。鹅厂二面&#xff0c;nginx回忆录[1]所谓正向&#xff0c;反向代理取决于代理的是出站请求&#xff0c;还是入站请求。正向代理&#xff1a;代理的出站请求&#xff0c; 客户端能感知到代理程序&#xff0c;架构上距离客户端更近…

你认识的C# foreach语法糖,真的是全部吗?

本文的知识点其实由golang知名的for循环陷阱发散而来&#xff0c; 对应到我的主力语言C#&#xff0c; 其实牵涉到闭包、foreach。为了便于理解&#xff0c;我重新组织了语言&#xff0c;以倒叙结构行文。 先给大家提炼出一个C#题&#xff1a;观察for、foreach闭包的差异 左边输…

项目管理概述

项目管理 项目是为创造独特的产品、服务或成果而进行的临时性工作。 临时性&#xff1a;指的是有开始、结束时间&#xff0c;而不是指时间很短 独特性&#xff1a;项目中某些可交付成果或者活动中的某些元素是重复的&#xff0c;但项目仍然具有独特性 项目管理让项目目标落地&…