一文搞懂go并发编程设计原理
创始人
2024-05-15 17:24:04
0

前言

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

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

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")}// 创建新contextreturn &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 {Contextmu       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()// 第一次checkoldVal, ok := m.data[key]m.lock.Unlock()if ok {return oldVal, true}m.lock.Lock()defer m.lock.Unlock()// 第二次checkoldVal, ok = m.data[key]if ok {return value, true}m.data[key] = valuereturn value, false
}
  • 第一次check能不能不要?

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

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

读优先和写优先

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

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

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

Mutex原理

加锁流程:

在这里插入图片描述

mutex的结构如下:

type Mutex struct {state int32sema  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 uint64state2 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用循环数组实现
  • 存放发送接收的等待队列

发送流程:

在这里插入图片描述

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

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

接收流程

在这里插入图片描述

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
【PdgCntEditor】解... 一、问题背景 大部分的图书对应的PDF,目录中的页码并非PDF中直接索引的页码...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
修复 爱普生 EPSON L4... L4151 L4153 L4156 L4158 L4163 L4165 L4166 L4168 L4...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...