GoFrame框架中的并发控制利器:gmlock

311 阅读6分钟

在高并发的后台服务中,对共享资源的并发访问控制是一个非常常见也非常重要的需求。如果没有合适的并发控制手段,就可能导致数据不一致、逻辑错乱等严重问题。而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包提供了简单易用的并发控制原语,可以帮助我们轻松应对各种并发场景的挑战。合理使用锁,对关键流程和敏感数据进行保护,是编写健壮、高效服务的基本功。