Golang的映射对于并发读/写操作有多安全?

108
根据Go博客,Map不适合并发使用:在同时读写时未定义会发生什么。如果需要从并发执行的goroutine中读取和写入Map,则访问必须通过某种同步机制进行调节。(来源:https://blog.golang.org/go-maps-in-action
可以有人详细解释一下吗?跨例程的并发读操作似乎是允许的,但尝试从同时读取和写入相同键时可能会产生竞争条件。
在某些情况下,可以减少上述风险吗?例如:
- 函数A生成k并设置m[k]=0。这是A写入map m的唯一时间。已知k不在m中。 - A将k传递给并发运行的函数B - 然后,A读取m[k]。如果m[k]==0,则等待,只有在m[k]!=0时才继续。 - B查找map中的k。如果找到它,则将m[k]设置为某个正整数。如果没有找到,则等待直到k在m中。
这不是代码(显然),但我认为它显示了即使A和B都尝试访问m,也不会有竞争条件的情况的轮廓,或者如果有竞争条件也无关紧要,因为有额外的约束条件。

1
我们需要一个可重现的示例:如何创建一个最小化、完整和可验证的示例。 - peterSO
1
使用竞争检测器运行你的代码。 - JimB
1
不安全。Go 1.6 增加了对并发地误用映射的最佳检测。当检测到误用时,运行时会使程序崩溃。 - Charlie Tumahai
2
Q: "这种风险在某些情况下可以减少吗?" A: "不行。" - Volker
2
可能是具有并发访问的Map的重复问题。 - Pavel Nikolov
@peterSO 这里 是一个例子,展示了在基准测试中多次调用意外并发写入访问所引起的 panic。 - user2127434
8个回答

121

在Golang 1.6之前,并发读取是可以的,但并发写入不可以,但是写入和并发读取是可以的。自从Golang 1.6以后,在写入map时不能进行读取。

所以,在Golang 1.6之后,并发访问map应该是这样的:

package main

import (
    "sync"
    "time"
)

var m = map[string]int{"a": 1}
var lock = sync.RWMutex{}

func main() {
    go Read()
    time.Sleep(1 * time.Second)
    go Write()
    time.Sleep(1 * time.Minute)
}

func Read() {
    for {
        read()
    }
}

func Write() {
    for {
        write()
    }
}

func read() {
    lock.RLock()
    defer lock.RUnlock()
    _ = m["a"]
}

func write() {
    lock.Lock()
    defer lock.Unlock()
    m["b"] = 2
}

否则,您将会得到以下错误:

enter image description here

添加:

您可以通过使用 go run -race race.go 来检测竞态。

更改 read 函数:

func read() {
    // lock.RLock()
    // defer lock.RUnlock()
    _ = m["a"]
}

输入图像说明

另一种选择:

众所周知,map 是通过桶(bucket)实现的,而 sync.RWMutex 会锁定所有的桶。 concurrent-map 使用 fnv32 对键进行散列分片(shard),每个桶使用一个 sync.RWMutex


22
即使在1.6版本之前,同时读取和写入也是不可以的,只是系统没有报错。 - Zan Lynx
1
这是一个非常好的解决方案。但是能否使用通道代替互斥锁呢? - newguy
@newguy 我认为通道无法做到这一点。因为map在写入时无法读取,所以只需使用一个通道来处理map即可。 - Bryce
@newguy 你可以使用一个通道与控制goroutine进行交流,该goroutine具有对map的独占访问权限。因此,可以有一个goroutine监听通道上的操作(读取或写入),以及一个新通道用于返回数据,但这仍然是同步的,调用者仍将被阻塞等待返回通道上的响应。不确定其影响如何,但似乎是不必要的。 - tsturzl
4
在Golang 1.6之前,并发读取是可以的,但并发写入不行。而写入和并发读取是可以的,这是错误的观念。同时读写一个 map 一直都是不正确的。 - Eric Lagergren
显示剩余3条评论

22

并发读(只读)是可以的。 并发写和/或读不行。

如果访问已同步,例如通过 sync 包、通道或其他方式,多个goroutine才能写入和/或读取同一个map。

您的示例:

  1. 函数A生成k并将m[k]=0设置为此时A第一次写入map m。已知k不存在于m中。
  2. A并发运行并将k传递给函数B
  3. A然后读取m[k]。 如果m[k]==0,则等待,直到m[k]!=0时再继续执行
  4. B查找map m中的k。 如果它找到了,B将m[k]设置为某个正整数。 如果它没有找到,它会等待,直到k在m中出现。

您的示例有两个goroutine:A和B,并且A尝试并发读取m(在步骤3中),B尝试并发写入m(在步骤4中)。 没有同步(您没有提到任何同步措施),因此这本身是不允许/未确定的。

什么意思? 未确定意味着即使B写入m,A可能永远不会观察到更改。 或者A可能观察到根本没有发生的更改。 或者可能会发生恐慌。 或者由于这种未同步的并发访问,地球可能会爆炸(尽管这种情况的机会极小,甚至可能小于1e-40)。

相关问题:

具有并发访问的Map

Go中的非线程安全意味着什么?

在Go中使用map时忽略协程/线程安全的危险是什么?


16

Go 1.6 Release Notes

运行时添加了轻量级的、尽力检测并发地误用映射的功能。一如既往,如果一个 goroutine 正在写入映射,其他任何 goroutine 都不应该同时读取或写入该映射。如果运行时检测到这种情况,它会打印诊断信息并崩溃程序。更多了解问题的最佳方式是在竞争检测器下运行程序,它将更可靠地识别竞争并提供更多详细信息。

映射是复杂的、自我重组的数据结构。并发读写访问是未定义的。

没有代码,就没有太多可说的了。


5
经过长时间的讨论,决定典型的地图使用并不需要从多个goroutine进行安全访问,在那些需要的情况下,该地图可能是某个更大的数据结构或计算的一部分,已经同步。因此,要求所有地图操作都获取互斥锁将减慢大多数程序的速度并增加少数程序的安全性。然而,这并不是一个容易的决定,因为它意味着未受控制的地图访问可能会导致程序崩溃。
语言并不排除原子地图更新。当需要时,例如在托管不受信任的程序时,实现可以交错地访问地图。
只有在更新发生时地图访问才是不安全的。只要所有的goroutine都只是读取-查找地图中的元素,包括使用for range循环迭代它,并且不通过赋值元素或进行删除来改变地图,它们就可以安全地同时访问地图,无需同步。
作为正确地图使用的辅助工具,语言的某些实现包含一个特殊检查,在运行时自动报告地图被并发执行不安全地修改的情况。

4
您可以使用sync.Map,它适用于并发使用。唯一需要注意的是,您将放弃类型安全性并更改对映射的所有读写操作以使用为该类型定义的方法。

7
请注意,sync.Map 仅适用于锁竞争成为瓶颈的特定用例。对于大多数常规情况,建议使用带有互斥锁的本地映射表: "Map 类型是专门的。大多数代码应该改用普通的 Go 映射表,使用单独的锁或协调,以获得更好的类型安全性,并使维护其他不变量与映射内容更容易。" https://golang.org/pkg/sync/#Map - alecbz

1
你可以在map中存储一个指向int的指针,并让多个goroutine读取指向的int,而另一个goroutine则写入新值到该int。在这种情况下,map不会被更新。
这不是Go的惯用方式,也不是你所要求的。
或者,你可以将索引传递给数组而不是map的键,并让一个goroutine更新该位置,而其他goroutine则读取该位置。
但你可能只是想知道为什么当key已经在map中时,不能用新值更新map的value。假设map的哈希方案没有被改变——至少目前没有。看起来Go的作者不想为这种特殊情况做出特别的允许。通常他们希望代码易于阅读和理解,并且像不允许map写入时其他goroutine可能正在读取一样的规则使事情保持简单,在1.6版本中甚至可以在正常运行时开始捕获误用,节省了很多人很多小时的调试时间。

已经过去了几年,我看到了更多有关竞态条件的讨论。我不喜欢三年前的答案,因为即使是读写int,这就像是写入由另一个变量指向的int,也不被那些知道的人认为是安全的。它会工作吗?在某些架构上,可能会。但大多数处理器硬件工程师仍然会说结果是未定义的。golang sync/atomic包是我们的朋友,并且可以从golang支持的各种架构中执行必要的操作。 - WeakPointer

1

正如其他答案所述,本地的map类型不是goroutine-安全的。阅读当前答案后,有几点需要注意:

  1. 不要使用defer解锁,它会影响性能(请参见this这篇好文章)。直接调用unlock。
  2. 通过分片地图,可以减少在锁定之间花费的时间,从而实现更好的性能。
  3. 有一个常见的包(在GitHub上接近400颗星),用于解决这个问题,叫做concurrent-maphere,它考虑了性能和可用性。您可以使用它来处理并发问题。

0

Golang中的Map在只读情况下是并发安全的。假设你的Map首先被写入,之后不再被写入,那么你就不需要任何互斥类型的东西来确保只有一个Go协程访问你的Map。我在下面给出了一个关于Map并发安全读取的示例。

package main

import (
    "fmt"
    "sync"
)

var freq map[int]int

// An example of concurrent read from a map
func main()  {
    // Map is written before accessing from go routines
    freq = make(map[int]int)
    freq[1] = 1
    freq[2] = 2

    wg := sync.WaitGroup{}
    wg.Add(10)

    for i:=1;i<=10;i++ {
        // In go routine we are only reading val from map
        go func(id int, loop int) {
            defer wg.Done()
            fmt.Println("In loop ", loop)
            fmt.Println("Freq of 1: ", freq[id])
        }(1, i)
    }

    wg.Wait()
}


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接