Golang中保护变量并发访问的标准解决方案

Golang中保护变量并发访问的标准解决方案 我编写了许多Web服务器代码,其中经常有多个goroutine(来自并发的Web请求)访问某些中心状态。我通常使用sync.RWMutex来保护对共享状态的访问,如下所示:

var services = map[string]Service
var serviceMutex = sync.RWMutex{}

func UpdateValue(name, value Service) {
  serviceMutex.Lock()
  defer serviceMutex.Unlock()
  services[name] = value
}

但我发现这有几个缺点:

  1. 有人可能在没有锁定互斥锁的情况下访问变量(只是简单地忘记了)。
  2. 有人可能编写一个长时间运行的方法,无意中长时间锁定变量(因为defer只有在整个方法返回后才会被调用)。
  3. 如果访问隐藏在Get/Set方法后面,有人可能会无意中写出类似v.Set(v.Get()+1)的代码,从而引入竞态条件。
  4. 有人可能无意中通过返回值传递对变量的引用(允许无锁访问)。

因此,我编写了一个小型库来包装对此类变量的访问,并思考类似这样的功能是否应该成为标准库的一部分(因为它似乎具有普遍用途)。

我已在codereview.stackexchange上发布了代码以供审查和讨论:

在Go中编写用于互斥锁保护变量的库

并且也在GoPlayground上创建了一个分享:

Go Playground - Go编程语言

我很乐意接受任何反馈和评论。

此致


更多关于Golang中保护变量并发访问的标准解决方案的实战教程也可以访问 https://www.itying.com/category-94-b0.html

13 回复

如果条目是 atomic.XXX 类型,那么是可以的。

更多关于Golang中保护变量并发访问的标准解决方案的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


总结讨论内容,我认为你可能想检查一下是否可以使用 struct 来实现这些服务。如果你在多个项目中复用相同的代码,那么你可以创建一个内部包并与你的团队一起维护它(作为一个想法)。

如果你必须维护一个共享状态(我认为总有一种更好的非共享方式可以实现),最正确的解决方案是使用一个并发缓存,它为你处理所有的访问控制,你的所有代码只需执行类似普通映射的操作,而无需担心共享状态。一个简单的谷歌搜索就能找到不少可供选择的开源方案。

我曾使用过 atomic.Value,它非常适合某些特定的使用场景,但它仅适用于简单的值。如果你将一个 Map 放入 atomic.Value 中,你仍然会得到对同一个映射的引用,而不是一个副本。你无法安全地使用 atomic.Value 来更新映射中的条目或结构体中的字段。

因为如果你是使用这种共享状态控制方式编写代码的维护者,那就意味着只有你才会犯这类错误

在理想世界中或许如此。但我通常在团队中工作,而且项目往往在几个月后就需要更新……所以,无论是初级开发人员还是几个月后的我自己——我们都需要尽一切可能来编写高质量的代码。因此,我使用代码检查工具(linters)和那些通过其API设计来减少错误的库,同时我也努力为我的团队提供基础的包/库,希望他们能借此避免那些本可避免的错误。

你是否尝试过或查看过 atomic.Value,或者仅仅是 atomic 包本身。它允许创建并发保存的不同类型的变量。一个使用示例是 context.cancel 函数,它允许我们在 select 中使用 Done()。

你好 @jarrodhroberson 我很好奇你会如何以一种非共享的方式实现一个共享状态,比如一个包含配置选项的映射。有一些可以更改配置的 Rest 端点,以及需要根据配置信息来操作的端点。因此,许多这样的读取器/写入器可能会并发运行,但它们也可能在一段时间后运行。你会创建一个带有共享通道的中心 goroutine,任何调用都通过另一个通道“请求”它来传递配置吗?我认为与共享变量相比,这可能需要大量代码,但收益甚微。

感谢你关于并发缓存的意见。我尝试搜索了一段时间的实现。但我找到的所有内容要么专注于带有淘汰机制的缓存(不是永久共享状态),要么相当过度设计(大量代码和依赖,而这些东西可以用不到 100 行代码安全地解决),并且通常只适用于映射,而不适用于例如结构体或其他可能用作共享状态的自定义数据类型。

你能给我指出一个我可能忽略的简单直接的实现吗?我正试图通过查看专业的解决方案来学习和改进。

感谢您抽出时间并提出建议。我也在使用等待组(wait groups)来同步协程,这极大地有助于保持状态清晰和简洁。

sync.Map 带有以下警告前缀:

// Map 类型是特化的。大多数代码应使用普通的 Go map 替代,
// 并配合独立的锁或协调机制,以获得更好的类型安全性,并使其
// 更容易在维护 map 内容的同时维护其他不变量。
//
// Map 类型针对两种常见用例进行了优化:(1) 当给定键的条目
// 仅写入一次但读取多次时,例如只增长的高速缓存;
// 或 (2) 当多个 goroutine 读取、写入和覆盖互不相交的
// 键集合的条目时。在这两种情况下,与配合独立的 Mutex 或 RWMutex 使用的
// Go map 相比,使用 Map 可以显著减少锁竞争。

因此,对于大多数用例(例如共享的可变配置)来说,它很可能不是正确的选择。

感谢您的反馈。我想就您提出的几点进行说明。

  • 闭包:我认为,对于那些仅在一个较大的函数内部使用的小型操作,使用闭包比定义自己的函数更可取。这样,您既可以获得函数的好处(提前返回、在panic时处理defer),又无需考虑函数名称或将代码移到其他地方。

  • 作为最佳实践,您通常应该尽可能在靠近使用的地方并以最窄的作用域定义变量。如果您想获取受保护值的副本,可以直接使用 x := sv.getCopy(),但如果您不想复制整个数据类型(可能是一个很大的map或结构体),则可以使用闭包来仅提取单个数据点。

  • 您说得完全正确,我无法阻止对该包的错误使用——但我认为使用这种语法更不容易意外出错。如果您想提取指向共享变量的指针,则必须显式地将其移出闭包。在源代码中,通过缩进可以清晰地看到变量被锁定的位置——因此,在Read代码块内调用长时间运行的操作应该更加明显,从而更容易避免。

感谢您的意见!我正在努力构建能够鼓励正确使用且更不容易被误用的API。

另一种实现全局锁的方法是使用 sync.WaitGroup。我有一个工具,它会建立许多 SSH 连接,执行一些操作,并将结果写入 Redis 数据库。为了停止所有向 “AddToRedis” 通道发送数据的 goroutine,我这样做了:

type RedisConn struct {
    sync.WaitGroup

    // connection, etc...
}

func (rc *RedisConn) Ping(ctx context.Context) {
    // 作为一个 goroutine 启动。
    // 在这里 ping 数据库连接,如果连接丢失,则调用 `reconnect` 函数...
    rc.reconnect(ctx)
}

func (rc *RedisConn) reconnect(ctx context.Context) {
    rc.WaitGroup.Add(1)
    defer rc.WaitGroup.Done()

    // 在这里重连...
}

func (rc *RedisConn) AddToRedis(k, v string) error {
    // 如果 ping goroutine 锁定了 WaitGroup,所有调用此函数的 goroutine
    // 都将等待释放。
    rc.WaitGroup.Wait()
    
    // 在这里添加...
}

在讨论的主题中,如果你需要始终获取更新后的状态,并且需要锁定某些代码的执行,这个解决方案也可以修改为适用于全局共享状态控制。回到原子操作的话题,有一个 Map 类型实现 服务于相同的目的。

说实话,我又读了一遍这个帖子,所有描述的问题在我看来都非常无关紧要。因为如果你是使用这种共享状态控制方式编写代码的维护者,那就意味着只有你才会犯那些在缺点中描述的错误。同时,据我所知,Go 语言倾向于不使用全局变量来共享状态。与其重新发明轮子,不如简单地将其封装到结构体中,就像你在建议的库中尝试做的那样。这减少了对数据的访问,因为字段不会被导出,并且允许你根据需要添加任意多的方法来控制数据或对数据进行操作。

type Value struct {
	sync.RWMutex

	data map[string]any
}

func (v *Value) Add() {
    v.Lock()
    defer v.Unlock()

    // 此处添加代码
}

func (v *Value) Read() {
    v.RLock()
    defer v.RUnlock()

    // 此处添加代码
}

func (v *Value) Delete() {
    v.Lock()
    defer v.Unlock()

    // 此处添加代码
}

我完全不同意结构体操作之外的某些函数应该持有该值的锁。其思路是从缓存中取出它,无论如何,然后将其传递下去。

如果你需要特定的复制方法,你必须自己编写它们。我知道手动编写结构体的所有字段可能会令人沮丧,但这就是它的工作方式。同时,你可以为复制函数添加闭包,并按照你喜欢的方式使用它。但再次强调,没有通用的解决方案,因为这取决于具体的使用场景。你的一些想法或特定需求对其他人来说可能是不必要的。

你好 @falco467

关于互斥锁缺点的这些观点很有道理,看到有人不仅意识到这些缺点,而且积极寻找解决方案,总是件好事。

然而,在我看来,你提出的解决方案似乎有点复杂。

  • 我必须定义闭包并将其作为参数传递给 ReadUpdate

  • Read 操作依赖于副作用来从受保护的数据结构中获取数据:

    var path string
    // 这两行代码之间可能有很多行代码
    sv.Read(func(v config) { path = v.path })
    

    这样一来,数据流可能会变得不清晰。我更喜欢像 path = sv.Read() 这样直接的语法。

更重要的是,这个解决方案似乎并没有主动防止问题 #1、#2 和 #3。我用你的 Playground 代码扮演了“魔鬼代言人”,发现了以下情况:

  • 我能够创建一个指向受保护数据的指针,并在互斥锁之外访问它。
  • 我能够在 ReadUpdate 函数内部调用长时间运行的函数。
  • 我能够在 Update 内部调用 Read 并导致死锁。

这是我的修改版本。搜索 // *** 注释可以找到我做的修改。

我认为你指出的这些问题,是共享内存并发访问工作方式所固有的。要避免这些问题,要么需要编程规范,要么就用通信(即使用通道和 goroutine 本地数据)来取代对共享内存的并发访问。

在Go中保护共享变量的并发访问,标准库提供了多种方案,每种方案适用于不同的场景。以下是几种标准解决方案及其示例代码:

1. sync.Mutex 和 sync.RWMutex

这是最基础的互斥锁方案,适用于大多数共享变量保护场景。你的示例中已经使用了sync.RWMutex,但可以进一步优化以避免长时间锁定。

var (
    services   = make(map[string]Service)
    serviceMu  sync.RWMutex
)

// 使用闭包封装临界区,减少锁定时间
func UpdateValue(name string, value Service) {
    serviceMu.Lock()
    services[name] = value
    serviceMu.Unlock()
}

// 读操作使用 RLock
func GetValue(name string) (Service, bool) {
    serviceMu.RLock()
    defer serviceMu.RUnlock()
    v, ok := services[name]
    return v, ok
}

2. sync/atomic 包

对于简单的数值类型(如int32、int64),可以使用原子操作避免锁开销。

import "sync/atomic"

var counter int64

func Increment() {
    atomic.AddInt64(&counter, 1)
}

func Get() int64 {
    return atomic.LoadInt64(&counter)
}

3. sync.Map

专为并发访问设计的map,适用于读多写少的场景。

import "sync"

var serviceMap sync.Map

func UpdateValue(name string, value Service) {
    serviceMap.Store(name, value)
}

func GetValue(name string) (Service, bool) {
    v, ok := serviceMap.Load(name)
    if !ok {
        return nil, false
    }
    return v.(Service), true
}

4. 通道序列化访问

通过通道将访问请求序列化,避免显式锁。

type request struct {
    name  string
    value Service
    resp  chan<- Service
}

var (
    services = make(map[string]Service)
    requests = make(chan request)
)

func init() {
    go func() {
        for req := range requests {
            services[req.name] = req.value
            if req.resp != nil {
                req.resp <- services[req.name]
            }
        }
    }()
}

func UpdateValue(name string, value Service) {
    requests <- request{name: name, value: value}
}

func GetValue(name string) Service {
    resp := make(chan Service)
    requests <- request{name: name, resp: resp}
    return <-resp
}

5. 封装结构体方法

将互斥锁和变量封装在结构体中,通过方法控制访问。

type ProtectedMap struct {
    mu   sync.RWMutex
    data map[string]Service
}

func (p *ProtectedMap) Update(name string, value Service) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.data[name] = value
}

func (p *ProtectedMap) Get(name string) (Service, bool) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    v, ok := p.data[name]
    return v, ok
}

// 原子操作封装
func (p *ProtectedMap) Increment(name string, delta int) {
    p.mu.Lock()
    defer p.mu.Unlock()
    if s, ok := p.data[name].(int); ok {
        p.data[name] = s + delta
    }
}

总结

标准库提供的方案已经覆盖了大多数并发访问场景:

  • 互斥锁:通用性强,控制精细
  • 原子操作:性能最佳,但仅限于简单类型
  • sync.Map:内置并发安全map,适用于特定模式
  • 通道序列化:CSP模型,逻辑清晰

你的库方案试图进一步抽象锁机制,但标准库更倾向于提供基础工具,由开发者根据具体场景组合使用。对于你提到的竞态条件问题,正确的做法是通过代码审查和测试(如go test -race)来确保安全。

回到顶部