- 后端
- 即时通讯
- 社交
- 游戏开发
【免费下载链接】nakama
Scalable open-source game backend server: multiplayer, matchmaking, leaderboards, chat, and social features for games.
bitset是 bits-and-blooms 组织开源的 Go 语言位集合(BitSet)库,核心能力是把非负整数到布尔值的映射关系以比特位的形式紧凑存储,性能显著优于map[uint]bool。它既提供Set、Clear、Flip、Test等单比特操作,也提供交、并、差、补、对称差等集合运算,以及序列化、迭代、内存收缩等实用能力。本文以 README.md 为骨架,结合本仓库内 v1.25.0 的完整源码(bitset.go、bitset_iter.go、popcnt.go、select.go),深入讲解其内部实现与实战用法,并说明它在本仓库 nakama 中作为间接依赖的角色。读完本文,你将能独立完成位集合的增删改查、集合运算、序列化持久化与内存调优。
一、什么是 BitSet:数据结构与适用场景
BitSet 是一个"非负整数集合"的高效载体:整数i在集合中,当且仅当第i个二进制位为 1。相比map[uint]bool,它的优势在于:
- 内存紧凑:N 个比特至少只需 N/8 字节(详见下文"内存模型"),无哈希表开销;
- 缓存友好:底层是连续的
uint64数组,顺序访问对 CPU 缓存极其友好; - 向量化集合运算:交、并、差等运算退化为逐 word 的
&、|、&^位运算,极快。
典型应用场景包括:在线状态跟踪(玩家/用户 ID 集合)、IP 地址分配位图、任务去重、布隆过滤器的底层存储、索引位图等。数据库、搜索引擎、区块链、基础设施等领域的大量 Go 项目都在生产环境中使用它,例如 milvus(向量数据库)、bleve(全文搜索)、cosmos-sdk(区块链 SDK)、Hugo(静态站点生成器)、Docker Swarmkit(ID 管理)等。
二、安装与快速上手
安装命令(当前仓库 go.mod 锁定版本为 v1.25.0):
go get github.com/bits-and-blooms/bitset原文档给出了一个"Go Fish"纸牌游戏风格的完整示例,演示了Set、Test、Clear、链式调用、NextSet迭代和Intersection的用法:
package main import ( "fmt" "math/rand" "github.com/bits-and-blooms/bitset" ) func main() { fmt.Printf("Hello from BitSet!\n") var b bitset.BitSet // play some Go Fish for i := 0; i < 100; i++ { card1 := uint(rand.Intn(52)) card2 := uint(rand.Intn(52)) b.Set(card1) if b.Test(card2) { fmt.Println("Go Fish!") } b.Clear(card1) } // Chaining b.Set(10).Set(11) for i, e := b.NextSet(0); e; i, e = b.NextSet(i+1) { fmt.Println("The following bit is set:", i) } if b.Intersection(bitset.New(100).Set(10)).Count() == 1 { fmt.Println("Intersection works.") } else { fmt.Println("Intersection doesn't work???") } }注意示例中var b bitset.BitSet使用的是零值初始化——源码注释明确指出"BitSet 的零值是一个长度为 0 的空集合"(bitset.go),首次Set时内部会自动扩容,无需显式构造。
三、核心 API:单比特操作与链式调用
3.1 基本操作
| 方法 | 作用 | 返回值 |
|---|---|---|
Set(i) | 将第 i 位置 1,容量自动扩展 | *BitSet(可链式) |
Clear(i) | 将第 i 位置 0,绝不触发内存分配 | *BitSet(可链式) |
Flip(i) | 翻转第 i 位 | *BitSet(可链式) |
Test(i) | 查询第 i 位是否为 1 | bool |
SetTo(i, value) | 按布尔值设置第 i 位 | *BitSet |
SetRange(start, end) | 将[start, end)区间全部置 1 | *BitSet |
FlipRange(start, end) | 翻转[start, end)区间 | *BitSet |
ClearAll()/SetAll() | 清空 / 全量置 1(不释放内存) | *BitSet |
从源码看,Set、Clear、Flip等方法都返回*BitSet指针,因此可以像示例那样b.Set(10).Set(11)链式调用(bitset.go)。
3.2 自动扩容机制
Set(i)在i >= b.length时会调用extendSet(i)扩容(bitset.go),其核心逻辑是:
- 若原数组
cap(b.set)足够,直接截断 slice 完成"快速扩容"(fast resize),零拷贝; - 否则分配容量为 2 倍的新数组并拷贝旧数据;
- 每次扩容后
b.length = i + 1,即集合长度始终等于"最大访问位下标 + 1"。
这也是原文档强调的:BitSet 会膨胀到最大置位位的大小,内存分配量近似等于 Max(最大置位位),因此使用非常大的下标可能引发内存不足甚至 panic——调用方需对参数负责。
3.3 底层存储结构
源码中的核心类型极其简洁(bitset.go):
type BitSet struct { length uint set []uint64 }wordSize = 64,即每个 word 是 64 位;length记录当前位数(不是置位数);- 定位公式:word 下标为
i >> 6(log2WordSize = 6),word 内偏移为i & 63(wordsIndex,bitset.go); - 构造函数
New(length)会按"提示位数量"预分配wordsNeeded(length)个 word,且分配失败时优雅降级为容量为 0 的空 BitSet(通过 defer + recover 实现,bitset.go);如需"分配失败即 panic"的严格语义,可使用MustNew。
四、集合运算:交、并、差、补与对称差
原文档强调,库不仅支持单比特操作,还提供了完整的集合代数能力。源码中每组运算都有三种形态(bitset.go):
| 语义 | 非破坏性(返回新集合) | 仅计算基数(Cardinality) | 原地(InPlace,破坏性) |
|---|---|---|---|
交集& | Intersection | IntersectionCardinality | InPlaceIntersection |
并集\| | Union | UnionCardinality | InPlaceUnion |
差集&^ | Difference | DifferenceCardinality | InPlaceDifference |
对称差^ | SymmetricDifference | SymmetricDifferenceCardinality | InPlaceSymmetricDifference |
补集~(局部,到 length 为止) | Complement | — | — |
实现细节值得注意:
- 非破坏性版本会先按长度排序(
sortByLength,bitset.go),让遍历始终发生在较短的集合上,并避免重复分配;例如Intersection的结果集合大小取较短者的长度; - 原地版本避免任何新分配:如
InPlaceDifference用带边界检查消除(BCE)技巧的循环直接改写底层数组(bitset.go); - 基数版本(如
IntersectionCardinality)不构造新集合,直接对两个 word 数组做&后累计 popcount,内存占用为零(bitset.go)。当只需要数量而不需要集合本身时,务必用 Cardinality 系列。
此外还有包含关系判定:IsSuperSet(other)判断是否为超集,IsStrictSuperSet(other)判断是否为真超集(bitset.go)。
五、查询与统计:Count、Any/All/None 与 Rank/Select
原文档提到库提供"检查是否 any / all / no 位被置位,以及查询当前长度与置位数"的能力:
Len():返回位数(b.length),注意它不同于置位数;Count():返回置位数量,即 popcount(bitset.go);Any()/All()/None():分别判断"至少一位被置位 / 所有位都被置位 / 没有任何位被置位"。None()对空集合返回true,All()对空集合也返回true(bitset.go);Rank(index):统计"到 index(含)为止的置位数",基于逐 word popcount 累加(bitset.go);Select(index):Rank的逆操作,返回"第 j 个置位位的下标",内部使用 select.go 中select64的分治查找(32/16/8 逐级二分)定位 word 内的第 j 个置位;OnesBetween(from, to):统计[from, to)半开区间内的置位数,对单 word 与跨 word 两种情形分别用掩码 + popcount 处理(bitset.go)。
这些方法组合起来,可以高效回答"第 100 个在线的玩家是谁""区间内有多少活跃用户"等游戏后端常见问题。
六、遍历置位位:NextSet、NextSetMany 与 Go 1.23+ 迭代器
原文档示例使用了经典的NextSet循环模式:
for i, e := b.NextSet(0); e; i, e = b.NextSet(i+1) { fmt.Println("The following bit is set:", i) }NextSet(i)从下标 i(含)开始查找下一个置位位,返回(index, found);其实现先处理第一个"部分 word",再借助bits.TrailingZeros64跳过全零 word,速度很快(bitset.go)。
对性能敏感的场景,源码注释推荐使用NextSetMany批量取出置位位以分摊调用开销——复用一个固定容量的 buffer:
buffer := make([]uint, 256) // 复用它 j := uint(0) j, buffer = bitmap.NextSetMany(j, buffer) for ; len(buffer) > 0; j, buffer = bitmap.NextSetMany(j, buffer) { for k := range buffer { // do something with buffer[k] } j += 1 }也可以用AppendTo(buf)追加所有置位位,或用AsSlice(buf)直接填充预分配切片(buf容量须不小于Count(),否则 panic,bitset.go)。反向遍历可用PreviousSet/PreviousClear。
如果你的 Go 版本在 1.23 及以上,还可以使用 range-over-function 迭代器EachSet()(bitset_iter.go),它按升序产出所有置位位,break即可提前终止:
for i := range b.EachSet() { // i 是某个置位位的下标 }注意该文件带有//go:build go1.23构建标签,旧版本 Go 会自动忽略。
七、内存模型:自动扩张、Shrink 与 Compact
7.1 内存下限
原文档明确指出:使用 N 个比特的 BitSet,内存至少为 N/8 字节;而位数至少是"最大访问位下标 + 1",所以向集合中写入一个巨大的下标,可能导致内存耗尽。这是位集合固有的代价——如果位非常稀疏,应改用压缩位图(见下文 Roaring 互操作)。
7.2 收缩方法
BitSet永远不会自动收缩,但提供两个手动方法:
Shrink(lastbitindex):把 lastbitindex 作为新的最大可存下标,清掉更高位、缩短 slice 并更新 length。注意参数不是"新长度",而是"最大下标",因此新长度 = 参数 + 1,最小只能缩到长度 1(bitset.go);Compact():找出最高的置位 word,将其作为新的长度边界并调用Shrink,即"在保留所有置位位的前提下最小化内存";集合为空时也会保留 1 个 word(bitset.go)。
源码注释提醒:两个方法都会新分配 slice,旧数组要等 GC 回收才释放,因此对超大 BitSet 会观察到瞬时内存上升;在内存受限环境可能 panic。
7.3 创建时预留容量
bitset.New(length)的length参数是提示:如果你知道最多会用到多少位,提前传入可以避免反复扩容拷贝(extendSet采用 2 倍扩容策略)。默认的make([]uint64, wordsNeeded(length))会一次性分配到位。
八、序列化:安全可移植的二进制格式与 JSON
原文档提供了完整的序列化与反序列化代码。写出(WriteTo)的格式为:1 个 uint64 长度 + 连续的 uint64 word 数组(bitset.go)。
const length = 9585 const oneEvery = 97 bs := bitset.New(length) // Add some bits for i := uint(0); i < length; i += oneEvery { bs = bs.Set(i) } var buf bytes.Buffer n, err := bs.WriteTo(&buf) if err != nil { // failure } // Here n == buf.Len()读出时使用ReadFrom,它会尽量复用现有 BitSet 的底层数组以减少内存分配:
// Read back from buf bs = bitset.New() n, err = bs.ReadFrom(&buf) if err != nil { // error } // n is the number of bytes read几个关键的实现细节(均可从源码确认):
- 字节序可配置:默认使用大端序(
binary.BigEndian),可通过LittleEndian()/BigEndian()全局切换;ReadFrom与WriteTo必须使用同一字节序,官方推荐保持默认(bitset.go); ReadFrom的失败处理:若读取中途出错,会把 BitSet 置空(set = set[:0]; length = 0),避免留下半填充的错误状态(bitset.go);MarshalBinary/UnmarshalBinary:直接基于WriteTo/ReadFrom的内存封装,可用于encoding/gob等场景;- JSON 支持:
MarshalJSON把二进制内容做 Base64 编码后作为 JSON 字符串输出,默认使用base64.URLEncoding,可调用Base64StdEncoding()切换为标准 Base64(bitset.go)。
性能提示:当向磁盘或网络读写时,用bufio包装流能显著提升吞吐:
f, err := os.Create("myfile") w := bufio.NewWriter(f) // 用 w 调用 WriteTof, err := os.Open("myfile") r := bufio.NewReader(f) // 用 r 调用 ReadFrom原因从源码可见:writeUint64Array内部按 128 个 word 一批(每批 1KB 缓冲)写入,配合bufio可以减少系统调用次数(bitset.go)。
九、性能实现细节:popcount 优化与 BCE
Count()依赖的 popcount 实现(popcnt.go)做了两层优化,是"位集合快于 map"的底层保障:
- 四路累加器:
popcntSlice用c0..c3四个独立累加器每轮处理 4 个 word,打破单一累加变量的依赖链,让现代 CPU 的多个执行单元并行执行bits.OnesCount64; - 边界检查消除(BCE):在
popcntMaskSlice等函数开头写_ = m[len(s)-1],帮助编译器消除循环内的边界检查,减少分支开销。
集合运算(popcntAndSlice、popcntOrSlice、popcntXorSlice)同样使用了 BCE 注释。这类微优化说明该库面向高频调用场景做了深度打磨。
十、Goroutine 安全与并发约定
原文档对此有明确约定:同一 BitSet 的并发访问是不安全的——所有方法都没有加锁,这是为了性能。
如果需要多 goroutine 共享,两种推荐做法:
- 通过 channel 传递
*BitSet,遵循 Go 的"谁拥有谁访问"风格,保证任意时刻只有一个持有者; - 或者用
sync.Mutex把操作串行化。
对游戏后端这类高并发场景,正确做法通常是把位集合视为单线程持有的数据结构,或者干脆按分片(如按用户 ID 区间分片)各自持有独立 BitSet。
十一、与 Roaring 压缩位图的互操作
当位很稀疏、置位位跨度极大时,N/8字节的下限会浪费大量内存。原文档给出的建议是改用压缩位图(Roaring Bitmap),并给出了双向转换的 API:
mybitset := roaringbitmap.ToBitSet() // Roaring -> bitset newroaringbitmap := roaring.FromBitSet(mybitset) // bitset -> Roaring这一互操作在本仓库中也有实证:roaring库的 roaring.go 中的ToBitSet通过bitset.From(rb.ToDense())把压缩位图转成 dense 的bitset.BitSet,FromBitSet则反向构造 Roaring 位图。实践建议:数据稀疏时用 Roaring 压缩存储,需要密集向量化运算(如批量交集计数)时再转回 bitset。
十二、在本仓库中的角色与测试
本仓库 nakama 的 go.mod 中,github.com/bits-and-blooms/bitset v1.25.0被标记为// indirect,即它并非直接使用,而是经由github.com/RoaringBitmap/roaring等依赖间接引入;从 roaring.go 的导入语句可以确认这条依赖链。因此,nakama 项目中位图相关逻辑经由 Roaring 位图间接受益于 bitset 的实现,这也印证了 bitset 作为"底层基础设施库"的定位——它被广泛嵌入到更高层的数据结构中。
作为独立库,运行全部测试非常简单:
go test go test -cover前者跑完功能测试,后者输出覆盖率报告;仓库还提供了模糊测试脚本 run_fuzz_tests.sh 供持续质量保障使用。
十三、使用注意事项小结
- 下标即内存:写入极大的下标会触发成比例的内存分配甚至 panic,调用方要确保参数在内存可承受范围内;
- 零值是合法的:
var b bitset.BitSet即可直接使用,首次Set自动扩容; - 绝不自动收缩:长生命周期且频繁增删的集合,适时调用
Compact()/Shrink()控制内存; - Count 与 Len 不同义:
Len()是位数(受最大置位位支配),Count()是实际置位数; - 并发需外部同步:默认无锁,多 goroutine 共享必须自行加锁或使用 channel 所有权模式;
- 序列化要匹配字节序:
WriteTo/ReadFrom、MarshalBinary/UnmarshalBinary成对使用,并保持相同的 Endian 设置; - 需要数量而非集合时用 Cardinality 系列:省掉中间集合的构造与内存。
掌握上述要点后,你就可以把 bitset 用于在线状态位图、任务去重、区间统计等高性能场景,并在数据稀疏时与 Roaring 位图配合,做到"该密集时密集、该压缩时压缩"。
- 后端
- 即时通讯
- 社交
- 游戏开发
【免费下载链接】nakama
Scalable open-source game backend server: multiplayer, matchmaking, leaderboards, chat, and social features for games.
相关推荐
go-gitignore:Go 语言高性能 .gitignore 匹配库的原理与实战
go gitignore:Go 语言高性能 .gitignore 匹配库的原理与实战 导读 go gitignore 是一个专为 Go 语言设计的快速 .git
测试云原生质量保障如何掌握AutoHotInterception:系统级输入拦截与模拟的进阶技巧
如何掌握AutoHotInterception:系统级输入拦截与模拟的进阶技巧 AutoHotInterception(简称AHI)是一个基于Intercept
Cilium 仓库中的 Gorilla WebSocket:Go 语言 RFC 6455 实现原理与实战指南
Cilium 仓库中的 Gorilla WebSocket:Go 语言 RFC 6455 实现原理与实战指南 Gorilla WebSocket 是 Go 语言
云原生网络服务网格可观测性网络安全eBPF
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考