Golang_包解析
Go map
底层实现
Go语言的map的底层实现基于Hash散列,Hash散列是一种著名的广义上的算法,它能将任意长度的数据映射到有限的值域当中。在实际工程中输入数据范围是无限的,而输出值域范围是有限的,因此必然存在不同的输入数据经过映射后得到相同的输出值,这种现象称为hash冲突,go底层通过链表解决hash冲突。值得注意的是:==Java1.8中使用平衡树来优化局部链表过长的性能问题。==
使用哈希表的目的就是要快速查找到目标key,然而,随着向map中添加的key越来越多,key发生碰撞的概率也越来越大,bucket中的8个cell会被逐渐塞满,插入、查找和删除key的效率也会越来越低,最理想的情况是bucket只装入一个key,这样,就能达到$O(1)$的效率,但这样空间消耗太大,代价太高。
底层源码如下所示:
1 |
|
其中,buckets unsafe.Pointer
指向具体的buckets数组,这个数组是bmap
结构,如下所示:
1 |
|
在实际编译期间,Go语言的bmap
会经过反射生成真正的bmap
类型:
1 |
|
map的访问即通过给定的key在map中寻找其对应value,过程如下:
- 原始的key通过hash函数映射成64位二进制;
- 末尾x位代表
bmap
的位置,从[]bmap
中找到对应的bmap
,值得注意的是,bmap的数量一般是2的幂,如果末尾x位代表bmap
的位置就代表有$2^x$个bmap
; - 首8位对应key的tophash,在bmap中进行检索,首先会比较
bmap
顶层的tophash
与原始key的tophash
是否相同,若不相同则直接跳过比较下一个;若相同则进一步比较key是否相同。
container
众所周知,Go对于一些基本的数据结构封装得不是很好,在container
中也只有三种常见的数据结构——链表、环形链表和堆。
container/list
我们先来看看list
的写入与打印:
1 |
|
我们看看打印结果:
1 |
|
为什么打印结果是这样的,如果我们弄懂list
的底层结构,就能知道答案了。list
的底层结构如下:
1 |
|
结合上面的打印,我们可以发现,{0xc00007c180 0xc00007c1b0 <nil> <nil>}
是root Element
,2
是len int
。我们接着打印:
1 |
|
其打印结果如下:
1 |
|
从这个结果我们不难看出,list
其实是一个环形结构。其中,root
的next指向链表的front
,root
的pre指向链表的back
,而root
自身是不存储数据的。如果我们再加上一个数据,就会变成这样了:
1 |
|
其打印结果如下:
1 |
|
container/heap
我们来看看如何初始化一个heap,如下所示:
1 |
|
先来读读Init()
函数,如下所示:
1 |
|
可以看到,这两个函数跟我们的堆排序很像,还是讲一下,
container/ring
环的结构有点特殊,环的尾部就是头部,所以每个元素实际上就可以代表自身的这个环。 它不需要像 list 一样保持 list 和 element 两个结构,只需要保持一个结构就行。
——[1. 3.3 container — 容器数据类型:heap、list 和 ring][https://books.studygolang.com/The-Golang-Standard-Library-by-Example/chapter03/03.3.html]
我觉得这句话总结得很好,
reflect
reflect.DeepEqual
DeepEqual
函数用来判断两个值是否深度一致。这和==
有什么不同吗?我们来看看下面例子:
1 |
|
其中a==b
为true
。如果我们将结构体中加入一个指针,就会返回false,这是因为指针指向的地址不一样,如下所示:
1 |
|
这时会返回false
。
值得注意的是:slice
、map
、function
是不能参与比较的,如果在结构体中出现以上类型,会panic
。在这里我们就有一个疑问?到底是什么原因导致slice
、map
、function
不能进行比较,如果是因为引用类型的原因,channel
却可以进行比较。我们先来看看比较slice
的时候返回的错误,如下所示:
1 |
|
有的博主说slice
不可比较有两个原因:
- 引用类型,比较地址没有意义。
- 切片有len,cap,比较的维度不好衡量,因此go设计的时候就不允许切片可比较。
但是我们来看看这个情况:
1 |
|
但是在[]int
的channel
是可以比较的,这就会比较迷惑。
无独有偶,两个interface{}
是能比较的,其规则如下:
- 两个类型相同且值相同的接口比较结果为
true
; - 两个类型相同且值不同的接口比较结果为
false
; - 两个类型不同的接口比较结果为
false
; - 如果空接口保存了上面的
slice
、map
和function
的值,则不能比较。
总结:类型不同的空接口间的比较结果不相同,不能比较空接口中的动态值。
回到正题,我们来看看reflect.DeepEqual
,reflect.DeepEqual
在可以时(主要是基本类型)会使用==
;但还会比较array
、slice
的成员,map
的键值对,结构体字段进行深入比对。如下所示:
1 |
|
当然,我们还是要从它的源码进行分析:
1 |
|
我们可以看到,如果x==nil
或者y==nil
,就比较二者的指针,否则比较二者的类型,如果类型相同,那么我们就用deepValueEqual
函数进行比较。我们看到这个函数:
1 |
|
输出结果如下:
1 |
|
可以发现这两个指针所指向的地址是一样的,这个方法能让你创造一个int32
的p2
指针去指向一个int64
的value
变量,而这个变量是使用p1
指针去访问的。我们通过以下例子来说明:
1 |
|
但是从byte
转换成int32
的时候会出现问题:
1 |
|
这是因为存放的b
中存放的实际是ASCII码,故而转换成int时输出53。那么为什么转换成byte
时就会输出5呢,我们用反射来看s的实际类型,显示是uint8
,也就是说我们只是在这只是修改了指针类型 或者说只是修改了编译器解释他的类型。
uintptr
和unsafe.Pointer
的区别
uintptr本质是一个无符号的整型,它的长度可以用来保存一个指针地址。unsafe包提供的Pointer表示可以指向任意类型的指针。
uintptr往往用来进行指针计算,因为它是整型,所以很容易计算出下一个指针所指向的位置。
- unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
- 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
- unsafe.Pointer 可以和 普通指针 进行相互转换;
- unsafe.Pointer 可以和 uintptr 进行相互转换。
sync
sync包中提供了常见的并发编程同步原语,所谓原语,一般是指由若干条指令组成的程序段,用来实现某个特定的功能,在执行过程中不可被中断,否则就会出现操作错误,造成系统混乱。
sync.Mutex
sync.Mutex是sync包中使用最广泛的原语,它允许在共享资源上互斥访问。
1 |
|
上述是mutex
的结构,state
表示当前互斥锁的状态,sema
是用于控制锁状态的信号量。在state
中有一个标志位叫mutexStarving
,表示当前的互斥锁是否进入了饥饿状态,在正常状态下,锁的等待者会按照先进先出的顺序获取锁;在饥饿模式下,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。
sync.Mutex.Lock
1 |
|
在这里用到原子操作CAS,常常被用于自旋机制,具体讲解在下面。当锁的状态是0时,就会将mutexLocked
位置设置为1,如果互斥锁的状态不是0就会调用lockSlow
尝试通过自旋等待锁的释放。
sync.Mutex.Unlock
1 |
|
在Unlock
中,用到了原子操作Add,这里这个原子操作的意思是:如果只是mutexLocked
位为1,那么我们将state设置为0,return;否则,调用UnlockSlow
进行缓慢解锁。也就是说如果有其他goroutine等待着,那么我们就会进入缓慢解锁的状态——移交锁的所有权给下一个goroutine。
sync.RWMutex
1 |
|
我们可以看到,RWMutex
继承了Mutex
,并且增加了几个成员。我们先来说说这几个成员:
writerSem
:写者等待读者完成的信号量;readerSem
:读者等待写者完成的信号量;readerCount
:当前有几个读者共享锁;readerWait
:离开的读者数。
Rlock
&RUnlock
1 |
|
如上述所示,上读锁的就是给readerCount
+1,如果有写者占用,就
1 |
|
如上述所示,解读锁就是给readerCount
-1,如果有
Lock
&Unlock
1 |
|
sync/atomic
CPU执行一条汇编语句就是一个原子操作吗?
原子操作是指不会被多线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何context switch。原子操作是不可分割的,在执行完毕之前不会被任何其它任务或事件中断。在单处理器系统(UniProcessor)中,能够在单条指令中完成的操作都可以认为是” 原子操作”,因为中断只能发生于指令之间。
说到这里,我们来看看维基百科上对原子操作的判定:
如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。
再看看其定义:
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。将整个操作视作一个整体是原子性的核心特征。
也就是说,绝大多数情况下,一个机器指令就是一个原子操作。在Intel的参考手册,CPU基于以下三种机制保证了多核中加锁的原子操作:
(1)一些基本的内存读写操作是本身已经被硬件提供了原子性保证(例如读写单个字节的操作);
(2)一些需要保证原子性但是没有被第(1)条机制提供支持的操作(例如read-modify-write)可以通过使用”LOCK#”来锁定总线,从而保证操作的原子性
(3)因为很多内存数据是已经存放在L1/L2 cache中了,对这些数据的原子操作只需要与本地的cache打交道,而不需要与总线打交道,所以CPU就提供了cache coherency机制来保证其它的那些也cache了这些数据的processor能读到最新的值。
上面(1)就是由硬件保持的原子操作,具体来说有以下操作:
• Reading or writing a byte(一个字节的读写)
• Reading or writing a word aligned on a 16-bit boundary(对齐到16位边界的字的读写)
• Reading or writing a doubleword aligned on a 32-bit boundary(对齐到32位边界的双字的读写)• Reading or writing a quadword aligned on a 64-bit boundary(对齐到64位边界的四字的读写)
• 16-bit accesses to uncached memory locations that fit within a 32-bit data bus(未缓存且在32位数据总线范围之内的内存地址的访问)• Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line(对单个cache line中缓存地址的未对齐的16/32/64位访问)
下面我们再看看(2)总线锁。
总线锁
处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。
上述操作就是通过总线锁实现的,在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段,这样能实现并行时的原子操作。
Go支持的原子操作概述
Win32 API中常用的原子操作主要有三类,一种是加一减一操作,二是比较交换操作,三赋值操作。对于一个整数类型T
,sync/atomic
标准库包提供了下列原子操作函数。 其中T
可以是内置int32
、int64
、uint32
、uint64
和uintptr
类型。
1 |
|
我们看看CompareAndSwap
的源码:
1 |
|
Go语言的常量有个不同寻常之处。虽然一个常量可以有任意有一个确定的基础类型,例如int或float64,或者是类似time.Duration这样命名的基础类型,但是许多常量并没有一个明确的基础类型。编译器为这些没有明确的基础类型的数字常量提供比基础类型更高精度的算术运算;你可以认为至少有256bit的运算精度。这里有六种未明确类型的常量类型,分别是无类型的布尔型、无类型的整数、无类型的字符、无类型的浮点数、无类型的复数、无类型的字符串。
panic需要等defer结束后才会向上传递。出现panic的时候,会先按照defer的**先出的顺序执行,最后才会执行panic。
encoding/json
Marshal
UnMarshal
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!