作为一个非科班的自学出生的野生程序员来说,刚接触位运算确实挺懵逼的,但是熟悉了以后也没有那么神秘,在平时看一些优秀的开源项目时或者自定义网络协议封装时,经常用到,最近看到一篇不错的老外讲位运算的文章。所以翻译一下。随便用自己在项目中的实际应用做下拓展。
注意:本人翻译水平有限,有错误欢迎指正
当我们写一个多人玩的在线服务器类似角色游戏MMORPG,在游戏中玩家会收集大量的keys,如何去设计为每一个玩家存储这些keys 呢?
例如,想象这些keys 是copper(铜),jade(翡翠),crystal(水晶),我们可能考虑下面存储key 的方法
[]string
map[string]bool
两种方法都是有效的,但是我们有没有考虑使用位掩码的第三种方法呢?用位掩码将会使存储和处理keys 更加高效,一但你懂了原理,这种方法也将容易阅读和维护。
位运算介绍
首先了解下计算机是如何存储用8位的byte 去存储数字的。2进制是怎么转换成10进制的,每一位是2的整数幂
| 2⁷| 2⁶| 2⁵| 2⁴| 2³| 2²| 2¹| 2⁰| <- Bit Position
|---|---|---|---|---|---|---|---|
|128| 64| 32| 16| 8 | 4 | 2 | 1 | <- Base 10 Value
最右边的位代表2⁰(最低位),在10进制是1,第二位代表2¹ or 2),最左边的位代表2⁷ or 128
例如:表示数字13,我们用8和4、1拆分,下面表示结果
| 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | <- Bit Position
|---|---|---|---|---|---|---|---|
|128| 64| 32| 16| 8 | 4 | 2 | 1 | <- Base 10 Value
=================================
0+ 0+ 0+ 0+ 8+ 4+ 0+ 1 = 13
我们可以用%b去打印数字代表的 二进制,fmt.Printf("%08b\n", 13)将会打印00001101,这也意味这最大值只能代表255。
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | <- Bit Position
|---|---|---|---|---|---|---|---|
|128| 64| 32| 16| 8 | 4 | 2 | 1 | <- Base 10 Value
=================================
128+ 64+ 32+ 16+ 8+ 4+ 2+ 1 = 255
AND (&
)
两位同时为1,结果才为1
0 & 0 -> 0 (false)
0 & 1 -> 0 (false)
1 & 0 -> 0 (false)
1 & 1 -> 1 (true)
例子:5和3 做AND运算
00000101 AND (4, 1)
00000011 (2, 1)
--------------------
00000001 (1)
OR (|
)
只要有一位是true,那么结果就为true
0 | 0 -> 0 (false)
0 | 1 -> 1 (true)
1 | 0 -> 1 (true)
1 | 1 -> 1 (true)
例子:5和3 做OR运算
00000101 OR (4, 1)
00000011 (2, 1)
-------------------
00000111 (4, 2, 1)
NOT (^
)
^1 -> 0
^0 -> 1
<<
左移操作符代表乘以2
00001010 (10) << 1
------------------
00010100 (20)
>>
右移操作符代表除以2
00010100 (20) >> 1
------------------
00001010 (10)
使用这些操作我们可以实施复杂的逻辑,在我们的案例里面我们可以设置或者取消某一位,可以检查某一位没有设置
回到我们的问题
应用需要支持3个key ,我们只需要3位,仅仅只需要分配1个字节内存,首先用type定义一个新类型,原始类型是byte,byte 和uint8 是等价的在go 里面。
type KeySet byte
11 const (
12 Copper KeySet = 1 << iota // 1
13 Jade // 2
14 Crystal // 4
15 maxKey // 8
16 )
上面是我们支持的keys,我们用iota 定义第一个枚举,下面就会自动左移iota + 1 的位数,为了让我们的keys 在打印的时候有更好的语义,我们实现 fmt.Stringer接口
18 // String implements the fmt.Stringer interface
19 func (k KeySet) String() string {
20 if k >= maxKey {
21 return fmt.Sprintf("<unknown key: %d>", k)
22 }
23
24 switch k {
25 case Copper:
26 return "copper"
27 case Jade:
28 return "jade"
29 case Crystal:
30 return "crystal"
31 }
32
33 // multiple keys
34 var names []string
35 for key := Copper; key < maxKey; key <<= 1 {
36 if k&key != 0 {
37 names = append(names, key.String())
38 }
39 }
40 return strings.Join(names, "|")
41 }
现在在我们的结构体里面定义 KeySet
43 // Player is a player in the game
44 type Player struct {
45 Name string
46 Keys KeySet
47 }
45 行定义玩家名字,46行定义这些key,当游戏开发的时候可能有更多字段添加
AddKey
49 // AddKey adds a key to the player keys
50 func (p *Player) AddKey(key KeySet) {
51 p.Keys |= key
52 }
上面显示了如何使用位掩码添加key,在51行,我们使用位操作OR和传进来的KeySet去设置Keys字段,传入Crystal 。
p.Keys : 00000001 OR (Copper)
key : 00000100 (Crystal)
---------------------------------------
result : 00000101 (Copper, Crystal)
我们可以看到结果包含Crystal了
HasKey
54 // HasKey returns true if player has a key
55 func (p *Player) HasKey(key KeySet) bool {
56 return p.Keys & key != 0
57 }
在56行,我们用位运算AND,去检查传进来的key 是否在p.Keys 里面。我们可以传入Crystal通过HasKey 方法和 AND 运算符去操作。
p.Keys : 00000101 AND (Copper, Crystal)
key : 00000100 (Crystal)
----------------------------------------
result : 00000100 (Crystal)
我们可以看到结果包含Crystal,所以是匹配的,当我们检查Jade时,就不能找到
p.Keys : 00000101 AND (Copper, Crystal)
key : 00000010 (Jade)
----------------------------------------
result : 00000000 Nothing
RemoveKey
59 // RemoveKey removes key from player
60 func (p *Player) RemoveKey(key KeySet) {
61 p.Keys &= ^key
62 }
我们先将key取反,然后用And运算符重设key。
p.Keys : 00000101 AND (Copper, Crystal)
^key : 11111011 (org: 00000100 Crystal)
----------------------------------------
result : 00000001 (Copper)
我们可以看到结果的key不再包含了 Crystal
在实际中我们也能看到减号去清空,那么这是可行的吗?下面运行得到了同样的结果,原理是常量只有设置位才会是1,减的话只会减掉这位,不会发生借位,项目开发中,如果我们想保持原子操作,就只能用加减操作了,而不是位运算,必须满足前提,同理设置可以用加法。
注意:减法运算前提是这位为1,加法运算前提是这位为0,所以使用前先判断满足前提
fmt.Printf("%08b\n",0b00000101-0b00000100) //00000001
结论
- go 的类型系统允许组合底层代码位运算和上层函数方法去写出更优雅的代码
- 位掩码在性能上怎么样,看下面的benchmark,比较了
[]string
,map[string]bool
,byte
三种方式实现的结果。
$ go test -bench . -benchmem
goos: linux
goarch: amd64
pkg: github.com/353words/bitmask
cpu: Intel(R) Core(TM) i7-7500U CPU @ 2.70GHz
BenchmarkMap-4 249177445 4.800 ns/op 0 B/op 0 allocs/op
BenchmarkSlice-4 243485120 4.901 ns/op 0 B/op 0 allocs/op
BenchmarkBits-4 1000000000 0.2898 ns/op 0 B/op 0 allocs/op
BenchmarkMemory-4 21515095 52.25 ns/op 32 B/op 1 allocs/op
PASS
ok github.com/353words/bitmask 4.881s
- 从结果来看,位运算性能是其它的16倍多,用
-benchmem
参数去显示内存分配,在内存分配上我们可以看到[]string{"copper", "jade"}
消耗了32 字节,是位运算单字节的32倍
拓展
判断是不是2的整数幂
原理同位运算取模一样,以32为例,32 -1 所有位都是1,除了最高位,而32只有最高位是1,所以& 的话得到结果会是0
0001 1111 (31) &
0010 0000 (32)
------------------
0000 0000 (0)
以16为例,一样的结果,假设这个数不是2的整数幂,那么最肯定存在某一位为1,结果不为0
0000 1111 (15) &
0001 0000 (16)
------------------
0000 0000 (0)
所以最终计算的函数为IsPowerTwo,排除0及负数情况,0其实也是满足条件但不是2的整数幂
package main
import "fmt"
func main() {
fmt.Println(IsPowerTwo(32))//true
fmt.Println(IsPowerTwo(16))//true
fmt.Println(IsPowerTwo(10))//false
fmt.Println(IsPowerTwo(100))//false
fmt.Println(IsPowerTwo(0))//false
}
func IsPowerTwo(num int) bool {
return (num>0)&&(num&(num-1)==0)
}
位运算取模
与常规%取模不同,将遵守下面规则
- 被取模的数必须是2的整数幂-1,举个例子,如果是4,则被取模数是4-1=3
原理:
首先来看2的整数幂有什么特点: 都是某一位为1
0000 0001 1
0000 0010 2
0000 0100 4
0000 1000 8
0001 0000 16
0010 0000 32
如果减一将会得到下面的结果,结果是最高位为0,其它位为1
0000 0000 0
0000 0001 1
0000 0011 3
0000 0111 7
0000 1111 15
0001 1111 31
此时如何进行AND 运算会发送什么呢?以31 和8 为例,这里可以用任何数,假设是比31小的数据,因为31都是1,所以参与运算的数每一位运算都将会得到本身,就是结果为自己。
0001 1111 (31) &
0000 1000 (8)
------------------ =====>8%32=8
0000 1000 (8)
如果是一个大的数据呢,比如100,超过的位在31里面是没有的所以直接补0,最终结果如下:
0001 1111 (31) &
0110 0100 (100)
------------------ =====>100%32=4
0000 0100 (4)
应用:
- 高效率取模
- 将数组长度定位2的整数幂,取模数作为索引,这时候,对2的整数幂-1取模去取索引一定不会越界
互斥锁中位运算的使用
锁的状态定义
var state int32
const (
mutexLocked = 1 << iota 1(1<<0)
mutexWoken 2(1<<1)
mutexStarving 4(1<<2)
mutexWaiterShift = iota 8(1<<3)
)
- 判断状态是否只有mutexLocked
state&(mutexLocked|mutexStarving) == mutexLocked
- 判断状态是否包含mutexWoken
state&mutexWoken == 0
- 判断状态是否存在mutexLocked,mutexStarving 其中的一个
state&(mutexLocked|mutexStarving) != 0
- 去掉mutexWoken
state &^= mutexWoken ==>state &=^mutexWoken =>state=state&(^mutexWoken)
- 原子操作一系列值,加法和减法都可以。下面意思代表将大于mutexWaiterShift位表示的数-1,加上mutexLocked这个值,去掉mutexStarving这个值。加法操作前是保证mutexLocked这位是不会存在的,而- mutexStarving也加了个判断,判断这位否存在
delta := int32(mutexLocked - 1<<mutexWaiterShift)
delta -= mutexStarving
atomic.AddInt32(&m.state, delta)