主要学习其设计原则,大体流程,权衡利弊
不要纠结于部分难懂的实现细节,因为不同的人对相同接口的实现细节不一样,就算是相同的人实现两次也可能不一样
context的作用主要有两个:
在整个请求的执行过程中传递一些业务无关的数据,例如userId,logId,避免所有方法都要加这些参数
控制整个链路的取消,超时
一般将context.Context作为方法的第一个参数,就算现阶段用不着也建议加上,减少后期代码修改。除非明确知道不需要context的功能,例如util,helper包下的工具方法
context不应该作为结构体的字段:
通过funcWithValue(parent Context, key, val any) Context
可以向ctx中并发安全地设置一个键值对
为什么需要并发安全?
如果我们来设计会如何实现?
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”)
}
查找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的修改父也能看到
如果父的链条中有两个节点的key相同,会返回离自己最近的节点的value
为什么不用map存储,而是用类似链表的方式存储数据?
为什么链表方式串联数据是并发安全的?因为其巧妙地使用了子节点指向父节点的串联方式:
用链表存储数据的优劣势:
通过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
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信号,说明父永远不会被取消,什么也不用监听
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方法主要干了两件事:
close(done)
通过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,避免开启定时器的开销
用于保护需要被保护的资源
哪些资源需要被保护?
哪些资源不需要被保护?
如果一个资源需要被保护,则需要暴露出被封装的方法,而不是将资源和锁都暴露出去,让用户自己考虑要不要加锁,怎么加锁
错误使用:
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
模式,一般采用双重检测的模式进行操作,即:
例如,要对一个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一次?
读写锁中有读优先和写优先两种模式:
一般的语言,例如go都是是写优先,防止写饥饿
加锁流程:
mutex的结构如下:
type Mutex struct {state int32sema uint32
}
流程图中的要点:
自旋:分为快路径:一次性的自旋和慢路劲:多次自旋
快路径:CAS将其从0改为加锁状态
慢路径:什么情况下可以进行慢路径自旋?
为什么有饥饿模式和正常模式?
正常模式:效率更高,新来的goroutine可以和队列中的goroutine抢锁,因为新来的已经占着cpu,大概率能拿到锁。为什么效率更高?避免了先阻塞进入队列,在被唤醒执行的调度开销
饥饿模式:保证公平,防止饥饿,新来的不能抢锁,需要进入队列等待
什么情况下锁变为饥饿模式?
什么情况退出饥饿模式?
RWLock
当需要缓存对象时,可以使用sync.Pool
从pool中获取时,会先看池中有没有,如果没有创建新的对象
gc时pool会释放一部分资源
pool的优点:
当对象要被复用时,需要重置掉对象的属性,避免两个请求共用相同的用户数据
例如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)
}
放回去之前重置还是取出来重置?
如果我们自己设计会怎么实现?
sync.Pool的实现原理:
每个p有一个poolLocalInternal对象
poolLocalInternal包含private
和shared
private只会被对应的p使用
shared指向poolChain
为什么poolDequeue设计成循环数组?
为什么从队头放入数据,从队尾获取数据?
从victim中获取数据可以从private获取,而偷取其他poolChain的数据可能需要加锁,为什么可能需要加锁的优先级更高?
sync.Pool的容量设置和淘汰策略,用户无法手动控制,其中淘汰策略完全依赖gc
为什么需要victim,而不是每次gc都把正常数据清空?
如果在业务处理中需要开goroutine,不能直接将gin.Context传给新的goroutine
如果直接传过去,当本次请求结束后该ctx被复用时,此时就有两个goroutine同时在使用该ctx:
而g1需要使用的用户数据是原先请求的,但当新业务请求到来时,g2会给ctx设置新的用户数据
导致用户数据发生窜用
因此当需要在业务请求中开goroutine时,需要调用Copy方法复制一份gin.Context
用于同步多个goroutine之间的工作:
WaitGrout其实需要保存3个信息:
type WaitGroup struct {state1 uint64state2 uint32
}
Add流程:
Wait流程:
需要注意state的+1和-1需要成对出现:
因此可以用errgroup来完成WaitGroup的功能,因为其封装了对+1-1的操作,保证一定成对出现
channel使用不当时,会导致goroutine泄露:
channel主要的结构有两个:
存放缓存数据的队列
存放发送接收的等待队列
发送流程:
为什么有接收者,可以直接将数据给接收者?
接收流程