Golang知识点总结

数据结构

Context

Context的调用链: 和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点

重要概念:(源码位置:src/context/context.go)

  • 主要的context结构: emptyCtx, cancelCtx, timerCtx, valueCtx

  • parentCancelCtx(parent Context): 往上寻找第一个可以cancel的context

  • propagateCancel(parent Context, child canceler): 每次依据一个context创建另一个可取消的context时,都要往上去找到第一个可取消的context,然后将当前的canceler挂到那个节点下面,这样当父节点执行取消的时候,所有子节点都要被取消(通过执行创建子节点context时挂载的canceler即可)

  • cancel(removeFromParent bool, err error) : 只有在当前context执行取消的时候,removeFromParent = true,避免导致重复close done channel), 执行子节点取消的时候(removeFromParent = false)。

1. 当父节点取消的时候,子节点也会取消并且删除父节点挂载的所有子节点的canceler。

2. 当子节点先取消时,需要将父节点的挂载的当前子节点canceler删除,这样可以避免重复调用已经cancel的节点(虽然重复调用cancal是幂等的,已经取消的会记录err,通过判断err是否为nil就知道是否已经cancel了)。

Channel

1. CSP模型

传统的并发模型主要分为 Actor 模型和 CSP 模型,CSP 模型全称为 communicating sequential processes

CSP 模型由并发执行实体(进程,线程或协程)和消息通道组成,实体之间通过消息通道发送消息进行通信。

CSP模型关注的是消息发送的载体,即通道,而不是发送消息的执行实体。

Go 语言的并发模型参考了 CSP 理论,其中执行实体对应的是 goroutine, 消息通道对应的就是 channel

2. channel介绍

channel和 map 类似,make 创建了一个底层数据结构的引用,当赋值或参数传递时,只是拷贝了一个 channel 引用,指向相同的 channel 对象。和其他引用类型一样,channel 的空值为 nil 。使用 == 可以对类型相同的 channel 进行比较,只有指向相同对象或同为 nil 时,才返回 true,channel 一定要初始化后才能进行读写操作,否则会永久阻塞。通过内置的 close 函数对 channel 进行关闭操作。

3. channel 的关闭

关闭 channel 会产生一个广播机制(如何实现?),所有向 channel 读取消息的 goroutine 都会收到消息,这个机制也可以用来控制goroutine的生命周期(配合select使用)

4. channel的类型channel 分为不带缓冲的 channel 和带缓冲的 channel

5. channel 的用法:

range 遍历

channel 也可以使用 range 取值,并且会一直从 channel 中读取数据,直到有 goroutine 对改 channel 执行 close 操作,循环才会结束

6. 单向 channel

可以防止 channel 被滥用,这种防机制发生再编译期间

Pipeline 模式

func build( in <-chan string) <-chan string {
 // 可以是带有缓冲的,因为out返回是只读模式,不写就没事,提前close掉,不会影响程序正常运行,
 // 主要是防止goroutine泄露,不退出
    out := make(chan string)  
    go func() {
     defer close(out)
     for c := range in {
       out <-  "custom build operation func call with paramter 'c' and return new string"
     }
    }()
   return out
}
Fall in / Fall out

新增的 merge 函数的核心逻辑就是对输入的每个 channel 使用单独的协程处理,并将每个协程处理的结果都发送到变量 out 中,达到Fall in的目的。总结起来就是通过多个协程并发,把多个 channel 合成一个

func merge(ins … <-chan string) <-chan string {
    var wg sync.WaitGroup
    out := make(chan string)
    // 处理其中某个流的操作
    process := func (in <-chan string) {
        defer wg.Done()
        for c:= range in {
           out <- “func(c)"
        }
    }
   wg.Add(len(ins))
   // Fall in
   for _, in := range ins {
     go process(in)
   }
  // 处理关闭channel的逻辑,以防goroutine泄露
   go func() {
      wg.Wait()
      close(out)
    }()
  return out
}

Channel实现原理

channel 的主要组成有:

  • 一个环形数组实现(主要是针对:有缓冲区的channel) 的队列,用于存储消息元素;

  • 两个链表实现的 goroutine 等待队列,用于存储阻塞在 recv 和 send 操作上的 goroutine;

  • 一个互斥锁,用于各个属性变动的同步;

channel send

逻辑处理顺序

  1. 当 channel 未初始化或为 nil 时,向其中发送数据将会永久阻塞

  1. 已经关闭的(closed)channel 发送消息会产生 panic

  1. CASE1: 当有 goroutine 在 recv 队列上等待时,跳过缓存队列,将消息直接发给 reciever goroutine

  1. CASE2: 缓存队列未满,则将消息复制到缓存队列上

  1. CASE3: 缓存队列已满,将goroutine 加入 send 队列

send几种情况说明:

  • 有 goroutine 阻塞在 channel recv 队列上,此时缓存队列为空,直接将消息发送给 reciever goroutine,只产生一次复制

  • 当 channel 缓存队列有剩余空间时,将数据放到队列里,等待接收,接收后总共产生两次复制

  • 当 channel 缓存队列已满时,将当前 goroutine 加入 send 队列并阻塞。

channel recieve

逻辑处理顺序

  1. 从 nil 的 channel 中接收消息,永久阻塞

  1. CASE1: 从已经 close 且为空的 channel recv 数据,返回空值

  1. CASE2: send 队列不为空 (发送者先开启了,接受者后开启导致的)

  1. 缓存队列为空,直接从 sender recv 元素

  1. 缓存队列不为空,此时只有可能是缓存队列已满,从队列头取出元素,并唤醒 sender 将元素写入缓存队列尾部。由于为环形队列,因此,队列满时只需要将队列头复制给 reciever,同时将 sender 元素复制到该位置,并移动队列头尾索引,不需要移动队列元素

  1. CASE3: 缓存队列不为空,直接从队列取元素,移动队头索引

  1. CASE4: 缓存队列为空,将 goroutine 加入 recv 队列,并阻塞(调用gopark 会使当前 goroutine 休眠)

Channel总结

无缓冲的 channel 是同步的,而有缓冲的 channel 是非同步的

两处会引起panic的操作:

1.关闭一个 nil channel 将会发生 panic, 2. 给一个已经关闭的 channel 发送数据,引起 panic

Golang知识点总结_第1张图片

如何优雅的关闭channel

原则:don’t close (or send values to) closed channels

根据 sender 和 receiver 的个数,分下面几种情况:

  1. 一个 sender,一个 receiver

  1. 一个 sender, M 个 receiver

  1. N 个 sender,一个 reciver

  1. N 个 sender, M 个 receiver

第1,2种情形下

只有一个 sender 的情况就不用说了,直接从 sender 端关闭就好了,没有问题。重点关注第 3,4 种情况。

第 3 种情形下

优雅关闭 channel 的方法是:

the only receiver says “please stop sending more” by closing an additional signal channel。

解决方案:就是增加一个传递关闭信号的 channel(stopCh),receiver 通过信号 channel 下达关闭stopCh channel 指令。senders 监听到关闭信号后,停止发送数据。

需要说明的是,可以不明确去关闭 dataCh。在 Go 语言中,对于一个 channel,如果最终没有任何 goroutine 引用它,不管 channel 有没有被关闭,最终都会被 gc 回收。所以,在这种情形下,所谓的优雅地关闭 channel 就是不关闭 channel,让 gc 代劳。

第 4 种情形下

优雅关闭 channel 的方法是:

any one of them says “let’s end the game” by notifying a moderator to close an additional signal channel。

这里有 M 个 receiver,如果直接还是采取第 3 种解决方案,由 receiver 直接关闭 stopCh 的话,就会重复关闭一个 channel,导致 panic。因此需要增加一个中间人(可以是后台协程,从带缓冲的channel中监听数据,读到数据前阻塞,有数据后立刻执行关闭stopCh操作,然后协程退出,只关闭了一次),M 个 receiver 都向它发送关闭 stopCh 的“请求”,中间人收到第一个请求后,就会直接下达关闭 stopCh 的指令(通过关闭 stopCh,这时就不会发生重复关闭的情况,因为 stopCh 的关闭只有中间人一个)。另外,这里的 N 个 sender 也可以向中间人发送信号关闭 stopCh 的请求。

slice

扩容算法

  • 1. 如果需要的最小容量比2倍原有容量大,那么就取需要的容量;

  • 2. 如果原有 slice 长度(len)小于 1024 那么每次就扩容为原来的2倍;

  • 3. 如果原 slice 长度(len)大于等于 1024 那么每次扩容就扩为原来的1.25倍;

  • 4. 除此之外扩容容量计算完成之后,还会进行一次内存对齐操作 )( 按照上面三条原则计算得到cap size后 * 8 取最接近 {0,8,16,32,48,64,80,96,112, …} 中的某个数 = capmem , 然后,new cap size =capmem / 8

注意: 可以使用 s2 = s1[:127:127] 的方式,重新构造一个与之前slice无关的新slice

大端小端字节序(两种存储数据的方式)

在计算机系统中,内存地址是按照一定规则排列的,通常是从低地址向高地址递增。这种地址排列方式被称为“从低地址到高地址的顺序”,也称为“小端序”(Little Endian)

例如,对于一个 32 位整数 0x12345678,它在内存中的存储方式如下所示:

地址        |     值
-----------------------
0x1000    |      0x78
0x1001    |      0x56
0x1002    |      0x34
0x1003    |      0x12

可以看到,该整数的低字节(最右边的字节)存储在内存的低地址处,而高字节(最左边的字节)存储在内存的高地址处。

除了小端序,还有一种内存地址的排列方式是“从高地址到低地址的顺序”,也称为“大端序”(Big Endian)。在大端序中,整数的高字节存储在内存的低地址处,而低字节存储在内存的高地址处。这种内存地址的排列方式常用于一些网络协议和嵌入式系统中。

在 Golang 中,默认使用小端序。可以通过 encoding/binary 包中的函数来实现不同序的二进制数据的转换。

总结:

  • 大端模式(Big Endian):高位字节排放在内存的低地址端,低位字节排放在内存的高地址端;

  • 小端模式(Little Endian):低位字节排放在内存的低地址端,高位字节排放在内存的高地址端;

字节序列最小单位是一个字节

如何区分大小端

func IsLittleEndian()  bool{
    var value int32 = 1 // 占4byte 转换成16进制 0x00 00 00 01
    // 大端(16进制):00 00 00 01
    // 小端(16进制):01 00 00 00
     pointer := unsafe.Pointer(&value)
     pb := (*byte)(pointer)
     if *pb != 1{
      return false
     }
     return true
}
// 运行结果:ture

大小端字节序转化

func SwapEndianUin32(val uint32)  uint32 {
    return (val & 0xff000000) >> 24 | (val & 0x00ff0000) >> 8 | (val & 0x0000ff00) << 8 | (val & 0x000000ff) <<24
}

Varint编码

无符号整数

protocol buffer中大量使用了varint编码, Varint是一种使用一个或多个字节序列化整数的方法,会把整数编码为变长字节。

  • 对于32位无符号整型数据经过Varint编码后需要1~5个字节,小的数字使用1个byte,大的数字使用5个bytes。

  • 64位无符号整型数据编码后占用1~10个字节。

在实际场景中小数字的使用率远远多于大数字,因此通过Varint编码对于大部分场景都可以起到很好的压缩效果 。除了最后一个字节外,varint编码中的每个字节都设置了最高有效位(most significant bit - msb),msb为1则表明后面的字节还是属于当前数据的,如果是0那么这是当前数据的最后一个字节数据。

varint编码示意图:

Golang知识点总结_第2张图片
代码实现
package main

import (
    "encoding/binary"
    "fmt"
)

// Varint 编码,返回编码后的字节数组
func encodeVarint(x uint64) []byte {
    var buf [binary.MaxVarintLen64]byte
    n := binary.PutUvarint(buf[:], x)
    return buf[:n]
}

// Varint 解码,接收一个字节数组,返回解码后的 uint64 数据
func decodeVarint(buf []byte) uint64 {
    x, _ := binary.Uvarint(buf)
    return x
}

func main() {
    // 测试数据
    x := uint64(123456789)
    y := uint32(987654321)

    // 编码
    encodedBufX := encodeVarint(x)
    encodedBufY := encodeVarint(uint64(y))

    // 解码
    decodedX := decodeVarint(encodedBufX)
    decodedY := uint32(decodeVarint(encodedBufY))

    fmt.Printf("原始数据: %d, %d\n编码后数据: %v, %v\n解码后数据: %d, %d\n", x, y, encodedBufX, encodedBufY, decodedX, decodedY)
}

有符号整数

如果要对有符号整数类型进行变长编码,需要先将其转换成无符号整数类型,然后再进行编码。具体来说,可以通过 ZigZag 编码(也称为符号扩展编码)来实现这个转换。ZigZag 编码可以将一个有符号整数转换为一个无符号整数,同时保留了其相对大小,这样可以在编码时不失去原始信息。

代码实现
// 将 int64 编码为 varint 格式的 byte 数组
func encodeVarint64(value int64) []byte {
    var buf [binary.MaxVarintLen64]byte
    uvalue := uint64(value) << 1
    if value < 0 {
        uvalue = ^uvalue
    }
    n := binary.PutUvarint(buf[:], uvalue)
    return buf[:n]
}

// 将 byte 数组解码为 int64
func decodeVarint64(data []byte) int64 {
    u, n := binary.Uvarint(data)
    if n <= 0 {
        return 0
    }
    value := int64(u >> 1)
    if u&1 != 0 {
        value = ^value
    }
    return value
}

// 将 int32 编码为 varint 格式的 byte 数组
func encodeVarint32(value int32) []byte {
    var buf [binary.MaxVarintLen32]byte
    uvalue := uint32(value) << 1
    if value < 0 {
        uvalue = ^uvalue
    }
    n := binary.PutUvarint(buf[:], uint64(uvalue))
    return buf[:n]
}

// 将 byte 数组解码为 int32
func decodeVarint32(data []byte) int32 {
    u, n := binary.Uvarint(data)
    if n <= 0 {
        return 0
    }
    value := int32(u >> 1)
    if u&1 != 0 {
        value = ^value
    }
    return value
}

nil类型

只有当接口的类型和值都为nil的时候,接口变量才为nil

//  底层存储表示的是:(*interface{}, nil), 这样的接口之总是非nil, 即使该指针内部为nil
var interface{} = (*interface{})(nil)
                                                    
// error是一个接口类型,指针p虽然数据是nil,但是如果它被返回成包装的error类型时即它是有类型的。
// 所以它的底层结构应该是(*data, nil),很明显它是非nil的
var p *data = nil // data实现了error接口类型

nil的比较

  • 不可以比较两个nil的: nil标识符是没有类型的,所以==对于nil来说是一种未定义的操作,不可以进行比较

  • 同一个类型的nil值比较:指针类型nilchannel类型的nilinterface类型可以相互比较,而func类型、map类型、slice类型只能与nil标识符比较,两个类型相互比较是不合法的

  • 不同类型的nil值比较:只有指针类型nilchannel类型的nil 能与interface类型进行比较,其他类型的之间是不可以相互比较的

rune

官方解释:

// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.

// int32的别名,几乎在所有方面等同于int32
// 它用来区分字符值和整数值
type rune = int32

golang中还有一个byte数据类型rune相似,它们都是用来表示字符类型的变量类型。它们的不同在于:

1. byte 等同于int8, 常用来处理ASCII字符
2. rune 等同于int32, 常用来处理unicode或utf-8字符, range string遍历是按照unicode字符处理的,与len(str)的方式获取的长度可能不一致(存在非ASCII字符时)

sync包

Map

  1. 如果 read map 中已经找到了该值,则不需要去访问 dirty map(慢)。

  1. 但如果没找到,且 dirty map 与 read map 没有差异,则也不需要去访问 dirty map。

  1. 如果 dirty map 和 read map 有差异,则我们需要锁住整个 Map,然后再读取一次 read map 来防止并发导致的上一次读取失误

  1. 如果锁住后,确实 read map 读取不到且 dirty map 和 read map 一致,则不需要去读 dirty map 了,直接解锁返回。

  1. 如果锁住后,read map 读不到,且 dirty map 与 read map 不一致 ,则该 key 可能在 dirty map 中,我们需要从 dirty map 中读取,并记录一次 miss(在 read map 中 miss)

  1. miss 如果大于了 dirty 所存储的 key 数时,会将 dirty map 同步到 read map,并将自身清空,miss 计数归零

  1. 删除操作相对简单,当需要删除一个值时,移除 read map 中的值,本质上仅仅只是解除对变量的引用。实际的回收是由 GC 进行处理。 如果 read map 中并未找到要删除的值,才会去尝试删除 dirty map 中的值

  1. Range 整个 map,则需要考虑 dirty map 与 read map 不一致的问题,如果不一致,则直接将 dirty map 同步到 read map 中。

总结:

sync.Map 中 read map 和 dirty map 的同步过程

  1. read map --> dirty map ,此时dirty为未初始化时,将read map同步到dirty map中,不过只同步entry是处于非expunged状态的(expunged状态指的是:被标记为已经在dirty map中被删除的entry), entry在read map 和 dirty map中都是同一个对象的引用,一处被修改两个map中该entry都发生改变。

  1. dirty map --> read map:当 read map 进行 Load 失败次数等于或者超过len(dirty map) 后发生

因此

  • 无论是存储还是读取,read map 中的值一定能在 dirty map 中找到。

  • 无论两者如何同步,sync.Map 通过 entry 指针操作, 保证数据永远只有一份,一旦 read map 中的值修改,dirty map 中保存的指针就能直接读到修改后的值。

  • 当存储新值时,一定发生在 dirty map 中。当读取旧值时,如果 read map 读到则直接返回,如果没有读到,则尝试加锁去 dirty map 中取。

  • 这也就是官方宣称的 sync.Map 适用于一次写入多次读取的情景。

Cond

sync.Cond 在生产者消费者模型中非常典型,带有互斥锁的队列当元素满时, 如果生产在向队列插入元素时将队列锁住,会产生既不能读,也不能写的情况。 sync.Cond 就解决了这个问题 ( 具有唤醒和等待通知的功能)。

Cond实现了一种条件变量,可以使用在多个 Reader 等待共享资源 ready 的场景(如果只有一读一写,一个锁或者 channel 就搞定了)

每个Cond 都会关联一个 Lock(*sync.Mutex or *sync.RWMutex),当修改条件或者调用 Wait 方法时,必须加锁,保护 condition。

  • Broadcast: Broadcast 会唤醒所有等待 cond 的 goroutine。调用 Broadcast 的时候,可以加锁,也可以不加锁。

  • Wait:Wait()会自动释放 c.L 锁,并挂起调用者的 goroutine。之后恢复执行,Wait()会在返回时对 c.L 加锁。除非被 Signal 或者 Broadcast 唤醒,否则 Wait()不会返回。此时协程进入阻塞等待唤醒 (内部实现:进入阻塞等待前,会释放Mutex锁,唤醒后:会再次尝试获取Mutex锁; 所以在Wait被调用之前,需要显示调用Mutex加锁,然后在Wait调用之后,释放Mutex锁;总的来说,可以忽略Wait内部的实现,只需知道,Wait调用后协程阻塞等待通知,同时调用前后需要显示调用Mutex加锁和释放操作,Broadcast 和 Signal 可以在没有获取Mutex的锁的情况下被调用

  • Signal: 通知队首等待cond的一个后台协程,调用 Signal 的时候,可以加锁,也可以不加锁

Pool

一个 goroutine 固定在 P 上,从当前 P 对应的 private 取值( Get 操作的时候,都是根据当前goroutine运行的P上是否存在缓存对象, Put 也是将结果绑定到运行当前goroutine的P上的,这样需要注意:如果Put在某一个P上进行的,之后该goroutine被调度到另一个P上时,直接GET得到的就不是当时PUT的值了

shared 字段作为一个优化过的链式无锁变长队列(atomic原子操作 和 CAS),当在 private 取不到值的情况下,从对应的 shared 队列的队首取,若还是取不到,则尝试从其他 P 的 shared 队列队尾中偷取。若偷不到,则尝试从上一个 GC 周期遗留到 victim 缓存中取,否则调用 New 创建一个新的对象。

对于回收而言,池中所有临时对象在一次 GC 后会被放入 victim 缓存中,而前一个周期被放入 victim 的缓存则会被清理掉。

对于调用方而言,当 Get 到临时对象后,便脱离了池本身不受控制。用方有责任将使用完的对象放回池中。

本文中介绍的 sync.Pool 实现为 Go 1.13 优化过后的版本。
主要有以下几点优化:
引入了 victim (二级)缓存,每次 GC 周期不再清理所有的缓存对象,而是将 locals 中的对象暂时放入 victim ,从而延迟到下一个 GC 周期进行回收;
在下一个周期到来前,victim 中的缓存对象可能会被偷取,在 Put 操作后又重新回到 locals 中,这个过程发生在从其他 P 的 shared 队列中偷取不到、以及 New 一个新对象之前,进而是在牺牲了 New 新对象的速度的情况下换取的;
poolLocal 不再使用 Mutex 这类昂贵的锁来保证并发安全,取而代之的是使用了 CAS 算法优化实现的 poolChain 变长无锁双向链式队列。
这种两级缓存的优化的优势在于:
显著降低了 GC 发生前清理当前周期中产生的大量缓存对象的影响:因为回收被推迟到了下个 GC 周期;
显著降低了 GC 发生后 New 对象的成本:因为密集的缓存对象读写可能从上个周期中未清理的对象中偷取。

相关文章: Go语言之sync.Pool

atomic

原子操作和锁的区别

原子操作:由底层硬件支持,一个独立的 CPU 指令代表和完成,而锁则由操作系统的调度器实现。锁应当用来保护一段逻辑,对于一个变量更新的保护。

原子操作通常执行上会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用 atomic.Value 封装好的实现。

atomic.Value

atomic.Value 提供了一种具备原子存取的结构。其自身的结构非常简单,只包含一个存放数据的 interface{}:

Go 中的 interface{} 本质上有两段内容组成,一个是typ区域,另一个是实际的数据data区域

划重点:

  • interface {} ,eface ,ifaceWords 这三个结构体内存布局完全一致,只是用的地方不同而已,本质无差别。这给类型的强制转化创造了前提。

  • atomic.Value 使用 CAS 操作只在初始赋值的时候,一旦赋值过,后续赋值的原子操作更简单,依赖于 StorePointer ,指针值的原子赋值;

  • atomic.Value 的 Store 和 Load 方法都不涉及到数据拷贝,只涉及到指针操作;

  • atomic.Value 的神奇的核心在于:每次 Store 的时候用的是全新的内存块 (value的typ和data域都指向新的val对象) 且 Load(LoadPointer) 和 Store(StorePointer) 都是以完整结构体的地址进行操作,所以才有原子操作的效果。

  • atomic.Value 实现多字段原子赋值的原理千万不要以为是并发操作同一块多字段内存,还能保证原子性

Mutex & RWMutex

Mutex状态:
  • mutexLocked — 表示互斥锁的锁定状态;

  • mutexWoken — 表示从正常模式被从唤醒;

  • mutexStarving — 当前的互斥锁进入饥饿状态;

  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;

Mutex 可能处于两种不同的模式:正常模式(非公平锁)和 饥饿模式(公平锁)

  1. 正常模式(normal):等待者按照 FIFO 的顺序排队获取锁,但是一个被唤醒的等待者有时候并不能获取 mutex, 它还需要和新到来的 goroutine 们竞争 mutex 的使用权。

新到来的 goroutine 存在一个优势,它们已经在 CPU 上运行且它们数量很多(一个正在运行的goroutine如果有多次需求来请求锁是都可以获取到的,性能好), 因此一个被唤醒的等待者有很大的概率获取不到锁,在这种情况下它处在等待队列的前面。

  1. Mutex 切换到饥饿模式: 如果一个 goroutine 等待 mutex 释放的时间超过 1ms,它就会进入饥饿模式(starvation),此时mutex 的所有权直接从解锁的 goroutine 递交到等待队列中排在最前方的 goroutine。 新到达的 goroutine 们不要尝试去获取 mutex,即使Mutex看起来是在解锁状态,也不试图自旋(spin: 线程忙等直到获取锁), 而是排到等待队列的尾部,这样很好的解决了等待 goroutine 队列的长尾问题。

  1. Mutex 切换到正常模式: 如果一个等待者获得 mutex 的所有权,并且看到以下两种情况中的任一种:(1)它是等待队列中的最后一个 或者 (2)它等待的时间少于 1ms

总结:正常模式下的性能会更好,因为一个 goroutine 能在即使有很多阻塞的等待者时多次连续的获得一个 mutex,饥饿模式的重要性则在于避免了病态情况下的部分协程获取锁的尾部延迟,但是性能会下降,这其实就是性能和公平的一个权衡模式。

Mutex允许自旋的条件

  • 锁已被占用,并且锁不处于饥饿模式。

  • 积累的自旋次数小于最大自旋次数(active_spin=4)。

  • CPU 核数大于 1。

  • 有空闲的 P。

  • 当前 Goroutine 所挂载的 P 下,本地待运行队列为空。

RWMutex注意事项
  • RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁

  • 读锁占用的情况下会阻止写,不会阻止读,多个 Goroutine 可以同时获取读锁

  • 写锁会阻止其他 Goroutine(无论读和写)进来,整个锁由该 Goroutine 独占

  • 适用于读多写少的场景

  • RWMutex 类型变量的零值是一个未锁定状态的互斥锁

  • RWMutex 在首次被使用之后就不能再被拷贝

  • RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic

  • RWMutex 的一个写锁去锁定临界区的共享资源,如果临界区的共享资源已被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到解锁

  • RWMutex 的读锁不要用于递归调用,比较容易产生死锁

  • RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)

  • 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并都可以成功锁定读锁

  • 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而被阻塞的 Goroutine,其中等待时间最长的一个 Goroutine 会被唤醒

WaitGroup

sync.WaitGroup的结构

// A WaitGroup must not be copied after first use.
type WaitGroup struct { 
  noCopy noCopy
  // 64-bit value: high 32 bits are counter, low 32 bits are waiter count. 
  // 64-bit atomic operations require 64-bit alignment, but 32-bit
  // compilers do not ensure it. So we allocate 12 bytes and then use 
  // the aligned 8 bytes in them as state, and the other 4 as storage 
  // for the sema. 
  state1 [3]uint32 
}

sync.WaitGroup 可以达到并发 goroutine 的执行屏障的效果,等待多个 goroutine 执行完毕 (支持场景:一个等待多个,或者多个等待多个

我们来完成考虑一下整个流程:

wg := sync.WaitGroup{} 
wg.Add(1)
go func() {
  wg.Done()
} 
wg.Wait() 

在 wg 创建之初,计数器、等待器、存储原语的值均初始化为零值

不妨假设调用 wg.Add(1),则计数器加 1,等待器、存储原语保持不变,均为 0。

wg.Done() 和 wg.Wait() 的调用顺序可能成两种情况:

  • 情况 1: 先调用 wg.Done() 再调用 wg.Wait()。

这时候 wg.Done() 使计数器减 1 ,这时计数器、等待器、存储原语均为 0,由于等待器为 0 则 runtime_Semrelease 不会被调用。 于是当 wg.Wait() 开始调用时,读取到计数器已经为 0,循环退出,wg.Wait() 调用完毕。

  • 情况 2: 先调用 wg.Wait() 再调用 wg.Done()。

这时候 wg.Wait() 每开始调用一次时,读取到计数器为 1(不为0),则等待器加 1,并调用 runtime_Semacquire 开始阻塞在存储原语为 0 的状态。在阻塞的过程中,其他goroutine 被调度器调度,开始执行 wg.Done(),等计数器清零之后,由于等待器为 >=1 (不为0),这时会将等待器也清零,并调用与等待器计数相同次数(此处为 1 次)的 runtime_Semrelease,这导致存储原语的值变为 1,计数器和等待器均为零。这时,runtime_Semacquire在存储原语大于零后唤醒了所有调用了wg.Wait()在等待中的goroutine

注意用法

  • Add方法与wait方法不可以并发同时调用

  • Add()设置的值必须与实际等待的goroutine个数一致,否则会panic.

  • Add方法要在wait方法之前调用:调用了wait方法后,必须要在wait方法返回以后才能再次重新使用waitGroup,也就是Wait没有返回之前不要在调用Add方法,否则会发生Panic.

  • Done 只是对Add 方法的简单封装,我们可以向 Add方法传入任意负数(需要保证计数器非负)快速将计数器归零以唤醒等待的 Goroutine.

  • WaitGroup对象只能有一份,不可以拷贝给其他变量,否则会造成意想不到的Bug

性能优化

Golang逃逸分析

加权有向图(程序引用计数器)

分析范围:

  • 流程控制: 不考虑,分析不考虑具体走哪个控制流程分支,所有代码都包括在内

  • 对象子域: 不考虑,如果子域对象逃逸,整个对象发生逃逸

  • 函数嵌套参数调用: 考虑,逃逸可能发生在嵌套函数调用的参数传递中

Golang中的数据变量是被分配到堆还是栈(分为:系统栈和用户栈)呢 ?

在编译原理中,分析指针动态范围的方法称之为逃逸分析

通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。

Go语言的逃逸分析是编译器执行静态代码分析后,对内存管理进行的优化和简化,它可以决定一个变量是分配到堆还栈上。

Go语言逃逸分析是在编译器完成的,最基本的原则是(会发生逃逸):

  • 变量所占内存较大 (>32kb

  • 暴露给外部指针 (如果函数外部没有引用,则优先放到栈中;如果函数外部存在引用,则必定放到堆中)

  • 变量类型不确定

  • 变量大小不确定

通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了(减轻分配堆内存的开销,同时也会减少gc的压力),提高程序的运行速度。 概括为以下:

  • 在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法,用于分析在程序的哪些地方可以访问到指针。

  • Golang在编译时的逃逸分析可以减少gc的压力:不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除。栈回收快,可自动回收内存且是连续内存,堆回收慢,gc很长时间可能没有回收对象,不连续的空间。栈上的内存分配比堆上快很多,堆不像栈可以自动清理,它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%)。

  • 如果你定义的对象的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行,提高效率。

可以执行 go build -gcflags '-m -N -l' test.go 来进行逃逸分析

-m: 打印逃逸分析信息,
-l: 禁止内联优化
-N: disable optimization

示例3:

package main
type S struct {}
func main() { 
    var x S  _ = *ref(x)
}
func ref(z S) *S {
  return &z 
} 

分析:z是对x的拷贝,ref函数中对z取了引用,所以z不能放在栈上,否则在ref函数之外,通过引用如何找到z,所以z必须要逃逸到堆上。仅管在main函数中,直接丢弃了ref的结果,但是Go的编译器还没有那么智能,分析不出来这种情况。而对x从来就没有取引用,所以x不会发生逃逸。

GC垃圾回收机制

GC详细内容参考

垃圾回收器的执行过程被划分为两个半独立的组件:

* 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
* 回收器(Collector):负责执行垃圾回收的代码。

根对象到底是什么

根对象:一组发现堆内存可达数据的起点。在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局数据区(数据段):程序在编译期就能确定的那些存在于程序整个生命周期的变量(指针位图信息)。

  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针 (同样,每个栈帧也有对应的指针位图,可以很快的扫描找出root节点,也就是该变量类型是否是包含指针的节点)。

  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

GC 算法有四种:

  1. 引用计数(reference counting)

  1. 标记-清除/压缩(mark & sweep): 基于追踪的垃圾收集算法

  1. 节点复制(Copying Garbage Collection)

  1. 分代收集(Generational Garbage Collection)。

Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法, 原因在于:

1. 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。并且顺序内存分配器在多线程的场景下并不适用。
Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。

2. 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。
但 Go 的编译器会通过 逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上, 当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势, 并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

三色标记算法

从垃圾回收器的视角来看,三色抽象规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。

  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。

  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

STW 是什么意思

STW是 Stop the World 的缩写,即万物静止,是指在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大

早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响

我们来看一个例子:

package main
import (
  "runtime"
  "time"
)
func main() {
  go func() {
    for {
    }
  }()
  time.Sleep(time.Millisecond)
  runtime.GC()
  println("OK")
}

上面的这个程序在 Go 1.14 以前永远都不会输出 OK,其罪魁祸首是 STW 无限制的被延长。

尽管 STW 如今已经优化到了半毫秒级别以下,但这个程序被卡死原因在于仍然是 STW 导致的。原因在于,GC 在进入 STW 时,需要等待让所有的用户态代码停止,但是 for { } 所在的 goroutine 永远都不会被中断,从而停留在 STW 阶段。

实际实践中也是如此,当程序的某个 goroutine 长时间得不到停止,强行拖慢 STW,这种情况下造成的影响(卡死)是非常可怕的。自 Go 1.14 之后,这类 goroutine 能够被异步地抢占,从而使得 STW 的时间如同普通程序那样,不会超过半个毫秒,程序也不会因为仅仅等待一个 goroutine 的停止而停顿在 STW 阶段。

Go语言中GC 的流程

Golang中垃圾回收支持三种模式

(1)gcBackgroundMode,默认模式,标记与清扫过程都是并发执行的;

(2)gcForceMode,只在清扫阶段支持并发;

(3)gcForceBlockMode,GC全程需要STW。

当前版本(1.14)的 Go 以 STW 为界限,可以将 GC 划分为五个阶段:

阶段

说明

赋值器状态

GCMark

标记准备阶段,为并发标记做准备工作,启动写屏障

STW

GCMark

扫描标记阶段,与赋值器并发执行,写屏障开启

并发

GCMarkTermination

标记终止阶段,保证一个周期内标记任务完成,停止写屏障

STW

GCoff

内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭

并发

GCoff

内存归还阶段,将过多的内存归还给操作系统,写屏障关闭

并发

具体而言,各个阶段的触发函数分别为:

Golang知识点总结_第3张图片

一共分为四个阶段:

1. 栈扫描(开始时STW)

2. 第一次标记(并发)

3. 第二次标记(STW)

4. 清除(并发)

  • 起初所有对象都是白色

  • 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。

  • 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色

  • 重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。

三色标记的一个明显好处是能够让用户程序和 mark 并发的进行.

Golang知识点总结_第4张图片

详细的过程如下图所示:

Golang知识点总结_第5张图片

这里需要解释下:

  1. 首先从 root 开始遍历,root 包括全局指针和,goroutine 栈上的指针, 寄存器上的指针等。

  1. mark 有两个过程。第一是从 root 开始遍历,标记为灰色。遍历灰色队列。第二re-scan 全局指针和栈。因为 mark 和用户程序是并行的,所以在过程 1 的时候可能会有新的对象分配,这个时候就需要通过写屏障(write barrier)记录下来。re-scan 再完成检查一下。

  1. Stop The World 有两个过程。第一个是 GC 将要开始的时候,这个时候主要是一些准备工作,比如 enable write barrier。第二个过程就是上面提到的 re-scan 过程。如果这个时候没有 stw,那么 mark 将无休止。

GC时的回调函数

在Go语言中,垃圾回收器(Garbage Collector,简称GC)是负责自动管理内存的重要组成部分。当对象不再被程序所引用时,垃圾回收器会自动将其释放,从而避免了手动管理内存所带来的问题,如内存泄漏、重复释放等。

然而,对于某些需要依赖底层资源的对象(例如文件、网络连接、锁,数据库连接等),GC并不能直接回收它们,还需要手动释放底层资源,否则会导致资源泄露或者产生其他的问题。

这时候就可以使用Finalizer来释放资源。

Finalizer是一种在垃圾回收器执行回收操作之前被调用的函数。在回收对象之前,垃圾回收器会调用Finalizer函数,如果存在的话,来释放对象所占用的非堆内存资源。由于垃圾回收器的执行时间不可控,因此在函数返回时或者对象被回收时无法保证立即释放资源,但是一旦垃圾回收器执行了Finalizer函数,就可以确保非堆内存资源得到释放。

// runtime中定义Finalizer函数原型
// obj必须是一个指针类型的
// finalizer 是一个function,单一的obj参数传入,无返回值
func SetFinalizer(obj any, finalizer any)

// 使用示例
type Test struct {
  // some fields inside
}

func NewTest() *Test {
   t := &Test{}
   runtime.SetFinalizer(t, func(p *Test) {
      fmt.Println("test gc SetFinalizer runs")
      // release some resource operations here
   })
   return t
}

需要注意,使用Finalizer也有一些潜在的问题,下面是一些可能的情况:

  • 垃圾回收器的执行时间和频率是不可控, 某次GC中被回收的对象也不可控,因此Finalizer的执行时间也不可控。

  • 某些场景下,Finalizer使用不当可能导致被过早被执行,从而使底层资源对象过早被释放,引发异常。为了避免这种情况,可以使用 runtime.KeepAlive(x) 来保证x在被调用KeepAlive代码块之前不被GC回收,Finalizer就不会被提前执行

  • runtime是用一个单goroutine来执行所有的Finalizer回调,还是串行化的,一旦执行某个Finalizer出现问题,可能影响全局的回调函数的执行,使得该释放的内存没有得到释放,从而造成内存泄漏。

因此,在实际使用Finalizer时需要谨慎考虑,并且最好在必要时才使用。

触发 GC 的时机

Go 语言中对 GC 的触发时机存在两种形式:

  1. 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。

  1. 被动触发,分为两种方式:

  • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。

  • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。

GC 如何调优

  1. 控制内存分配的速度,限制 goroutine 的数量,从而提高赋值器对 CPU 的利用率。

  1. 减少并复用内存,例如使用 sync.Pool来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。

  1. 需要时,增大 GOGC 的值,降低 GC 的运行频率。

GC 的触发原则是由步调算法来控制的,其关键在于估计下一次需要触发 GC 时,堆的大小。

可想而知,如果我们在遇到海量请求的时,为了避免 GC 频繁触发,是否可以通过将 GOGC 的值设置得更大,让 GC 触发的时间变得更晚,从而减少其触发频率,进而增加用户代码对机器的使用率呢?

答案:是可以的。

如何观察 GC

  1. GODEBUG=gctrace=1

  1. go tool trace

  1. runtime.ReadMemStats / debug.ReadGCStats

写屏障、混合写屏障

分析: Mark Worker和其他协程并发执行,如果将白色对象写入黑色对象,同时没有其他未扫描的路径可以抵达该白色对象,它就不会被Mark Worker发现从而被误判为垃圾,被回收掉。

解决办法:

  1. 插入写屏障: 在执行写操作的时候,额外做一些标记工作,比如将白色对象写入黑色对象时,标记白色对象为灰色待扫描对象,避免该白色对象被误判。 大量插入写屏障会使可执行文件变大,同时程序性能会受到影响

  1. 取消栈上写屏障,标记完之后重新扫描活跃栈帧: 活跃栈帧太多,每次重新扫描程序性能依旧影响很大

  1. 混合写屏障: 不在栈上加写屏障, 不重新扫描协程栈。在栈之外设置插入写屏障 + 删除写屏障(删除写屏障: 在删除指针引用的时候做一些标记工作),比如将白色对象移除某个引用的时候,标记白色对象为灰色待扫描对象。使用混合写屏障既不用在当前栈帧设置写屏障,也不用在第二次STW时重新扫描所有活跃G的堆栈。

注意: 如果GC标记阶段压力大,申请新内存的速度又很快,根本来不及标记,那么内存很快就不够了,如何处理?

方案: GC Assist机制(招募临时工), 各位打算申请内存的协程,基于本时段GC标记压力大,请诸位(协程)在申请内存前,先完成相应的标记工作。

“辅助标记”和“辅助清扫”可以避免出现并发垃圾回收中,因过大的内存分配压力导致GC来不及回收的情况

我们所说的 Go 中的写屏障、混合写屏障,其实是指赋值器的写屏障,赋值器的写屏障用来保证赋值器在进行指针写操作时,不会破坏弱三色不变性。

有两种非常经典的写屏障:`Dijkstra 插入屏障`和 `Yuasa 删除屏障`

Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。

Golang知识点总结_第6张图片

垃圾回收器API

在 Go 中存在数量极少的与 GC 相关的 API,它们是

  • runtime.GC:手动触发 GC

  • runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息

  • debug.FreeOSMemory:手动将内存归还给操作系统

  • debug.ReadGCStats:读取关于 GC 的相关统计信息

  • debug.SetGCPercent:设置 GOGC 调步变量

  • debug.SetMaxHeap(尚未发布):设置 Go 程序堆的上限值

Go 调度器 (GMP)

线程和协程

对于 进程、线程,都是有内核(涉及用户态切换到内核态)进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)

对于 协程 (用户级线程) ,通常只能进行 协作式调度(Go1.13之前),需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。Go 1.14 实现了基于信号的 抢占式调度

协程实现

和线程的原理是一样的,当 a线程 切换到 b线程 的时候,需要将 a线程 的相关执行进度压入栈,然后将 b线程 的执行进度出栈,进入 b线程 的执行序列

协程只不过是在 应用层 实现这一点。但是,协程并不是由操作系统调度的,而且应用程序也没有能力和权限执行 cpu 调度。

协程执行

协程是基于线程的。内部实现上,维护了一组数据结构和 n 个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这 n 个线程从队列中拉出来执行,这就解决了协程的执行问题 ( 结合GMP模型里面的内核级线程理解)

协程切换

golang 对各种 io函数 进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步 io函数,当这些异步函数返回 busy 或 blocking 时,golang 利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行,利用并封装了操作系统的异步函数。包括 linux 的 epoll、select 和 windows 的 iocp、event 等(这个概念不就和GMP调度器的实现串起来了,协程的概念更加清晰了)。Golang协程本质上其实就是对 IO 事件的封装,并且通过语言级的支持让异步的代码看上去像同步执行的一样 。

基于信号的抢占式调度

一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 STW 时,会让所有的 goroutine 都停止,其实就是抢占。

go1.14 之前
  1. 如果 sysmon 监控线程发现有个协程 A 执行之间太长了(或者 gc 场景,或者 stw 场景),那么会友好的在这个 A 协程的某个字段设置一个抢占标记 ;

  1. 协程 A 在 CALL 一个函数的时候,会复用到扩容栈(morestack)的部分逻辑,检查到抢占标记之后,让出 cpu,切到调度主协程里;

这样 A 就算是被抢占了。A 调度权被抢占有个前提:A 必须主动 call 函数,这样才能有走到 morestack 的机会。

go1.14后

每个 M 在初始化的时候都会设置一个SIGURG信号处理函数,注册sighandler (initsig -> setsig -> sighandler),收到信号后内核执行 sighandler 函数,通过 pushCall 插入 asyncPreempt 函数调用, 同时保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了并将当前 goroutine 插入到全局可运行队列 (普通 goroutine 退出后,则进行了一系列的调用,最终又切到 g0 栈,执行 schedule 函数调度其他的G

如果是同步系统调用,M被阻塞,M和G都被调度走,等待阻塞完成,同时会产生一个新的M和P绑定,来处理其他G的执行.

如果是异步系统调用,那么M没有被阻塞,G被调度走,等待阻塞完成,此时 M 则继续寻找其他 goroutine 来运行.

被抢占的 goroutine(也可以是在系统调用被阻塞调度走的G在阻塞完成后) 再次调度过来执行时,通过寄存器值恢复现场后,继续原来的执行流程

线程实现模型

线程模型有三类:内核级线程模型、用户级线程模型、混合型线程模型

三者的区别:主要在于线程与内核调度实体KSE(Kernel Scheduling Entity)之间的对应关系上。

内核调度实体KSE: 指操作系统内核调度器调度的对象实体,是内核调度的最小单元。

线程模型

用户线程与KSE之前的关系

特点

优点

缺点

内核级线程模型

1:1

1条用户线程对应一条内核进程/线程来调度,即以核心态线程实现

不同用户线程之间不会互相影响。可以利用多核系统的优势。

线程的创建、删除、切换的代价更昂贵

用户级线程模型

M:1

N条用户线程只由一条内核进程/线程调度,即以用户态线程实现

线程的创建、删除和环境切换都很高效

一个线程发生阻塞,整个进程下的其他线程也会被阻塞

混合型线程模型

M:N

M条用户线程由N条内核线程动态关联。又称两级线程模型

可以快速地执行上下文切换。当某个线程发生阻塞可以调度出CPU关联到可以执行的线程上

动态关联机制实现复杂,需要用户或runtime去实现

目前Go就是采用这种 混合型线程模型

线程模型示意图

Golang知识点总结_第7张图片

Go scheduler 的核心思想是:

  1. reuse threads;

  1. 限制同时运行(不包含阻塞)的线程数为 N,N 等于 CPU 的核心数目;

  1. 线程私有的 runqueues,并且可以从其他线程 stealing goroutine 来运行,线程阻塞后,可以将 runqueues 传递给其他线程。

  1. P和M是动态形式的一对一的关系,P和G是动态形式的一对多的关系。

GMP 全局运行示意图

Golang知识点总结_第8张图片

有三个基础的结构体来实现 goroutines 的调度: g,m,p。

  • G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息.代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值。

  • M(Machine) :对内核级线程的封装,数量对应真实的CPU数(真正干活的对象),表示内核线程,包含正在运行的 goroutine 等字段。

  • P(Processor) :即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数, 代表一个虚拟的 Processor,它维护一个处于 Runnable 状态的 g 队列, m 需要获得 p才能运行 g。

当然还有一个核心的结构体:sched,它总览全局。

全局锁问题 (P结构体的作用

Runtime 起始时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G;并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。

当然,在 Go 的早期版本,并没有 p 这个结构体, m 必须从一个全局的队列里获取要运行的 g,因此需要获取一个全局的锁,当并发量大的时候,锁就成了瓶颈。

后来Dmitry Vyokov 在实现里,加上了 p 结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列,解决了原来的全局锁问题。

Golang知识点总结_第9张图片

Go scheduler 使用 M:N 模型,在任一时刻,M 个 goroutines(G) 要分配到 N 个内核线程(M),这些 M 跑在个数最多为 GOMAXPROCS 的逻辑处理器(P)上。每个 M 必须依附于一个 P,每个 P 在同一时刻只能运行一个 M。如果 P 上的 M 阻塞了,那它就需要其他的 M 来运行 P 的 LRQ 里的 goroutines。

当goroutine发生阻塞的时候,可以通过P将剩余的G切换给新的M来执行,而不会导致剩余的G无法执行,如果没有M则创建M来匹配P。

当阻塞的goroutine返回后,进程会尝试获取一个P来执行这个goroutine。一般是先从其他线程中"偷取"一个P,如果"偷取"不成功,则将goroutine放入全局的goroutine中

Golang知识点总结_第10张图片
Golang知识点总结_第11张图片

注:三角形表示 M,圆形表示 G,矩形表示 P

同步和异步系统调用

同步系统调用

Golang知识点总结_第12张图片

异步系统调用

Golang知识点总结_第13张图片

异步情况下,通过调度,Go scheduler 成功地将 I/O 的任务转变成了 CPU 任务或者说将内核级别的线程切换转变成了用户级别的 goroutine 切换, 同步情况下,切换了M,M是内核级线程, 异步情况下只切换了G),大大提高了效率

调试工具和手段

go tool trace

trace 是 golang 内置的一种调试手段,能够 trace 一段时间程序的运行情况。能看到:

  • 协程的调度运行情况;

  • 跑在每个处理器 P 上协程情况;

  • 协程出发的事件链;


package main

import (
    "os"
    "runtime"
    "runtime/trace"
    "sync"
)

func fn(w *sync.WaitGroup) {
    defer w.Done()
    // do some things
    
}

func main() {

    // 设置GMP中P的数量(Processor数量)
    // P 中维护了该P下需要调度的goroutine队列
    runtime.GOMAXPROCS(1)

    f, _ := os.Create("trace.output")
    defer f.Close()

    _ = trace.Start(f)
    defer trace.Stop()

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go fn(&wg)
    }
    wg.Wait()
}

分析 trace 输出:

$ go tool trace -http=":8086" ./trace.output

浏览器打开显示如下:

Golang知识点总结_第14张图片

输出名词解释:

其中带有向下箭头的并且是以profile结尾是可下载文件,打开需要安装Graphviz

如果本地有一些类似的文件需要打开查看,可以使用另一个调试工具 go tool pprof xxx.profie, 进入后,输入web命令,就可以在浏览器上查看profile的具体内容,原理一样对于profile类型文件的可视化输出都需要提前安装好Graphviz

MacOS下安装Graphviz

brew install graphviz

  • View trace:查看跟踪,一段时间内 goroutine 的调度执行情况,包括事件触发链;

  • Goroutine analysis:Goroutine 分析,所有 goroutine 执行情况信息;

其中某个goroutine的运行情况可能如下:

Golang知识点总结_第15张图片
  • Network blocking profile:网络阻塞

  • Synchronization blocking profile:同步阻塞

  • Syscall blocking profile:系统调用阻塞

  • Scheduler latency profile:调度延迟

  • User defined tasks:自定义任务

  • User defined regions:自定义区域

  • Minimum mutator utilization:Mutator 利用率使用情况

pprof功能

可以在你的程序中开启runtime的profiling data导出,开启方式也很简单,如果你的程序本身是一个HTTP 服务的话, 直接导入下面一段代码到程序中就可以

import _ "net/http/pprof"

如果你的程序不是HTTP服务,那么可以选择开启一个

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

支持的所有profile类型, 可以在你的浏览其中打开地址 http://localhost:6060/debug/pprof/ 查看

使用方式:

// heap
go tool pprof http://localhost:6060/debug/pprof/heap
// cpu
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// blocking profile of goroutine, 需要在程序中先调用runtime.SetBlockProfileRate 
go tool pprof http://localhost:6060/debug/pprof/block
// mutex profile, 需要在程序中先调用runtime.SetMutexProfileFraction
go tool pprof http://localhost:6060/debug/pprof/mutex
// 上面通过嵌入trace代码获取的trace.out数据,也可以通过pprof获取,代码侵入性降低
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
go tool trace trace.out

最佳实践

三种并发模型

  1. 通过channel通知实现并发控制

  1. 通过sync包中的WaitGroup实现并发控制

  • Wait死锁: 在 WaitGroup 第一次使用后,不能被拷贝。 所以wg 的传入类型应该改为 *sync.WaitGroup 或者将匿名函数中的 wg 的传入参数去掉, Go支持闭包类型,在匿名函数中可以直接使用外面的 wg 变量.

  • WaitGroup: 不仅可实现一个等待多个,还可以实现多个等待多个(wg.Wait() 每调用一次,等待队列就多一个等待者,直到wg的count计算器为0,此时所有等待者将被唤醒)

  1. 在Go1.7以后引进的强大的Context上下文,实现并发控制

Linux 内核的负载均衡

Linux 3.9 内核引入了 SO_REUSEPORT选项, 支持多个进程或者线程绑定到同一端口,用于提高服务器程序的性能。它的特性包含以下几点:

  • 允许多个套接字 bind 同一个TCP/UDP 端口

  • 每一个线程拥有自己的服务器套接字

  • 在服务器套接字上没有了锁的竞争

  • 内核层面实现负载均衡

  • 安全层面,监听同一个端口的套接字只能位于同一个用户下(same effective UID)

有了 SO_RESUEPORT 后,每个进程可以 bind 相同的地址和端口,各自是独立平等的。

推荐阅读

深度解密Go语言之基于信号的抢占式调度

Why golang garbage-collector not implement Generational and Compact gc?

写一个内存分配器

观察 GC

煎鱼 Go debug

煎鱼 go tool trace

trace 讲解

An Introduction to go tool trace

http pprof 官方文档

runtime pprof 官方文档

trace 官方文档

设计模式

创建型(4种)

  • 单例模式: 饿汉式(无锁), 懒汉式(有锁)

  • 工厂模式: 简单工厂,工厂方法,抽象工厂

  • 建造者模式: Option选项

  • 原型模式: 深拷贝(递归复制,序列化与反序列化),浅拷贝

结构型(7种)

  • 代理模式(添加原始类无关联的功能): 静态代理(手动编写代理类), 动态代理(反射)

  • 桥接模式: 组合(将抽象和实现解耦,方便组合)

  • 装饰器模式: 通过组合代替继承(给原始类添加有关联的功能)

  • 适配器模式: 接口转换(兼容)

  • 门面模式: 为子系统提供一组统一的接口定义

  • 组合模式: 树形结构

  • 享元模式: 复用对象(不可变对象),节省内存

行为型(11种)

  • 观察者模式: 发布订阅模式

  • 模版模式: 定义模版方法,子类实现

  • 策略模式: 策略接口 + 不同策略类

  • 职责链模式:(链表,数组), gin中间件的实现

  • 状态模式: 状态机状态转移(状态,事件,动作)

  • 迭代器模式: hashNext(), next(), currentItem(), 迭代器专门负责不同遍历算法饿实现,职责更单一

  • 访问者模式: 一个或多个操作应用到一组对象,操作和对象解耦

  • 备忘录模式: 状态恢复

  • 命令模式: 实现不同命令参数不同的封装

  • 解释器模式:语法解析器等

  • 中介模式: 中介对象封装一组对象的交互,避免它们直接交互

Go实现: 说到底,go写法不需要这么多模式,做好代码抽象,解耦合,用提炼出来的这些模式的思想,写起来就可以,不要过早优化,持续重构才是正道

设计原则

  • 单一职责

  • 开闭原则

  • 里式替换

  • 接口隔离

  • 依赖倒置

你可能感兴趣的:(Golang,go)