Go 并发编程实践

avatar
工程师 @掘金

前言:
最近,优化了公司内部的一个常用接口,请求的耗时稳定优化在 200 ms 内(对比之前 300~500 ms
优化耗时降低了 50% ~ 60% 左右。
在这总结一下我的解决方案,不一定是最佳方案,但可以参考下。
如果你有更好的方案,欢迎留言讨论。


读完本篇,你将收获 —— 如何用 Go 高质量完成并发需求

一、什么是并发?

并发,简单来说,就是多线程(多协程)“同时” 工作。

注意:这里的 “同时” 是打引号的。 对于 单核 CPU 来说,某个时刻只能处理一件事。因此,单核 CPU 是通过不断切换时间片(切换速度非常快,近乎同时)来达到并发的效果。 同理,多核 CPU,有几个核就代表同一时刻最多能处理几件事。 举个例子,iPhone 126CPU1个主核,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()
	}
}