Golang中并发操作map会导致panic或其他问题吗?

Golang中并发操作map会导致panic或其他问题吗? 在某些场景中,配置会定期加载到映射中,并且对实时性没有要求,这意味着允许读取过时的数据。

  1. 这种并发操作是否会导致恐慌或其他问题?
  2. 这种操作能否确保我在一段时间后能够读取到新加载的数据? (例如:在 0 秒时加载了新数据, 在 0 秒读取数据时,可能会返回旧数据, 但在 3 秒读取数据时,必须返回新数据。)
// 全局变量
var myMap map[int]string
...
// 定期加载
go func() {
    tmpMap := loadMap()
    myMap = tmpMap
} ()

// 读取
go func() {
    ...
    v, ok := myMap[key]
    ...
} ()

更详细地说,如果我将映射替换为一个包含映射的结构体,是否可行?如下所示:

type myStruct struct {
    m map[int]string
}
// 全局变量
var s *myStruct

...

// 定期加载
go func() {
    tmp := loadStruct()
    s = tmp
} ()

// 读取
go func() {
    ...
    v, ok := s.m[key]
    ...
} ()

更多关于Golang中并发操作map会导致panic或其他问题吗?的实战教程也可以访问 https://www.itying.com/category-94-b0.html

8 回复

好的,谢谢!

更多关于Golang中并发操作map会导致panic或其他问题吗?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


非常感谢!在阅读了您的回复后,我决定添加一个 RWMutex,因为写操作非常罕见,而读操作是常态。 另一个小问题是:您更喜欢哪种模式?

m.Lock()
// write operation 
m.UnLock

或者

m.Lock()
defer m.UnLock()
// write operation

感谢您的回复!是的,互斥锁和通道可以保证在一段时间后读取到新数据。然而,我担心使用互斥锁或通道可能会延长读取时间。实际上,这个映射是一个配置文件,当需要其他数据时,会读取配置映射来确定应返回哪些数据。如果使用互斥锁和通道,我认为请求其他数据的过程可能会被阻塞,因为此时映射可能正在被写入且被互斥锁锁定。在这种情况下,其他数据的请求可能会导致超时,这是不允许的。

这个映射表是一个配置文件

如果长时间的等待是由于写入文件造成的,或许你可以改变一下方法:

  1. 让映射表来控制文件 1.1. 将配置加载到内存中; 1.2. 要么通过通道推送给其他 goroutine,要么 1.3. 设置一个方法供其他 goroutine 来拉取配置; 1.4. 仅当内存中的配置数据有实质性更新时才写入文件; 1.5. 提供一个“更新”方法,通知映射表从文件拉取更新。

而不是:

  1. 每个人都反复读取同一个文件;
  2. 与写入文件的操作竞争。

第一种方法仅在配置数据发生任何更改时会导致短暂的等待时间。其余时间,配置通过内存提供,因此你只在需要时才从磁盘读取/写入。

第二种方法会因为频繁的读取/写入而缩短磁盘的使用寿命。

sqrt7:

然而,我担心使用互斥锁或通道可能会延长读取时间。

影响不会很显著。这完全取决于你如何编写 read 处理代码。此外,锁或通道传输是 goroutine 之间同步所必需的代价。

那种许多并发进程使锁饱和的极端情况是另一个问题(通常与你整体的算法有关)。在断定这种担忧对于非实时应用程序是否成立之前,你需要在你的硬件上用测试代码进行实验。

无论如何,由于数据竞争条件,你必须同步所有 3 个 goroutine。

sqrt7:

如果使用互斥锁和通道,我认为请求其他数据的过程可能会被阻塞,因为此时映射可能正在被写入并被互斥锁锁定。

这完全与你如何在同步期间处理等待有关。正如我之前提到的,read 必须知道如何处理数据不存在、数据过时、数据损坏、何时超时等待、是否应该重试等情况。

无论是同步还是通道,在等待期间都不会像“延迟计数器算法”那样占用 CPU 资源,所以不用担心。

sqrt7:

m.Lock()
defer m.UnLock()
// write operation

这是我的默认做法,因为在编写函数/方法时,你不需要在脑海中追踪锁的状态。defer 确实是个非常好的伙伴。

sqrt7:

m.Lock()
// write operation
m.UnLock

这种方式仅在我需要根据条件手动控制锁,或者需要将锁传递到其他地方时才会使用。例如:

  1. 如果我遇到一些错误,我会将锁作为参数完整地传递给我的自定义 handleError(...) 函数,然后由 handleError(...) 来执行解锁步骤。

使用这种方式时,你需要像在其他编程语言中一样,在开发函数/方法时追踪锁的状态。

常见的错误通常是:

  1. 忘记解锁,或者
  2. 没有在脑海中检查代码中锁的状态。

编辑 1: 这种方法也用于高并发访问的场景(例如,变量被频繁地读写)。defer 只会在函数结束后才执行,所以如果你的函数非常繁忙,锁定的时间会不必要地延长。

sqrt7:

那么这种并发操作会导致恐慌或其他问题吗?

你存在数据竞争条件(读取和写入都在竞相访问全局变量)。

sqrt7:

这个操作能确保我在一段时间后可以读取到新加载的数据吗? (例如:新数据在 0 秒时加载, 在 0 秒读取数据时,可能会返回旧数据, 但在 3 秒读取数据时,必须返回新数据。)

无法保证。如果你想要保证,你需要在你的 myStruct 中实现某种同步机制,例如互斥锁。然后确保在你的 writeread 两个 goroutine 在访问全局变量之前都先获取锁。或者,如果你的整个程序执行允许使用通道,你可以使用通道来代替传统的互斥锁。

如果你使用互斥锁,负责 read 的 goroutine 需要知道如何处理在其端缺失或过时的数据,例如通过轮询等待等。

如果你使用通道,这就不重要了,因为 read 将始终等待数据到达或关闭信号。

使用互斥锁或通道没有严格的规则。选择能让你的并发程序不那么复杂/难以维护的那个。

是的,并发操作 map 确实会导致 panic 或其他问题。在 Go 中,map 不是并发安全的,多个 goroutine 同时读写同一个 map 会导致 panic。

对于你提供的两种方案:

  1. 直接替换 map:这种方案是安全的,因为 map 的赋值是原子的。读取操作可能会读到旧数据,但不会 panic。

  2. 替换包含 map 的结构体指针:这种方案也是安全的,指针的赋值是原子的。读取操作同样可能读到旧数据,但不会 panic。

两种方案都能确保在一段时间后读取到新数据。由于 Go 的内存模型保证,一旦 goroutine 观察到新的指针值,它就能看到该指针指向的完整数据结构。

示例代码:

// 方案1:直接替换map
var myMap map[int]string

func updateMap() {
    tmpMap := loadMap()
    myMap = tmpMap // 原子操作
}

func readMap(key int) (string, bool) {
    m := myMap // 获取当前map的快照
    v, ok := m[key]
    return v, ok
}

// 方案2:替换结构体指针
type myStruct struct {
    m map[int]string
}

var s *myStruct

func updateStruct() {
    tmp := loadStruct()
    s = tmp // 原子操作
}

func readStruct(key int) (string, bool) {
    current := s // 获取当前结构体的快照
    if current == nil {
        return "", false
    }
    v, ok := current.m[key]
    return v, ok
}

需要注意的是,虽然这两种方案避免了并发 panic,但读取操作可能获取到不一致的数据快照。如果你需要更强的数据一致性保证,可以考虑使用 sync.RWMutexsync.Map

回到顶部