线程加锁
锁
Go 语言不仅仅提供基于 CSP 的通讯模型,也支持基于共享内存的多线程数据访问
Sync 包提供了锁的基本原语
sync.Mutex 互斥锁
Lock()加锁,Unlock 解锁sync.RWMutex 读写分离锁
不限制并发读,只限制并发写和并发读写sync.WaitGroup
等待一组 goroutine 返回sync.Once
保证某段代码只执行一次sync.Cond
让一组 goroutine 在满足特定条件时被唤醒
Mutex 示例
Kubernetes 中的 informer factory
1 | // Start initializes all requested informers. |
WaitGroup 示例
1 | // CreateBatch create a batch of pods. All pods are created before waiting. |
Cond 示例
Kubernetes 中的队列,标准的生产者消费者模式
cond: sync.NewCond(&sync.Mutex{}),
1 | // Add marks item as needing processing. |
线程调度
深入理解 Go 语言线程调度
- 进程:资源分配的基本单位
- 线程:调度的基本单位
- 无论是线程还是进程,在 linux 中都以 task_struct 描述,从内核角度看,与进程无本质区别
- Glibc 中的 pthread 库提供 NPTL(Native POSIX Threading Library)支持
Linux 进程的内存使用
CPU 对内存的访问
- CPU 上有个 Memory Management Unit(MMU) 单元
- CPU 把虚拟地址给 MMU,MMU 去物理内存中查询页表,得到实际的物理地址
- CPU 维护一份缓存 Translation Lookaside Buffer(TLB),缓存虚拟地址和物理地址的映射关系
进程切换开销
直接开销
- 切换页表全局目录(PGD)
- 切换内核态堆栈
- 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
- 刷新 TLB
- 系统调度器的代码执行
间接开销
- CPU 缓存失效导致的进程需要到内存直接访问的 IO 操作变多
线程切换开销
- 线程本质上只是一批共享资源的进程,线程切换本质上依然需要内核进行进程切换
- 一组线程因为共享内存资源,因此一个进程的所有线程共享虚拟地址空间,线程切换相比进程切换,主要节省了虚拟地址空间的切换
用户线程
无需内核帮助,应用程序在用户空间创建的可执行单元,创建销毁完全在用户态完成。
Goroutine
Go 语言基于 GMP 模型实现用户态线程
- G:表示 goroutine,每个 goroutine 都有自己的栈空间,定时器,初始化的栈空间在 2k 左右,空间会随着需求增长。
- M:抽象化代表内核线程,记录内核线程栈信息,当 goroutine 调度到线程时,使用该 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 作为下一个处理器执行的任务
- 当处理器的本地运行队列已经没有剩余空间时,就会把本地队列中的一部分 Goroutine 和待加入的 Goroutine通过 runtime.runqputslow 添加到调度器持有的全局运行队列上
调度器行为
- 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine
- 从处理器本地的运行队列中查找待执行的 Goroutine
- 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找Goroutine
从本地运行队列、全局运行队列中查找
从网络轮询器中查找是否有 Goroutine 等待运行
通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine
课后练习 2.1
将练习1.2中的生产者消费者模型修改成为多个生产者和多个消费者模式
内存管理
关于内存管理的争论
- java/golang:内存管理太重要了!手动管理麻烦且容易出错,所以我们应该交给机器去管理!
- c/c++:内存管理太重要了!所以如果交给机器管理我不能放心!
堆内存管理
- 初始化连续内存块作为堆
- 有内存申请的时候,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 出去
对象大小定义
小对象大小:0256KB1MB
中对象大小:256KB
大对象大小:>1MB小对象的分配流程
ThreadCache -> CentralCache -> HeapPage,大部分时候,ThreadCache 缓存都是足够的,不需要去访问CentralCache 和 HeapPage,无系统调用配合无锁分配,分配效率是非常高的中对象分配流程
直接在 PageHeap 中选择适当的大小即可,128 Page 的 Span 所保存的最大内存就是 1MB大对象分配流程
从 large span set 选择合适数量的页面组成 span,用来存储数据
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 申请spanmheap
当 mheap 没有足够的内存时,mheap 会向 OS 申请内存
Mheap 把 Span 组织成了树结构,而不是链表
然后把 Span 分配到 heapArena 进行管理,它包含地址映射和 span 是否包含指针等位图
为了更高效的分配、回收和再利用内存
内存回收
引用计数(Python,PHP,Swift)
- 对每一个对象维护一个引用计数,当引用该对象的对象被销毁的时候,引用计数减 1,当引用计数为 0 的时候,回收该对象
- 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收
- 缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价
标记-清除(Golang)
- 从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收
- 优点:解决引用计数的缺点
- 缺点:需要 STW(stop the word),即要暂停程序运行
分代收集(Java)
- 按照生命周期进行划分不同的代空间,生命周期长的放入老年代,短的放入新生代,新生代的回收频率高于老年代的频率
mspan
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 性能测试和统计。
包引用与依赖管理
Go 语言依赖管理的演进
回顾 GOPATH
- 通过环境变量设置系统级的 Go 语言类库目录
- GOPATH 的问题?
不同项目可能依赖不同版本
代码被 clone 以后需要设置 GOPATH 才能编译
vendor
- 自 1.6 版本,支持 vendor 目录,在每个 Go 语言项目中,创建一个名叫 vendor 的目录,并将依赖拷贝至该目录。
- Go 语言项目会自动将 vendor 目录作为自身的项目依赖路径
- 好处?
每个项目的 vendor目录是独立的,可以灵活的选择版本
Vendor 目录与源代码一起 check in 到 github,其他人 checkout 以后可直接编译
无需在编译期间下载依赖包,所有依赖都已经与源代码保存在一起
vendor 管理工具
通过声明式配置,实现 vendor 管理的自动化
- 在早期,Go 语言无自带依赖管理工具,社区方案鱼龙混杂比较出名的包括
Godeps, Glide
- Go 语言随后发布了自带的依赖管理工具 Gopkg
- 很快用新的工具 gomod 替换掉了 gopkg
切换 mod 开启模式:export GO111MODULE=on/off/auto
Go mod 相比之前的工具更灵活易用,以基本统一了 Go 语言依赖管理
思考:用依赖管理工具的目的?
- 版本管理
- 防篡改
Go mod 使用
创建项目
初始化 Go 模块
go mod init
下载依赖包
go mod download(下载的依赖包在$GOPATH/pkg,如果没有设置 GOPATH,则下载在项目根目录/pkg)
在源代码中使用某个依赖包,如 github.com/emicklei/go-restful添加缺少的依赖并为依赖包瘦身
go mod tidy
把 Go 依赖模块添加到 vendor 目录
go mod vendor
配置细节会被保存在项目根目录的 go.mod 中,可在 require 或者 replacement 中指定版本
GOPROXY 和 GOPRIVATE
GOPROXY
为拉取 Go 依赖设置代理export GOPROXY=https://goproxy.cn
在设置 GOPROXY 以后,默认所有依赖拉取都需要经过 proxy 连接 git repo,拉取代码,并做checksum 校验
某些私有代码仓库是 goproxy.cn 无法连接的,因此需要设置 GOPRIVATE 来声明私有代码仓库
1
2
3GOPRIVATE=*.corp.example.com
GOPROXY=proxy.example.com
GONOPROXY=myrepo.corp.example.com
Makefile
Go 语言项目多采用 Makefile 组织项目编译
1 | root: |
课后作业:动手编写一个 HTTP Server
课后练习 2.2
- 编写一个 HTTP 服务器
接收客户端请求并将请求的 Header 打印出来返回给客户端