前言:
最近,优化了公司内部的一个常用接口,请求的耗时稳定优化在200 ms
内(对比之前300
~500 ms
)
优化耗时降低了50%
~60%
左右。
在这总结一下我的解决方案,不一定是最佳方案,但可以参考下。
如果你有更好的方案,欢迎留言讨论。
读完本篇,你将收获 —— 如何用 Go
高质量完成并发需求
一、什么是并发?
并发,简单来说,就是多线程(多协程)“同时” 工作。
注意:这里的 “同时” 是打引号的。 对于 单核
CPU
来说,某个时刻只能处理一件事。因此,单核CPU
是通过不断切换时间片(切换速度非常快,近乎同时)来达到并发的效果。 同理,多核CPU
,有几个核就代表同一时刻最多能处理几件事。 举个例子,iPhone 12
是6
核CPU
(1
个主核,5
个辅核)。 因此可以理解为:某一时刻下,iPhone 12
最多能同时处理6
件事。
在 Go
中,实现并发十分简单。
只要在调用方法的时候,在前面加上关键字go
。
即可开辟一条协程,实现并发。
go func() {
//...
}()
Go
的并发是基于协程机制的,为什么我们会说:协程比线程更加 轻量 、 高效 ?
1. 为什么协程比线程轻量?
创建一个线程,一般会需要 1M
左右的默认栈大小。而创建一个协程默认只需要 2k
。
2. 为什么协程比线程高效?
一个线程,实际上背后对应的是一个系统内核线程(kernel entity
)。
而在 Go
语言中,多个相关的协程,对应一个系统内核线程(kernel entity
)。
我们知道线程间的切换与通信,是有一定性能上的开销的。
而相关协程之间切换,并不会造成性能上的开销。(协程的本质是存 context
,同一系统线程上的多协程的切换,实际上是换的 context
,他们实际都在一个系统线程上,因此不存在线程间通信的开销)
更多细节可以看我之前写的博客:《Go语言基础(五)—— 并发编程》
二、并发的危险性
Go
本身借助于其强大的协程调度机制,其实很容易完成高并发处理的需求。 但如果并发用法不当,危害也是很明显的。 加大服务端性能开销,多协程同时读写同一块内存导致服务偶现panic
等等等等。 因此,我们只有足够了解并发可能带来的问题,避免这些问题,才能高质量完成并发需求。
Case 1:同一时刻,读写同一块内存,导致服务 panic
举个例子, 两个协程,在某个时刻,同时写同一个变量。结果导致服务直接崩溃。 甚至是我们传入 go routine 的 ctx,也不能忽略。 如果对 ctx 有同时读写操作,比如一些中间件的处理,会直接导致服务 panic。 并且这类问题很难排查出问题。因为偶现的概率,导致的 panic,十分难排查。
传入协程的 gin.context
一定要 copy
一下!
// Copy returns a copy of the current context that can be safely used outside the request's scope.
// This has to be used when the context has to be passed to a goroutine.
func (c *Context) Copy() *Context {
cp := Context{
writermem: c.writermem,
Request: c.Request,
Params: c.Params,
engine: c.engine,
}
cp.writermem.ResponseWriter = nil
cp.Writer = &cp.writermem
cp.index = abortIndex
cp.handlers = nil
cp.Keys = map[string]interface{}{}
for k, v := range c.Keys {
cp.Keys[k] = v
}
paramCopy := make([]Param, len(cp.Params))
copy(paramCopy, cp.Params)
cp.Params = paramCopy
return &cp
}
Case 2:不同时刻,读写同一块内存,导致返回值不可预判
举个例子, 两个协程,并发执行,都需要修改同一块内存地址,但无法判断先后关系。 会导致先改的协程效果失效,后改的协程生效,这类问题也比较难排查。
因此,这类问题的解决方案只有一个,就是 —— 多协程同时工作时,不能读写同一块内存空间。(代码规范) 可用临时变量存储,多协程处理完后,由主线程串行赋值。
三、如何高质量完成并发需求?
一句话口诀:串并串
什么时候该串行?
什么时候该并发?
并发一定要操作自己协程内的所有内存。避免其他协程同时操作引发 panic
。
1. 基于 WaitGroup
// 串行定义各个协程的需要拿到的变量
var wg sync.WaitGroup
var a
var b
// 开始并发
wg.Add(1)
go func(ctx context.Context, xxx) {
defer wg.Done()
}(ctx, xxx)
wg.Add(1)
go func(ctx context.Context, xxx) {
defer wg.Done()
}(ctx, xxx)
wg.Add(1)
go func(ctx context.Context, xxx) {
defer wg.Done()
}(ctx, xxx)
wg.Add(1)
go func(ctx context.Context, xxx) {
defer wg.Done()
}(ctx, xxx)
wg.Wait()
// 串行赋值
2. 基于 Channel
做一个简单的并发go routine pool。
type content struct {
work func() error
end *struct{}
}
func work(w func() error) content {
return content{work: w}
}
func end() content {
return content{end: &struct{}{}}
}
// Goroutine routine_pool
type RoutinePool struct {
capacity uint
ch chan content
}
func NewRoutinePool(ctx context.Context, capacity uint) *RoutinePool {
ch := make(chan content)
pool := RoutinePool{
capacity: capacity,
ch: ch,
}
for i := uint(0); i < capacity; i++ {
go func() {
for {
select {
case cont := <-ch:
if cont.end != nil {
return
}
if cont.work != nil {
if err := cont.work(); err != nil {
common_utils.LogCtxError(ctx, "run work failed: %v", err)
}
}
}
}
}()
}
return &pool
}
func (pool *RoutinePool) Submit(w func() error) {
pool.ch <- work(w)
}
func (pool *RoutinePool) Shutdown() {
defer close(pool.ch)
for i := uint(0); i < pool.capacity; i++ {
pool.ch <- end()
}
}