一文搞懂go并发编程设计原理

news2024/9/26 1:25:53

前言

主要学习其设计原则,大体流程,权衡利弊

不要纠结于部分难懂的实现细节,因为不同的人对相同接口的实现细节不一样,就算是相同的人实现两次也可能不一样

context

context的作用主要有两个:

  • 在整个请求的执行过程中传递一些业务无关的数据,例如userId,logId,避免所有方法都要加这些参数

    • 其他语言,例如java用过ThreadLocal实现该功能
  • 控制整个链路的取消,超时

在这里插入图片描述

  • 一般将context.Context作为方法的第一个参数,就算现阶段用不着也建议加上,减少后期代码修改。除非明确知道不需要context的功能,例如util,helper包下的工具方法

  • context不应该作为结构体的字段

    • context代表一个请求上下文,只应该在该请求的生命周期内被使用
    • 如果放到结构体里,请求的生命周期结束后还能访问该ctx,违背了context的设计原则,除非这个结构体的生命周期和请求相同,例如http.Request

valueCtx

通过funcWithValue(parent Context, key, val any) Context可以向ctx中并发安全地设置一个键值对

  • 为什么需要并发安全?

    • ctx可以在不同goroutine之间传递,如果不同goroutine同时往ctx中读写数据,就会有并发问题
  • 如果我们来设计会如何实现?

    • map:不是并发安全
    • sync.Map:可以满足要求,但go没有这么做

go通过创建新context来实现:

func WithValue(parent Context, key, val any) Context {
   if parent == nil {
      panic("cannot create context from nil parent")
   }
   if key == nil {
      panic("nil key")
   }
   if !reflectlite.TypeOf(key).Comparable() {
      panic("key is not comparable")
   }
   // 创建新context
   return &valueCtx{parent, key, val}
}
```go

例如下面的例子,会构造出很多层context

func main() {
ctx := context.TODO()
ctxa := context.WithValue(ctx, “a”, “aa”)
ctxb := context.WithValue(ctxa, “b”, “bb”)
ctxc := context.WithValue(ctxb, “c”, “cc”)
}


![在这里插入图片描述](https://img-blog.csdnimg.cn/7f1fa0af7f034cb194fe1ce4d37b0a30.jpeg#pic_center)

  


查找value就是先看自己的key是否和参数相同,如果相同返回自己的value,否则去父节点找

```go
func (c *valueCtx) Value(key any) any {
   if c.key == key {
      return c.val
   }
   return value(c.Context, key)
}

注意事项

  • 父context无法获取子context设置的kv

    • 因为从父contetx向上的路径中,无法找到子context设置的kv

    • 当然可以可以绕开这个限制,父context放个map进去,这样子context对该map的修改父也能看到

      • 不推荐,因为违反了ctx中数据不可变的特性
  • 如果父的链条中有两个节点的key相同,会返回离自己最近的节点的value

设计要点

  • 为什么不用map存储,而是用类似链表的方式存储数据?

    • context的设计理念是不可变
  • 为什么链表方式串联数据是并发安全的?因为其巧妙地使用了子节点指向父节点的串联方式:

    • :当一个context创建好后,从自己往父的链表是固定的,无法被修改
    • :将自己追加到一个链表中这个操作,只会在各自的goroutine中进行
  • 用链表存储数据的优劣势:

    • 优势:实现简单,不用加锁就能实现并发 安全
    • 劣势:层级较多时性能较低,因此gin.Context采用map来实现

cancelCtx

通过funcWithCancel(parent Context) (ctx Context, cancel CancelFunc)

创建出可以取消的ctx

type cancelCtx struct {
   Context

   mu       sync.Mutex
   // 一个channel,当能从channel读取数据时,表示该context被关闭           
   done     atomic.Value
   // 维护子context,本context被关闭时,同时会关闭子的          
   children map[canceler]struct{} 
   err      error                 
}

当调用WithCancel返回的cancel方法时,可以取消所有监听该ctx和子ctx的goroutine

  • 并不是调用了cancel就会平白无故地取消子goroutine,需要在子goroutine里需要配合监听ctx.Done
func main() {
   ctx := context.TODO()
   cancelCtx, cancel := context.WithCancel(ctx)
   defer cancel()

   go func() {
      for  {
          <-cancelCtx.Done()
          // 被上游取消,结束业务处理
          return

         /**
         业务处理
         */
      }
   }()
   /**
   业务处理
    */
}

设计要点

  • 创建cancelCtx时,核心是找到最近的cancelCtx类型的祖先,将自己加到该祖先的children里面,这样祖先被cancel时,自己也会被cancel,达到级联取消的效果

  • 如果找不到,就新起一个goroutine监听父的done信号自己的done信号

    • go func() {
         select {
         case <-parent.Done():
            child.cancel(false, parent.Err())
         case <-child.Done():
         }
      }()
      
    • 为什么要监听父的done信号?因为无法加到父的children字段里,只能通过监听父的done信号来达到级联取消的效果
    • 为什么要监听自己的?如果自己比父早被取消,该gouroutine也可以退出
  • 如果父没有done信号,说明父永远不会被取消,什么也不用监听

    • 这样只有在cancel方法被调用时才会取消

Done方法用一个双重检测的方式,确保c.done只被初始化一次

如果第一次不检测在功能上完全没有问题,但原子操作比加锁快,性能更好

func (c *cancelCtx) Done() <-chan struct{} {
   d := c.done.Load()
   // 第一次检测
   if d != nil {
      return d.(chan struct{})
   }
   
   c.mu.Lock()
   defer c.mu.Unlock()
   d = c.done.Load()
   // 第二次检测
   if d == nil {
      d = make(chan struct{})
      c.done.Store(d)
   }
   return d.(chan struct{})
}

cancel方法主要干了两件事:

  • 关闭自己的done:close(done)
  • 遍历children,挨个调用child的cancel方法

timerCtx

通过funcWithDeadline(parent Context, d time.Time) (Context, CancelFunc)

可以创建一个带超时控制的ctx

WithTimeout底层调的WithDeadline,两者本质上一样

timerCtx用装饰器模式,在cancelCtx上增加了超时的功能

用 time.AfterFunc开启计时器,时间到了执行cancel方法

c.timer = time.AfterFunc(dur, func() {
   c.cancel(true, DeadlineExceeded)
})

设计要点

  • 如果parent也是timerCtx,且parent的过期时间比当前ctx的过期时间早,就只创建一个cancelCtx,避免开启定时器的开销

    • 为什么?因为parent时间到被cancel时,自己时间没到,因此自己的定时器一定不会被触发,还不如不创建

sync.Mutex和sync.RWMutex

用于保护需要被保护的资源

  • 哪些资源需要被保护?

    • 可能被多个goroutine同时读写
  • 哪些资源不需要被保护?

    • 只在单个goroutine中操作
    • 多个goroutine只读该资源

封装模式

如果一个资源需要被保护,则需要暴露出被封装的方法,而不是将资源和锁都暴露出去,让用户自己考虑要不要加锁,怎么加锁

错误使用:

var Resource map[string]interface{}
var ResourceLock sync.Mutex

正确使用:

// 包私有
var resource map[string]interface{}
var resourceLock sync.Mutex

// 封装Get
func GetResource(key string) interface{} {
   resourceLock.Lock()
   defer resourceLock.Unlock()
   return resource[key]
}

// 封装Set
func SetResource(key string, value interface{}) {
   resourceLock.Lock()
   defer resourceLock.Unlock()
   resource[key] = value
}

这样保证对resource的使用不会出错,也体现封装的设计模式

双重检测

对于check and do模式,一般采用双重检测的模式进行操作,即:

  1. 加读锁
  2. 执行check,如果不满足要求直接返回
  3. 释放读锁
  4. 如果满足要求,加写锁
  5. 再check一次,如果还满足要求,执行do

例如,要对一个map实现LoadAndStore功能:如果某个key存在,就返回对应的value,否则插入kv

type SafeMap struct {
   data map[string]interface{}
   lock sync.RWMutex
}

func (m *SafeMap) LoadAndStore(key string, value interface{}) (interface{}, bool) {
   m.lock.RLock()
   // 第一次check
   oldVal, ok := m.data[key]
   m.lock.Unlock()
   if ok {
      return oldVal, true
   }

   m.lock.Lock()
   defer m.lock.Unlock()
   // 第二次check
   oldVal, ok = m.data[key]
   if ok {
      return value, true
   }
   m.data[key] = value
   return value, false
}
  • 第一次check能不能不要?

    • 第一次check其实可以不要,不会影响流程的正确性,但在读多写少的场景下先加读锁check一次的性能更高
  • 为什么需要加写锁后再check一次?

    • 因此释放读锁后,可能其他goroutine已经插进来执行了do操作,如果这里不再检测一次,就会在check不成功的情况执行do操作,不满足check and do的语义

读优先和写优先

读写锁中有读优先和写优先两种模式:

  • 读优先:已经有写锁在等待的情况下,还能成功加读锁
  • 写优先:已经有写锁在等待的情况下,不能再加读锁,这样当之前的读锁都释放后,写锁就能加成功

一般的语言,例如go都是是写优先防止写饥饿

Mutex原理

加锁流程:

在这里插入图片描述

mutex的结构如下:

type Mutex struct {
   state int32
   sema  uint32
}
  • state是锁的核心状态,加锁就是将state修改为某个值
  • sema用于阻塞,唤醒goroutine

流程图中的要点:

  • 自旋:分为快路径:一次性的自旋和慢路劲:多次自旋

    • 快路径:CAS将其从0改为加锁状态

    • 慢路径:什么情况下可以进行慢路径自旋?

      • 自旋次数小于4
      • cpu核数大于1
      • 至少存在1个其他运行的P
      • P的本地队列为空
  • 为什么有饥饿模式和正常模式?

    • 正常模式:效率更高,新来的goroutine可以和队列中的goroutine抢锁,因为新来的已经占着cpu,大概率能拿到锁。为什么效率更高?避免了先阻塞进入队列,在被唤醒执行的调度开销

    • 饥饿模式:保证公平,防止饥饿,新来的不能抢锁,需要进入队列等待

    • 什么情况下锁变为饥饿模式?

      • 某个goroutine等待超过1ms
    • 什么情况退出饥饿模式?

      • 队列只剩下一个goroutine
      • 等待时间小于1ms

注意事项

  1. 加锁和解锁需要成对出现,推线使用defer解锁
  2. 锁都是不可重入的,若需要重入功能需要自己封装
  3. 对于读多写少的场景使用RWLock

sync.Pool

当需要缓存对象时,可以使用sync.Pool

从pool中获取时,会先看池中有没有,如果没有创建新的对象

gc时pool会释放一部分资源

pool的优点:

  • 减少内存分配开销
  • 内存分配少了,gc 的压力也会变小

对象重置

当对象要被复用时,需要重置掉对象的属性,避免两个请求共用相同的用户数据

例如gin框架在从pool中取出gin.Context时,先重置其属性,再交给用户的方法使用:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   c := engine.pool.Get().(*Context)
   c.writermem.reset(w)
   c.Request = req
   // 重置属性
   c.reset()

   engine.handleHTTPRequest(c)

   engine.pool.Put(c)
}
  • 放回去之前重置还是取出来重置?

    • 放回去之前重置:如果对象占用空间很大,放回去之前重置能减少pool的内存占用
    • 取出来重置:如果对象占用空间不大,则放回去之前重置的对象可能被gc回收,导致白做工,此时用取出来重置的方式比较好

pool原理

  • 如果我们自己设计会怎么实现?

    • 首先考虑用一个channel来存放对象,但这样放入,获取都要加锁
    • 全局锁性能不高,那可以用分段锁的方式,每个P一个队列,用锁保护

sync.Pool的实现原理:

  • 每个p有一个poolLocalInternal对象

  • poolLocalInternal包含privateshared

  • private只会被对应的p使用

    • 也就是说往private放入数据,从private获取数据都不需要加锁
  • shared指向poolChain

    • poolChain从整体上来说是一个双向链表
    • poolChain的每个节点poolDequeue都是一个循环数组

在这里插入图片描述

在这里插入图片描述

  • 为什么poolDequeue设计成循环数组?

    • 即数组的优点:一次性分配好内存,对cpu 缓存友好

Get

在这里插入图片描述

Put

在这里插入图片描述

设计要点

  • 为什么从队头放入数据,从队尾获取数据?

    • 在分段锁的基础上,进一步减少锁竞争的几率,这样只有在同时操作一个双向链表的同一个节点时才会加锁
  • 从victim中获取数据可以从private获取,而偷取其他poolChain的数据可能需要加锁,为什么可能需要加锁的优先级更高?

    • 因为sync.Pool希望victim中的数据被尽快回收,只有在偷不到的情况下才尝试从victim中获取

注意事项

  • sync.Pool的容量设置和淘汰策略,用户无法手动控制,其中淘汰策略完全依赖gc

    • gc每次触发,都会把victim中的数据清空,将正常数据放入victim
    • 若需要手动控制容量,可以用装饰器模式包装pool,自己决定大对象不放入pool,超过一定数量不放入
  • 为什么需要victim,而不是每次gc都把正常数据清空?

    • 防止性能抖动,这样正常数据要经过两次gc才会被清空gingin

使用gin的框架的一个坑

如果在业务处理中需要开goroutine,不能直接将gin.Context传给新的goroutine

如果直接传过去,当本次请求结束后该ctx被复用时,此时就有两个goroutine同时在使用该ctx:

  • 原先请求中新开的goroutine:g1
  • 新的业务请求goroutine:g2

而g1需要使用的用户数据是原先请求的,但当新业务请求到来时,g2会给ctx设置新的用户数据

导致用户数据发生窜用

因此当需要在业务请求中开goroutine时,需要调用Copy方法复制一份gin.Context

sync.WaitGroup

用于同步多个goroutine之间的工作:

  1. Add(1):开启子任务,state+1
  2. Done():结束子任务,state-1
  3. Wait():阻塞等待,直到所有子任务执行完毕

设计原理

WaitGrout其实需要保存3个信息:

  • 多少个子任务
  • 多少个goroutine在等待
  • 等待的goroutine阻塞在哪个队列上
type WaitGroup struct {
   state1 uint64
   state2 uint32
}
  • state1:高32位记录子任务个数,低32位记录多少个goroutine在等待
  • state2:信号量,用于挂起和唤醒等待的goroutine

Add流程:

  1. 给state1高32位加delta(delta可以是负数)
  2. 如果加完后高32位大于0(还有其他子任务),或者低32位等于0(没有waiter),返回
  3. 如果高32位等于0,且低32位不等于0,挨个唤醒waiter

Wait流程:

  1. 如果高32位为0,说明没有子任务,直接返回
  2. 阻塞到队列上,直到被Add唤醒

注意事项

需要注意state的+1和-1需要成对出现

  • 加多了:Wait的goroutine会永久阻塞
  • 减多了:直接panic

因此可以用errgroup来完成WaitGroup的功能,因为其封装了对+1-1的操作,保证一定成对出现

channel

导致goroutine泄露

channel使用不当时,会导致goroutine泄露:

  • 只发送不接收:该goroutine会一直等到有其他goroutine接收,但一直没被接收
  • 只接收不发送
  • 读写nil的channel

实现原理

channel主要的结构有两个:

  • 存放缓存数据的队列

    • channel用循环数组实现
  • 存放发送接收的等待队列

发送流程:

在这里插入图片描述

  • 为什么有接收者,可以直接将数据给接收者?

    • 数据不用经过缓冲区绕一次,性能更高
    • 有接收者时,说明缓存区一定没有数据,不会破坏队列先入先出的特性

接收流程

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/175658.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

stm32 笔记 PWM输入模式测量脉宽和占空比原理

一、PWM 输入模式测量脉宽 1.1 测量脉宽简介 在测量占空比之前&#xff0c;我们先一步一步来&#xff0c;先让 STM32 可以测量脉宽。 TIM3_CH1&#xff08;tim3 定时器通道 1&#xff09;捕获模式测量脉宽步骤如下&#xff1a; 1.输入捕获到 PWM 上升沿触发 2.发送中断&am…

机器视觉_HALCON_快速向导_2.用HALCON开发程序

文章目录使用HALCON开发应用程序1. 认识HALCON&#xff1a;架构&数据结构1.1. HALCON算子1.2. 参数与数据结构1.2.1. Images 图像1.2.2. Regions 区域1.2.3. XLDS 扩展线1.2.4. Handles 句柄1.2.5. Tuple Mode 元组模式1.3. HALCON与并行编程1.4. HALCON支持计算设备1.5. H…

grant之后要跟着flush privileges吗?

在 MySQL 里面,grant 语句是用来给用户赋权的。不知道你有没有见过一些操作文档里面提到,grant 之后要马上跟着执行一个 flush privileges 命令,才能使赋权语句生效。我最开始使用 MySQL 的时候,就是照着一个操作文档的说明按照这个顺序操作的。 那么,grant 之后真的需要…

33.Isaac教程--操纵运动学

操纵运动学 ISAAC教程合集地址文章目录操纵运动学应用架构实施细节正向运动学逆运动学小码为了控制机器人手臂的运动&#xff0c;需要数学表示法来计算执行器输入并为轨迹规划器表示障碍物。 为实现这一点&#xff0c;操纵运动学 GEM 将铰接式机器人系统表示为连接的刚体&#…

Linux常用命令——sudo命令

在线Linux命令查询工具(http://www.lzltool.com/LinuxCommand) sudo 以其他身份来执行命令 补充说明 sudo命令用来以其他身份来执行命令&#xff0c;预设的身份为root。在/etc/sudoers中设置了可执行sudo指令的用户。若其未经授权的用户企图使用sudo&#xff0c;则会发出警…

pytorch深度学习基础(九)——深入浅析卷积核

深入浅析卷积核引言单通道卷积简单图像边缘检测锐化高斯滤波引言 提到卷积&#xff0c;应该多数人都会想到类似上图的这种示例&#xff0c;可以简单的理解成卷积核与图像中和卷积核相同大小的一块区域与卷积核相乘再求和&#xff0c;通过移动区域产生一个有和组成的新的图像&am…

Python烟花秀

前言 Python跨年烟花表演&#xff0c;具体源码见&#xff1a;Python跨年烟花代码-Python文档类资源-CSDN下载 烟花的粒子类 class particle: #烟花的粒子类 def __init__(self,canvas,num,sums,x,y,x_speed,y_speed,explosion_speed,color,size,max_life): sel…

第四章必备前端基础知识-第二节2:CSS属性

文章目录一&#xff1a;CSS属性一览表二&#xff1a;常用属性详解&#xff08;1&#xff09;字体属性&#xff08;2&#xff09;文本属性&#xff08;3&#xff09;背景属性一&#xff1a;CSS属性一览表 W3C&#xff1a;元素属性 A&#xff1a; align-content规定弹性容器内…

Android studio版本对用的gradle版本和插件版本(注意事项)

简介 Android Studio 构建系统以 Gradle 为基础&#xff0c;并且 Android Gradle 插件添加了几项专用于构建 Android 应用的功能。虽然 Android 插件通常会与 Android Studio 的更新步调保持一致&#xff0c;但插件&#xff08;以及 Gradle 系统的其余部分&#xff09;可独立于…

实体店运营:能提高顾客留存率的店铺陈列方式

今天是大年初一&#xff0c;秦丝祝各位商户老板新年快乐&#xff0c;喜迎开门红&#xff0c;赚个盆满钵满&#xff01;现在还在营业的实体店应该不多了吧&#xff1f;大部分老板都回家团圆了。忙忙碌碌一整年&#xff0c;好不容易到了年关&#xff0c;好好休息是应该的。但是店…

Go存储引擎相关资料汇总

背景 ​ 最近逛知乎的时候看到了这个问题&#xff0c;“Go语言如何写数据库&#xff1f;”。说来我业余时间在这个领域有一些时间精力的投入了&#xff0c;所以想回答一下。我投入的方向是存储引擎方面&#xff0c;所以这篇文章主要是总结一下我看过的一些比较好的Go存储引擎的…

二维费用背包问题

二维费用背包问题一、问题二、思路1、状态表示2、状态转移3、循环设计4、注意三、代码一、问题 二、思路 这道题归根结底还是背包问题的一种&#xff0c;面对背包问题&#xff0c;我们的思路就是面对前i个物品的时候&#xff0c;我们的第i个物品是选还是不选&#xff0c;如果条…

关于ARM的向量中断控制器NVIC

学习或者了解过ARM的朋友应该都会知道NVIC这么个东西&#xff0c;这个东西也是ARM中非常重要的东西&#xff0c;它是ARM不可分离的部分&#xff0c;搭配着内核共同完成着对中断的响应。 1、那到底NVIC是个啥东西呢&#xff1f; NVIC&#xff1a;简称嵌套向量中断控制器。它管理…

【new操作符做了什么 —— js】

&#x1f9c1;个人主页&#xff1a;个人主页 ✌支持我 &#xff1a;点赞&#x1f44d;收藏&#x1f33c;关注&#x1f9e1; 文章目录new操作符具体做了什么&#xff1f;&#x1f388;创建了一个空的对象✨将空对象的原型&#xff0c;指向于构造函数的原型&#x1f367;将空对象…

【操作系统】—— Windows卸载与清除工具“ Geek 与 CCleaner ” (带你快速了解)

&#x1f4dc; “作者 久绊A” 专注记录自己所整理的Java、web、sql等&#xff0c;IT技术干货、学习经验、面试资料、刷题记录&#xff0c;以及遇到的问题和解决方案&#xff0c;记录自己成长的点滴。 &#x1f341; 操作系统【带你快速了解】对于电脑来说&#xff0c;如果说…

day23-网络编程01

1.网络编程入门 1.1 网络编程概述【理解】 计算机网络 是指将地理位置不同的具有独立功能的多台计算机及其外部设备&#xff0c;通过通信线路连接起来&#xff0c;在网络操作系统&#xff0c;网络管理软件及网络通信协议的管理和协调下&#xff0c;实现资源共享和信息传递的计…

微服务框架需要处理哪些问题?

文章目录简述架构选择统一版本管理基础框架包管理业务框架包管理模型分层全局上下文管理数据结构定义上下文的传播前后端数据格式协定统一数据格式字段规范协定异常处理orm配置公共字段处理分页处理字段加解密缓存key的序列化哪些数据进行缓存消息队列key的规范队列的管理注册中…

34.Isaac教程--操作示例应用程序

操作示例应用程序 ISAAC教程合集地址文章目录操作示例应用程序与 Jupyter Notebook 的简单联合控制Shuffle Box with Simulator与 Jupyter Notebook 的简单联合控制 此示例使用 Jupyter Notebook 提供交互式联合控制。 这是处理用于操作组件&#xff08;包括 LQR 规划器&#…

PowerShell 执行策略

在使用 SAPIEN 的PowerShell Studio时出现如下错误&#xff1a;无法在当前系统上运行该脚本。有关运行脚本和设置执行策略的详细信息&#xff0c;请参阅 https:/go.microsoft.com/fwlink/?LinkID135170 中的 about_Execution_Policies。 ERROR: 所在位置 行:1 字符: 2 ERROR: …

python基础——函数编程

python基础——函数编程 文章目录python基础——函数编程一、实验目的二、实验原理三、实验环境四、实验内容五、实验步骤一、实验目的 掌握函数编程 二、实验原理 在Python中&#xff0c;定义函数的语法如下&#xff1a; def 函数名([参数列表])&#xff1a; ‘’‘注解’…