context包
context包核心有4个API
- context.WithValue:设置键值对,并返回一个新的content实例
- context.WithCancel
- context.WithDeadline
- context.WithTimeout 三者都返回一个可以取消的context实例和取消函数
context实例是不可变的,每一次都是新创建
第一个常安全传递数据,只有我这个线程能访问我这个变量
后面用于控制链路
scss
func TestParent(t *testing.T) {
ctx := context.Background()
parent := context.WithValue(ctx, "key", "value")
child := context.WithValue(parent, "key2", "value5")
t.Log(parent.Value("key"))
t.Log(child.Value("key2"))
t.Log(parent.Value("key"))
}
当key相同的时候 子会覆盖父的值 子如果没有会继承父
父级是没有办法拿到子的值的
sync包
加锁
mutex可以看做是锁,而RWMutex则是读写锁
一般的用法是将Mutex或者RWMutex和需要被保护的资源封装一个结构体内
- 如果有多个goroutine同时读写的资源,就一定要保护起来
- 如果多个goroutine只读某个资源,那就不需要保护
csharp
type safeResource struct{
resource interface{}
lock sync.Mutex
}
使用锁的时候,优先使用RWMutex
- RWMutex:核心就是RLock,RUnlick,Lock,Unlock
- Mutex:Lock和Unlock
Mutex细节
- 自旋作为快路径
- 等待队列作为慢路径
锁:本质上是一个状态 自旋:就是一个循环状态 ,原子操作 ,为了调整自己的状态(等待和饥饿);
自旋可以通过控制次数或者时间来退出循环
慢路径和语言特性相关,有些依赖于操作系统线程调度
成功
一把锁,没有人持有,也没有人抢,那么一个cas操作就能成功,一次性的自旋
为什么要有正常模式和饥饿模式
原先有一堆排列等待的goroutine
如果有一个新的goroutine进来争夺锁,而且队列里面也有等待的,你是设计者,你把锁给谁?
- 给等待的:我们要保证公平,先到先得
- 公平竞争:保证效率 新的肯定已经占用了cpu,大概率会得到锁
正常模式:就是新旧竞争的模式,避免goroutine的调度
那如果每次来都对新的抢走,怎么办?
饥饿模式
如果等待的时间超过1ms,锁就会变成饥饿模式,直接会交给原来等待的goroutine
要么队列只剩一个goroutine,要么队列的goroutine等待小于1ms,则退出饥饿
总结
- 先上来一个cas操作,如果这把锁空闲,并且没人抢,那么直接成功
- 否则,自旋几次,成功不加入队列
- 否则加入队列
- 从队列中唤醒
- 正常模式:和新来的一起抢锁,大概率失败
- 饥饿模式: 肯定拿到锁
解锁
加锁状态才能解锁
注意事项
- 适合读多写少的场景
- 写多读少不如直接加写锁
- Mutex和RWMutex都是不可重入的,加了锁在释放之前不能再次加锁
- 尽可能用defer来解锁,避免panic
Once
确保某个动作至多执行一次
必须要传递指针,或者包含的是指针
go
type Do struct {
once sync.Once
}
func (d *Do) Init(){
d.once.Do(func() {
})
}
是一种double-check的变种
没有直接利用读写锁,利用原子操作
Pool
用的时候记得还
-
先查看自己是否有资源,有则直接返回
-
没有创建新的
-
GC的时候会释放缓存的资源
-
一般情况考虑缓存资源
-
复用内存
- 减少内存分配,减轻GC压力
- 少消耗cpu资源
pool细节
TLB therad-local-buffer
每个线程搞一个队列,再来一个共享队列
- 每一个p一个poolLocal对象
- 每个poolLocal有一个private和shared
- shared指向的是一个poolChain,poolChain的数据会被别的p偷走
- poolChain是一个链表+ring buffer的双重结构
- 整体上一个双向列表
- 单个节点指向一个ringbuffer,后一个是前面的两倍
ringbuffer优势: -一次性分配好内存,循环利用 对缓存友好
获取步骤
- private可用就直接返回
- 不可用从自己的poolChain里面获取一个
- 从头开始 ,头是最近创建的ringbuffer
- 从队头往队尾开始
- 找不到就从别的P里面偷一个,偷的是全局并发的,理论上来讲别的P能恰好一起
- 偷是从队尾开始
- 如果偷也偷不到 就去找缓刑的victim
- 如果缓刑也没有,那么自己创建
Put步骤
- private 要是没放东西,就直接放 private
- 否则,准备放 poolChain
- 如果 poolChain 的 HEAD 还没创建,就创建一个HEAD,然后创建一个8容量的 ring buffer,把数据丢过去
- 如果 pooIChain 的 HEAD 指向的 ring bufter 没满则丢过去 ring buffer
- 如果 poolChain 的 HEAD 指向的 ring buffer 已经满了,就创建一个新的节点,并且创建一个两倍容量的ring buffer,把数据丢过去
GC对pool的影响
纯粹的依赖GC,用户完全无法手工控制 核心依赖
- lcoals
- victim
GC
- locals会被挪过去变成victim
- victim会被直接回收到
复活:如果victim的对象再次被使用,那么就会被丢回local,逃过下一轮GC回收的命运
优点: 防止GC引起的性能抖动
为什么不先找缓刑,先偷别人的,还是全局竞争的?
因为sync.Pool希望victime里面的对象尽可能的被回收掉,垃圾回收尽可能的回收
要点
- sync.Pool 和 GC 的关系:数据默认在 local 里面,GC 的时候会被挪过去 victim 里面。如果这时候有P 用了 victim 的数据,那么数据会被放回去 local 里面。
- poolChain 的设计:核心在于理解 poolChain 是一个双向链表加 ring buffer 的双重结构。
由这两个核心衍生出来的各种问题:
- 什么时候 P 会用 victim 的数据:偷都偷不到的时候。
- 为什么 Go 会设计这种结构?一个全局共享队列不好吗?这个问题要结合 TLB 来回答,TLB 解决全局 锁竞争的方案,Go 结合自身 P这么一个优势,设计出来的。
- 窃取:这个可以作为一个刷亮点的东西,结合 GMP 调度里面的工作窃取,原理都是一样的。
- 使用 sync.Pool 有什么注意点(缺点、优点)?高版本的 Go 里面的 sync.Pool 没特别大的缺点,硬要说就是内存使用量不可控,以及 GC 之后即便可以用 victim,Get 的速率还是要差点。
waitgroup
同步多个goroutine之间的工作
拆分给多个goroutine执行任务,在完成后需要合并结果或者需要等所有小任务都完成
在进入goroutine之前,先+1,完成任务就-1
- +多了会导致wait一直阻塞,引起goroutine泄露
- -多了导致panic
实现
- 当前一共有多少个任务还没完成
- 当前多少个goroutine调用了wait方法
- 需要一个东西来进行协调
WaitGroup:
- state1:在 64 位下,高 32 位记录了还有多少任务在运行;低 32 位记录了有多少 goroutine 在等 Wait()方法返回
- state2:信号量,用于挂起或者唤醒goroutine,约等于 Mutex 里面的 sema 字段要注意横向对比)
本质上,WaitGroup 是一个无锁实现,严重依赖于CAS 对 state1 的操作。
channel
-
用于传递数据-队列
-
利用阻塞行;可以间接控制goroutine或者其他资源的消耗。有点想令牌桶机制
- channel有没有缓冲
- 谁在发
- 谁在收
- 关了没
无缓冲
- 要求两端都得有goroutine ,否则就是阻塞
有缓冲
- 没满或者没空之前都不会阻塞,但是满了或者空了就会阻塞
对于发送者,只要发出的数据没有地方放,就是阻塞
对于接受者,尝试拿数据但是没有拿到就是阻塞
发布订阅的模式:发布者不断往里面塞数据,订阅者从管道取数据
缺陷
- 没有消费组概念,不能说同一个事件被多个goroutine同时消费,有且只有一个
- 无法回退,也无法随机消费
代码实现并发消息队列
- 方案1:每一个订阅者创建一个channel
- 方案2: 一个channel我去遍历订阅者