深入解析go 位运算和位掩码原理及应用

本文介绍了如何使用位运算在Go语言中高效地存储和处理玩家在游戏中收集的钥匙。通过位掩码技术,仅用一个字节就能存储3个状态,同时展示了位运算如AND、OR、NOT、移位等操作在实现添加、检查和移除钥匙功能中的应用。位运算在性能和内存使用上优于传统的字符串或映射方法,并给出了性能基准测试结果。此外,还探讨了位运算在判断2的整数幂和取模运算中的用途。
摘要由CSDN通过智能技术生成

作为一个非科班的自学出生的野生程序员来说,刚接触位运算确实挺懵逼的,但是熟悉了以后也没有那么神秘,在平时看一些优秀的开源项目时或者自定义网络协议封装时,经常用到,最近看到一篇不错的老外讲位运算的文章。所以翻译一下。随便用自己在项目中的实际应用做下拓展。

原文:Using Bitmasks In Go

注意:本人翻译水平有限,有错误欢迎指正

当我们写一个多人玩的在线服务器类似角色游戏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,比较了[]stringmap[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)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值