✨你好啊,我是“ 罗师傅”,是一名程序猿哦。
🌍主页链接:楚门的世界 - 一个热爱学习和运动的程序猿
☀️博文主更方向为:分享自己的快乐
❤️一个“不想让我曾没有做好的也成为你的遗憾”的博主。
💪很高兴与你相遇,一起加油!

前言

随着Go语言基本语法学习结束,迫切需要对Go进行更深入的学习。

主要分为三个模块:线程加锁、线程调度、内存管理

线程加锁

  • Go语言不仅仅提供基于CSP的通讯模型,也支持基于共享内存的多线程数据访问
  • Sync包提供了锁的基本原语
  • sync.Mutex 互斥锁
    • Lock()加锁,Unlock()解锁
  • sync.RWMutex 读写分离锁
    • 不限制并发读,只限制并发写和并发读写
  • sync.waitGroup
    • 等待一组goroutine返回
  • sync.Once
    • 保证某段代码只执行一次
  • sync.Cond
    • 让一组 goroutine 在满足特定条件时被唤醒

线程调度

深入理解Go语言线程调度

  • 进程:资源分配的基本单位
  • 线程:调度的基本单位
  • 无论是线程还是进程,在linux中都以task_struct描述,从内核角度看,与进程无本质区别
  • Glibc中的pthread库提供NPTL(Native POSIX Threading Library)支持

通过图片可以看出:进程是由一个PID=1不断fork出来的,线程在同一进程中的资源是共享的

Linux进程的内存使用

CPU对内存

  • CPU 上有个Memory Management Unit(MMU) 单元
  • CPU 把虚拟地址给MMU,MMU 去物理内存中查询页表,得到实际的物理地址
  • CPU 维护一份缓存Translation Lookaside Buffer(TLB) 缓存虚拟地址和物理地址的映射关系

进程切换开销

  • 直接开销
    • 切换页表全局目录(PGD)
    • 切换内核态堆栈
    • 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文
    • 刷新TLB
    • 系统调度器的代码执行
  • 间接开销
    • CPU 缓存失效导致的进程需要到内存直接访问的IO操作变多

线程切花开销

  • 线程本质上只是一批共享资源的进程,线程切换本质上依然需要内核进行进程切换
  • 一组线程因为共享内存资源,因此一个进程的所有线程共享虚拟地址空间,线程切换相比进程切换,主要节省了虚拟地址空间的切换。

用户线程

无需内核帮助,应用程序在用户空间创建的可执行单元,创建销毁完全在用户态完成

Goroutine

Go 语言基于GMP(=MPG)模型实现用户态线程

  • G:表示goroutine, 每个goroutine 都有自己的栈空间,定时器,初始化的栈空间2k左右,空间会随着需求增长。
  • M:抽象化代表内核线程,记录内核线程栈信息,当goroutine调度到线程时,使用该goroutine自己的栈信息(相当于把CPU分给了当前的goroutine了)
  • P:代表调度器,负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。

GMP 模型细节

G 所处的位置

  • 进程都有一个全局的G队列
  • 每一个P拥有自己的本地执行队列
  • 有不在运行队列中的 G
    • 处于channel阻塞态的G被放在sudog
    • 脱离P绑定在M上的G,如系统调用
    • 为了复用,执行结束进入P的gFree列表中的G(线程池)

Goroutine 创建过程

  • 获取或者创建新的Goroutine结构体
    • 从处理器的gFree 列表中查找空闲的Goroutine
    • 如果不存在空闲的Goroutine,会通过runtime.malg 创建一个栈大小足够的新结构体
  • 将函数传入的参数移到Goroutine的栈上
  • 更新Goroutine 调度相关的 属性,更新状态为_Grunnable
  • 返回的Goroutine 会存储到全局变量allgs中

将Goroutine放到运行队列上

  • Goroutine设置到处理器的runnext作为下一个处理器执行的任务(分配CPU)
  • 当处理器的本地运行队列已经没有剩余空间时,就会把本地队列中的一部分Goroutine和待加入的Goroutine通过runtime.runqputslow添加到调度器持有的全局运行队列上。

调度器行为

  • 为了保证公平,当全局运行队列中有待执行的Goroutine时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine
  • 从处理器本地的运行队列中查找待执行的Goroutine
  • 如果前两种方法都没有找到Goroutine,会通过runtime.findrunnable进行阻塞地查找Goroutine
    • 本地运行队列、全局运行队列中查找
    • 网络轮询器中查找是否有Goroutine等待运行
    • 通过runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的Goroutine

内存管理

关于内存管理的争论

堆内存管理

  • 初始化连续内存块作为堆
  • 有内存申请的时候,Allocator从堆内存的未分配区域分割小内存块
  • 链表将已分配内存连接起来
  • 需要信息描述每个内存块的元数据:大小,是否使用,下一个内存块的地址

TCMalloc 概览

  • page:内存页,一块8K大小的内存空间。Go与操作系统之间的内存申请和释放,都是以page为单位的
  • span:内存块,一个或多个连续的page组成一个span
  • sizeclass:空间规格,每个span都带有一个sizeclass,标记着该span中的page应该如何使用
  • object:对象,用来存储一个变量数据内存空间,一个span在初始化时,会被切割成一堆等大的object;假设object的大小是16B,span大小是8K,那么就会把span中的page就会被初始化8K/16B = 512个object。所谓内存分配,就是分配一个object出去。

Go语言内存分配

  • mcache:小对象的内存分配直接走
    • size class 从1到66,每个class两个span
    • Span 大小是8KB,按span class 大小切分
  • mcentral
    • Span内的所有内存块都被占用时,没有剩余空间继续分配对象,mcache会向mcentral申请1个span,mcache拿到span后继续分配对象
    • 当mcentral向mcache提供span时,如果没有符合条件的span,mcentral会向mheap申请span
  • mheap
    • 当mheap没有足够的内存时,mheap会向OS申请内存
    • Mheap把Span组织成了树结构,而不是链表
    • 然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图
      • 为了更高效的分配、回收和再利用内存

内存回收

内存回收 不同语言使用的算法是不一样的~

  • 引用计数(Python,PHP,Swift)
    • 对每一个对象维护一个引用计数,当引用该对象的对象被销毁的时候,引用计数减1,当引用计数为0的时候,回收该对象(if count== 0 回收)
    • 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阈值时才回收
    • 缺点:不能很好的处理循环引用,而且实时维护引用计数,也有一定的代价
  • 标记-清除(Golang)
    • 从根变量开始遍历所有引用的对象,引用的对象标记为“被引用”,没有被标记的进行回收
    • 优点:解决引用计数的缺点
    • 缺点:需要STW(stop the world),即要暂停程序运行
  • 分代收集(Java)
    • 按照生命周期进行划分不同的代空间,生命周期长的放入老年代,短的放入新生代,新生代的回收频率高于老年代的频率

mspan

mspan 是分配内存时的基本单元(machine)

  • allocBits
    • 记录了每块内存分配的情况
  • gcmarkBits
    • 记录了每块内存的引用情况,标记阶段对每块内存进行标记,有对象引用的内存标记为1,没有标记为0
  • 这两个位图的数据结构是完全一致的,标记结束则进行内存回收,回收的时候,将allocbits指向gcmarkBits, 标记过的则存在,未进行标记的则进行回收

GC 工作流程

Golang GC的大部分处理是和用户代码并行的

  • Mark:
  • Mark Prepare:初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等。这个过程需要STW
  • GC Drains:扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,知道灰色队列为空。该过程后台并行执行
  • Mark Termination:完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan再检查一下,这个过程也是会STW的
  • Sweep:按照标记结果回收所有的白色对象,该过程后台并行执行
  • Sweep Termination:对未清扫的span进行清扫,只有上一轮的GC的清扫工作完成才可以开始新一轮的GC

三色标记

  • GC开始时,认为所有object都是白色,即垃圾
  • 从root区开始遍历,被触达的object置成灰色
  • 遍历所有灰色object,将他们内部的引用变量置成灰色,自身置成黑色
  • 循环第3步,直到没有灰色object了,只剩下了黑白两种,白色都是垃圾
  • 对于黑色object如果在标记期间发生了写操作,写屏障会在真正赋值前将新对象标记为灰色
  • 标记过程中,mallocgc新分配的object,会先被标记成黑色再返回

垃圾回收触发机制

  • 内存分配量达到阈值触发GC
    • 每次内存分配时都会检查当前内存分配量是否已达到阈值,如果达到阈值则立即启动GC。
    • 阈值 = 上次GC内存分配量 * 内存增长率
    • 内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。
  • 定期触发GC
    • 默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod变量中被声明
  • 手动触发
    • 程序代码中也可以使用**runtime.GC()**来手动触发GC。这主要用于GC性能测试和统计

❤️❤️❤️忙碌的敲代码也不要忘了浪漫鸭!

🍉🍉🍉第三周golang的进阶学习到这里就结束啦

期待下周的学习,我们下周见 拜拜咯~~💪。