深入理解 go sync.Map - 基本原理

这篇具有很好参考价值的文章主要介绍了深入理解 go sync.Map - 基本原理。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

我们知道,go 里面提供了 map 这种类型让我们可以存储键值对数据,但是如果我们在并发的情况下使用 map 的话,就会发现它是不支持并发地进行读写的(会报错)。
在这种情况下,我们可以使用 sync.Mutex 来保证并发安全,但是这样会导致我们在读写的时候,都需要加锁,这样就会导致性能的下降。
除了使用互斥锁这种相对低效的方式,我们还可以使用 sync.Map 来保证并发安全,它在某些场景下有比使用 sync.Mutex 更高的性能。
本文就来探讨一下 sync.Map 中的一些大家比较感兴趣的问题,比如为什么有了 map 还要 sync.Map?它为什么快?sync.Map 的适用场景(注意:不是所有情况下都快。)等。

关于 sync.Map 的设计与实现原理,会在下一篇中再做讲解。

map 在并发下的问题

如果我们看过 map 的源码,就会发现其中有不少会引起 fatal 错误的地方,比如 mapaccess1(从 map 中读取 key 的函数)里面,如果发现正在写 map,则会有 fatal 错误。
(如果还没看过,可以跟着这篇 《go map 设计与实现》 看一下)

if h.flags&hashWriting != 0 {
    fatal("concurrent map read and map write")
}

map 并发读写异常的例子

下面是一个实际使用中的例子:

var m = make(map[int]int)

// 往 map 写 key 的协程
go func() {
	// 往 map 写入数据
    for i := 0; i < 10000; i++ {
        m[i] = i
    }
}()

// 从 map 读取 key 的协程
go func() {
	// 从 map 读取数据
    for i := 10000; i > 0; i-- {
        _ = m[i]
    }
}()

// 等待两个协程执行完毕
time.Sleep(time.Second)

这会导致报错:

fatal error: concurrent map read and map write

这是因为我们同时对 map 进行读写,而 map 不支持并发读写,所以会报错。如果 map 允许并发读写,那么可能在我们使用的时候会有很多错乱的情况出现。
(具体如何错乱,我们可以对比多线程的场景思考一下,本文不展开了)。

使用 sync.Mutex 保证并发安全

对于 map 并发读写报错的问题,其中一种解决方案就是使用 sync.Mutex 来保证并发安全,
但是这样会导致我们在读写的时候,都需要加锁,这样就会导致性能的下降。

使用 sync.Mutex 来保证并发安全,上面的代码可以改成下面这样:

var m = make(map[int]int)
// 互斥锁
var mu sync.Mutex

// 写 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        mu.Lock() // 写 map,加互斥锁
        m[i] = i
        mu.Unlock()
    }
}()

// 读 map 的协程序
go func() {
    for i := 10000; i > 0; i-- {
        mu.Lock() // 读 map,加互斥锁
        _ = m[i]
        mu.Unlock()
    }
}()

time.Sleep(time.Second)

这样就不会报错了,但是性能会有所下降,因为我们在读写的时候都需要加锁。(如果需要更高性能,可以继续读下去,不要急着使用 sync.Mutex

sync.Mutex 的常见的用法是在结构体中嵌入 sync.Mutex,而不是定义独立的两个变量。

使用 sync.RWMutex 保证并发安全

在上一小节中,我们使用了 sync.Mutex 来保证并发安全,但是在读和写的时候我们都需要加互斥锁。
这就意味着,就算多个协程进行并发读,也需要等待锁
但是互斥锁的粒度太大了,但实际上,并发读是没有什么太大问题的,应该被允许才对,如果我们允许并发读,那么就可以提高性能

当然 go 的开发者也考虑到了这一点,所以在 sync 包中提供了 sync.RWMutex,这个锁可以允许进行并发读,但是写的时候还是需要等待锁。
也就是说,一个协程在持有写锁的时候,其他协程是既不能读也不能写的,只能等待写锁释放才能进行读写

使用 sync.RWMutex 来保证并发安全,我们可以改成下面这样:

var m = make(map[int]int)
// 读写锁(允许并发读,写的时候是互斥的)
var mu sync.RWMutex

// 写入 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        // 写入的时候需要加锁
        mu.Lock()
        m[i] = i
        mu.Unlock()
    }
}()

// 读取 map 的协程
go func() {
    for i := 10000; i > 0; i-- {
        // 读取的时候需要加锁,但是这个锁是读锁
        // 多个协程可以同时使用 RLock 而不需要等待
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

// 另外一个读取 map 的协程
go func() {
    for i := 20000; i > 10000; i-- {
        // 读取的时候需要加锁,但是这个锁是读锁
        // 多个协程可以同时使用 RLock 而不需要等待
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

time.Sleep(time.Second)

这样就不会报错了,而且性能也提高了,因为我们在读的时候,不需要等待锁。

说明:

  • 多个协程可以同时使用 RLock 而不需要等待,这是读锁。
  • 只有一个协程可以使用 Lock,这是写锁,有写锁的时候,其他协程不能读也不能写。
  • 持有写锁的协程,可以使用 Unlock 来释放锁。
  • 写锁释放之后,其他协程才能获取到锁(读锁或者写锁)。

也就是说,使用 sync.RWMutex 的时候,读操作是可以并发执行的,但是写操作是互斥的。
这样一来,相比 sync.Mutex 来说等待锁的次数就少了,自然也就能获得更好的性能了。

gin 框架里面就使用了 sync.RWMutex 来保证 Keys 读写操作的并发安全。

有了读写锁为什么还要有 sync.Map?

通过上面的内容,我们知道了,有下面两种方式可以保证并发安全:

  • 使用 sync.Mutex,但是这样的话,读写都是互斥的,性能不好。
  • 使用 sync.RWMutex,可以并发读,但是写的时候是互斥的,性能相对 sync.Mutex 要好一些。

但是就算我们使用了 sync.RWMutex,也还是有一些锁的开销。那么我们能不能再优化一下呢?答案是可以的。那就是使用 sync.Map

sync.Map 在锁的基础上做了进一步优化,在一些场景下使用原子操作来保证并发安全,性能更好。

使用原子操作替代读锁

但是就算使用 sync.RWMutex,读操作依然还有锁的开销,那么有没有更好的方式呢?
答案是有的,就是使用原子操作来替代读锁。

举一个很常见的例子就是多个协程同时读取一个变量,然后对这个变量进行累加操作:

var a int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

wg.Wait()

// a 期望结果应该是 20000才对。
fmt.Println(a) // 实际:17089,而且每次都不一样

这个例子中,我们期望的结果是 a 的值是 20000,但是实际上,每次运行的结果都不一样,而且都不会等于 20000
其中很简单粗暴的一种解决方法是加锁,但是这样的话,性能就不好了,但是我们可以使用原子操作来解决这个问题:

var a atomic.Int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

wg.Wait()

fmt.Println(a.Load()) // 20000

锁跟原子操作的性能差多少?

我们来看一下,使用锁和原子操作的性能差多少:

func BenchmarkMutexAdd(b *testing.B) {
	var a int32
	var mu sync.Mutex

	for i := 0; i < b.N; i++ {
		mu.Lock()
		a++
		mu.Unlock()
	}
}

func BenchmarkAtomicAdd(b *testing.B) {
	var a atomic.Int32
	for i := 0; i < b.N; i++ {
		a.Add(1)
	}
}

结果:

BenchmarkMutexAdd-12     	100000000	        10.07 ns/op
BenchmarkAtomicAdd-12    	205196968	         5.847 ns/op

我们可以看到,使用原子操作的性能比使用锁的性能要好一些。

也许我们会觉得上面这个例子是写操作,那么读操作呢?我们来看一下:

func BenchmarkMutex(b *testing.B) {
	var mu sync.RWMutex

	for i := 0; i < b.N; i++ {
		mu.RLock()
		mu.RUnlock()
	}
}

func BenchmarkAtomic(b *testing.B) {
	var a atomic.Int32
	for i := 0; i < b.N; i++ {
		_ = a.Load()
	}
}

结果:

BenchmarkMutex-12     	100000000	        10.12 ns/op
BenchmarkAtomic-12    	1000000000	         0.3133 ns/op

我们可以看到,使用原子操作的性能比使用锁的性能要好很多。而且在 BenchmarkMutex 里面甚至还没有做读取数据的操作。

sync.Map 里面的原子操作

sync.Map 里面相比 sync.RWMutex,性能更好的原因就是使用了原子操作。
在我们从 sync.Map 里面读取数据的时候,会先使用一个原子 Load 操作来读取 sync.Map 里面的 key(从 read 中读取)。
注意:这里拿到的是 key 的一份快照,我们对其进行读操作的时候也可以同时往 sync.Map 中写入新的 key,这是保证它高性能的一个很关键的设计(类似读写分离)。

sync.Map 里面的 Load 方法里面就包含了上述的流程:

// Load 方法从 sync.Map 里面读取数据。
func (m *Map) Load(key any) (value any, ok bool) {
	// 先从只读 map 里面读取数据。
	// 这一步是不需要锁的,只有一个原子操作。
	read := m.loadReadOnly()
	e, ok := read.m[key]
	if !ok && read.amended { // 如果没有找到,并且 dirty 里面有一些 read 中没有的 key,那么就需要从 dirty 里面读取数据。
		// 这里才需要锁
		m.mu.Lock()
		read = m.loadReadOnly()
		e, ok = read.m[key]
		if !ok && read.amended {
			e, ok = m.dirty[key]
			m.missLocked()
		}
		m.mu.Unlock()
	}
	
	// key 不存在
	if !ok {
		return nil, false
	}
	// 使用原子操作读取
	return e.Load()
}

上面的代码我们可能还看不懂,但是没关系,这里我们只需要知道的是,从 sync.Map 读取数据的时候,会先做原子操作,如果没找到,再进行加锁操作,这样就减少了使用锁的频率了,自然也就可以获得更好的性能(但要注意的是并不是所有情况下都能获得更好的性能)。至于具体实现,在下一篇文章中会进行更加详细的分析。

也就是说,sync.Map 之所以更快,是因为相比 RWMutex,进一步减少了锁的使用,而这也就是 sync.Map 存在的原因了

sync.Map 的基本用法

现在我们知道了,sync.Map 里面是利用了原子操作来减少锁的使用。但是我们好像连 sync.Map 的一些基本操作都还不了解,现在就让我们再来看看 sync.Map 的基本用法。

sync.Map 的使用还是挺简单的,map 中有的操作,在 sync.Map 都有,只不过区别是,在 sync.Map 中,所有的操作都需要通过调用其方法来进行。
sync.Map 里面几个常用的方法有(CRUD):

  • Store:我们新增或者修改数据的时候,都可以使用 Store 方法。
  • Load:读取数据的方法。
  • Range:遍历数据的方法。
  • Delete:删除数据的方法。
var m sync.Map

// 写入/修改
m.Store("foo", 1)

// 读取
fmt.Println(m.Load("foo")) // 1 true

// 遍历
m.Range(func(key, value interface{}) bool {
    fmt.Println(key, value) // foo 1
    return true
})

// 删除
m.Delete("foo")
fmt.Println(m.Load("foo")) // nil false

注意:在 sync.Map 中,keyvalue 都是 interface{} 类型的,也就是说,我们可以使用任意类型的 keyvalue
而不像 map,只能存在一种类型的 keyvalue。从这个角度来看,它的类型类似于 map[any]any

另外一个需要注意的是,Range 方法的参数是一个函数,这个函数如果返回 false,那么遍历就会停止。

sync.Map 的使用场景

sync.Map 源码中,已经告诉了我们 sync.Map 的使用场景:

The Map type is optimized for two common use cases: (1) when the entry for a given
key is only ever written once but read many times, as in caches that only grow,
or (2) when multiple goroutines read, write, and overwrite entries for disjoint
sets of keys. In these two cases, use of a Map may significantly reduce lock
contention compared to a Go map paired with a separate Mutex or RWMutex.

翻译过来就是,Map 类型针对两种常见用例进行了优化:

  • 当给定 key 的条目只写入一次但读取多次时,如在只会增长的缓存中。(读多写少)
  • 当多个 goroutine 读取、写入和覆盖不相交的键集的条目。(不同 goroutine 操作不同的 key)

在这两种情况下,与 Go map 与单独的 MutexRWMutex 配对相比,使用 sync.Map 可以显著减少锁竞争(很多时候只需要原子操作就可以)。文章来源地址https://www.toymoban.com/news/detail-802349.html

总结

  • 普通的 map 不支持并发读写。
  • 有以下两种方式可以实现 map 的并发读写:
    • 使用 sync.Mutex 互斥锁。读和写的时候都使用互斥锁,性能相比 sync.RWMutex 会差一些。
    • 使用 sync.RWMutex 读写锁。读的锁是可以共享的,但是写锁是独占的。性能相比 sync.Mutex 会好一些。
  • sync.Map 里面会先进行原子操作来读取 key,如果读取不到的时候,才会需要加锁。所以性能相比 sync.Mutexsync.RWMutex 会好一些。
  • sync.Map 里面几个常用的方法有(CRUD):
    • Store:我们新增或者修改数据的时候,都可以使用 Store 方法。
    • Load:读取数据的方法。
    • Range:遍历数据的方法。
    • Delete:删除数据的方法。
  • sync.Map 的使用场景,sync.Map 针对以下两种场景做了优化:
    • key 只会写入一次,但是会被读取多次的场景。
    • 多个 goroutine 读取、写入和覆盖不相交的键集的条目。

到了这里,关于深入理解 go sync.Map - 基本原理的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 【Golang】Golang进阶系列教程--Go 语言 map 如何顺序读取?

    Go 语言中的 map 是一种非常强大的数据结构,它允许我们快速地存储和检索键值对。 然而,当我们遍历 map 时,会有一个有趣的现象,那就是输出的键值对顺序是不确定的。 先看一段代码示例: 当我们多执行几次这段代码时,就会发现,输出的顺序是不同的。 首先,Go 语言

    2024年02月14日
    浏览(69)
  • golang--sync.map(安全字典)

    引言:在Go语言中,多个goroutine之间安全地共享数据是一项挑战。为了解决这个问题,Go语言提供了sync包,并在其中引入了sync.Map类型。sync.Map是一种并发安全的映射数据结构,它提供了高效的并发访问方式,避免了显式的锁操作。本文将深入探讨sync.Map的使用方法和底层实现

    2024年02月13日
    浏览(47)
  • Golang 线程安全与 sync.Map

    前言 线程安全通常是指在并发环境下,共享资源的访问被适当地管理,以防止竞争条件(race conditions)导致的数据不一致 Go语言中的线程安全可以通过多种方式实现 实现方式 互斥锁(Mutexes) Go的sync包提供了Mutex和RWMutex类型来确保在一个时间点只有一个协程可以访问某个资

    2024年01月24日
    浏览(39)
  • Golang实践:用Sync.Map实现简易内存缓存系统

    定义了一个Cache结构体,其中使用sync.Map作为底层数据结构来存储缓存项。Set方法用于设置缓存项,指定键、值以及过期时间。Get方法用于获取缓存项,如果缓存项存在且未过期,则返回值和true,否则返回nil和false。方法的接受者为指针类型,是为了对Cache对象进行操作,并在

    2024年04月15日
    浏览(43)
  • 深入探讨 Go 语言中的 Map 类型

    Go 语言中的 map 类型是一种非常强大且常用的数据结构,它提供了一种键值对的映射关系。本篇博客将深入讨论 Go 中的 map 类型,包括其基本用法、特性、以及一些最佳实践。 1. 声明和初始化 在 Go 中,你可以使用 make 函数来创建一个空的 map 。 map 的键和值可以是任意数据类

    2024年01月17日
    浏览(43)
  • go数据类型-sync.map

    在runtime的sync.map包中有定义: 整体的结构就如上图。 sync.map中有两个map 都是通过指针的形式 ,指向同一份value,但是key两个map都有各自存储。 看究竟是如何解决map并发的问题的。 正常读写走 m,就是read那个通过泛型结构体指向的那个 map,通过m这个map进行查找后,读取或者

    2024年02月05日
    浏览(41)
  • go src - sync.Map

    在并发编程中,我们经常会遇到多个goroutine同时操作一个map的情况。如果在这种情况下直接使用普通的map,那么就可能会引发竞态条件,造成数据不一致或者更严重的问题。 sync.Map 是Go语言中内置的一种并发安全的map,但是他的实现和用法与普通的map完全不同,这篇文章将详

    2024年02月11日
    浏览(62)
  • 深入理解Go语言接口

    接口是一种定义了软件组件之间交互规范的重要概念,其促进了代码的解耦、模块化和可扩展性,提供了多态性和抽象的能力,简化了依赖管理和替换,方便进行单元测试和集成测试。这些特性使得接口成为构建可靠、可维护和可扩展的软件系统的关键工具之一。 在现代编程

    2024年02月09日
    浏览(48)
  • 【Golang】认识Go语言中基本的数据类型

    目录 整形 基本整型  特殊整型 浮点型 布尔型 字符型 字符串转义符  多行字符串 字符串的常用操作 复数 我们不论在学习什么语言中,我们都要去认识一下这个语言中的数据类型,当然学习Go也不例外,我们也要去认识一下其相关的数据类型,当然这些数据类型基本上是大

    2023年04月08日
    浏览(46)
  • 深入理解 Go 语言中的 iota

    iota是go语言的常量计数器,只能在常量表达式中使用,iota在const出现时将被重置为0,const中每新增一行常量声明将使iota计数一次,可理解为const语句块中的行索引。它会自动递增,从0开始。 尽管默认步长为1,但我们也可以通过在常量声明中显式赋值来修改 iota 的步长

    2024年02月10日
    浏览(47)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包