Go: 并发访问 Map — Part III

用户头像
陈思敏捷
关注
发布于: 2020 年 07 月 31 日
Go: 并发访问 Map — Part III

ℹ️ 在上一篇文章 “Go: 通过代码学习 Map 的设计” 中讲解了 map 的内部实现。



Go博客中专门针对map的文章清楚地表明:



map不能安全地并发使用:同时读写map时的行为是未知的。 如果你需要并发goroutine读写map,则必须通过某种同步机制来协调访问

然而,正如 FAQ  中解释的,Google 提供了一些帮助:



为了正确的使用map,该语言的某些实现包含特殊检查,当通过并发执行不安全地修改map时,该检查会在运行时自动报告。

数据竞争检测

我们可以从Go获得的第一个帮助是数据竞争检测。 使用标志-race运行程序或测试将使你了解潜在的数据竞争。 让我们看一个例子:

func main() {
m := make(map[string]int, 1)
m[`foo`] = 1
var wg sync.WaitGroup
wg.Add(2)
go func() {
for i := 0; i < 1000; i++ {
m[`foo`]++
}
}()
go func() {
for i := 0; i < 1000; i++ {
m[`foo`]++
}
}()
wg.Wait()
}

在这个例子中,我们清楚地看到,在某个时刻,两个goroutine将尝试同时写一个新值。下面是竞争检测器的输出:

==================
WARNING: DATA RACE
Read at 0x00c00008e000 by goroutine 6:
runtime.mapaccess1_faststr()
/usr/local/go/src/runtime/map_faststr.go:12 +0x0
main.main.func2()
main.go:19 +0x69
Previous write at 0x00c00008e000 by goroutine 5:
runtime.mapassign_faststr()
/usr/local/go/src/runtime/map_faststr.go:202 +0x0
main.main.func1()
main.go:14 +0xb8

竞争检测器解释第二个goroutine正在读取,而第一个goroutine在这个内存地址写入一个新值。



并发写入检测

Go 提供的另一个帮助是并发写入检测。让我们使用之前看到的那个例子。运行这个程序时,我们将看到一个错误:

fatal error: concurrent map writes

在 map 结构的内部标志 flags 的帮助下,Go 处理了这次并发。当代码尝试修改 map 时(赋新值,删除值或者清空 map),flags 的某一位会被置为 1:

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
h.flags ^= hashWriting

hashWriting值为4并会将相对应的位置设为1。



^是一个异或操作,如果两个操作数的某一位不同则置为1:





然后,此标志将在操作结束时重置:

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
h.flags &^= hashWriting
}



现在为修改map的每个操作进行标志检查,它可以通过检查此标志的状态来防止并发写入。以下是标志生命周期的一个示例:

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
[...]
// if another process is currently writing, throw error
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
[...]
// no one is writing, we can set now the flag
h.flags ^= hashWriting
[...]
// flag reset
h.flags &^= hashWriting
}

sync.Map 和 带锁Map

sync包同时也提供了并发安全的map。不过,正如sync包文档中解释的那样,你应该谨慎的选择使用哪种map。



sync包中的map类型是专业的。大多数的代码应当使用原生map,配合单独的锁或者其他协调机制,以提高类型安全性并更易维护其他的不变量和map的内容。

实际上,正如上一篇 “Go: 通过代码学习 Map 的设计” 中所解释的,map 根据我们处理的具体类型提供了不同的方法。



让我们运行一个简单的基准测试,比较带有锁的常规 map 和 sync 包的 map。一个基准测试并发写入 map,另一个仅仅读取 map 中的值:

MapWithLockWithWriteOnlyInConcurrentEnc-8 68.2µs ± 2%
SyncMapWithWriteOnlyInConcurrentEnc-8 192µs ± 2%
MapWithLockWithReadOnlyInConcurrentEnc-8 76.8µs ± 3%
SyncMapWithReadOnlyInConcurrentEnc-8 55.7µs ± 4%

正如我们所见,没有哪种map是完胜对方的。根据不同的场景我们需要选择其中之一。并发下写多读少的优先考虑带锁map,读多写少的优先考虑sync.map。更多关于sync.map细节可以阅读 我的 sync.Map源码分析 。



Map 和 sync.Map

FAQ 中也解释了他们做出了原生map并非并发安全这个决定的原因:



需要所有的map操作都得带互斥锁,这会拖慢大部分程序,而只为了少部分程序的安全性

让我们运行一个并不需要并发goroutine的基准测试,来理解当你不需要并发但内置map提供并发安全map时所带来的影响:

MapWithWriteOnly-8 11.1ns ± 3%
SyncMapWithWriteOnly-8 121ns ± 6%
MapWithReadOnly-8 4.87ns ± 7%
SyncMapWithReadOnly-8 29.2ns ± 4%

简单map快7到10倍,显然非并发情况下,使用内置简单map更合理。



编译自https://medium.com/a-journey-with-go/go-concurrency-access-with-maps-part-iii-8c0a0e4eb27e

博客地址https://www.chenjie.info/2543

本文首发于我的公众号:



发布于: 2020 年 07 月 31 日 阅读数: 79
用户头像

陈思敏捷

关注

多动脑不痴呆 2017.12.21 加入

gopher

评论

发布
暂无评论
Go: 并发访问 Map — Part III