在高并发的后台服务中,对共享资源的并发访问控制是一个非常常见也非常重要的需求。如果没有合适的并发控制手段,就可能导致数据不一致、逻辑错乱等严重问题。而Golang语言标准库sync包中提供的Mutex、RWMutex等并发原语,可以很好地解决这类问题。
GoFrame作为一个优秀的Web服务开发框架,当然也提供了更高层的并发控制工具,那就是gmlock包。本文将介绍如何在GoFrame中使用gmlock进行并发控制。
并发控制场景
在实际开发中,我们经常会遇到下面这些需要进行并发控制的场景:
-
对用户账户的余额、积分等信息的修改
-
对订单、商品库存等关键数据的更新
-
对配置管理、功能开关等全局设置的调整
-
一次性的定时任务、后台作业
这些操作通常是由多个goroutine并发执行的,如果不加控制,就可能出现数据不一致、逻辑错乱的问题。比如:
-
用户A和B同时读取余额为100元,然后A扣除10元,B扣除20元,结果余额变成70元,而不是期望的80元
-
系统管理员在不同终端同时修改了某个开关,一个改成了开,一个改成了关,到底应该以哪个为准?
-
定时任务本来一天只应该执行一次,但由于部署了多个实例,结果一天执行了多次
要解决这些问题,最常用的办法就是加锁。加锁可以保证在同一时刻,只有一个goroutine能够进入临界区,对共享资源进行访问,从而避免并发冲突。
gmlock包简介
gmlock包是对Golang标准库sync包的一个简单封装,提供了一些常用的并发控制原语。主要有:
-
Mutex:互斥锁,同一时间只允许一个goroutine持有。
-
RWMutex:读写锁,允许多个goroutine同时读,但只允许一个goroutine写。适合读多写少的场景。
-
Locker:通用的锁接口,Mutex和RWMutex都实现了该接口。 此外,gmlock还提供了方便的工具函数,用于快速创建和管理锁:
-
Lock:创建一个Mutex并加锁
-
RLock:创建一个RWMutex并加读锁
-
TryLock:尝试创建一个Mutex并加锁,如果失败则立即返回
-
TryRLock:尝试创建一个RWMutex并加读锁,如果失败则立即返回
如何使用gmlock
使用gmlock进行并发控制非常简单,下面是几个典型的例子。
(1) 对敏感数据的修改加锁保护
package main
import (
"github.com/gogf/gf/v2/os/gmlock"
)
var balance int
func updateBalance(amount int) {
gmlock.Lock("balance")
defer gmlock.Unlock("balance")
balance += amount
// write balance to db ...
}
这里使用gmlock.Lock()快速创建了一个互斥锁,并通过defer语句保证锁一定会被释放。在updateBalance函数内部的代码,就受到了锁的保护,不会有多个goroutine同时进入。
(2) 对配置信息的修改加锁保护
package main
import (
"github.com/gogf/gf/v2/os/gmlock"
)
type Config struct {
}
var config *Config
func loadConfig() {
gmlock.Lock("config")
defer gmlock.Unlock("config")
if config != nil {
return
}
// load config from file ...
config = &Config{ /* ... */ }
}
func getConfig() *Config {
gmlock.RLock("config")
defer gmlock.RUnlock("config")
return config
}
这个例子展示了读写锁的用法。loadConfig函数使用写锁来保护配置信息的加载过程,保证不会有其他goroutine同时修改config变量。而getConfig函数只需要读锁,允许多个goroutine并发读取配置信息。这种锁的粒度控制,在读多写少时可以显著提高并发性能。
(3) 利用TryLock避免死锁 有时我们需要同时获取多把锁,这时如果锁获取的顺序不当,就可能导致死锁。比如:
func transferBalance(from, to string, amount int) {
gmlock.Lock(from)
defer gmlock.Unlock(from)
gmlock.Lock(to)
defer gmlock.Unlock(to)
// ...
}
如果两个goroutine同时调用transferBalance("a","b",10) 和transferBalance("b","a",20)就会死锁。避免死锁的一个办法是,总是以固定的顺序获取锁;另一个办法是使用TryLock:
func transferBalance(from, to string, amount int) error {
if !gmlock.TryLock(from) {
return errors.New("lock failed")
}
defer gmlock.Unlock(from)
if !gmlock.TryLock(to) {
return errors.New("lock failed")
}
defer gmlock.Unlock(to)
// ...
return nil
}
TryLock会尝试获取锁,如果锁已被其他goroutine持有,不会阻塞等待,而是直接返回获取失败的结果。我们可以根据这个结果,进行重试或者返回错误,避免产生死锁。
处理死锁
使用gmlock进行并发控制时,如果锁的粒度过大、持锁时间过长或者锁获取顺序不当,就可能导致死锁问题。下面介绍几种常见的处理gmlock死锁的方法:
避免多个goroutine循环等待
死锁的一个常见原因是多个goroutine相互等待对方释放锁。比如:
// goroutine 1
gmlock.Lock("A")
gmlock.Lock("B")
// goroutine 2
gmlock.Lock("B")
gmlock.Lock("A")
goroutine 1获取了A锁,在等待B锁;而goroutine 2获取了B锁,在等待A锁。这就造成了循环等待,导致死锁。 解决办法是,总是以固定的顺序获取锁。将上面的代码改成:
// goroutine 1
gmlock.Lock("A")
gmlock.Lock("B")
// goroutine 2
gmlock.Lock("A")
gmlock.Lock("B")
这样两个goroutine获取锁的顺序是一致的,就不会产生循环等待。
使用TryLock
另一个避免死锁的办法是使用TryLock或者TryRLock。它们会尝试获取锁,如果锁已被占用,不会阻塞等待,而是直接返回失败:
func doBusiness() error {
if !gmlock.TryLock("lock") {
return errors.New("lock failed")
}
defer gmlock.Unlock("lock")
// do business ...
return nil
}
如果TryLock获取失败,可以等待一段时间再重试,或者直接返回错误。这样可以避免长时间的阻塞等待,降低死锁的风险。
缩小锁的粒度
持有锁的时间越长,代码执行路径越复杂,就越容易发生死锁。所以应该尽量缩小锁的粒度,只对必要的代码进行加锁保护:
// bad
gmlock.Lock()
loadConfigFromDB()
doSomeBusiness()
gmlock.Unlock()
// good
loadConfigFromDB()
gmlock.Lock()
doSomeBusiness()
gmlock.Unlock()
第二个例子只对真正需要并发保护的doSomeBusiness部分加了锁,减少了持锁时间,降低了死锁的可能。
设置超时时间
我们还可以为锁设置一个超时时间。如果一定时间内没能获取到锁,就不再等待,而是返回超时错误:
func doBusiness() error {
ctx, _ := context.WithTimeout(context.Background(), time.Second)
if !gmlock.TryLockWithCtx(ctx, "lock") {
return errors.New("lock timeout")
}
defer gmlock.Unlock("lock")
// do business ...
return nil
}
TryLockWithCtx函数接受一个带超时的context.Context参数。它会在超时时间内尝试获取锁,如果超时则返回失败。这样可以避免无限期地等待锁,降低死锁风险。
总结
并发控制是高性能后台服务必须要考虑的问题。GoFrame的gmlock包提供了简单易用的并发控制原语,可以帮助我们轻松应对各种并发场景的挑战。合理使用锁,对关键流程和敏感数据进行保护,是编写健壮、高效服务的基本功。