- VisualStudio2022插件的安装及使用-编程手把手系列文章
- pprof-在现网场景怎么用
- C#实现的下拉多选框,下拉多选树,多级节点
- 【学习笔记】基础数据结构:猫树
最近在寻找 Go 的并发 map 库的时候,翻到一个 github 宝藏库,xsync (https://github.com/puzpuzpuz/xsync) 。这个库提供了一些支持并发的数据结构,计数器Counter,哈希 Map,队列Queue。我着重看了下它的 Map 的实现,遇到一个新的知识点:Cache-Line Hash Table (CLHT) 。问了半天 GPT,大致了解了其中的内容,这里总结下.
CacheLine 是 CPU 一次性读取内存的最小单元。它在不同的硬件设备上有不同的大小。在x86-64的机器上是 64 字节。就代表着 CPU 一次性能从内存中获取64 字节的大小。CPU处理器在处理一个变量数据的时候,会依次从寄存器,CPU 缓存,内存,磁盘中进行获取,当然他们的处理速度也是依次递减.
当计算机中多个 CPU 核读写同一个数据结构的时候,他们每次都会读取CacheLine 结构的数据进入自己的 CPU 缓存中。这里不同的数据结构设计,就会有不同的性能.
假设有一个大的数据结构 Object,a1 核负责读Object 的 field1 字段,而 a2 核负责写 Object 的 field2 字段,而 field1 和 field2 都在同一个 Cache Line 中,这就意味着 a1 和 a2 在并行计算的时候,都会把包含有 field1 和 field2 字段的 Cache line 读取到自己的 CPU 缓存中。那么问题来了,当 a2 核变更 field2 字段的时候,就要想办法通知a1 核,更新 CPU 缓存,否则a1 核计算可能是有问题的。a2核变更通知 a1 核更新 CPU 缓存,这种交互机制叫做 MESI.
当然这种交互机制是非常低效的。我们应该想办法尽量避免.
其中一种避免的方法之一就是使用锁,在修改数据的时候上锁,读取数据的时候读锁。但这种方式并不高效。我们在想,是否有一种无锁的编程方式呢?
控制 Object 结构的设计是个好办法,我们设计结构将其中的 field1 字段放在一个 CacheLine 中,另外一个 field2 在第二个 CacheLine 中,那么如果我们使用 a1 线程读 field1,a2 线程写 field2,那么我们就能做到无锁读写.
这就是利用 Cache-line 实现的无锁编程.
现在回到 hash 表,我们使用 hash 表的时候最头疼的就是 hash 表是非并发安全的,一般我们使用 hash 表的时候,都会带一个全局锁,我们读写hash 表的时候会读或者写一下这个全局锁.
但是这明显效率就比较低了.
要想效率高,Cache-Line Hash Table (CLHT) 就提出了使用 CacheLine 的逻辑来优化 hash 表。一个 hash 表一般就是一个 hash 函数+节点链表,我们如果让每个节点都保持一个 CacheLine 的大小(64 byte)。那么每次 cpu 读写的时候,就只会读取一个完整的节点进入到 cpu 缓存中,这样不是就能无锁使用 hash 表了吗?
是的,这种方案确实可行,但是最重要的就是设计这个 由多个hash节点组成的 hash 表结构.
我们先需要回答:一个 CacheLine(64 bytes = 64 * 8 bit = 512 bit) 能保存多少个hash key-value 对呢?
解:
一个指针是 uint64 类型,hash 的每个 key-value 对中 key 和 value 都是指针,key 指向一个 string,value 指向一个 interface{}, 即任何的数据结构, 那么一个 key-value 对占2*64bit = 128bit.
由于 hash 表中相同 hash的节点是通过指针链链接起来的,所以至少节点中要保存一个指向 next 节点的指针,uint64 = 64bit.
所以一个 cacheline 最多可以有3 个key-value 对 + 1 个 next 指针 = 128 * 3 + 64 = 448 bit.
解答完毕.
但是如此设计,cacheline 的空间还有盈余,还多了一个 512 - 448 = 64 bit 的大小,我们利用这个空间设计了一个 topHashMutex 结构(uint64),具体它是做什么用的,后面详聊.
我们的 bucke 节点在代码中如上设计,实现如下:
type bucket struct {
next unsafe.Pointer // *bucketPadded
keys [3]unsafe.Pointer
values [3]unsafe.Pointer
...
topHashMutex uint64
}
而我们的 hash 表结构就有如下展示:
这样根据 cacheline 设计 hash 表,是否能实现真正的无锁化呢?我们需要分析不同场景:
这是我们最希望见到的情况,由于我们事先设计了每个 bucket 节点正好是一个 hash 大小.
所以两个 cpu 读取自己的cpu 缓存即可,里面的节点互相不干扰,这个时候效率非常高.
这个 bucket 节点会从内存中被复制两份到两个 cpu 缓存中,但是这种场景,由于没有任何更新操作,我们也用不到任何锁.
这种情况,我们要保证的是读取的操作一定是原子的,我们可以读取更新前的值,也可以读取更新后的值,但是不能读取一个中间无效的值.
所以读取的 cpu 核在读取自己 cpu 缓存内容的时候,必须小心 cpu 缓存被修改,而导致了无效值。那么我们能怎么做呢?
指针快照的方法(snapshot) 。
首先,我们先从 bucket 节点中找到目标 key-value 对(这里如何快速找到后面会说),我们先读取一次key1 和 value1 ,但是注意,由于之前设计,我们bucket 里面存储的是key1 指针,value1 指针,所以我们实际读取的是指针。这个时候并不直接使用这个指针指向的内容,而是相当于我们为 key1 和 value1 做了一个快照.
这里要注意的是,读取 key1 和 value1 的指针快照是2 个原子操作。但是这两个原子操作,由于另外一个核在更新这个 key-value 对,就是在通过 MESI 机制同步修改我们的 cpu 缓存,我们是有可能读取到一个无效指针 value1 的(我们是否不会读到无效指针 key1,因为更新操作不会修改 key1 的指针).
那么我们如何确定 value1 是可用的呢?办法就是我们再取一次cpu 缓存中的 key1 和 value1 指针,判断他们是否有变化.
如果快照 key1和快照 value1 等于第二次查询的 key1 和 key2,那么就证明快照的 key1 和 value1 是可用的,不是正被修改中的内存.
如果快照 key1和快照 value1 不等于第二次查询的 key1 和 key2,那么就证明快照的 key1 和 value1 是不可用的,当前正在有其他cpu 在修改我的 cpu 缓存,这时候要做的就是重新进行快照过程.
这就是 atomic snapshot 的方法.
代码实现如下:
func (m *Map) Load(key string) (value interface{}, ok bool) {
...
for {
...
atomic_snapshot:
// Start atomic snapshot.
vp := atomic.LoadPointer(&b.values[i])
kp := atomic.LoadPointer(&b.keys[i])
if kp != nil && vp != nil {
if key == derefKey(kp) {
if uintptr(vp) == uintptr(atomic.LoadPointer(&b.values[i])) {
// Atomic snapshot succeeded.
return derefValue(vp), true
}
// Concurrent update/remove. Go for another spin.
goto atomic_snapshot
}
}
}
bptr := atomic.LoadPointer(&b.next)
if bptr == nil {
return
}
b = (*bucketPadded)(bptr)
}
}
引申一下,这个更新操作,实际换成删除操作也是生效的,因为删除操作相当于试一次特殊的更新(将 value1 的指针替换为 nil).
在这种场景下,我们需要保证只有一个 cpu 核在写,另外一个需要等待,我们不得不使用锁了,但是这个锁是非常小的,它只保证锁住 cacheline 就行了.
锁放在哪里呢?前面设计的 uint64 topHashMutex , 我们只需要使用1bit 的大小(最后一个 bit),标记 0/1就行了,0 代表没有锁,1 代表锁.
更新操作的时候,我们需要用 atomic.CompareAndSwapUint64 来抢到这个 topHashMutex 的最后一个 bit 的锁.
如果抢到的话,当前 cpu 核就可以心安理得的处理自己的 cpu 缓存区的内容,并且通知其他的 cpu 缓存区内容进行更新.
如果没有抢到的话,当前 cpu 核使用自旋锁,进入锁等待阶段,runtime.Gosched(), 让渡这个 goroutine 的执行权。等着go 调度机制再次调度到到这个goroutine,再次抢锁.
加锁代码逻辑如下:
func lockBucket(mu *uint64) {
for {
var v uint64
for {
v = atomic.LoadUint64(mu)
if v&1 != 1 {
break
}
runtime.Gosched()
}
if atomic.CompareAndSwapUint64(mu, v, v|1) {
return
}
runtime.Gosched()
}
}
同样引申一下,这个情景也适用于两个 CPU的删除操作,或者更新删除操作并行的情况。cacheline 小锁的机制,保证了同一时间只有一个 cpu 核能对这个节点进行操作.
好了,以上四种情况基本把并发读写同一个 map 节点的情景都列出来了.
但是还差一点,bucket 中的 topHashMutex 结构还有 63bit 的剩余空间,我们是否可以利用它来加速key-value 对的查找?答案是可以的,我们可以通过建立索引机制来加速.
我们将这个 63bit 分为 3 x 20 + 3 。前面的 3 个 20 是3 个 key-value 对的 key 值的索引。至于索引方式嘛,我们可以简单将 key 的 hash 值的前 20 位作为索引。这样我们在查找一个 key 的时候,先判断下其 hash 值的前 20 位在不在这个索引中,就能大概率判断出是否在这个 bucket 节点中了.
但是建立索引还是不够,前面说过了,删除某个 key-value,我们是直接将 value 的指针置为 nil,那么这个时候,它的 key 还存在,我们需要标记位来标记这个 key 是否能用.
topHashMutex 后面的 3 个 bit 就启动用处了, 0/1表示3 个 key-value 是否可用.
topHashMutex 的结构如下:
我们再举例说明:
假设我有一个 key 为 "foo", value 为 struct Bar。存储时,我们算好要存入 bucket 的第二个 key-value 位置.
"foo" 的 hash 值为 uint64: 1721009463561,转为二进制:1100010000110110110110110110110111101001001,取前 20 位,11000100001101101101.
我们把 topHashMutex 的第二个 20bit 设置为11000100001101101101。再把 topHashMutex 的第 62(3 x 20 + 2)设置为 1.表示可用.
在查询操作,我们在拿着 key = "foo" 来查找 value 的时候,先去判断 key 的 hash是否在前 60bit 中,然后再确认下对应的 bitmap 是否是可用的,我们就能判断目标 key 大概率是在这个 bucket 的第二个位置,我们这时候再走快照逻辑,判断快照的 key 的值是否是 “foo”,并且快照原子获取其value 值.
这就是这个开源 go 的 xsync 库中的 Map 结构的核心原理了。确实是非常巧妙的设计思路。核心思想就是利用cpu 一次读取 cacheline 大小的内容进 cpu 缓存区,就设计一个符合这个特性的 hash 表,尽量保证每个 cpu 的读取互不干扰,对于可能出现的并发干扰的情况,使用快照机制能保证读取的原子性,这样能有效避免全局锁的使用,提高性能.
至于可以看这个 benchmark 测评,https://github.com/puzpuzpuz/xsync/blob/main/BENCHMARKS.md 比较了xsync 的 map 和标准库 sync.Map。基本上真是秒杀,特别是在读写混杂的情况下,xsync 能比 sync.Map 节省2/3 的时间消耗.
cacheline 对 Go 程序的影响 。
CPU高速缓存与极性代码设计 。
最后此篇关于解码xsync的map实现的文章就讲到这里了,如果你想了解更多关于解码xsync的map实现的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
我正在尝试从一个 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 map 的 m
我是 Haskell 的新手,我认为函数 map map和 map.map在 Haskell 中是一样的。 我的终端给了我两种不同的类型, (map.map) :: (a -> b) -> [[a]
我的目标是创建一个 map 的 map ,这样我就可以通过它的键检索外部 map 的信息,然后通过它们的键访问它的“内部” map 。 但是,当我得到每个内部映射时,我最初创建的映射变成了一个对象,我
如何使用 Java8 编写以下代码? for (Entry> entry : data.entrySet()) { Map value = entry.getValue(); if (valu
我有覆盖整个南非的图片。它们为Tiff格式,并已将坐标嵌入其中。我正在尝试拍摄这些图像(大约20张图像),并将它们用作我的iPhone应用程序中的地图叠加层。我的问题在于(准确地)将地图切成图块。 我
所以我有 2 std::map s >一个是“旧的”,一个是“新的”,我想知道哪些文件被删除了,这样就能够遍历差异并对 shared_ptr 做一些事情。这样的事情可能吗?如何做到? 最佳答案 虽然
是否可以将当前查看的 google.maps.Map 转换为静态图像链接,以便我可以获取图像并将其嵌入到 PDF 中? 我在 map 上添加了一些带有自定义图标的标记,所以我不确定这是否真的可行。 如
你能帮我吗 Java Streams ? 从标题可以看出我需要合并List>>进入Map> . 列表表示为List>>看起来像: [ { "USER_1":{
对于 idAndTags 的第二个条目,内部映射被打乱,但第一个条目则不然 第一次接近! for (Map.Entry> entryOne : idAndTags.entrySet()) {
我将从我的代码开始,因为它应该更容易理解我想要做什么: @function get-color($color, $lightness) { @return map-get(map-get($col
我过去曾在许多网站上使用过 Google map ,但遇到了以前从未遇到过的问题。 map 窗口正在显示,但它只显示左上角的 map 片段,以及之后的任何内容(即使我在周围导航时),右侧也不会加载任何
众所周知,这些 map ,无论是常规街道 map 还是卫星 map ,在中国的特定地区都无法正确排列。那么哪个 map 排列正确,是卫星 map 还是默认街道 map ?一些网站表明卫星 map 是正
在拖尾事件之后,我面临着获取此处 map 中的 map 边界的问题。我需要新的经纬度来在新更改的视口(viewport)中获取一些项目/点。我只是想在拖动结束时获得谷歌地图map.getBounds(
我想做的是通过 ajax API 显示以英国邮政编码为中心的小型 bing 生成 map 。我相信这是可能的;我在 Bing map 文档中找不到如何将英国邮政编码转换为可以插入 map Ajax 控
我有一个 List我想转换成的 e Map>其中外部字符串应为“Name”,内部字符串应为“Domain”。 Name Id Domain e(0) - Emp1, 1, Insuran
我的第 2 部分:https://stackoverflow.com/questions/21780627/c-map-of-maps-typedef-doubts-queries 然后我继续创建 I
是否可以在 1 行中使用 Java8 编写以下所有 null 和空字符串检查? Map> data = new HashMap<>(holdings.rowMap()); Set>> entrySet
我正在审查一个项目的旧代码,并使用 Map 的 Map 的 Map 获得了如下数据结构(3 层 map ): // data structure Map>>> tagTree
这可能是一种不好的做法,但我还没有找到更好的解决方案来解决我的问题。所以我有这张 map // Map>> private Map>> properties; 我想初始化它,这样我就不会得到 Null
我们在 JDK 1.7 中使用 HashMap,我在使用 SonarQube 进行代码审查时遇到了一些问题。 请考虑以下示例: public class SerializationTest imple
我是一名优秀的程序员,十分优秀!