• Go底层总结


    Go专家编程

    常见数据结构实现原理

    channel

    channel主要用于进程内各goroutine间通信,如果需要跨进程通信,建议使用分布式系统的方法来解决

    向channel写数据
    1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
    2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
    3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;
    从channel读数据
    1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
    2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
    3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
    4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;
    关闭channel

    关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。

    除此之外,panic出现的常见场景还有:

    1. 关闭值为nil的channel
    2. 关闭已经被关闭的channel
    3. 向已经关闭的channel写数据

    slice

    依托数组实现

    数据结构

    type slice struct {
        array unsafe.Pointer  指针,array指针指向底层数组
        len   int    长度
        cap   int    容量
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    使用make来创建Slice时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即容量。

    使用数组来创建Slice时,Slice将与原数组共用一部分内存

    slice扩容

    扩容容量的选择遵循以下规则:

    • 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍;
    • 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍;
    • copy过程不会发生扩容

    使用append()向Slice添加一个元素的实现步骤如下:

    • 假如Slice容量够用,则将新元素追加进去,Slice.len++,返回原Slice
    • 原Slice容量不够,则将Slice先扩容,扩容后得到新Slice
    • 将新元素追加进新Slice,Slice.len++,返回新的Slice。

    map

    type hmap struct {
        count     int // 当前保存的元素个数
        ...
        B         uint8
        ...
        buckets    unsafe.Pointer // bucket数组指针,数组的大小为2^B
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    bucket数据结构

    type bmap struct {
        tophash [8]uint8 //存储哈希值的高8位
        data    byte[1]  //key value数据:key/key/key/.../value/value/value...
        overflow *bmap   //溢出bucket的地址
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的。

    hash冲突

    根据key(键)即经过一个函数f(key)得到的结果的作为地址去存放当前的key value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经被占用了。这就是所谓的*hash冲突*

    使用溢出桶解决hash冲突

    负载因子
    负载因子 = 键数量/bucket数量
    
    • 1
    map扩容

    条件

    1. 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。
    2. overflow数量 > 2^15时,也即overflow数量超过32768时。

    增量扩容和等量扩容的区别

    增量扩容是当容量不够时发生的,会在内存中开辟一块新的空间,是原空间的两倍,然后将数据搬迁到新的桶中,

    等量扩容是容量不变,发生在键值对不集中,操作和增量扩容一致,但是容量不变,会将里面的简直对重新排列

    struct

    主要了解tag,主要用于反射

    iota

    iota代表了const声明块的行索引(下标从0开始)

    常见控制结构实现原理

    defer

    规则

    1、延迟函数的参数在defer语句出现时就已经确定下来了

    func a() {
        i := 0
        defer fmt.Println(i)
        i++
        return
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    defer语句中的fmt.Println()参数i值在defer出现时就已经确定下来,实际上是拷贝了一份。后面对变量i的修改不会影响fmt.Println()函数的执行,仍然打印”0”。

    注意:对于指针类型参数,规则仍然适用,只不过延迟函数的参数是一个地址值,这种情况下,defer后面的语句对变量的修改可能会影响延迟函数。

    2、延迟函数执行按后进先出顺序执行,即先出现的defer最后执行

    定义defer类似于入栈操作,执行defer类似于出栈操作。

    设计defer的初衷是简化函数返回时资源清理的动作,资源往往有依赖顺序,比如先申请A资源,再根据A资源申请B资源,根据B资源申请C资源,即申请顺序是:A–>B–>C,释放时往往又要反向进行。这就是把defer设计成LIFO的原因。

    3、延迟函数可能操作主函数的具名返回值

    return分两步,即将返回值存入栈中做返回值。然后执行跳转,而defer执行时机是在跳转前

    recover失效的条件
    1. panic时指定的参数为nil;(一般panic语句如panic("xxx failed...")
    2. 当前协程没有发生panic;
    3. recover没有被defer方法直接调用;

    select

    • select语句中除default外,每个case操作一个channel,要么读要么写
    • select语句中除default外,各case执行顺序是随机的
    • select语句中如果没有default语句,则会阻塞等待任一case
    • select语句中读操作要判断是否成功读取,关闭的channel也可以读取

    range

    遍历切片

    遍历slice前会先获取slice的长度len_temp作为循环次数,循环体中,每次循环会先获取元素值,如果for-range中接收index和value的话,则会对index和value进行一次赋值。

    由于循环开始前循环次数就已经确定了,所以循环过程中新添加的元素是没办法遍历到的。

    另外,数组与数组指针的遍历过程与slice基本一致

    map遍历

    遍历map时没有指定循环次数,循环体与遍历slice类似。由于map底层实现与slice不同,map底层使用hash表实现,插入数据位置是随机的,所以遍历过程中新插入的数据不能保证遍历到。

    channel遍历

    channel遍历是依次从channel中读取数据,读取前是不知道里面有多少个元素的。如果channel中没有元素,则会阻塞等待,如果channel已被关闭,则会解除阻塞并退出循环。

    mutex(互斥锁)

    结构体

    type Mutex struct {
        state int32  //表示互斥锁的状态,比如是否被锁定
        sema  uint32  //表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程。
    }
    
    • 1
    • 2
    • 3
    • 4

    四种状态

    • Locked: 表示该Mutex是否已被锁定,0:没有锁定 1:已被锁定。
    • Woken: 表示是否有协程已被唤醒,0:没有协程唤醒 1:已有协程唤醒,正在加锁过程中。
    • Starving:表示该Mutex是否处于饥饿状态,0:没有饥饿 1:饥饿状态,说明有协程阻塞了超过1ms。
    • Waiter: 表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。

    加锁后立即使用defer对其解锁,可以有效的避免死锁。

    RWMutex(读写锁)

    提高并发能力

    读锁不阻塞读锁、其他都会造成阻塞

    type RWMutex struct {
        w           Mutex  //用于控制多个写锁,获得写锁首先要获取该锁,如果有一个写锁在进行,那么再到来的写锁将会阻塞于此
        writerSem   uint32 //写阻塞等待的信号量,最后一个读者释放锁时会释放信号量
        readerSem   uint32 //读阻塞的协程等待的信号量,持有写锁的协程释放锁后会释放信号量
        readerCount int32  //记录读者个数
        readerWait  int32  //记录写阻塞时读者个数
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    协程

    协程调度

    GMP(go中的协程调度)

    • G(Goroutine): 即Go协程,每个go关键字都会创建一个协程。
    • M(Machine): 工作线程,在Go中称为Machine。
    • P(Processor): 处理器(Go中定义的一个摡念,不是指CPU),包含运行Go代码的必要资源,也有调度goroutine的能力。

    M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行

    图中M是交给操作系统调度的线程,M持有一个P,P将G调度进M中执行。P同时还维护着一个包含G的队列(图中灰色部分),可以按照一定的策略将G调度到M中执行。

    P的个数在程序启动时决定,默认情况下等同于CPU的核数,由于M必须持有一个P才可以运行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能的使用CPU而又不至于产生过多的线程切换开销。

    Goroutine调度策略
    队列轮转

    每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。

    除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性地查看全局队列中是否有G待运行并将其调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G被饿死。

    内存管理

    内存分配原理

    在Golang程序启动时会向系统申请一块内存

    Golang内存分配是个相当复杂的过程,其中还掺杂了GC的处理,这里仅仅对其关键数据结构进行了说明,了解其原理而又不至于深陷实现细节。

    1. Golang程序启动时申请一大块内存,并划分成spans、bitmap、arena区域
    2. arena区域按页划分成一个个小块
    3. span管理一个或多个页
    4. mcentral管理多个span供线程申请使用
    5. mcache作为线程私有资源,资源来源于mcentral

    垃圾回收(GC)

    垃圾回收的核心就是标记出哪些内存还在使用中(即被引用到),哪些内存不再使用了(即未被引用),把未被引用的内存回收掉,以供后续内存分配时使用。

    垃圾回收触发时机

    内存分配量达到阀值触发GC

    每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。

    阀值 = 上次GC内存分配量 * 内存增长率
    
    • 1

    内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。

    初始状态下所有对象都是白色的。

    接着开始扫描根对象a、b; 由于根对象引用了对象A、B,那么A、B变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。

    灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束

    最终,黑色的对象会被保留下来,白色对象会被回收掉。

    定期触发GC
    手动触发

    程序代码中也可以使用runtime.GC()来手动触发GC。这主要用于GC性能测试和统计。

    GO的GC是并行GC, 也就是GC的大部分处理和普通的go代码是同时运行的, 这让GO的GC流程比较复杂.

    Stack scan:Collect pointers from globals and goroutine stacks。收集根对象(全局变量,和G stack),开启写屏障。全局变量、开启写屏障需要STW,G stack只需要停止该G就好,时间比较少。

    Mark: Mark objects and follow pointers。标记所有根对象, 和根对象可以到达的所有对象不被回收。

    Mark Termination: Rescan globals/changed stack, finish mark。重新扫描全局变量,和上一轮改变的stack(写屏障),完成标记工作。这个过程需要STW。

    Sweep: 按标记结果清扫span

    内存逃逸

    栈上分配的内存不需要GC处理

    就是本该在栈中的现在在堆中

    场景

    1、指针逃逸

    2、栈空间不足逃逸

    栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中

    3、动态类型逃逸

    参数在编译阶段难以确定其类型,会产生逃逸

    4、闭包引用对象逃逸

    并发控制

    1、channel

    channel一般用于协程之间的通信,channel也可以用于并发控制。比如主协程启动N个子协程,主协程等待所有子协程退出后再继续后续流程

    2、WaitGroup

    WaitGroup是Golang应用开发过程中经常使用的并发控制技术。

    等待一组协程结束后,某个协程才可继续的功能

    Add()做了两件事,一是把delta值累加到counter中,因为delta可以为负值,也就是说counter有可能变成0或负值,所以第二件事就是当counter值变为0时,根据waiter数值释放等量的信号量,把等待的goroutine全部唤醒,如果counter变为负值,则panic.

    3、Context

    它与WaitGroup最大的不同点是context对于派生goroutine有更强的控制力,它可以控制多级的goroutine。

    反射机制

    定时器

    Timer

    Timer实际上是一种单一事件的定时器,即经过指定的时间后触发一个事件,这个事件通过其本身提供的channel进行通知。之所以叫单一事件,是因为Timer只执行一次就结束,这也是Timer与Ticker的最重要的区别之一。

    使用场景

    设定超时时间

    延迟执行某个方法

    底层结构
    type Timer struct {
        C <-chan Time
        r runtimeTimer
    }
    
    • 1
    • 2
    • 3
    • 4

    Ticker

    Ticker是周期性定时器,即周期性的触发一个事件,通过Ticker本身提供的管道将事件传递出去。

    Ticker的数据结构与Timer完全一致:

    语法糖

    :=的使用

    可变参数函数

    • 可变参数必须要位于函数列表尾部;
    • 可变参数是被当作切片来处理的;
    • 函数调用时,可变参数可以不填;
    • 函数调用时,可变参数可以填入切片;

  • 相关阅读:
    多线程怎么共用一个事务
    Vue实现复制粘贴功能
    mac电脑版矢量绘图推荐 Sketch for mac最新中文
    Linux内核分析(十九)--内存管理之Linux中的内存管理机制汇总
    猿创征文|GaussDB(for openGauss):基于 GaussDB 迁移、智能管理构建应用解决方案
    4.MidBook项目经验之MonogoDB和easyExcel导入导出
    【Redis】Redis实现分布式锁
    学生python编辑1--慢慢变大的小球
    IntelliJ IDEA、.NET 工具变贵,JetBrains 宣布全家桶涨价!
    springcloudalibaba架构(25):RocketMQ事务消息
  • 原文地址:https://blog.csdn.net/paterl/article/details/132939223