1. 基本知识

# 1.1 数组与slice

# 1.1.1 数组

我们先看看数组有哪些定义方式:

Go语言中数组是值语义,一个数组变量即表示整个数组,它并不是隐式的指向第一个元素的指针(比如C语言的数组),而是一个完整的值。 当一个数组变量被赋值或者被传递的时候,实际上会复制整个数组。如果数组较大的话,数组的赋值也会有较大的开销,为了避免复制数组带来的开销,可以传递一个指向数组的指针,但是数组指针并不是数组。

对数组类型而言,数组的长度是数组类型的组成部分,不同长度或不同类型的数据组成的数组都是不同的类型。

# 1.1.2 空数组

不占内存空间

# 1.1.3 数组与slice的区别

  • 数组是一个由固定长度的特定类型元素组成的序列;
  • 切片是可以动态增长和收缩的序列
  • slice的底层是一个数据结构,有指向底层数组的指针,len长度,cap容量组成;

go

# 1.1.4 数组转成切片

除了通过make创建slice, 还可以使用数组来创建slice时,slice将与原数组共用一部分内存

go

数组和切片操作可能作用于同一块内存,这也是使用过程中需要注意的地方(也是可能会引发内存泄漏的地方)。

# 1.1.5 slice扩容

使用append向slice追加元素时,如果slice空间不足,将会触发slice扩容,扩容实际上是重新分配一块更大的内存,将原slice数据拷贝进新slice,然后返回新slice,扩容后再将数据追加进去(此时底层将指向新的数组,所以之前说数组和切片操作是可能作用于同一块内存)。

# 1.1.5.1 头部追加和尾部追加

除了在切片的尾部追加,我们还可以在切片的开头添加元素:

在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制 1 次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

# 1.1.5.2 slice扩容规则

  • 如果原slice容量小于1024,则新slice容量将扩大为原来2倍(cap < 1024, x2);
  • 如果原slice容量大于1024,则新slice容量将扩大为原来1.25倍(cap >1024, x1.25);
  • 使用append()向slice添加一个元素的实现步骤如下:
    • 假如slice容量够用,则将新元素追加进去,slice.len++,返回原slice.
    • 原slice容量不够,则将slice先扩容,扩容后得到新slice; 将新元素追加进新slice,slice.len++,返回新的slice。

# 1.1.5.3 可能的内存泄漏

切片操作并不会复制底层的数据, 底层的数组会被保存在内存中,直到它不再被引用。但是有时候可能会因为一个小的内存引用而导致底层整个数组处于被使用的状态,这会延迟自动内存回收器对底层数组的回收。

案例1:

改进后:

案例2:

改进后:

# 1.1.5.4 总结

  • slice的append操作在cap容量不足情况下,会导致slice扩容,涉及到内存申请和内存拷贝,会影响性能,所以我们在创建切片时,最好指定slice容量,避免出现扩容操作;
  • 使用copy()方法来进行slice的复制,可避免append产生临时slice;

# 1.1.6 如何判断 2 个字符串切片(slice) 是相等的?

reflect.DeepEqual(a, b)

通常采用的方式如下,遍历比较切片中的每一个元素(注意处理越界的情况)。

# 1.1.7 切片是在栈上分配内存的还是在堆?

  • 切片的容量有关:当切片的容量非常小的时候,直接在栈上分配内存,如果非常大则会直接在堆上分配内存,这点与数组是类似的。
  • 切片指针的变量是否发生了逃逸:我们可以使用go build --gcflags来观察变量内存的分配过程:

# 1.2 字符串

# 1.2.1 字符串特性

一个字符串是一个不可改变的字节序列,字符串通常是用来包含人类可读的文本数据,和数组不同的是,字符串的元素不可修改,是一个只读的字节数组。

# 1.2.2 字符串拼接

+fmt.Sprintfstrings.Builderbytes.Bufferstrings.Join
"+"fmt.Sprintfstrings.BuilderWriteString()进String()bytes.Bufferstrings.joinstrings.joinstrings.builderb.Grow(n)

性能比较:

strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf

5种拼接方法的实例代码:

# 1.2.3 什么是 rune 类型?

Go语言的字符有以下两种:

  • uint8类型,或者叫byte型,代表了ASCII码的一个字符。
  • rune类型,代表一个UTF-8字符,当需要处理中文、日文或者其他复合字符时,则需要用到rune类型。rune类型等价于int32类型(4个字节)。

# 1.3 Map

# 1.3.1 Map实现原理

Go中map的基于哈希表(也被称为散列表)实现。

# 1.3.1.1 哈希函数

哈希函数(常被称为散列函数)是可以用于将任意大小的数据映射到固定大小值的函数,常见的包括MD5、SHA系列等。一个设计优秀的哈希函数应该包含以下特性:

均匀性效率高可确定性雪崩效应不可逆

# 1.3.1.2 哈希冲突

哈希函数是将任意大小的数据映射到固定大小值的函数。那么可以预见到,即使哈希函数设计得足够优秀,几乎每个输入值都能映射为不同的哈希值。但是,当输入数据足够大,大到能超过固定大小值的组合能表达的最大数量数,冲突将不可避免!

链地址法

在了解链地址法之前,先明确两个概念:

哈希桶装载因子装载因子=填入哈希表中的元素个数/哈希表的长度

# 1.3.1.3 链地址法

链地址法的思想就是将映射在一个桶里的所有元素用链表串起来。

H(key) = key MOD 7

go

链地址法解决冲突的方式是当发生冲突,就用单链表组织起来,当不存在哈希碰撞时查找复杂度为O(1),存在哈希碰撞时复杂度为O(N)。

# 1.3.1.4 HashKey

Golang中hash key经过哈希计算后得到64bit位的哈希值。如果B=5,buckets数组的长度,即桶的数量是32(2的5次方)。 例如,现要置一key于map中,该key经过哈希后,得到的哈希值如下:

go

低位(low-order bits)高位(high-order bits)

当B等于5时,那么我们选择的哈希值低位也是5位,即01010,它的十进制值为10,代表10号桶。再用哈希值的高8位,找到此key在桶中的位置。最开始桶中还没有key,那么新加入的key和value就会被放入第一个key空位和value空位。

go

上图中的B值为5,所以桶的数量为32。通过哈希函数计算出待插入key的哈希值,低5位哈希00110,对应于6号桶;高8位10010111,十进制为151,由于桶中前6个cell已经有正常哈希值填充了(遍历),所以将151对应的高位哈希值放置于第7位cell,对应将key和value分别置于相应的第七个空位。

# 1.3.1.5 数据结构

countlen()countlen()O(1)flagBnoverflowhash0fastrandbucketsoldbucketsnevacuatebucketsmapextra
bmapkeyelemkeyelem

bmap是go map哈希桶的结构, 其中:

topbitskeyselemsoverflow
overflow

这里有一个需要注意的点是在哈希桶中, 键值之间并不是相邻排列的, 这是为了保证内存对齐

bmap也就是bucket(桶)的内存模型图解如下: go

Go 的 map 结构可以用如下所示的结构图来表示: go

# 1.3.2 Map扩容

# 1.3.2.1 扩容的目的

o(1)o(n)

# 1.3.2.2 扩容的原则

提到装载因子是决定哈希表是否进行扩容的关键指标。在go的map扩容中,除了装载因子会决定是否需要扩容,溢出桶的数量也是扩容的另一关键指标。

LoadFactor(负载因子)= hash表中已存储的键值对的总数量/hash桶的个数

# 1.3.2.3 map扩容的方式

  • 增量扩容 将 B + 1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。
  • 等量扩容 buckets数量维持不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。

对于等量扩容而言,由于buckets的数量不变,因此可以按照序号来搬迁。例如老的的0号bucket,仍然搬至新的0号bucket中。 go 但是,对于增量扩容而言,就会有所不同。例如原来的B=5,那么增量扩容时,B就会变成6。那么决定key值落入哪个bucket的低位哈希值就会发生变化(从取5位变为取6位),取新的低位hash值得过程称为rehash(重新Hash)。 go 因此,在增量扩容中,某个 key 在搬迁前后 bucket 序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于低 hash 值第倒数第B+1位是 0 还是 1。

扩容过程是渐进性的,主要是防止一次扩容需要搬迁的key数量过多,引发性能问题。触发扩容的时机是增加了新元素, 桶搬迁的时机则发生在赋值、删除期间,每次最多搬迁两个桶。

go为了保证遍历map的结果是无序的,做了以下事情:map在遍历时,并不是从固定的0号bucket开始遍历的,每次遍历,都会从一个随机值序号的bucket,再从其中随机的cell开始遍历。然后再按照桶序遍历下去,直到回到起始桶结束。

go

上图的例子,是遍历一个处于未扩容状态的map。如果map正处于扩容状态时,需要先判断当前遍历bucket是否已经完成搬迁,如果数据还在老的bucket,那么就去老bucket中拿数据。

# 1.3.1 Map总结

  • Map并不是一个并发安全的数据结构。同时对map进行读写时,程序很容易出错。因此,要想在并发情况下使用map,请加上锁(sync.Mutex或者sync.RwMutex)
  • 遍历map的结果是无序的。
  • 通过map的结构体可以知道,它其实是通过指针指向底层buckets数组。所以和slice一样,尽管go函数都是值传递,但是,当map作为参数被函数调用时,在函数内部对map的操作同样会影响到外部的map。

# 1.3.2 Sync.Map实现原理

sync.Map的实现原理可概括为:

  • 通过read和dirty两个字段将读写分离,读的数据存在只读字段read上,将最新写入的数据则存在dirty字段上;
  • 读取时会先查询read,不存在再查询dirty,写入时则只写入dirty;
  • 读取read并不需要加锁,而读或写dirty都需要加锁;
  • 另外有misses字段来统计read被穿透的次数(被穿透指需要读dirty的情况), 超过一定次数则将dirty数据同步到read上;
  • 对于删除数据则直接通过标记来延迟删除. go

# 1.3.2.1 总结

  • 空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。
  • 使用只读数据(read),避免读写冲突。
  • 动态调整,miss次数多了之后,将dirty数据提升为read。
  • double-checking。
  • 延迟删除。 删除一个键值只是打标记(会将key对应value的pointer置为nil,但read中仍然有这个key:key;value:nil的键值对),只有在提升dirty的时候才清理删除的数据。
  • 优先从read读取、更新、删除,因为对read的读取不需要锁。
  • 虽然read和dirty有冗余数据,但这些数据是通过指针指向同一个数据,所以尽管Map的value会很大,但是冗余的空间占用还是有限的。

# 1.3.3 实现有序map

无序的map就是hash map,有序的map一般用红黑树来实现。

# 1.4 Struct

# 1.4.1 空struct使用场景

struct{}channel<-struct{}{}

使用示例:

  • 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
  • 不发送数据的信道(channel) 使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
  • 结构体只包含方法,不包含任何的字段

# 1.5.2 结构体和结构体指针的区别

  • 值结构体在函数的传递过程中,会复制一份,所以其他函数修改后,不会影响到最初初始化的结构体。
  • 指针结构体在函数的传递过程中,会复制一份地址进行传递,该地址仍然指向最初的结构体。所以在其他函数修改后,会影响到最初的结构体值。

# 1.5.3 值接收和指针接收的区别

接收者有两种,一种是值接收者,一种是指针接收者。顾名思义,值接收者,是接收者的类型是一个值,是一个副本,方法内部无法对其真正的接收者做更改;指针接收者,接收者的类型是一个指针,是接收者的引用,对这个引用的修改之间影响真正的接收者。

总结:通过指针接收器修改的参数,会影响到结构体的值,修改值接收器的参数,不会影响到结构体的值。

# 1.5.4 深拷贝/浅拷贝

  • 浅拷贝:只拷贝对象的第一层属性,如果对象中还有对象,只是拷贝的内存地址(引用),两者修改会相互影响。用于对象中都是基本数据类型的情况。
  • 深拷贝:拷贝对象的多层属性,如果对象中还有对象,会继续拷贝,使用递归实现。
  • map/channel: 通过make初始化变量,返回一个指针,所以当map、channel作为参数,其实是值传递,但是因为是指针类型,所以函数内的操作会影响到函数外
  • slice:通过make初始化,返回slice结构体,slice作为参数传递,struct会通过值传递,将其中的数据拷贝,不过由于slice中的array是一个指针,在操作array的数据时,会影响到函数外的数据(上述操作只是建立在(slice不扩容,函数内的slice没有增删数组)的情况下)
  • go中不存在浅拷贝和深拷贝,所有的都是值传递

# 1.5.5 结构体比较

只有相同类型的结构体才可以比较,结构体是否相同不但与属性类型个数有关,还与属性顺序相关。

Go的结构体有时候并不能直接比较,当其基本类型包含:slice、map、function时,是不能比较的, 编译器会直接报错,如果包含指针,也属于不同的结构体。

结果:

reflect.DeepEqual()

结果:

# 1.2 Context

Golang context是Golang应用开发常用的并发控制技术,context翻译成中文是"上下文",即它可以控制一组呈树状结构的goroutine,每个goroutine拥有相同的上下文, 这样context对于派生goroutine有更强的控制力,它可以控制多级的goroutine。

go

context实际上只定义了接口,凡是实现该接口的类都可称为是一种context,官方包中实现了几个常用的context,分别可用于不同的场景。 context包提供了4个方法创建不同类型的context(用于不同的使用场景),使用这四个方法时如果没有父context,都需要传入backgroud,即backgroud作为其父节点:

  • WithCancel()
  • WithDeadline()
  • WithTimeout()
  • WithValue()

总结起就是一句话:context的作用就是在不同的goroutine之间同步请求特定的数据、取消信号以及处理请求的截止日期 (传值、超时、取消)

# 1.3 闭包

闭包示例:

结果:

总结

  • Go语言支持闭包;
  • Go语言能通过escape analyze识别出变量的作用域,自动将变量在堆上分配。将闭包环境变量在堆上分配是Go实现闭包的基础;
  • 返回闭包时并不是单纯返回一个函数,而是返回了一个结构体,记录下函数返回地址和引用的环境中的变量地址;
2. GMP模型

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程,其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

# 2.1 基础知识

# 2.1.1 GMP的代表含义

  • M:Machine,对内核级线程的封装,数量对应真实的CPU数量(真正干活的对象), 一个 M 直接关联了一个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。
  • P:Processor,处理器,提供M所需的上下文环境,并且处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接,其数量可通过GOMAXPROCS来设置,默认是CPU核心数量
  • G:Goroutine,协程,相当于轻量级的线程,包括了调用栈,调度信息。
GOMAXPROCS

# 2.1.2 调度流程

go

流程如下:

work stealinghand off

# 2.1.3 G0和M0

  • M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
  • G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。

# 2.1.4 P/M数量限制

$GOMAXPROCSGOMAXPROCS()$GOMAXPROCSSetMaxThreads

M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

# 2.1.5 P和M何时会被创建

  • P何时创建:在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P(即一开始就创建了N个P)。
  • M何时创建:没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。

# 2.1.6 调度器的设计策略

# 2.1.6.1 复用线程

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

work stealinghand off

# 2.1.6.2 利用并行

利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

# 2.1.6.3 抢占试调度

抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

# 2.1.7 GMP调度过程中存在哪些阻塞

  • 系统调用system call
  • I/O
  • channel阻塞
  • 锁等待

# 2.1.8 Sysmon

sysmondaemon
sysmon

# 2.1.8.1 sysmon工作职责

在GPM模型中,我们知道当一个G由于网络请求或系统调用sysycall导致运行阻塞时,go runtime scheduler会对其进行调度,把阻塞G切换出去,再拿一个新的G继续执行,始终让P处于全部执行状态。

那么切换出去的G什么时候再回来继续执行呢?这就是本文要讲的sysmon监控线程。

# 2.1.8.1.1 网络轮询器监控

在GPM模型中当网络请求导致的运行阻塞时,调度器会让当前阻塞的G放入网络轮询器(NetPoller)中,由网络轮询器处理异步网络系统调用,从而让出 P 执行其它goroutine。当异步网络调用由网络轮询器完成后,再由sysmon监控线程将其切换回来继续执行。

runtime.netpoll
# 2.1.8.1.2 系统调用syscall监控
_Psyscallretake()_Psyscall
# 2.1.8.1.3 垃圾回收

如果垃圾回收器超过两分钟没有执行的话,sysmon 监控线程也会强制进行GC。

# 2.1.8.2 总结

sysmon也叫监控线程,变动的周期性检查, 有如下作用:

  • 释放超过5分钟的span物理内存
  • 如果超过两分钟没有垃圾回收,强制执行
  • 将长时间未处理的netpoll加入到全局队列
  • 向长时间运行的g任务发出抢占调度(超过10ms的g,会进行retake)

# 2.2 runtime常用函数

runtime.Goexit()runtime.Gosched()runtime.NumGoroutine()
3. 内存

# 3.1 GC回收机制

# 3.1.1 常见的垃圾回收算法

常见的垃圾回收算法有以下几种:

# 3.1.1.1 引用计数

对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1,当引用计数器为0时回收该对象。

  • 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
  • 缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
  • 代表语言:Python、PHP

# 3.1.1.2 标记-清除

从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收。

  • 优点:解决了引用计数的缺点。
  • 缺点:需要STW(Stop The World),即要暂时停掉程序运行。
  • 代表语言:Golang(其采用三色标记法)

# 3.1.1.3 分代收集:

按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率。

  • 优点:回收性能好
  • 缺点:算法复杂
  • 代表语言: JAVA

# 3.1.2 v1.3 标记清除(mark and sweep)

go

第一步,暂停程序业务逻辑, 分类出可达和不可达的对象,然后做上标记。 第二步, 开始标记,程序找出它所有可达的对象,并做上标记。 第三步, 标记完了之后,然后开始清除未标记的对象; 第四步, 停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束。

操作非常简单,但是有一点需要额外注意:mark and sweep 算法在执行的时候,需要程序暂停!即 STW(stop the world),STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点。所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕。

# 3.1.2.1 标记清除算法的缺点

标记清除算法明了,过程鲜明干脆,但是也有非常严重的问题。

  • STW,stop the world;让程序暂停,程序出现卡顿 (重要问题);
  • 标记需要扫描整个heap;
  • 清除数据会产生heap碎片。

go

# 3.1.3 v1.5 三色标记法

三色标记主要是解决之前标记慢的问题。 go

# 3.1.3.1 工作原理

第一步 , 每次新创建的对象,默认的颜色都是标记为“白色”, 第二步, 每次GC回收开始, 会从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合 第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合, 第四步, 重复第三步, 直到灰色中无任何对象, 当我们全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色。那么黑色对象就是我们程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除。 第五步: 回收所有的白色标记表的对象. 也就是回收垃圾

# 3.1.3.2 缺点

以上便是三色并发标记法,不难看出,我们上面已经清楚的体现三色的特性。但是这里面可能会有很多并发流程均会被扫描,执行并发流程的内存可能相互依赖,为了在GC过程中保证数据的安全,我们在开始三色标记之前就会加上STW,在扫描确定黑白对象之后再放开STW。但是很明显这样的GC扫描的性能实在是太低了。

  • STW的问题依然存在

假设三个标记法,不会STW保护,则会出现对象丢失; go 强三色不变式:强制性的不允许黑色对象引用白色对象。(破坏条件1) 弱三色不变式:黑色可以引用白色对象,白色对象存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。(破坏条件2)

# 3.1.3.1 强三色不变式

强制不允许黑色对象引用白色对象; go

# 3.1.3.2 弱三色不变式

黑色对象可以引用白色对象,但是有个前提,以下情况比如满足其一:

  • 白色对象被灰色对象引用;
  • 白色对象可达引用链上有灰色对象; go

go

# 3.1.4 v1.8混合写屏障

引入混合写入屏障,实现强三色和弱三色问题,即可动态实现GC,不使用STW. 屏障是指在程序执行过程,增加额外判断机制,类似于hook. go

屏障分两种,插入屏障和删除屏障。

  • 插入屏障:对象被引用时触发的机制
  • 删除屏障:对象被删除时,触发的机制

# 3.1.4.1 插入屏障

对象被引用时触发的屏障。 当标记和程序是并发执行的,这就会造成一个问题. 在标记过程中,有新的引用产生,可能会导致误清扫. 清扫开始前,标记为黑色的对象引用了一个新申请的对象,它肯定是白色的,而黑色对象不会被再次扫描,那么这个白色对象无法被扫描变成灰色、黑色,它就会最终被清扫,而实际它不应该被清扫. 这就需要用到屏障技术,golang 采用了写屏障,作用就是为了避免这类误清扫问题. 写屏障即在内存写操作前,维护一个约束,从而确保清扫开始前,黑色的对象不能引用白色对象.

  • 在A对象引用B对象时,不管B对象什么颜色,都标记为灰色。

go

  • 为了保障栈的效率,栈上的对象不执行插入屏障 为了避免栈出现对象丢失,在准备回收白色对象前,启动STW,扫描栈空间,重新对栈上的对象进行标记) go

这样减少了STW的执行时间(栈空间大小有限,所以存储的对象也不多)。

# 3.1.4.1.1 插入写屏障的不足
  • 结束时需要STW来重新扫描栈 go

# 3.1.4.2 删除屏障

对象被删除时触发的屏障。 被删除的对象,如果自身是灰色或者白色,都标记为灰色,满足弱三色不变式,保障灰色到白色对象的路径不会断。保护对象会被延迟一轮被删除。 go

go

# 3.1.4.3 混合写屏障

go

# 3.1.4.4 插入写屏障与删除写屏障的缺点
  • 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活。仅适用于堆(程序运行基本在栈中,存在大量变量声明、赋值及函数调用,若栈中使用插入写屏障,将极大增加复杂度、降低性能)
  • 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。仅适用于堆

# 3.1.5 GC时机

内存分配量达到阀值定期触发GCsrc/runtime/proc.go:forcegcperiod手动触发runtime.GC()

# 3.1.6 GC调优

# 3.1.6.1 分析GC方法

  • GODEBUG='gctrace=1'
  • go tool trace
  • debug.ReadGCStats
  • runtime.ReadMemStats

# 3.1.6.2 调优

GC调优,通常是指减少用户代码对GC产生的压力,这一方面包含了减少用户代码分配内存的数量(即对程序的代码行为进行调优),另一方面包含了最小化Go的GC对CPU的使用率(即调整 GOGC)。

GC的调优是在特定场景下产生的,并非所有程序都需要针对GC进行调优。只有那些对执行延迟非常敏感、当GC的开销成为程序性能瓶颈的程序,才需要针对GC进行性能调优,几乎不存在于实际开发中 99% 的情况。

总的来说,我们可以在现在的开发中处理的有以下几种情况:

  • 对停顿敏感:GC过程中产生的长时间停顿、或由于需要执行GC而没有执行用户代码,导致需要立即执行的用户代码执行滞后。
  • 对资源消耗敏感:对于频繁分配内存的应用而言,频繁分配内存增加GC的工作量,原本可以充分利用CPU的应用不得不频繁地执行垃圾回收,影响用户代码对CPU的利用率,进而影响用户代码的执行效率。

从这两点来看,所谓GC调优的核心思想也就是充分的围绕上面的两点来展开:优化内存的申请速度尽可能的少申请内存复用已申请的内存。或者简单来说,不外乎这三个关键字:控制、减少、复用。

GC优化一般有如下方法:

sync.PoolGOGC

golang gc调整的方案大概如下:

# 3.2 逃逸分析

Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

# 3.2.1 逃逸的可能情况

stackheap

逃逸分析的作用:

  • 减少gc的压力,不逃逸的对象(包括map,slice这些)分配在栈上,当函数返回时就回收了资源,不需要gc标记清除, 逃逸到堆上的内存需要GC进行回收;
  • 逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好;

关于逃逸的可能情况:

  • 指针逃逸:返回函数内的局部变量指针,指针指向内存,所以不能被回收
  • 栈空间不足逃逸,即一次性分配了较大的对象,比如make([]int, 0, 10000)
  • 动态类型逃逸,很多函数参数为interface类型,编译期间很难确定其参数的具体类型,也能产生逃逸。如下,在有无fmt.Println,打印的结果不同,因为产生了内存逃逸,而在不同的内存中
  • 闭包引用对象逃逸

# 3.3 内存泄漏

# 3.3.1 内存泄漏的可能情况

暂时性内存泄露永久性内存泄露

暂时性内存泄露:

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏
  • string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏

永久性内存泄露:

  • goroutine永久阻塞而导致泄漏
  • time.Ticker未关闭导致泄漏
  • 不正确使用Finalizer(Go版本的析构函数)导致泄漏

# 3.3.2 常见内存泄漏

  • 协程泄漏:协程泄漏是指协程创建之后没有得到释放,主要原因有:
    1. 缺少接收器,导致发送阻塞
    2. 缺少发送器,导致接收阻塞
    3. 死锁。多个协程由于竞争资源导致死锁。
    4. 创建协程的没有回收。
  • waitgroup的Add、Done和wait数量不匹配会导致wait一直在等待
  • slice引起的内存泄漏:当两个slice 共享地址,其中一个为全局变量,另一个也无法被GC;

# 3.3.3 内存泄漏的解决办法

针对可能进行泄漏的地方进行排查,可以通过Golang自带的pprof工具进行分析。

# 3.4 内存对齐

CPU访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如32位的CPU ,字长为4字节,那么CPU访问内存的单位也是4字节。 CPU始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:

go

变量a、b各占据3字节的空间,内存对齐后,a、b占据4字节空间,CPU读取b变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU读取b变量的值需要进行2次内存访问。第一次访问得到b变量的第1个字节,第二次访问得到b变量的后两个字节。

也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后,对该变量的访问就是原子的,这个特性在并发场景下至关重要。

# 3.4.1 什么是内存对齐

为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。 编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。

# 3.4.2 对齐系数

unsafe.Alignof

可以查看到各种类型在Mac 64位上的对齐系数如下:

  • 优点
    • 提高可移植性,有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了
    • 提高内存的访问效率,32位CPU下一次可以从内存中读取32位(4个字节)的数据,64位CPU下一次可以从内存中读取64位(8个字节)的数据,这个长度也称为CPU的字长。CPU一次可以读取1个字长的数据到内存中,如果所需要读取的数据正好跨了1个字长,那就得花两个CPU周期的时间去读取了。因此在内存中存放数据时进行对齐,可以提高内存访问效率。
  • 缺点
    • 存在内存空间的浪费,实际上是空间换时间

# 3.4.3 结构体对齐

对齐原则:

  1. 结构体变量中成员的偏移量必须是成员大小的整数倍
  2. 整个结构体的地址必须是最大字节的整数倍(结构体的内存占用是1/4/8/16byte...)

以T1结构体为例,实际存储数据的只有3字节,但实际用了4字节,浪费了1个字节: i16并没有直接放在bool的后面,而是在bool中填充了一个空白后,放到了偏移量为2的位置上。如果i16从偏移量为1的位置开始占用2个字节,根据对齐原则2:构体变量中成员的偏移量必须是成员大小的整数倍,套用公式 1 % 2 = 1,就不满足对齐的要求,所以i16从偏移量为2的位置开始 go 以T2结构体为例,实际存储数据的只有13字节,但实际用了24字节,浪费了11个字节: go 以T3结构体为例,实际存储数据的只有13字节,但实际用了16字节,浪费了3个字节: go

# 3.5 问题

# 3.5.1 make与new的异同

  • 相同点:都是为引用类型分配内存空间(不一定在堆上)
  • 不同点
    • make只适用于slice,map和chanel的初始化;
    • new用于类型内存的初始化,返回指向分配类型的内存地址,但是现实的编码中,它是不常用的。我们通常都是采用短语句声明以及结构体的字面量达到我们的目的。
    • make返回的还是这三个引用类型本身;而new返回的是指向类型的指针。

# 3.5.2 如何减少小对象分配?

优化工作经常和特定应用程序相关,但也有一些普遍建议:

for k, v := range m {
k, v := k, v   // copy for capturing by the goroutinego func() {
    // use k and v
}()
}
for k, v := range m {
x := struct{ k, v string }{k, v}   // copy for capturing by the goroutinego func() {
    // use x.k and x.v
}()
}
type X struct {
    buf      []byte
    bufArray [16]byte // Buf usually does not grow beyond 16 bytes.
}
func MakeX() *X {
    x := &X{}
    // Preinitialize buf with the backing array.
    x.buf = x.bufArray[:0]
    return x
}
4 并发

# 4.1 Channel

# 4.1.1 Channel特性

nil channelnil channelchannelpanicchannel

# 4.1.2 原理

go

使用一个环形队列来保存groutine之间传递的数据(如果是缓存channel的话),使用两个list保存向该chan发送和从该chan接收数据的goroutine,还有一个mutex来保证操作这些结构的安全。

# 4.1.3 源码分析

go

qcountdataqsizbufelemtypeelemsizsendxrecvxrecvqsendq

sendq和recvq的类型是waitq的结构体:

waitq里面连接的是一个sudog双向链表,保存的是等待的goroutine 。整个chan的图例大概是这样:

go

# 4.1.3.1 发送数据

go

  1. 检查 recvq 是否为空,如果不为空,则从 recvq 头部取一个 goroutine,将数据发送过去;
  2. 如果 recvq 为空,,并且buf没有满,则将数据放入到 buf中;
  3. 如果 buf已满,则将要发送的数据和当前 goroutine 打包成sudog,然后入队到sendq队列中,并将当前 goroutine 置为 waiting 状态进行阻塞。

# 4.1.4 Channel与共享内存的区别

Go 语言中实现了两种并发模型,一种是依赖于共享内存实现的线程-锁并发模型,另一种则是CSP(Communicationing Sequential Processes,通信顺序进程)并发模型。主要是 Go sync 包中的互斥锁、读写锁、条件变量、原子操作等同步原语。后者(CSP模型)目的在于简化并发程序的编写,让并发程序的编写顺序像编写顺序程序一样简单,是 Go 的主流并发模型。

# 4.1.5 Channel的优势和弊端

优点:使用 channel 可以帮助我们解耦生产者和消费者,可以降低并发当中的耦合 缺点:容易出现死锁的情况

# 4.2 waitGroup

# 4.2.1 waitGroup原理概括

waitGroup主要用来阻塞主协程,可以等待所有协程执行完。 WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数 器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。 每次 Add 执行,请求计数器 v 加 1,Done 方法执行,等待计数器减 1,v 为 0 时通过信号量唤醒 Wait()。

# 4.2.2 底层数据结构

noCopyWaitGroupgo vet

# 4.2.3 使用方法

在WaitGroup里主要有3个方法:

WaitGroup.Add()WaitGroup.Done()WaitGroup.Wait()

# 4.3 sync.Once

sync.once通过cas和互斥锁实现了程序只执行一次。

# 4.4 aotomic

atomicsync\atomic

# 4.4.1 CAS

CAS,即Compare and Swap,是基于硬件级别的指令实现的同步原语。处理器(包括 Intel 和 Sparc 处理器)使用的最通用的方法是实现名为比较并转换或CAS的原语,在 Intel 处理器中,比较并交换通过指令的 cmpxchg系列实现。

内存位置(V)预期原值(A)新值(B)

CAS存在的问题:

  • 效率问题:前面提到,如果存在多个线程竞争,可能导致CAS失败,此时可能需要循环(自旋)执行CAS,竞争激烈情况下会对性能有一定影响;
  • ABA问题:CAS过程中其他线程把变量从A改成B,然后又改回A,CAS判断值没变于是执行更新操作,但事实上值是被修改了的,与设计原语不符,atomic包引入AtomicStampReference类解决ABA问题,每次变量更新的时候,将变量的版本号+1,之前的ABA问题中,变量经过两次操作以后,变量的版本号就会由1变成3,也就是说只要线程对变量进行过操作,变量的版本号就会发生更改,从而解决了ABA问题;但实际应用中ABA问题如果对业务逻辑不会造成影响,可以忽略;

# 4.5 unsafe.Pointer/uintptr

unsafe.Pointervoid*uintptrunsafe.Pointeruintptr

Go 官方文档对这个类型有如下四个描述:

unsafe.Pointerunsafe.Pointeruintptrunsafe.Pointerunsafe.Pointeruintptr
unsafe.Offsetof

总结:

  • unsafe.Pointer 可以实现不同类型指针之间相互转化;
  • uintptr 搭配着 unsafe.Pointer 使用,实现指针运算;

# 4.6 sync.pool

sync.Pool

sync.pool的使用非常简单:

  1. 初始化Pool,提供一个New函数,当Pool中未缓存该对象时调用
  2. 使用Get从缓存池中获取对象,接着进行业务逻辑处理即可
  3. 使用完毕 利用Put将对象交还给缓存池

# 4.6.1 缓存对象的数量和期限

sync.Poolsync.poolsrc/pkg/sync/pool.go

可以看到pool包在init的时候注册了一个poolCleanup函数,它会清除所有的pool里面的所有缓存的对象,该函数注册进去之后会在每次gc之前都会调用,因此sync.Pool缓存的期限只是两次gc之间这段时间。正因gc的时候会清掉缓存对象,也不用担心pool会无限增大的问题。

go

正因为这样,我们是不可以使用sync.Pool去实现一个socket连接池的。

# 4.7 sync.cond

sync.Cond

# 4.7.1 底层数据结构

主要有4个字段:

nocopycheckerLnotifyWait()

# 4.7.2 使用方法

在Cond里主要有3个方法:

sync.NewCond(l Locker)Cond.Wait()Cond.Signal()Cond.Broadcast()

# 4.8 互斥锁

sync.Mutexsync.RWMutex

# 4.8.1 底层实现结构

互斥锁对应的是底层结构是sync.Mutex结构体,,位于 src/sync/mutex.go中:

state
semamutex

# 4.8.2 操作

atomic

# 4.8.3 加锁

通过原子操作cas加锁,如果加锁不成功,根据不同的场景选择自旋重试加锁或者阻塞等待被唤醒后加锁

go

# 4.8.4 解锁

通过原子操作add解锁,如果仍有goroutine在等待,唤醒等待的goroutine。

go

注意点:

Lock()panicLock()Lock()Unlock()

# 4.8.5 Go 互斥锁正常模式和饥饿模式

在Go一共可以分为两种抢锁的模式,一种是正常模式,另外一种是饥饿模式

# 4.8.5.1 正常模式(非公平锁)

在刚开始的时候,是处于正常模式(Barging),也就是,当一个G1持有着一个锁的时候,G2会自旋的去尝试获取这个锁。

当自旋超过4次还没有能获取到锁的时候,这个G2就会被加入到获取锁的等待队列里面,并阻塞等待唤醒。

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁。新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败,长时间获取不到锁,就会切换到饥饿模式。

# 4.8.5.2 饥饿模式(公平锁)

当一个 goroutine 等待锁时间超过 1 毫秒时,它可能会遇到饥饿问题。 在版本1.9中,这种场景下Go Mutex 切换到饥饿模式(handoff),解决饥饿问题。

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁。

新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败,长时间获取不到锁,就会切换到饥饿模式。

那么也不可能说永远的保持一个饥饿的状态,总归会有吃饱的时候,也就是总有那么一刻Mutex会回归到正常模式,那么回归正常模式必须具备的条件有以下几种:

  1. G的执行时间小于1ms
  2. 等待队列已经全部清空了

当满足上述两个条件的任意一个的时候,Mutex会切换回正常模式,而Go的抢锁的过程,就是在这个正常模式和饥饿模式中来回切换进行的。

总结:

对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,其实是性能和公平的一个平衡模式。

# 4.9 Go 原子操作和锁的区别?

  • 原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率
  • 原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围
  • 原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁
  • 原子操作存在于各个指令/语言层级,比如“机器指令层级的原子操作”,“汇编指令层级的原子操作”,“Go语言层级的原子操作”等。
  • 锁也存在于各个指令/语言层级中,比如“机器指令层级的锁”,“汇编指令层级的锁”,“Go语言层级的锁”等

# 4.10 死锁和解决方案

常见死锁情况:

  • channel阻塞
  • 锁嵌套/重复加锁

解决方案:

  • 控制锁的力度,保持最够小
  • 谨记channel的使用特性
5. 其他

# 5.1 defer常见问题

# 5.1.1 特性

deferreturnreturnret

# 5.1.2 defer的执行顺序

  • 多个defer出现的时候,它是一个“栈”的关系,也就是先进后出。一个函数中,写在前面的defer会比写在后面的defer调用的晚(定义顺序与实际执行顺序相反)。
  • defer会影响return返回,有名函数返回的时候,如果defer中操作了有名函数的返回列表中的变量,将会影响return返回(这是变量作用域的问题)

# 5.1.3 defer可以捕获goroutine的子goroutine吗?

recover()

# 5.2 Go程序性能分析优化

time
go run test.go

real:从程序开始到结束,实际度过的时间 user:程序在用户态度过的时间 sys:程序在内核态度过的时间

/usr/bin/time/usr/bin/time
top
gctrace=1
  • runtime.ReadMemStats
  • pprof工具

# 5.3 Go如何避免panic

# 5.3.1 常见panic情况

  • 空指针: 对空指针进行操作;
  • 数组/切片:数组/切片越界操作;
  • Channel: 如果我们关闭未初始化的通道,重复关闭通道,向已经关闭的通道中发送数据;
  • Map: map未进行初始化进行操作;map并发读写;
  • 类型断言:未对类型断言结果进行判断;

# 5.3.2 避免panic方案

recover()recover()

# 5.4 常见坑

for i,v := range

# 5.5 init函数

go

  • init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。
  • 每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的init()函数。同一个包,甚至是同一个源文件可以有多个init()函数。init()函数没有入参和返回值,不能被其他函数调用,同一个包内多个init()函数的执行顺序不作保证。
  • 如果一个包有多个 init 函数的话,调用顺序未定义(实现可能是以文件名的顺序调用),同一个文件内的多个 init 则是以出现的顺序依次调用。

执行顺序:

import –> const –> var –>init()–>main()

# 5.6 位运算

go

# 5.6.1 位运算的使用场景

# 5.6.1.1 与运算符

  • 清零:如果想将一个单元清零,即使其全部二进制位为0,只要与一个各位都为零的数值相与,结果为零。
  • 判断奇偶:只要根据最未位是0还是1来决定,为0就是偶数,为1就是奇数。因此可以用if ((a & 1) == 0)代替if (a % 2 == 0)来判断a是不是偶数。

# 5.6.1.2 左移/右移

A. 简单记法

  • 3<<4 :表示为3乘以2的4次方
  • 2>>3 :表示为2除以2的3次方 左移乘以2的N次方,右移除以2的N次方

B. 使用场景 假设有两个数,A和B(A>0,B>0)。B为2^n,期中n>=0,A>=0。则:(原来B得为2^n)

  • 要求A * B的话,则可使用<<操作符,A << n。
  • 要求A / B的话,则可使用>>操作符,A >> n。
  • 要求A % B的话,则可使用&操作符,A&(B-1)。

# 5.7 Go编程哲学

# 5.7.1 少即是多

只提供一种方法,把事情做到极致;比如循环只提供for关键字,没有必要引入 while , do while。

# 5.7.2 组合优于继承

继承关系是世界万物的关系中一个非常小的子集,组合才是世界万物基本的、常见的关系。所以以继承为基础的面向对象语言存在表现力不足,最后不得不高出一套设计模式来弥补这个缺陷。Go语言选择的是组合的思想,这是和现实世界万物关系比较吻合的设计,表现力更强。Go编程中不会有所谓的23种设计模式,Go使用一种很自然的方式来建模世界,解决问题。

# 5.7.3 非侵入式的接口

Go语言接口采用的是一种Duck模型,具体类型不需要显式地声明自己实现了某个接口,只要其方法集是接口方法集的超集即可。使接口和实现者彻底解耦。显式的借口声明显然要求人具备上帝视角,整个接口继承体系一开始就要进行精心和周全的设计。Go不一样,Go倾向于小粒度的接口设计,通过接口组合,自由组合成新的接口,这和人类对世界基本认知的过程一致,便于后续代码的重构和优化。

# 5.7.4 Goroutine设计

  • 虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort这套机制。即便有了libevent、libev这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),会给程序员带来不小的心智负担。
  • Go果断放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类协程(coroutine),Go将之称为goroutine。goroutine占用的资源非常少,Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。
  • 一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现Go程序内goroutine之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)
  • 去除包的循环依赖。循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建速度。在处理依赖关系时,有时会通过允许一部分重复代码来避免引入较多依赖关系。比如:net包具有其自己的整数到十进制转换实现,以避免依赖于较大且依赖性较强的格式化io包。
6. 新特性

# 6.1 泛型

泛型是Go1.18版本发布的新特性,接触过Java编程的都知道泛型的用法。

泛型示例: