最近整理一下Go学习中的一些要点,包括Go性能优化及底层源码要点,作此笔记,巩固基础。
一、并发
1、Go语言的goroutine类似于线程和协程的综合体,能最大限度提升执行效率,发挥多核处理能力;
2、通常情况下,用多进程来实现分布式负载均衡,减轻单进程垃圾回收压力;用多线程(LWP)抢夺更多的处理器资源;用协程来提高处理器时间片利用率;
3、相比较系统默认MB级别的线程栈,goroutine自定义栈初始仅需2KB,所以才能创建成千上万的并发任务。自定义栈采用按需分配策略,在需要时进行扩容,最大能到GB规模;
4、Go在运行时可能会创建很多线程,但任何时候仅有限的几个线程参与并发任务执行。该量默认与处理器核数相等,可用runtime.GOMAXPROCS函数或者环境变量修改;
5、通道:
1)、通道(channel)相当于一个并发安全的队列;
2)、goroutine leak是指goroutine处于发送或者接受阻塞状态,但一直未被唤醒,垃圾回收器并不收集此类资源,导致它们会在队列里长期休眠,形成资源泄露;
6、同步:
1)、通道并不是用来取代锁的,它们有各自不同的使用场景。通道倾向于解决逻辑层次的并发处理架构,而锁则用来保护局部范围的数据安全;
2)、将Mutex作为匿名字段时,相关方法必须实现为pointer-receiver模式,否则会因为复制导致锁机制失效;
3)、应将Mutex锁粒度控制在最小范围内,及早释放;
7、建议:
1)、对性能要求较高时,应避免使用defer Unlock;
2)、读写并发时,用RWMutex性能会更好一些;
3)、对单个数据读写保护,可尝试用原子操作;
4)、执行严格测试,尽可能打开数据竞争检查;
二、测试/监控/性能调优
Go语言除了高性能、跨平台、[模块化的设计思想、大道至简的设计原则]、语言级的并发支持等优点以外,还有一项比较突出的优点,那就是提供了丰富的工具链,为代码测试、性能监控及调优提供了很好的支持。
1、测试
1)、可测试性也是代码质量的一个体现;
2)、单元测试可通过测试结果为代码审查(code review)提供筛选依据,避免因烦琐导致代码审查沦为形式主义;
3)、代码覆盖率可通过例如:go test -cover -covermode count -coverprofile cover.out 命令来实现,并且可以在浏览器上查看结果;
4)、通过编写测试代码,使用命令:go test -bench . 进行基准测试,可以有针对性地测试出模块某部分的性能瓶颈;
2、监控
1)、出现性能瓶颈的地方,往往都是可预知的,可以有针对性地写一些基准测试代码,使用相应的工具进行分析检测;
2)、通过编写测试代码,使用命令(通过man go-testflag查看更多参数):
go test -run NONE -bench . -memprofile mem.out -cpuprofile cpu.out -blockprofile block.out net/http
来测试并监控性能指标(CPU&MEM&BLOCK),可以有针对性地测试出某些模块或者方法的性能瓶颈.
注意,这里生成profile性能监控结果文件应当使用 go tool pprof 文件名 来打开查看;
3)、使用Go自带的性能监控工具 – pprof(由Perl语言编写的):
a、runtime/pprof :引入后调用在代码中使用相关runtime命令进行局部代码监控,并输出监控结果,通过go tool pprof命令进行查看;
b、net/http/pprof:对runtime/pprof的HTTP封装, 通过 import _ “net/http/pprof” 引入即可;
4)、GC监控:运行时加上环境变量GODEBUG gctrace=1
3、其他工具:
go-torch – 功能类似于pprof,但是生成更直观的性能火炬图
goreporter – 生成Go代码质量评估报告
dingo-hunter – 用于在Go程序中找出deadlocks的静态分析器
flen – 在Go程序包中获取函数长度信息
go/ast – Package ast声明了关于Go程序包用于表示语法树的类型
gocyclo – 在Go源代码中测算cyclomatic函数复杂性
Go Meta Linter – 同时Go lint工具且工具的输出标准化
go vet – 检测Go源代码并报告可疑的构造
ineffassign – 在Go代码中检测无效赋值
safesql – Golang静态分析工具,防止SQL注入
4、其他一些设计/编码时的优化:
http://johng.cn/go-optimize-brief/
1)、内存优化:
a)、小对象合并成结构体一次分配,减少内存分配次数;
b)、缓存区内容一次分配足够大小空间,并适当复用;
c)、slice和map采make创建时,预估大小指定容量;
d)、长调用栈避免申请较多的临时对象;
e)、避免频繁创建临时对象;
2)、并发优化:
a)、高并发的任务处理使用goroutine池;
b)、避免高并发调用同步系统接口;
c)、高并发时避免共享对象互斥;
3)、其他优化:
a)、避免使用CGO或者减少CGO调用次数;
b)、减少[]byte与string之间转换,尽量采用[]byte来字符串处理;
c)、字符串的拼接优先考虑bytes.Buffer;
三、内存分配
1、内置运行时的编程语言通常会抛弃传统的内存分配方式,改由自主管理。这样可以完成类似预分配、内存池等操作,以避免系统调用带来的性能问题。当然,有一个重要原因是为了更好地配合垃圾回收;
2、Go内存分配的基本策略:
1)、每次从操作系统申请一大块内存(比如1MB),以减少系统调用;
2)、将申请到的大块内存按照特定大小预先切分成小块,构成链表;
3)、为对象分配内存时,只需从大小合适的链表提取一小块即可;
4)、回收对象内存时,将该小块内存重新归还到原链表,以便复用;
5)、如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销;
3、内存分配器只管理内存块,并不关心对象状态。且它不会主动回收内存,垃圾回收器在完成清理操作后,触发内存分配器的回收操作;
4、内存分配器将其管理的内存块分为两种:
1)、span: 由多个地址连续的页(page)组成的大块内存;
2)、object:将span按照特定大小切分成多个小块,每个小块可存储一个对象;
5、内存分配器按照页数来区分不同大小的span。比如,以页数为单位将span存放到管理数组(数组长度固定60)中,需要时就以页数为索引进行查找;
6、内存分配器会尝试将多个微小对象组合到一个object块内,以节省内存;
7、内存分配器由三种组件组成:
1)、cache: 每个运行期工作线程都会绑定一个cache,用于无锁object分配(请求内存块规格检索在此完成);
2)、central:为所有cache提供切分好的后备span资源;
3)、heap: 管理闲置span,需要时向操作系统申请新内存;
8、分配流程:
1)、计算待分配对象对应的规格(size class);
2)、从cache.alloc数组找到规格相同的span;
3)、从span.freelist链表提取可用object;
4)、如span.freelist为空,从central获取新span;
5)、如central.nonempty为空,从heap.free/freelarge(32k作为界限)获取,并切分为object链表;
6)、如heap没有大小合适的闲置的span,向操作系统申请新内存块;
9、释放流程:
1)、将标记为可回收的object交还给所属span.freelist;
2)、该span被放回central,可供任意cache重新获取使用;
3)、如span已收回全部object,则将其交还给heap,以便重新切分复用;
4)、定期扫描heap里长时间闲置的span,释放其占用的内存;
5)、以上不包括大对象,它直接从heap分配和回收;
10、通常情况下,编译器有责任尽可能使用寄存器和栈来存储对象,这有助于提升性能,减少垃圾回收器的压力;
11、Go编译器支持逃逸分析(escape analysis),它会在编译期通过构建调用图来分析局部变量是否会被外部引用,从而决定是否可直接分配在栈/堆上;
12、内存回收:
1)、之所以说“回收”而不是“释放”,是因为整个内存分配器的核心思想是内存复用;
2)、基于效率考虑,回收操作自然不会直接盯着单个对象,而是以span为基本单位。通过比对bitmap里的扫描标记,逐步将object收归原span,最终上交central或heap复用;
3)、无论是向操作系统申请内存,还是清理回收内存,只要往heap里放span,都会尝试合并左右相邻的闲置span,以构成更大的自由块;
13、内存释放:
1)、在运行时入口函数main.main里,会专门启动一个监控任务sysmon,它每个一段时间(约5分钟)就会检查heap里的闲置内存块;
2)、遍历free、freelarge里所有的span,如闲置时间超过阈值,则释放其关联的物理内存;
3)、所谓物理内存释放,其实是调用madvise告知操作系统(*nix),某段内存暂不使用,建议内核收回对应物理内存;
四、垃圾回收
1、Go语言的垃圾回收策略为标记-清除(mark and sweep);
2、垃圾回收器是Go一直在改进最努力的部分,所有的变化都是为了缩短STW(Stop-The-World)时间,提高程序实时性;
3、从Go 1.5开始增加三色并发标记检测,此处的并发,是指垃圾回收和用户逻辑并发执行;
4、三色标记基本原理:
白色:待回收对象
灰色:处理中对象
黑色:活跃的对象
1)、起初所有对象都是白色(虽然是白色,但是未标记,不能直接回收);
2)、扫描找出所有可达对象,标记为灰色,放入待处理队列(gcWork高性能缓存队列);
3)、从队列提起灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色;
4)、写屏障见识对象内存修改,重新标色或放回队列;
5)、当完成全部扫描和标记工作后,剩余的不是白色就是黑色,分别代表待回收和活跃对象,清理操作只需将白色对象内存收回即可;
5、虽然标记是并发执行的,但STW执行垃圾回收时,仍然会暂停所有的用户逻辑线程(虽1.7版本已大幅优化GC性能,1.8甚至量坏情况下GC为100us,但暂停时间还是取决于临时对象的个数,临时对象数量越多,暂停时间可能越长,并消耗CPU);
五、并发调度
1、内置运行时,在进程和线程的基础上做更高层次的抽象是现代语言最流行的做法;
2、并发调度模型相关组件:
1)、Processor(简称P),其作用类似于CPU核,用来控制可同时并发执行的任务数量;
2)、Goroutine(简称G),进程内的一切都是在以G方式运行,包括运行时相关服务,以及main.main入口函数;
3)、系统线程(简称M),它和P绑定,以调度循环方式不停执行G并发任务。M通过修改寄存器(CPU指令存储区),将执行栈指向G自带的栈内存,并在此空间内分配堆栈帧,执行任务函数;
3、尽管P/M构成执行组合体,但两者数量并非一一对应。通常情况下,P的数量相对恒定,默认与CPU核数相同,但也可能更多或者更少(runtime.GOMAXPROCS),而M则是由调度器按需创建的。
4、虽然可在运行期间用runtime.GOMAXPROCS函数修改P的数量,但需付出极大代价:stopTheWorld && startTheWorld;
5、系统监控线程(sysmon)对于内存分配、垃圾回收、并发调度非常重要,主要作用如下:
1)、释放闲置超过5分钟的span物理内存块;
2)、如果超过2分钟没有垃圾回收,则强制执行;
3)、将长时间未处理的netpoll结果添加到任务队列;
4)、向长时间运行的G任务发出抢占调度;
5)、收回因syscall而长时间阻塞的P;
6、抢占调度只是在目标G上设置一个抢占标志,当改任务调用某个函数时,被编译器安插的指令就会检查这个标志,从而决定是否会暂停当前任务;
7、stopTheWorld:用户逻辑必须暂停在一个安全点上,否则会引发很多意外问题。因此,stopTheWorld同样是通过“通知”机制,向所有正在运行的G任务发出抢占调度,使其暂停;
8、defer延迟调用:延迟调用远不是一个CALL指令那么简单,会涉及很多内容。诸如对象分配、缓存,以及多次函数调用。在某些性能要求比较高的场合,应该避免使用defer;
有疑问加站长微信联系(非本文作者)