在我们的日常开发中,总会有时候需要对一些变量做并发读写,比如 web 应用在同时接到多个请求之后, 需要对一些资源做初始化,而这些资源可能是只需要初始化一次的,而不是每一个 http 请求都初始化, 在这种情况下,我们需要限制只能一个协程来做初始化的操作,比如初始化数据库连接等, 这个时候,我们就需要有一种机制,可以限制只有一个协程来执行这些初始化的代码。 在 go 语言中,我们可以使用互斥锁(Mutex
)来实现这种功能。
互斥锁的定义
这里引用一下维基百科的定义:
互斥锁(Mutual exclusion,缩写 Mutex
)是一种用于多线程编程中,防止两个线程同时对同一公共资源 (比如全局变量)进行读写的机制。该目的通过将代码切片成一个一个的临界区域(critical section)达成。 临街区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。
互斥,顾名思义,也就是只有一个线程能持有锁。当然,在 go 中,是只有一个协程能持有锁。
下面是一个简单的例子:
var sum int // 和
var mu sync.Mutex // 互斥锁
// add 将 sum 加 1
func add() {
// 获取锁,只能有一个协程获取到锁,
// 其他协程需要阻塞等待锁释放才能获取到锁。
mu.Lock()
// 临界区域
sum++
mu.Unlock()
}
func TestMutex(t *testing.T) {
// 启动 1000 个协程
var wg sync.WaitGroup
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
// 每个协程里面调用 add()
add()
wg.Done()
}()
}
// 等待所有协程执行完毕
wg.Wait()
// 最终 sum 的值应该是 1000
assert.Equal(t, 1000, sum)
}
复制代码
上面的例子中,我们定义了一个全局变量 sum
,用于存储和,然后定义了一个互斥锁 mu
, 在 add()
函数中,我们使用 mu.Lock()
来加锁,然后对 sum
进行加 1 操作, 最后使用 mu.Unlock()
来解锁,这样就保证了在任意时刻,只有一个协程能够对 sum
进行加 1 操作, 从而保证了在并发执行 add()
操作的时候 sum
的值是正确的。
上面这个例子,在我之前的文章中已经作为例子出现过很多次了,这里不再赘述了。
go Mutex 的基本用法
Mutex
我们一般只会用到它的两个方法:
Lock
:获取互斥锁。(只会有一个协程可以获取到锁,通常用在临界区开始的地方。)Unlock
: 释放互斥锁。(释放获取到的锁,通常用在临界区结束的地方。)
Mutex
的模型可以用下图表示:
说明:
- 同一时刻只能有一个协程获取到
Mutex
的使用权,其他协程需要排队等待(也就是上图的G1->G2->Gn
)。 - 拥有锁的协程从临界区退出的时候需要使用
Unlock
来释放锁,这个时候等待队列的下一个协程可以获取到锁(实际实现比这里说的复杂很多,后面会细说),从而进入临界区。 - 等待的协程会在
Lock
调用处阻塞,Unlock
的时候会使得一个等待的协程解除阻塞的状态,得以继续执行。
上面提到的这几点也是
Mutex
的基本原理。
互斥锁使用的两个例子
了解了 go Mutex
基本原理之后,让我们再来看看 Mutex
的一些使用的例子。
gin Context 中的 Set 方法
一个很常见的场景就是,并发对 map
进行读写,熟悉 go 的朋友应该知道,go 中的 map
是不支持并发读写的, 如果我们对 map
进行并发读写会导致 panic
。
而在 gin
的 Context
结构体中,也有一个 map
类型的字段 Keys
,用来在上下文间传递键值对数据, 所以在通过 Set
来设置键值对的时候需要使用 c.mu.Lock()
来先获取互斥锁,然后再对 Keys
做设置。
// Set is used to store a new key/value pair exclusively for this context.
// It also lazy initializes c.Keys if it was not used previously.
func (c *Context) Set(key string, value any) {
// 获取锁
c.mu.Lock()
// 如果 Keys 还没初始化,则进行初始化
if c.Keys == nil {
c.Keys = make(map[string]any)
}
// 设置键值对
c.Keys[key] = value
// 释放锁
c.mu.Unlock()
}
复制代码
同样的,对 Keys
做读操作的时候也需要使用互斥锁:
// Get returns the value for the given key, ie: (value, true).
// If the value does not exist it returns (nil, false)
func (c *Context) Get(key string) (value any, exists bool) {
// 获取锁
c.mu.RLock()
// 读取 key
value, exists = c.Keys[key]
// 释放锁
c.mu.RUnlock()
return
}
复制代码
可能会有人觉得奇怪,为什么从
map
中读也还需要锁。这是因为,如果读的时候没有锁保护, 那么就有可能在Set
设置的过程中,同时也在进行读操作,这样就会panic
了。
这个例子想要说明的是,像 map
这种数据结构本身就不支持并发读写,我们这种情况下只有使用 Mutex
了。
sync.Pool 中的 pinSlow 方法
在 sync.Pool
的实现中,有一个全局变量记录了进程内所有的 sync.Pool
对象,那就是 allPools
变量, 另外有一个锁 allPoolsMu
用来保护对 allPools
的读写操作:
var (
// 保护 allPools 和 oldPools 的互斥锁。
allPoolsMu Mutex
// allPools is the set of pools that have non-empty primary
// caches. Protected by either 1) allPoolsMu and pinning or 2)
// STW.
allPools []*Pool
// oldPools is the set of pools that may have non-empty victim
// caches. Protected by STW.
oldPools []*Pool
)
复制代码
pinSlow
方法中会在 allPoolsMu
的保护下对 allPools
做读写操作:
func (p *Pool) pinSlow() (*poolLocal, int) {
// Retry under the mutex.
// Can not lock the mutex while pinned.
runtime_procUnpin()
allPoolsMu.Lock() // 获取锁
defer allPoolsMu.Unlock() // 函数返回的时候释放锁
pid := runtime_procPin()
// poolCleanup won't be called while we are pinned.
s := p.localSize
l := p.local
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
if p.local == nil {
allPools = append(allPools, p) // 全局变量修改
}
// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.
size := runtime.GOMAXPROCS(0)
local := make([]poolLocal, size)
atomic.Stor