Go进阶
文章目录
一、Goroutien
1.1 Goroutien简介
Goroutine 是 Go 语言中实现并发的核心概念,它是一种轻量级的线程,由 Go 语言运行时(runtime)管理。以下是 Goroutine 的一些关键特点:
- 轻量级:Goroutine 的内存开销非常小,大约只需 2KB,这使得单个程序中可以同时运行成千上万个 Goroutine。
- 高效的调度:Goroutine 由 Go 语言的运行时进行调度,而非操作系统。这种调度方式使得 Goroutine 切换的成本远低于传统线程切换的成本。
- 简化的并发:在 Go 语言中,启动一个 Goroutine 非常简单,只需在函数调用前加上
go
关键字。这种简洁的语法大大降低了并发编程的复杂性。 - 动态堆栈:Goroutine 的堆栈大小不是固定的,它可以根据需要动态地增长和收缩,这使得在同一程序中可以创建成千上万个 Goroutine。
- M:N 调度模型:Goroutine 的调度是基于 M:N 模型,即多个 Goroutine(M)可以被调度到少量的操作系统线程(N)上运行。
- 协作式抢占:在早期版本的 Go 语言中,Goroutine 采用的是完全协作式调度,意味着一个 Goroutine 需要显式地放弃控制权,其他 Goroutine 才有机会运行。在现代的 Go 语言版本中,引入了基于信号的抢占式调度,这使得长时间运行的 Goroutine 可以被强制暂停,以让出处理器时间给其他 Goroutine。
- 网络轮询器:Go 语言的运行时还包含一个网络轮询器(network poller),它能够高效地处理 I/O 操作,使得在进行网络通信或文件操作时 Goroutine 能够被挂起,从而释放 CPU 资源给其他 Goroutine 使用。
- 主 Goroutine 结束导致子 Goroutine 终止:在主 Goroutine 结束之后其他的所有 Goroutine 都会直接退出。
1.2 Channel
Goroutine 通过通信共享内存的机制主要基于 Go 语言的 CSP(Communicating Sequential Processes)并发模型。这个模型的核心思想是“不要通过共享内存来通信,而应该通过通信来共享内存”。
跟操作系统的进程通过通道进行通信大同小异。
1.3 并发安全lock
对共享的数据加锁。
1.4 WaitGroup
还是跟操作系统一样。
sync.WaitGroup
是 Go 语言标准库 sync
包中提供的一个同步原语,用于等待一组并发操作完成。它常用于主 Goroutine 需要等待多个子 Goroutine 完成工作的场景。
WaitGroup
提供了三个方法:
-
Add(delta int):增加 WaitGroup 的计数器,通常在启动 Goroutine 之前调用,
delta
表示要增加的计数。如果delta
大于 0,计数器增加delta
;如果delta
小于 0,计数器减少绝对值的delta
;如果delta
等于 0,Add
调用将不会有任何效果。 -
Done():减少 WaitGroup 的计数器,通常在 Goroutine 完成工作后调用。
-
Wait():阻塞调用的 Goroutine,直到 WaitGroup 的计数器为 0。这意味着主 Goroutine 会在所有子 Goroutine 调用了
Done()
之后才继续执行。
使用 WaitGroup
的典型模式如下:
var wg sync.WaitGroup
// 启动 Goroutine 之前
wg.Add(1) // 假设我们有一个子 Goroutine 要启动
go someFunction()
// 启动另一个 Goroutine
wg.Add(1)
go anotherFunction()
// 等待所有 Goroutine 完成
wg.Wait()
// 这里的代码会在所有 Goroutine 完成后执行
在子 Goroutine 中,当任务完成后,需要调用 Done()
来减少计数器:
func someFunction() {
defer wg.Done() // 确保在函数返回时调用 Done
// ... 执行任务 ...
}
func anotherFunction() {
defer wg.Done() // 确保在函数返回时调用 Done
// ... 执行任务 ...
}
使用 defer
确保即使函数中发生 panic,Done
也会被调用,从而避免主 Goroutine 永久阻塞。
WaitGroup
是一个非常有用的工具,它帮助开发者管理并发任务,确保所有任务都完成后再继续执行后续操作。
二、依赖管理
2.1 Go Module
本质上和 Maven 区别不大。
-
模块化:Go Module 允许开发者在项目中独立管理依赖包,不再依赖于
GOPATH
。每个模块都有自己的go.mod
文件,记录模块名称和依赖包的版本信息。 -
版本管理:Go Module 自动管理依赖包的版本,支持版本回滚、升级和降级等操作。
-
go.mod
文件:go mod init <module-name>
命令会生成一个go.mod
文件,记录模块名称和依赖包的版本信息。 -
go.sum
文件:与go.mod
一起,Go Module 还会生成一个go.sum
文件,记录依赖包的精确版本和校验和(checksum),确保包的内容不被篡改或修改。
2.2 go mod
的常见方法
go mod tidy
:整理模块,添加丢失的依赖和移除未使用的依赖。go mod graph
:显示模块之间的依赖关系图,用于分析依赖冲突和包的依赖结构。go mod vendor
:生成vendor
目录,将所有依赖包的源代码下载到项目中的vendor
文件夹中,常用于需要将依赖包一起提交的场景。go mod verify
:验证依赖是否正确。go mod why
:解释为什么需要某个包或模块。
三、测试
3.1 单元测试
- 在企业中,一般的测试覆盖率:50%-60%
- 重点业务例如资金型业务:80%+
Go 语言的单元测试是使用 testing
包来实现的,这是一个内置的包,专门用于编写和运行测试代码。单元测试是验证代码的最小可测试单元(通常是函数)的正确性的过程。以下是 Go 语言单元测试的一些基本概念和步骤:
- 测试文件
测试文件通常以 _test.go
结尾,例如,如果你有一个 mymath.go
文件,那么它的测试文件应该是 mymath_test.go
。
- 测试函数
在测试文件中,测试函数必须以 Test
开头,并且接受一个 *testing.T
类型的参数。例如:
func TestMyFunction(t *testing.T) {
// 测试代码
}
- 断言
testing
包提供了一些方法来验证测试结果是否符合预期,这些方法会记录测试是否通过,并在失败时提供详细的错误信息。常用的断言方法包括:
t.Errorf
:用于报告错误,并终止当前测试函数。t.FailNow
:用于立即失败并停止当前测试函数。t.Fatalf
:用于报告错误,并终止当前测试函数,同时终止整个测试。t.Skip
:用于跳过当前测试函数。
- 运行测试
可以使用 go test
命令来运行测试。例如:
go test mymath_test.go
或者,如果你想运行特定包中的所有测试:
go test mypackage
- 测试覆盖率
Go 还支持测试覆盖率报告,可以使用以下命令生成:
go test -cover mypackage
- 表驱动测试
Go 支持表驱动测试,这是一种编写测试的方式,通过将多个测试用例组织在一个循环中来减少重复代码。例如:
func TestMyFunction(t *testing.T) {
tests := []struct {
input int
expected int
}{
{5, 10},
{10, 20},
// 更多测试用例...
}
for _, tt := range tests {
result := myFunction(tt.input)
assert.Equal(t,result,tt.expected)
}
}
- 并行测试
Go 允许并行运行测试以提高效率。可以通过设置 -parallel
标志来指定并行度:
go test -parallel 10 mypackage
3.2 Mock和Stub
Mock 和打桩测试是软件测试中常用的技术,它们用于模拟外部依赖或系统组件的行为,以便在隔离的环境中测试代码。
Mock 是一种测试技术,用于模拟外部依赖或系统组件(如数据库、网络服务、文件系统等)的行为。在单元测试中,Mock 对象被用来替代真实的依赖项,以便可以控制这些依赖项的行为和返回值,从而专注于测试当前单元的功能。
Mock 的主要目的包括:
- 隔离测试:确保测试只关注当前的代码单元,不受外部系统的影响。
- 控制行为:可以预设 Mock 对象的行为和返回值,使得测试更加可预测。
- 提高测试速度:通过避免真实的数据库操作或网络请求,可以加快测试执行速度。
- 测试异常情况:可以模拟异常情况,如网络超时、数据库错误等,以测试代码的健壮性。
打桩测试是一种测试方法,其中打桩(Stub)是被测试模块的简化版本,它提供了必要的接口和行为,但不包含实际的业务逻辑。打桩用于模拟外部系统或模块的行为,以便在测试中替代真实的依赖。
打桩的主要特点包括:
- 简化接口:打桩提供了与真实依赖相同的接口,但实现更简单,只包含测试所需的基本功能。
- 返回固定值:打桩可以返回固定的值或响应,以便测试特定的场景。
- 模拟延迟:在网络服务的测试中,打桩可以模拟网络延迟或超时。
- 减少依赖:通过使用打桩,可以减少对外部系统的依赖,使得测试更加独立和可靠。
虽然 Mock 和 Stub 都是用于模拟外部依赖的技术,但它们之间有一些区别:
- Mock:通常更复杂,可以模拟更丰富的行为和交互,包括验证方法调用的次数、顺序和参数等。
- Stub:通常更简单,只提供必要的接口和固定的行为,不包含复杂的交互验证。
在实际应用中,Mock 和 Stub 可以结合使用,以实现更全面的测试覆盖。例如,可以使用 Mock 来验证与外部系统的交互,同时使用 Stub 来提供必要的接口和行为。
3.3 基准测试
基准测试(benchmarking)是一种测量和评估软件性能指标的活动。你可以在某个时候通过基准测试建立一个已知的性能水平(称为基准线),当系统的软硬件环境发生变化之后再进行一次基准测试以确定那些变化对性能的影响。这是基准测试最常见的用途。其他用途包括测定某种负载水平下的性能极限、管理系统或环境的变化、发现可能导致性能问题的条件,等等。
基准测试的具体做法是:在系统上运行一系列测试程序并把性能计数器的结果保存起来。这些结构称为“性能指标”。性能指标通常都保存或归档,并在系统环境的描述中进行注解。比如说,有经验的数据库专业人员会把基准测试的结果以及当时的系统配置和环境一起存入他们的档案。这可以让他们对系统过去和现在的性能表现进行对照比较,确认系统或环境的所有变化。
- 基准测试以Benchmark开头,参数是testing.B。
- 用b中的N值反复递增循环测试(对一个测试用例的默认测试时间是1秒,当测试用例函数返回时还不到1秒,那么testing.B中的N值将按1、2、5、 10、 20、 50 .递增,并以递增后的值重新进行用例函数测试。)
- Resttimer重置计时器,我们再reset之前做了init或其他的准备操作,这些操作不应该作为基准测试的范围;
- runparallel是多协程并发测试;执行2个基准测试,发现代码在并发情况下存在劣化,主要原因是rand为了保证全局的随机性和并发安全,持有了一把全局锁。
四、性能调优
4.1 常见提高性能的手段
4.1.1 slice 预分配内存
4.1.2 copy 代替 re-slice
4.1.3 map 预分配内存
4.1.4 使用 string.builder
4.1.5 使用 map+空结构体 代替 +set
4.1.6 使用atomic包
4.2 使用性能分析工具 pprof
性能调优原则
- 要依靠数据不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过度优化
- 不要过早优化
回头试试吧,没时间了
4.3 企业性能调优
- 业务服务优化
- 基础库优化
- Go语言优化
五、Go 内存管理与编译器优化
5.1 自动内存管理
5.1.1 简介
-
动态内存
- 程序在运行时根据需求动态分配的内存:malloc()
-
自动内存管理(垃圾回收) :由程序语言的运行时系统管理动态内存
- 避免手动内存管理,专注于实现业务逻辑
- 保证内存使用的正确性和安全性: double-free problem, use-after-free problem
-
三个任务
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
-
Mutator:业务线程,分配新对象,修改对象指向关系
-
Collector: GC 线程,找到存活对象,回收死亡对象的内存空间
- Serial GC:只有一个collector
- Parallel GC: 支持多个collectors同时回收的GC算法
- Concurrent GC: mutator(s) 和 collector(s) 可以同时执行
5.1.2 对象被回收的策略
根据对象的生命周期使用不同的策略:
5.1.3 分代 GC
5.1.4 引用计数
5.2 Go 内存管理与优化
5.2.1 Go 内存分配 —— 分块
1. 使用 mmap()
申请大块内存
mmap()
是一个系统调用,用于在用户空间与内核空间之间映射文件或设备到内存。在这里,我们使用mmap()
来申请一大块内存,例如 4 MB。- 这种方法的好处是可以避免频繁的系统调用,因为之后的内存分配可以从这块大内存中进行,而不是每次都向操作系统请求小块内存。
2. 内存划分为大块(mspan)
- 将申请到的内存划分为较大的块,称为 mspan,例如每块 8 KB。
- 这种划分使得内存管理更加高效,因为可以更好地控制内存的使用和分配。
3. 进一步划分为小块
- 每个 mspan 可以进一步划分为更小的块,用于实际的对象分配。这些小块的大小可以根据预期的对象大小进行调整。
- 这种策略可以减少内存碎片,提高内存利用率。
4. 分配方式分类
- noscan mspan:
- 这种类型的 mspan 用于分配不包含指针的对象。由于这些对象不持有任何引用,垃圾收集器(GC)在运行时不需要扫描这些对象的内存。
- 这意味着分配和释放这些对象时,GC 的负担会减轻,从而提高性能。
- scan mspan:
- 这种类型的 mspan 用于分配包含指针的对象。GC 需要扫描这些对象,以确定它们所引用的其他对象是否仍然存活。
- 由于需要扫描,GC 的实现必须相对复杂,以确保内存的有效管理。
5. 对象分配策略
- 在实际进行对象分配时,系统会根据对象的大小选择最合适的块(无论是来自 noscan mspan 还是 scan mspan)。
- 这样的策略使得内存管理更加灵活,同时也能够有效地应对不同类型对象的内存需求。
通过这种方式,内存的分配和管理可以变得更加高效和优化,尤其是在需要频繁分配和释放对象的情况下。
5.2.2 Go 内存分配 —— 缓存
TCMalloc 是 Google 开发的一种高效的内存分配器,专为多线程环境优化。其核心思想是通过线程缓存(Thread Caching)来减少锁竞争,提高内存分配的速度。
1. 每个线程的缓存(mcache)
- 每个线程(通常标记为
p
)都有一个自己的 mcache,用于快速分配对象。这个设计减少了多线程环境中对全局锁的需求,从而提高了性能。 - mcache 是一个缓存结构,专门用于存储为特定线程分配的对象,以便快速重复使用。
2. mcache 管理 mspan
- mcache 管理一组 mspan,每个 mspan 是一个较大的内存块,进一步划分为小块以供对象分配。
- 当线程需要分配对象时,它首先检查自己的 mcache。如果 mcache 中有可用的对象,则直接从中分配,避免了更复杂的全局分配过程。
3. 向 mcentral 申请 mspan
- 当 mcache 中的 mspan 所有对象都被分配完毕,线程会向 mcentral 申请新的 mspan。mcentral 是一个全局的内存管理器,负责管理多个线程的 mcache。
- 这个申请过程是为了确保线程能够继续快速分配新的对象,而不必等待其他线程释放对象。
4. mspan 的缓存机制
- 如果一个 mspan 中的所有对象都被分配并使用完毕,TCMalloc 不会立即释放这块内存并归还给操作系统。
- 相反,这个 mspan 会被缓存到 mcentral 中,以便将来可能的再次分配。这种设计可以减少频繁的内存申请和释放操作,从而提高性能。
- 这样,mcentral 可以快速响应不同线程的内存需求,而无需每次都请求操作系统分配新的内存。
通过上述机制,TCMalloc 在多线程环境中实现了高效的内存管理。每个线程的 mcache 提供了快速的对象分配,减少了锁竞争;而 mcentral 则通过缓存机制优化了内存的使用,确保了系统的高效性和响应性。这种设计理念使得 TCMalloc 特别适合在需要高性能内存分配的应用场景中使用。
5.2.3 字节的方案 Balanced GC
使用pprof发现:Go中,小对象的分配超级超级多。
1. 对象本质与小对象分配
- 在 Go 中,内存管理的一个挑战是处理多个小对象的分配。传统的分配方式可能会导致频繁的内存申请和释放,从而引起内存碎片和延迟释放。
- GAB(Garbage Allocated Blocks)是一种对象分配策略,旨在将多个小对象的分配合并为一次的对象分配,以提高效率。
2. 内存延迟释放的问题
- GAB 的对象分配方式可能导致内存被延迟释放。即使某些对象不再被使用,它们仍可能占用内存,直到垃圾收集器运行并释放它们。
- 这种延迟释放可能会导致内存的使用效率降低,尤其是在创建和销毁大量小对象时。
3. 解决方案:移动存活的对象
- 为了优化内存管理,Balanced GCGAB 提出了一种机制:当 GAB 的总大小超过某个设定的阈值时,系统会对存活的对象进行移动。
- 具体来说,将 GAB 中仍然存活的对象复制到一个新的、分配的 GAB 中。这意味着:
- 只保留那些仍然需要的对象,释放掉不再需要的对象的内存。
- 原先的 GAB 可以被标记为可释放的,从而避免内存泄漏。
4. 使用复制垃圾收集算法
- Balanced GCGAB 的本质在于使用 复制垃圾收集(copying GC) 的算法来管理小对象。复制垃圾收集是一种高效的垃圾收集策略,通过复制存活对象到新的内存空间,从而实现内存的回收和整理。
- 这种算法的优势在于:
- 它能够有效地整理内存,减少碎片,提高内存的使用效率。
- 通过移动存活对象,能够快速释放不再使用的内存,减少延迟释放的情况。
5.3 编译器与静态分析
5.3.1 编译器简介
编译器分为前端和后端,我们重点优化的是后端,数据流和控制流的分析是优化的基础
5.3.2 静态分析
- 静态分析:不执行程序代码,推导程序的行为,分析程序的性质。
- 控制流(Control flow):程序执行的流程
- 数据流(Data flow):数据在控制流上的传递
- 通过分析控制流和数据流,我们可以知道更多关于程序的性质(properties)
- 根据这些性质优化代码
5.4 Go 编译器优化
5.4.1 函数内联
5.4.2 逃逸分析
5.4.3 beast mode
内联(Inlining):
- 将小函数的代码直接插入到调用点,以减少函数调用的开销。这可以减少栈帧的创建和销毁,提高执行速度。
逃逸分析(Escape Analysis):
- 编译器分析变量的作用域,决定它们是否可以在栈上分配,而不是在堆上分配。这可以减少垃圾回收的负担,提高性能。
死代码消除(Dead Code Elimination):
- 移除不再使用的代码,减少不必要的代码路径,从而减小可执行文件的大小和提高执行效率。
循环优化:
- 对循环进行优化,例如循环展开、移动不变代码等,以减少循环体内的重复计算。