前言

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

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

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用循环数组实现
  • 存放发送接收的等待队列

发送流程:

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

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

接收流程

一文搞懂go并发编程设计原理相关推荐

  1. 一文搞明白Python并发编程和并行编程

    目录 前言 一.基础知识 1.并行和并发 (1)定义 (2)联系 2.进程.线程和协程 (1)定义 (2)联系 3.生成器 (1)yield (2)send, next (3)yield from 4 ...

  2. 一文搞懂: 响应式编程是什么?

    文章目录 导言 响应式编程是什么? 响应式宣言 响应式系统应该具有什么特性 什么是弹性? 回弹性? 消息驱动还是事件驱动? 响应式编程相关讨论 为什么使用观察者模式 什么是背压(backPressur ...

  3. 一文搞懂 响应式编程是什么

    文章目录 导言 响应式编程是什么? 响应式宣言 响应式系统应该具有什么特性 什么是弹性? 回弹性? 消息驱动还是事件驱动? 响应式编程相关讨论 为什么使用观察者模式 什么是背压(backPressur ...

  4. 一个故事搞懂Java并发编程

    故事可能比较奇怪.有这么一个学校,里面有好多好多人,我们简单分成学生.老师.以及宿管阿姨.学校中间还有一个很奇葩的水果超市,里面有个仓库放着苹果.西瓜.橘子.来这个超市的人,一方面可以拿走水果吃掉,另 ...

  5. 一文搞懂后台高性能服务器设计的常见套路, BAT 高频面试系列

    微信搜索

  6. 一文搞懂 Node入门框架 Koa2 原理,学它!

    点击上方 前端Q,关注公众号 回复加群,加入前端Q技术交流群 作者:林不渡 (已获转载授权) 链接:https://juejin.cn/post/6844904071934001160 写在前面 看了 ...

  7. 从零入门 Serverless | 一文搞懂函数计算及其工作原理

    作者 | 孔德慧(夏莞)  阿里云函数计算开发工程师 本文整理自<Serverless 技术公开课>,关注"Serverless"公众号,回复"入门" ...

  8. 从零入门Serverless|一文搞懂函数计算及其工作原理

    云栖号资讯:[点击查看更多行业资讯] 在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 什么是函数计算? 大家都了解,Serverless 并不是没有服务器,而是开发者不再需要关心服务器 ...

  9. 一文搞懂指标采集利器 Telegraf

    作者| 姜闻名 来源|尔达 Erda 公众号 ​ 导读:为了让大家更好的了解 MSP 中 APM 系统的设计实现,我们决定编写一个<详聊微服务观测>系列文章,深入 APM 系统的产品.架构 ...

最新文章

  1. 一.vtun源码学习笔记
  2. Google Capture The Flag 2018 (Quals) - Beginner's Quest - Reverse - Firmware
  3. 基于梯度下降法的——线性回归拟合
  4. 怎么使用缓存、缓存穿透、热点数据处理、缓存雪崩解决方案 - 公开课笔记
  5. linux svn 常用命令
  6. 计算机语言平均数怎么算,使用python怎么求三个数的平均值
  7. Log4cpp 使用手册
  8. 单链表的尾插,头插,遍历,查找和插入
  9. 7 分钟了解 eBay Flink 服务的端到端管理
  10. MyBatis分页插件PageHelper
  11. JavaScript函数的参数
  12. 模拟生成微软序列号python_【python】13位随机序列号生成工具 源码分析
  13. 疯壳AI开源无人机SPI(六轴传感器数据获取)
  14. 手把手教你搭建高逼格监控平台,动起来吧
  15. 【iOS】—— Foundation框架(二)- 数组(NSArray与NSMutableArray)
  16. 使用Qt实现一个必应壁纸客户端
  17. 黔程似锦——2021国庆游攻略
  18. 华为鸿蒙,希望是务实的操作系统
  19. 戴尔服务器r510怎么系统,DELLR510服务器上安系统.docx
  20. Ubuntu mate自启脚本/命令+关闭图形桌面

热门文章

  1. 【报告分享】健身内容创作者发展研究报告-Keep×新榜(附下载)
  2. 文件系统区别ntfs ext fat、mbr guid
  3. linux检查邮件命令,linux下mail 邮件查看命令
  4. 实验二实验结论实验总结与体会
  5. 高新技术企业申报材料汇编
  6. 黑马程序员_音乐(视频)播放器学习
  7. 现有小程序平台有哪些?如何让自己的App运行小程序?
  8. 少儿编程----颜值打分系统
  9. ps中颜色和图片本身颜色不一样,白色呈米色
  10. java runnable执行完_java – 如何停止Runnable计划在一定数量的执行后重复执行