2025-01-30 00:44:01
Go 语言的 io/fs
包是 Go 1.16 版本引入的一个标准库包,它定义了文件系统的抽象接口。这个包提供了一种统一的方式来访问不同类型的文件系统,包括本地文件系统、内存文件系统、zip 文件等。
io/fs
包的主要作用io/fs
包定义了一组接口,用于描述文件系统的基本操作,如打开文件、读取目录等。通过这些接口,我们可以编写与具体文件系统无关的代码。io/fs
包定义的接口,就可以使用相同的代码进行访问。io/fs
包,我们可以方便地mock文件系统,从而提高代码的可测试性。io/fs
包的核心接口fs.FS
: 表示一个文件系统,定义了打开文件的方法 Open
。fs.File
: 表示一个打开的文件,定义了读取、写入、关闭等方法。fs.FileInfo
: 表示文件的元信息,包括文件名、大小、修改时间等。fs.DirEntry
接口表示一个目录项,它可以是文件或子目录。fs.FileInfo
接口表示文件的元信息。fs.FileMode
类型表示文件的权限和类型,它是一个位掩码。还有一些基于fs.FS
、fs.File
等接口扩展的一些接口:
fs.GlobFS
接口扩展了 fs.FS
接口,增加了 Glob(pattern string) ([]string, error)
方法。该方法允许使用通配符模式匹配文件和目录。fs.ReadDirFS
接口也扩展了 fs.FS
接口,增加了 ReadDir(name string) ([]fs.DirEntry, error)
方法。该方法用于读取指定目录下的所有文件和子目录。fs.ReadDirFile
接口扩展了 fs.File
接口,增加了 ReadDir(n int) ([]fs.DirEntry, error)
方法。这个接口主要用于读取目录文件中的内容,返回一个 fs.DirEntry
列表。它通常用于实现了 fs.ReadDirFS
的文件系统。fs.ReadFileFS
接口扩展了 fs.FS
接口,增加了 ReadFile(name string) ([]byte, error)
方法。这个接口允许直接读取指定文件的全部内容,返回字节切片。 它提供了一种更便捷的方式来读取文件内容,避免了先打开文件再读取的步骤。fs.StatFS
接口也扩展了 fs.FS
接口,增加了 Stat(name string) (fs.FileInfo, error)
方法。该方法用于获取指定文件的元信息,返回一个 fs.FileInfo
对象。fs.SubFS
接口也扩展了 fs.FS
接口,增加了 Sub(dir string) (fs.FS, error)
方法。该方法用于创建一个新的文件系统,它表示原始文件系统的一个子目录。这在需要限制访问文件系统的特定部分时非常有用。fs.WalkDirFunc
类型定义了一个函数签名,用于 fs.WalkDir
函数的回调。io/fs
包的应用场景io/fs
包进行访问。fs.FS
接口fs.FS
接口是 io/fs
包的核心,它表示一个文件系统。最常见的实现是 os.DirFS
,它表示本地文件系统的一个目录。
|
|
这个例子展示了如何使用 os.DirFS
创建一个文件系统,然后使用 fsys.Open
方法打开一个文件并读取其内容。
fs.File
接口fs.File
接口表示一个打开的文件,它提供了读取、写入、关闭等方法。
|
|
这个例子展示了如何使用 f.Stat 方法获取文件的元信息。
fs.DirEntry
接口fs.DirEntry
接口表示一个目录项,它可以是文件或子目录。
|
|
这个例子展示了如何使用 fs.ReadDir
函数读取目录中的所有条目,并使用 entry.Name
和 entry.IsDir
方法获取条目的名称和类型。
fs.GlobFS
接口fs.GlobFS
接口扩展了 fs.FS
接口,增加了 Glob
方法,允许使用通配符模式匹配文件和目录。
|
|
这个例子展示了如何使用 fs.Glob 函数查找所有以 .go 结尾的文件。
fs.ReadDirFS
接口fs.ReadDirFS
接口也扩展了 fs.FS
接口,增加了 ReadDir
方法,用于读取指定目录下的所有文件和子目录。
|
|
这个例子展示了如何使用 fs.ReadDir
函数读取目录中的所有条目。
fs.SubFS
接口fs.SubFS
接口也扩展了 fs.FS
接口,增加了 Sub
方法,用于创建一个新的文件系统,它表示原始文件系统的一个子目录。
|
|
这个例子展示了如何使用 fs.Sub
函数创建一个表示子目录的文件系统,并读取其内容。
fs.WalkDirFunc
接口fs.WalkDirFunc
类型定义了一个函数签名,用于 fs.WalkDir
函数的回调。
|
|
这个例子展示了如何使用 fs.WalkDir 函数遍历目录,并使用 fs.WalkDirFunc 函数打印每个文件和目录的路径。
内存文件系统是一种虚拟文件系统,它将文件存储在内存中而不是磁盘上。内存文件系统通常用于临时存储数据,或者用于测试和调试目的。
这种文件系统速度非常快,但数据在程序退出后会丢失。Go 语言的 testing/fstest
包提供了一个 MapFS
包,可以方便地创建内存文件系统。
|
|
也有一些第三方的库实现了内存文件系统,比如psanford/memfs,这是一个它的例子:
|
|
嵌入式文件系统将文件嵌入到程序中,这样可以方便地将静态资源打包到程序中。Go 语言标准库提供了一个 embed
包,可以方便地创建嵌入式文件系统。
|
|
这个例子展示了如何使用
embed.FS类型创建一个嵌入式文件系统,并使用
staticFiles.Open` 方法打开一个嵌入的文件。
有一些第三方库提供了将 S3 存储桶挂载为本地文件系统的功能,这样我们就可以像访问本地文件一样访问 S3 文件。例如,go-cloud
库就提供了对多种云存储服务的统一访问接口,包括 S3。
|
|
这个例子展示了如何使用 gocloud.dev/blob
包将 google GCS 存储桶挂载为本地文件系统,并使用 fs.WalkDir
函数遍历存储桶中的文件。
2025-01-27 00:38:47
自从加入百度负责物理网络的监控业务之后,我大部分的都是编写各种各样额度底层的网络程序。业余时间我也是编写一些有趣的网络程序,不仅仅是兴趣,也是为未来的某个业务探索一下技术方案。
而且这次,我想知道,就在我这一个10年前的小mini机器4核机器上,在家庭网络中扫描全国(中国大陆)的所有的公网IP地址需要多少时间。
利用它,我可以知道和全国各省市的运营商、云服务商的联通情况。有没有运营商的出口故障以及IP已没有被运营商或者有关部门劫持。
TL;DR: 一共扫描了3亿个地址(343142912),当前ping的通的IP 592万个(5923768),耗时1小时(1h2m57.973755197s)。
这次我重构了以前的一个扫描公网IP的程序。先前的程序使用gopacket收发包,也使用gopacket组装包。但是gopacket很讨厌的的一个地方是它依赖libpcap库,没有办法在禁用CGO的情况下。
事实上利用Go的扩展包icmp和ipv4,我们完全可以不使用gopacket实现这个功能,本文介绍具体的实现。
程序的全部代码在:https://github.com/smallnest/fishfinder
程序使用ICMP协议进行探测。
首先它启动一个goroutine解析全国的IP地址。IP地址文件每一行都是一个网段,它对每一个网段解析成一组IP地址,把这组IP地址扔进input channel。
一个发送goroutine从input通道中接收IP地址,然后组装成ICMP echo包发送给每一个IP地址,它只负责发送,发送完所有的地址就返回。
一个接收goroutine处理接收到的ICMP reply 回包,并将结果写入到output channel中。
主程序不断的从output中接收已经有回包的IP并打印到日志中,直到所有的IP都处理完就退出。
这里涉及到并发编程的问题,几个goroutine怎么协调:
如果你对Go并发编程有疑问,可以阅读极客时间上的《Go并发编程实战课》专栏,或者图书《深入理解Go并发编程》。
如果你是Rust程序员,不就我会推出《Go并发编程实战课》姊妹专栏,专门介绍Rust并发编程。
如果你对网络编程感兴趣,今年我还想推出《深入理解网络编程》的专栏或者图书,如果你感兴趣,欢迎和我探讨。
主程序的代码如下:
|
|
接下来介绍三个三个主要goroutine的逻辑。
首先你需要到互联网管理中心下载中国大陆所有的注册的IP网段,这是从亚太互联网络信息中心下载的公网IP信息,实际上可以探测全球的IP,这里以中国大陆的公网IP为例。
通过下面的代码转换成网段信息:
|
|
ipv4.txt文件中是一行行的网段:
|
|
数据量不大,我们全读取进来(如果太多的话我们就流式读取了)。
解析每一行的网段,转换成IP地址列表,然后发送给input通道。
等处理完就把inpout通道关闭。
|
|
我使用了ICMPScanner
结构体来管理发送和接收的逻辑。看名字你也可以猜测到我们将来还可以使用TCP/UDP等协议进行探测。
|
|
send
方法负责发送ICMP包,recv
方法负责接收ICMP包。
|
|
send
方法中,我们首先创建一个ICMP连接,我通过icmp包创建了一个连接,然后设置了一个BPF过滤器,过滤掉我们不关心的包。
这是一个技巧,这个连接我们不关心接收到的包,只关心发送的包,所以我们设置了一个空的过滤器。
这个设计本来是为了将来的性能扩展做准备,可以创建多个连接用来更快的发送。不过目前我们只使用一个连接,所以这个连接其实可以和接收goroutine共享,目前的设计还是发送和接收使用各自的连接。
接下来就是发送的逻辑了,也就是上面省略的部分:
|
|
发送循环从input通道中读取IP地址,然后构造ICMP echo报文,发送到目标地址。
icmp报文中的ID我们设置为进程的PID,在接收的时候可以用来判断是否是我们发送的回包。
接收逻辑比较简单,我们只需要接收ICMP回包,然后解析出IP地址,然后发送到output通道。
首先我们创建一个ICMP连接,然后循环接收ICMP回包,解析出IP地址,然后发送到output通道。
我们只需处理ICMPTypeEchoReply类型的回包,然后判断ID是否是我们发送的ID,如果是就把对端的IP发送到output通道。
我们通过ID判断回包针对我们的场景就足够了,不用再判断seq甚至payload信息。
|
|
可以看到,200行代码基本就可以我们扫描全国公网IP的程序了。你也可以尝试扫描一下全球的IP地址,看看需要多少时间。
对了,下面是我运行这个程序的输出:
|
|
2024-11-18 22:47:50
Go语言中Timer以及相关的Ticker、time.After、time.AfterFunc 等定时器最终是以四叉堆的数据形式存放的。
全局的 timer 堆也经历过三个阶段的重要升级。
常见的堆(heap)常常以二叉堆的形式实现。可是为什么Go timer使用四叉堆呢?
以最小堆为例,下图展示了二叉堆和四叉堆的区别:
父节点和子节点的索引计算也略有不同。二叉堆的父子索引如下:
|
|
四叉堆的父子索引如下:
|
|
他们的操作时间复杂度:
因为四叉树的高度相对更低,所以四叉堆适合数据量特别大,需要减少树的高度的场景, Go的timer很久以前(11年前)就使用四叉树来实现Timer的保存,当然Go开发者也是根据测试结果选择了四叉树,最早的这个提交可以查看: ## code review 13094043: time: make timers heap 4-ary (Closed)
在Go的运行时中,四叉堆的实现在 src/runtime/time.go
文件中,可以查看源码实现。timers
数据结构代表Timer的集合,每个P都有一个timers实例,用于维护当前P的所有Timer。
|
|
同时Timer
结构体还引用了Timers
, 这叫你中有我,我中有你,这样的设计是为了方便Timer的管理,Timer的创建、删除、执行都是通过Timers来实现的。
|
|
我们来看看对这个堆操作的一些方法。
timerHeapN
定义了堆是四叉堆,也就是每个节点最多有4个子节点。
|
|
堆常用的辅助方法就是siftUp
和siftDown
,分别用于上浮和下沉操作。
下面是上浮的方法,我把一些跟踪检查的代码去掉了。整体看代码还是比较简单的,就是不停的上浮,直到找到合适的位置。
|
|
类似的,下面是下沉的方法:
|
|
比上浮略微复杂,因为需要在兄弟节点中找到最小的节点,然后将当前节点下沉到这个位置。
对于一个任意的slice,我们可以把它初始化为一个四叉堆,方法如下:
|
|
当然timers还有一些辅助timer处理的一些方法,很多和四叉堆没有关系了,我就不一一介绍了,我主要介绍几个和四叉堆相关的方法。
这里吐槽一下,这个time.go文件中代码组织很乱,timer和timers的方法都穿插在一起。理论应该是timer方法和timers方法分开,这样更清晰。或者把timers抽取到一个单独的文件中。
|
|
增加一个timer到堆中:
|
|
d-ary 堆或 d-heap 是一种优先队列数据结构,是二进制堆的泛化,其中节点有d个子节点而不是 2 个子节点。因此,二进制堆是2堆,而三元堆是3堆。根据 Tarjan 和 Jensen 等人的说法,d-ary堆是由 Donald B. Johnson 1975 年发明的。
此数据结构允许比二进制堆更快地执行降低优先级操作(因为深度更浅了),但代价是删除最小操作速度较慢。这种权衡导致算法的运行时间更长,其中降低优先级操作比删除最小操作更常见。此外,d-ary堆比二进制堆具有更好的内存缓存行为,尽管理论上最坏情况下的运行时间更长,但它们在实践中运行得更快。与二进制堆一样,d-ary堆是一种就地数据结构,除了在堆中存储项目数组所需的存储空间外,它不使用任何额外的存储空间。
在Go生态圈已经有相应的库实现这个数据结构,比如ahrav/go-d-ary-heap,所以如果你有类似场景的需求,或者想对比测试,你可以使用这个库。
导入库:
|
|
下面的例子是创建三叉最小堆和四叉最大堆的例子:
|
|
往堆中增加元素:
|
|
从堆中移除最值:
|
|
返回但是不移除最值:
|
|
2024-11-17 17:17:13
今天在准备《秘而不宣》系列下一篇文章时,思绪飘散了,突然想到使用 Heap 的功能再加 HashTable (Map) 的功能,可以构造一种新的数据结构,然后把我聚合程序中的数据聚合数据结构替换掉,总之思绪翩翩。然后在网上搜了一下,这种数据结构其实早就有了,名字叫 HeapMap
。
HeapMap
(也叫做 PriorityMap
) 是一种结合了堆和哈希映射的数据结构,常用于需要按键排序并进行高效查找的场景。它可以在优先级队列的基础上,使用哈希映射来提供快速访问和更新。HeapMap
在实现过程中利用堆的有序性和哈希表的快速查找能力,以支持按键排序和常数时间查找。
Go 语言支付 Rob Pike 在他的 Rob Pike's 5 Rules of Programming 第 5 条就指出:
- Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident. Data structures, not algorithms, are central to programming.
数据为王。如果你选择了合适的数据结构并进行了良好的组织,算法通常会变得显而易见。编程的核心在于数据结构,而非算法。
所以,如果在合适的场景下,针对它的特点,使用 HeapMap 会取得事半功倍的效果。
HeapMap
的主要特点HeapMap
内部通过堆来维护键的顺序,可以快速获取最小或最大键。堆提供了插入和删除堆顶元素的 O(log n)
时间复杂度。HeapMap
同时使用哈希映射以支持快速查找。哈希映射的查找、插入、删除等操作在理想情况下时间复杂度为 O(1)
。HeapMap
适合需要频繁按键排序和快速查找的场景,比如带有优先级的缓存、调度系统、任务优先队列等。HeapMap
的基本结构你使用一个 container/heap
+ map
很容易实现一个 HeapMap
, 其实我们没必要自己去写一个重复的轮子了,网上其他语言比如 Rust、Java 都有现成的实现,Go 语言中也有一个很好的实现:nemars/heapmap
HeapMap
的实现nemars/heapmap
这个库是去年增加到 github 中的,我是第一个 star 它的人。我们看看它是怎么实现的。
|
|
Entry
代表这个数据结构中的一个节点 (元素、条目) , 它包含 key、value 值,还有优先级,index 记录它在堆的实现数组中的索引。
heapmap
代表 HeapMap
的实现,它包含两个字段,第一个字段其实就是 Heap
的实现,为了方便实现泛型,它就自己实现了一个堆。第二个字段就是一个 map 对象了。
数据结构定义清楚了,那就就可以实现它的方法了。它实现了一些便利的方法,我们值关注几个实现就好了。
|
|
读取h
字段或者m
字段的长度都可以。
返回root元素。
最小堆就是返回最小的元素,最大堆就是返回最大的元素。
|
|
弹出root元素。
|
|
注意涉及到元素的删除操作,要同时删除 map 中的元素。
其实作者没有实现 Push 方法,而是使用Set 方法来实现的。
|
|
Set方法有两个功能。如果元素的Key已经存在,那么就是更新元素,并且根据优先级进行调整。
如果元素的Key不存在,那么就是插入元素。
Get 方法就是获取任意的元素。
|
|
有一点你需要注意的是,这个数据结构不是线程安全的,如果你需要线程安全的话,你可以使用 sync.Mutex
/sync.RWMutex
来保护它。
2024-11-17 16:19:01
在现代多核处理器中,高效的缓存机制极大地提升了程序性能,而“伪共享”问题却常常导致缓存机制的低效。
cacheline 本文中有时又叫做 缓存行
在现代多核处理器中,三级缓存通常分为三级:L1、L2 和 L3,每一级缓存的大小、速度和共享方式都不同:
L1 缓存:这是速度最快的缓存,通常每个 CPU 核心都有独立的 L1 缓存。L1 缓存分为两个部分:一个用于存储指令(L1I),另一个用于存储数据(L1D)。L1 缓存的容量一般较小(通常 32KB - 64KB),但是读取速度极快,以极低的延迟为 CPU 核心提供服务。
L2 缓存:L2 缓存通常比 L1 缓存大一些,容量一般在 256KB - 1MB 左右,每个 CPU 核心通常也会有独立的 L2 缓存。虽然 L2 缓存的访问速度比 L1 缓存稍慢,但它仍然显著快于主存。
L3 缓存:这是三级缓存中容量最大的,通常在 8MB - 64MB 或更大。L3 缓存往往由所有 CPU 核心共享,并且主要用于减少核心之间的数据传输延迟。L3 缓存的读取速度比 L1、L2 缓存慢,但相对主存依然较快。对于多核处理器,L3 缓存是多核心之间协作的重要纽带。
CPU缓存将数据划分成若干个 cacheline
,使得 CPU 访问特定数据时,能以 cacheline 为单位加载或存储数据。cacheline
的大小通常是固定的,x86 架构中常见的 cacheline
大小是 64 字节,而 Apple M 系列等一些 ARM 架构处理器上可能达到 128 字节。
在 CPU 执行程序时,若数据在某级缓存中命中,整个 cacheline
会从该缓存加载到寄存器中;若数据不在 L1 缓存中,则会依次查找 L2、L3 缓存,并最终在主存中查找并加载到缓存。由于 cacheline
是缓存操作的基本单位,每次数据传输都是以 cacheline
为最小粒度的。
比如在 mac mini m2 机器是,我们可以查看此 CPU 的缓存行大小为 128 字节:
Linux 下可以查看另外一台机器的各级别缓存行大小为 64 字节:
伪共享 是指多个线程访问同一个 cache line 中的不同变量时,导致频繁的缓存失效(cache invalidation),从而大大降低程序性能。伪共享通常在多线程编程中发生,因为在多个线程中,如果两个或多个线程操作的变量在同一个 cache line 中,但它们并没有真正的共享关系,每个线程对其变量的写操作会导致其他线程的缓存失效。这样,CPU 核心会不断地将数据写回并重新加载,产生了不必要的资源浪费。
设有两个线程,各自操作两个独立的变量 x
和 y
:
|
|
如果变量 x
和 y
位于同一个 cache line 中,那么线程 A 更新 x
后,线程 B 也会因为缓存失效而重新加载 y
,尽管 B 实际上并未使用 x
的值。这种情况下,虽然两个变量并没有直接共享,但每次写操作都会导致另一方的缓存失效,从而形成了伪共享。
伪共享会对性能产生严重影响,但可以通过以下几种方法来优化:
cacheline
,以防止多个线程访问同一个 cacheline
。例如,可以在变量之间添加填充数据来分隔不同的 cacheline
(假定 CPU 缓存行是 64 字节):
|
|
虽然单线程不会出现伪共享的问题,但是单线程程序仍然有一些缓存优化的空间:
|
|
可以看到读取对齐的结构体性能要远远好于未对齐的结构体。
很多高性能的库都会采用 CacheLine 优化的数据结构,比如 Java 生态圈知名的 LMAX Disruptor。 Go 标准库中也有类似的优化,让我们一起来看看它的实现和应用场景。
我们支持,Go 语言支持不同的 CPU 架构,不同的 CPU 架构的缓存行的大小也可能不同,Go 语言是如何统一的呢?
方法很简单,就是针对不同的 CPU 架构,定义不同大小的缓存行。
首先定义统一的结构和变量:
|
|
然后针对不同的 CPU 架构定义不同的缓存行大小。
比如arm64的CPU, 文件go/src/internal/cpu/cpu_arm64.go
中定义了缓存行大小为128字节:
|
|
比如64bit的龙芯, 缓存行大小是64字节,文件go/src/internal/cpu/cpu_loong64.go
中定义了缓存行大小为64字节:
|
|
又比如x86和amd64的CPU, 缓存行大小是64字节,文件go/src/internal/cpu/cpu_x86.go
中定义了缓存行大小为64字节:
|
|
所以Go运行时是根据它支持的不同的 CPU 架构,定义不同的缓存行大小,以此来避免伪共享问题。
但是这个数据结构是定义在Go运行时internal
库中,不对外暴露,那么我们怎么用的?
没关系,Go的扩展库golang.org/x/sys/cpu
中提供了CacheLinePad
的定义,我们可以直接使用。
|
|
它的实现和Go运行时中的一样,只是把CacheLinePad
暴露出来了,所以我们可以在自己的项目中直接使用。
在这个系列的上一篇文章中,我们介绍了treap
, treap
使用在semTable
中,semTable
是Go运行时中的一个数据结构,用来管理semaphore
的等待队列。
|
|
等并发读取semTable
时,由于semTable
中的root
是一个semaRoot
结构体,semaRoot
中有mutex
,treap
等字段,这些字段可能会被不同的CPU核心同时访问,导致伪共享问题。
为了解决伪共享问题,它增加了一个Pad
字段,补齐字段的大小到CacheLineSize
,这样就可以避免伪共享问题。当然这里可以确定semaRoot
的大小不会超过一个CacheLineSize
。
mheap
结构体中展示了另外一种场景,将部分字段使用CacheLinePad
隔开, 避免arenas
字段和上面的字段之间的伪共享问题。
|
|
go/src/runtime/stack.go
中stackpool
结构体中也使用了CacheLinePad
,展示了另外一种用法:
|
|
因为item的大小不确定,可能小于一个CacheLineSize
,也可能大于一个CacheLineSize
,所以这里对CacheLinePad
求余,只需补充一个小于CacheLineSize
的字节即可。
一般软件开发中,我们不需要关心这些细节,但是当我们需要优化性能时,了解这些底层的实现,可以帮助我们更好的理解和优化程序。
2024-11-17 16:19:00
treap
是一棵二叉树,它同时维护二叉搜索树 (BST) 和堆的属性, 所以由此得名 (tree + heap ⇒ treap)。
从形式上讲,treap (tree + heap) 是一棵二叉树,其节点包含两个值,一个 key 和一个 priority,这样 key 保持 BST 属性,priority 是一个保持 heap 属性的随机值(至于是最大堆还是最小堆并不重要)。相对于其他的平衡二叉搜索树,treap的特点是实现简单,且能基本实现随机平衡的结构。属于弱平衡树。
treap
由 Raimund Siedel 和 Cecilia Aragon 于 1989 年提出。
treap 通常也被称为“笛卡尔树”,因为它很容易嵌入到笛卡尔平面中:
具体来说,treap
是一种在二叉树中存储键值对 (X,Y)
的数据结构,其特点是:按 X
值满足二叉搜索树的性质,同时按 Y
值满足二叉堆的性质。如果树中某个节点包含值 (X₀,Y₀)
,那么:
X ≤ X₀
(BST 属性)X₀ ≤ X
(BST 属性)在这种实现中, X是键(同时也是存储在 Treap 中的值),并且 Y称为优先级。如果没有优先级,则 treap 将是一个常规的二叉搜索树。
优先级(前提是每个节点的优先级都不相同)的特殊之处在于:它们可以确定性地决定树的最终结构(不会受到插入数据顺序的影响)。这一点是可以通过相关定理来证明的。
这里有个巧妙的设计:如果我们随机分配这些优先级值,就能在平均情况下得到一棵比较平衡的树(避免树退化成链表)。这样就能保证主要操作(如查找、插入、删除等)的时间复杂度保持在 O(log N) 水平。
正是因为这种随机分配优先级的特点,这种数据结构也被称为"随机二叉搜索树"。
Treap维护堆性质的方法用到了旋转,且只需要进行两种旋转操作,因此编程复杂度较红黑树、AVL树要小一些。
红黑树的操作:
插入
以最大堆为例
给节点随机分配一个优先级,先和二叉搜索树的插入一样,先把要插入的点插入到一个叶子上,然后跟维护堆一样进行以下操作:
删除
因为 treap满足堆性质,所以只需要把要删除的节点旋转到叶节点上,然后直接删除就可以了。具体的方法就是每次找到优先级最大的子叶,向与其相反的方向旋转,直到那个节点被旋转到了叶节点,然后直接删除。
查找
和一般的二叉搜索树一样,但是由于 treap的随机化结构,Treap中查找的期望复杂度是 O(logn)
以上是 treap 数据结构的背景知识,如果你想了解更多而关于 treap 的知识,你可以参考
在 Go 运行时 sema.go#semaRoot 中,定义了一个数据结构 semaRoot
:
|
|
这是Go语言互斥锁(Mutex)底层实现中的关键数据结构,用于管理等待获取互斥锁的goroutine队列。我们已经知道,在获取 sync.Mutex
时,如果锁已经被其它 goroutine 获取,那么当前请求锁的 goroutine 会被 block 住,就会被放入到这样一个数据结构中 (所以你也知道这个数据结构中的 goroutine 都是唯一的,不重复)。
semaRoot
保存了一个平衡树,树中的 sudog
节点都有不同的地址 (s.elem)
,每个 sudog
可能通过 s.waitlink
指向一个链表,该链表包含等待相同地址的其他 sudog
。对具有相同地址的 sudog
内部链表的操作时间复杂度都是O(1).。扫描顶层semaRoot列表的时间复杂度是 O(log n)
,其中 n
是具有被阻塞goroutine的不同地址的数量(这些地址会散列到给定的semaRoot)。
semaRoot
的 treap *sudog
其实就是一个 treap, 我们来看看它的实现。
增加一个等待的goroutine(sudog
)到 semaRoot
的 treap
中,如果 lifo
为 true
,则将 s
替换到 t
的位置,否则将 s
添加到 t
的等待列表的末尾。
|
|
① 是遍历 treap 的过程,当然它是通过搜索二叉树的方式实现。 addr
就是我们一开始讲的treap的key,也就是 s.elem
。
首先检查 addr
已经在 treap 中,如果存在,那么就把 s
加入到 addr
对应的 sudog
链表中,或者替换掉 addr
对应的 sudog
。
这个addr
, 如果对于sync.Mutex
来说,就是 Mutex.sema
字段的地址。
|
|
所以对于阻塞在同一个sync.Mutex
上的goroutine,他们的addr
是相同的,所以他们会被加入到同一个sudog
链表中。
如果是不同的sync.Mutex
锁,他们的addr
是不同的,那么他们会被加入到这个treap不同的节点。
进而,你可以知道,这个rootSema
是维护多个sync.Mutex
的等待队列的,可以快速找到不同的sync.Mutex
的等待队列,也可以维护同一个sync.Mutex
的等待队列。
这给了我们启发,如果你有类似的需求,可以参考这个实现。
③就是设置这个节点的优先级,它是一个随机值,用于保持treap的平衡。这里有个技巧就是总是把优先级最低位设置为1,这样保证优先级不为0.因为优先级经常和0做比较,我们将最低位设置为1,就表明优先级已经设置。
④ 就是将这个新加入的节点旋转到合适的位置,以保持treap的平衡。这里的旋转操作就是上面提到的左旋和右旋。稍后看。
对应的,还有出对的操作。这个操作就是从treap中移除一个节点,这个节点就是一个等待的goroutine(sudog
)。
dequeue
搜索并找到在semaRoot
中第一个因addr
而阻塞的goroutine
。
比如需要唤醒一个goroutine, 让它继续执行(比如直接将锁交给它,或者唤醒它去争抢锁)。
|
|
① 是遍历 treap 的过程,当然它是通过搜索二叉树的方式实现。 addr
就是我们一开始讲的treap的key,也就是 s.elem
。如果找到了,就跳到 Found
标签。如果没有找到,就返回 nil
。
④是检查这个地址上是不是有多个等待的goroutine,如果有,就把这个节点替换成链表中的下一个节点。把这个节点从treap中移除并返回。
如果就一个goroutine,那么把这个移除掉后,需要旋转treap,直到这个节点被旋转到叶节点,然后删除这个节点。
这里的旋转操作就是上面提到的左旋和右旋。
rotateLeft
函数将以 x
为根的子树左旋,使其变为 y
为根的子树。
左旋之前的结构为 (x a (y b c))
,旋转后变为 (y (x a b) c)
。
|
|
具体步骤:
y
设为 x
的父节点(②),x
设为 y
的左子节点(①)。b
设为 x
的右子节点(③),并更新其父节点为 x
(④)。y
的父节点为 p
(⑤),即 x
的原父节点。如果 p
为 nil,则 y 成为新的树根(⑥)。y
是 p
的左子节点还是右子节点,更新对应的指针(⑦)。
左旋为
rotateRight 旋转以节点 y 为根的树。
将 (y (x a b) c)
变为 (x a (y b c))
。
|
|
具体步骤:
右旋为
理解了左旋和右旋,你就理解了出队代码中这一段为什么把当前节点旋转到叶结点中了:
|
|
整体上看,treap这个数据结构确实简单可维护。左旋和右旋的代码量很少,结合图看起来也容易理解。 出入队的代码也很简单,只是简单的二叉搜索树的操作,加上旋转操作。
这是我介绍的Go秘而不宣的数据结构第三篇,希望你喜欢。你还希望看到Go运行时和标准库中的哪些数据结构呢,欢迎留言。
我会不定期的从关注者列表并点赞文章的同学中选出一位,送出版商和出版社的老师赠送的书,欢迎参与。