100 Go Mistakes and How to Avoid Them - 2
100 Go Mistakes and How to Avoid Them - 2

100 Go Mistakes and How to Avoid Them - 2

Created
Feb 25, 2023 10:08 AM
Tags
golang
Text
💡
本文是 100 Go Mistakes读书笔记 的下篇

关于本书

7 Error management

7.1 #48: Panicking(什么是panic)

func main() { // 在defer中执行 recover 可以恢复panic,defer也会在周围出现panic的时候执行 defer func() { if r := recover(); r != nil { fmt.Println("recover", r) } }() f() } func f() { fmt.Println("a") panic("foo") fmt.Println("b") }

7.2 #49: Ignoring when to wrap an error(不明确何时包装一个error,fmt.Errorf用法)

在如下场景你可以选择包装一下error:
  1. 你需要在错误上增加一些上下文
  1. 你需要把错误封装在另一个错误内
notion image
notion image
GO支持如下几种包装error的方式:
  1. 自定义Error结构体
    1. type BarError struct { Err error } func (b BarError) Error() string { return "bar failed:" + b.Err.Error() } func test() { err := bar() if err != nil { return BarError{Err: err} } }
  1. 使用 fmt.Errorf 方法,⚠️ %w 和 %v 有不同的执行效果
    1. if err != nil { // 使用 %w 指令,会返回一个包装了err的错误,接收方可以从父error中获取到源error // 并根据判断错误是否为某一个错误类型 // sourceErr --wrap--> WrapErr(sourceErr) return fmt.Errorf("bar failed: %w", err) } if err != nil { // 使用 %v 指令,不会包装错误,会直接转化为另一个错误,源错误不再可用 // sourceErr --transform--> otherErr return fmt.Errorf("bar failed: %v", err) }
notion image
如果保留源错误的信息,会有潜在的耦合信息,因为接收方需要直接函数实现的细节,需要知道被包装的错误是什么类型,所以没有特殊需求在使用fmt.Errorf的时候使用%v指令。

7.3 #50: Checking an error type inaccurately(如何正确的判断Error类型-error.As用法)

func handler(w http.ResponseWriter, r *http.Request) { transactionID := r.URL.Query().Get("transaction") amount, err := getTransactionAmount(transactionID) if err != nil { if errors.As(err, &transientError{}) { http.Error(w, err.Error(), http.StatusServiceUnavailable) } else { http.Error(w, err.Error(), http.StatusBadRequest) } return } // Write response _ = amount } func getTransactionAmount(transactionID string) (float32, error) { // Check transaction ID validity amount, err := getTransactionAmountFromDB(transactionID) if err != nil { return 0, fmt.Errorf("failed to get transaction %s: %w", transactionID, err) } return amount, nil } func getTransactionAmountFromDB(transactionID string) (float32, error) { // ... var err error if err != nil { return 0, transientError{err: err} } // ... return 0, nil }
当使用%w指令或者结构体封装的方式包装一个错误的时候,可以使用 errors.As 递归判断错误类型,errors.As函数需要传递一个目标错误类型的指针。

7.4 #51: Checking an error value inaccurately(如何正确的处理Error的值-error.Is用法)

sentinel error 是指定义为全局变量的错误类型。命名规约是以Err开头加上类型
import "errors" var ErrFoo = errors.New("foo")
这种类型的错误,有时候是程序允许存在的错误,比如查询数据库返回没有查询到结果的sql.ErrNoRows和io.Reader读取返回io.EOF.他们传递的信息客户端可以接受,并认为是正常的情况。
在查询数据的场景,我们想判断错误的值是否等于sql.ErrNoRows这种sentinel error,使用 == 可能会出现问题,因为ErrNoRows可能已经被包装过。对于这种问题,可以直接使用 errors.Is 来判断,他可以帮你递归判断出正确的错误。
func main() { err := query() if err != nil { // if err == sql.ErrNoRows { if errors.Is(err, sql.ErrNoRows) { // ... } else { // ... } } } func query() error { return nil }

7.5 #52: Handling an error twice(多次处理错误)

notion image

7.6 #53: Not handling an error(没有处理error)

如果代码中没有处理一个函数返回的error,需要编写注释明确指明不处理error的原因
// At-most once delivery. // Hence, it's accepted to miss some of them in case of errors. _ = notify()

7.7 #54: Not handling defer errors(没有处理defer语句中的error)

defer语句经常会做一些收尾工作,关闭socket,释放锁等..,有些语句会返回错误,如果不处理这些错误就会导致错误信息丢失,造成资源泄露等问题。本节提出了一个通过给错误返回值命名,在defer的时候把错误值赋值给返回结果,向上传递错误。
notion image
// 一种解决方式是使用命名结果,把错误信息通过命名结果返回 func getBalance(db *sql.DB, clientID string) (balance float32, err error) { rows, err := db.Query(query, clientID) if err != nil { return 0, err } defer func() { closeErr := rows.Close() if err != nil { if closeErr != nil { // 如果db.Query语句也出现了执行错误,把close错误的信息打印出来 log.Printf("failed to close rows: %v", err) } return } // 当只要close的时候有错误,把closeErr赋值给err传递到上一层 err = closeErr }() // Use rows return 0, nil }

8 Concurrency: Foundations

本章都是在讲基础知识,就不详细讲解,相关内容自行查找

8.1 #55: Mixing up concurrency and parallelism(混淆并行和并发)

8.2 #56: Thinking concurrency is always faster(认为并发就一定快)

6.3 MPG 模型与并发调度单元
6.3 MPG 模型与并发调度单元 我们首先了解一下调度器的设计原则及一些基本概念来建立对调度器较为宏观的认识。 理解调度器涉及的主要概念包括以下三个: G: Goroutine,即我们在 Go 程序中使用 go 关键字创建的执行体; M: Machine,或 worker thread,即传统意义上进程的线程; P: Processor,即一种人为抽象的、用于执行 Go 代码被要求局部资源。只有当 M 与一个 P 关联后才能执行 Go 代码。除非 M 发生阻塞或在进行系统调用时间过长时,没有与之关联的 P。 P 的存在不太好理解,我们暂时先记住这个概念,之后再来回顾这个概念。 6.3.1 工作线程的暂止和复始 运行时调度器的任务是给不同的工作线程 (worker thread) 分发可供运行的(ready-to-run)Goroutine。 我们不妨设每个工作线程总是贪心的执行所有存在的 Goroutine,那么当运行进程中存在 n 个线程(M),且 每个 M 在某个时刻有且只能调度一个 G。根据抽屉原理,可以很容易的证明这两条性质: 性质 1:当用户态代码创建了 $p (p > n)$ 个 G 时,则必定存在 $p-n$ 个 G 尚未被 M 调度执行; 性质 2:当用户态代码创建的 q (q < n) 时,则必定存在 n-q 个 M 不存在正在调度的 G。 这两条性质分别决定了工作线程的 暂止(park) 和 复始(unpark) 。
给出了归并排序的例子,一个串行版本和一个并发版本
func sequentialMergesort(s []int) { if len(s) <= 1 { return } middle := len(s) / 2 sequentialMergesort(s[:middle]) sequentialMergesort(s[middle:]) merge(s, middle) } func parallelMergesortV1(s []int) { if len(s) <= 1 { return } middle := len(s) / 2 var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() parallelMergesortV1(s[:middle]) }() go func() { defer wg.Done() parallelMergesortV1(s[middle:]) }() wg.Wait() merge(s, middle) }
测试结果,串行版本执行效率更高(因为切分的粒度太小,导致协程切换带来的开销更大)
Benchmark_sequentialMergesort-4 2278993555 ns/op Benchmark_parallelMergesortV1-4 17525998709 ns/op
可以划分一个阈值,小于这个阈值使用串行归并排序,大于这个阈值就按照并行方式归并排序
const max = 2048 func parallelMergesortV2(s []int) { if len(s) <= 1 { return } if len(s) <= max { sequentialMergesort(s) } else { middle := len(s) / 2 var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() parallelMergesortV2(s[:middle]) }() go func() { defer wg.Done() parallelMergesortV2(s[middle:]) }() wg.Wait() merge(s, middle) } }
测试结果也很明显
Benchmark_sequentialMergesort-4 2278993555 ns/op Benchmark_parallelMergesortV1-4 17525998709 ns/op Benchmark_parallelMergesortV2-4 1313010260 ns/op

8.3 #57: Being puzzled about when to use channels or mutexes(不清楚什么时候用锁什么时候用通道)

锁用来保护临界区,通道就用来消息传递和事件通知
Go 语言并发编程、同步原语与锁
6.2 同步原语与锁 # 各位读者朋友,很高兴大家通过本博客学习 Go 语言,感谢一路相伴!《Go语言设计与实现》的纸质版图书已经上架京东,有需要的朋友请点击 链接 购买。 Go 语言作为一个原生支持用户态进程(Goroutine)的语言,当提到并发编程、多线程编程时,往往都离不开锁这一概念。锁是一种并发编程中的同步原语(Synchronization Primitives),它能保证多个 Goroutine 在访问同一片内存时不会出现竞争条件(Race condition)等问题。 本节会介绍 Go 语言中常见的同步原语 sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 和 sync.Cond 以及扩展原语 golang/sync/errgroup.Group、golang/sync/semaphore.Weighted 和 golang/sync/singleflight.Group 的实现原理,同时也会涉及互斥锁、信号量等并发编程中的常见概念。 6.2.1 基本原语 # Go 语言在 sync 包中提供了用于同步的一些基本原语,包括常见的 sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 和 sync.Cond: 图 6-5 基本同步原语 这些基本原语提供了较为基础的同步功能,但是它们是一种相对原始的同步机制,在多数情况下,我们都应该使用抽象层级更高的 Channel 实现同步。 Mutex # Go 语言的 sync.Mutex 由两个字段 state 和 sema 组成。其中 state 表示当前互斥锁的状态,而 sema 是用于控制锁状态的信号量。 type Mutex struct { state int32 sema uint32 } 上述两个加起来只占 8 字节空间的结构体表示了 Go 语言中的互斥锁。
什么是 CSP
Do not communicate by sharing memory; instead, share memory by communicating. 不要通过共享内存来通信,而要通过通信来实现内存共享。 这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。 CSP 经常被认为是 Go 在并发编程上成功的关键因素。CSP 全称是 “Communicating Sequential Processes”,这也是 Tony Hoare 在 1978 年发表在 ACM 的一篇论文。论文里指出一门编程语言应该重视 input 和 output 的原语,尤其是并发编程的代码。 在那篇文章发表的时代,人们正在研究模块化编程的思想,该不该用 goto 语句在当时是最激烈的议题。彼时,面向对象编程的思想正在崛起,几乎没什么人关心并发编程。 在文章中,CSP 也是一门自定义的编程语言,作者定义了输入输出语句,用于 processes 间的通信(communication)。processes 被认为是需要输入驱动,并且产生输出,供其他 processes 消费,processes 可以是进程、线程、甚至是代码块。输入命令是:!,用来向 processes 写入;输出是:?,用来从 processes 读出。这篇文章要讲的 channel 正是借鉴了这一设计。 Hoare 还提出了一个 -> 命令,如果 -> 左边的语句返回 false,那它右边的语句就不会执行。 通过这些输入输出命令,Hoare 证明了如果一门编程语言中把 processes 间的通信看得第一等重要,那么并发编程的问题就会变得简单。 Go 是第一个将 CSP 的这些思想引入,并且发扬光大的语言。仅管内存同步访问控制(原文是 memory access synchronization)在某些情况下大有用处,Go 里也有相应的 sync 包支持,但是这在大型程序很容易出错。

8.4 #58: Not understanding race problems(不理解race问题)

当两个或多个goroutine同时访问同一个内存位置并且至少一个正在写入时,就会发生数据竞争。
下面的例子就会出现数据竞争的问题
func listing1() { i := 0 go func() { i++ }() go func() { i++ }() }
解决方案一:调用 atomic.AddInt64 做加法
func listing2() { var i int64 go func() { atomic.AddInt64(&i, 1) }() go func() { atomic.AddInt64(&i, 1) }() }
解决方案二:用mutex来保护临界区
func listing3() { i := 0 mutex := sync.Mutex{} go func() { mutex.Lock() i++ mutex.Unlock() }() go func() { mutex.Lock() i++ mutex.Unlock() }() }
解决方案三:用channel传递数据
func listing4() { i := 0 ch := make(chan int) go func() { ch <- 1 }() go func() { ch <- 1 }() i += <-ch i += <-ch }
5.9 内存一致模型
5.9 内存一致模型 读者可能注意到了,无论是在谈论 Go 的运行时还是编译器,直到目前为止我们都有意无意的 尝试去回避 Go 语言的「内存模型」这个话题。 这有非常多的原因,作为本章的收尾,也是全书对 Go 语言同步原语与同步模式的一个总结, 我们最后来详细展开内存模型这个话题,解答读者心中的疑惑。 对为什么到目前为止我们都刻意的回避有关内存模型的内容作出一个相对完整的解释。 5.9.1 内存模型的重要性 内存一致模型,或称内存模型,是一份语言用户与语言自身、语言自身与所在的操作系统平台、 所在操作系统平台与硬件平台之间的契约。它定义了并行状态下拥有确定读取和写入的时序的条件, 并回答了一个共享变量是否具有足够的同步机制来保障一个线程的写入能否发生在另一个线程的读取之前这个问题。 在一份 Go 语言的程序被写成后,将经过编译器的转换与优化、所运行操作系统或虚拟机等动态优化器的优化,以及 CPU 硬件平台对指令流的优化才最终得以被执行。这个过程意味着,对于某一个变量的读取与写入操作,可能 被这个过程中任何一个中间步骤进行调整,从而偏离程序员在程序中所指定的原有顺序。 没有内存模型的保障,就无法正确的推演程序在最终被执行时的正确性。 内存模型的策略同样有着长期影响,并且直接决定了程序的可移植性和可维护性。 例如,过强的内存模型将约束硬件和编译器优化的空间,从而严重降低程序性能上限; 已经选择了强内存模型的硬件体系结构,无法在不破坏兼容性的情况下向更弱的内存模型进行迁移, 这种兼容性破坏所带来的代价就是要求其平台上的程序重新实现其源码。 这种横跨用户、软件与硬件三大领域的主题使得内存模型的设计愿景变得异常的困难,至今仍是一个开放的研究问题。因此在讨论 Go 语言的内存模型之前,我们还需要了解现有的内存模型、历史上软硬件平台之间形成契约的经验教训。 5.9.2 强序与弱序 令同步模型为对内存访问的一组约束,这些约束指定了需要如何以及何时完成同步,则当且仅当硬件与遵循该同步模型的所有软件顺序一致时,称该同步模型对于硬件而言满足弱序(Weak Ordering)。 5.9.3 免数据竞争范式 当一个程序在特定输入上具有顺序一致的执行顺序时,且其中两个相互冲突的操作同时执行,则称其为无数据竞争(Data-Race-Free, DRF)。 5.9.4 历史实践 C++ 是一个在内存模型方面实践优秀的一个例子。 线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。 x.store(1) x.load() G1 ---------+----------------+------> G2 -------------------+-------------> x.store(2) 在这种情况下线程 G1, G2 对 x 的两次写操作是原子的,且 x.store(1) 是严格的发生在 x.store(2) 之前,x.store(2) 严格的发生在 x.load() 之前。 值得一提的是,线性一致性对全局时钟的要求是难以实现的,这也是人们不断研究比这个一致性更弱条件下其他一致性的算法的原因。 顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致。 x.store(1) x.

8.5 #59: Not understanding the concurrency impacts of a workload type(明确并发对不同类型工作负载的影响CPU密集型还是IO密集型)

这节表达的观念是根据任务类型是CPU密集型还是IO密集型来决定并发数

8.6 #60: Misunderstanding Go contexts(错误理解Context)

9 Concurrency: Practice

9.1 #61: Propagating an inappropriate context(错误传递context)

notion image
为了能够异步执行写入消息队列的动作,又能继承来自request context中的值,我们可以编写自定义的context,只继承父context的值。
type detach struct { ctx context.Context } func (d detach) Deadline() (time.Time, bool) { return time.Time{}, false } func (d detach) Done() <-chan struct{} { return nil } func (d detach) Err() error { return nil } func (d detach) Value(key any) any { return d.ctx.Value(key) } func handler(w http.ResponseWriter, r *http.Request) { response, err := doSomeTask(r.Context(), r) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } go func() { err := publish(detach{ctx: r.Context()}, response) // Do something with err _ = err }() writeResponse(response) }

9.2 #62: Starting a goroutine without knowing when to stop it(管理好协程)

func main() { w := newWatcher() defer w.close() // Run the application } func newWatcher() watcher { w := watcher{} go w.watch() return w } type watcher struct { /* Some resources */ } func (w watcher) watch() {} func (w watcher) close() { // Close the resources }

9.3 #63: Not being careful with goroutines and loop variables(在range loop中执行协程,注意不要引用range生成的变量)

参考 #30
func listing1() { s := []int{1, 2, 3} // 协程打印的结果可能会出现相同的值 for _, i := range s { go func() { fmt.Print(i) }() } } // 解决方案一 func listing2() { s := []int{1, 2, 3} for _, i := range s { val := i go func() { fmt.Print(val) }() } } // 解决方案二 func listing3() { s := []int{1, 2, 3} for _, i := range s { go func(val int) { fmt.Print(val) }(i) } }

9.4 #64: Expecting deterministic behavior using select and channels(错误的以为 select 的执行结果是确定的)

  • select 中的 case 执行时机是随机的
notion image
解决方案可以是使用for-select嵌套:
notion image

9.5 #65: Not using notification channels(chan struct{} 可以用于通知事件)

// struct{} 不占用内存,经常用于通知场景 chan struct{}

9.6 #66: Not using nil channels(利用nil的channel读写都会block的特性)

notion image
  • GO对于close的channel仍然能够读取到0值,所以需要使用 v, open := <-ch 语法中的open来判断channel是否被close
notion image
解决方案:通过 v, open := <-ch 语法判断 chan 是否被close,避免读取0值,close之后的channel置为nil,避免for循环空转
notion image

9.7 #67: Being puzzled about channel size(不清楚channel的大小如何设置)

  • 无缓存的channel可以用于同步场景
  • 带有缓存的channel经常用于传递消息,控制协程数量等等

9.8 #68: Forgetting about possible side effects with string formatting(字符串格式化可能会带来的并发错误)

如下例子,在 age < 0 的场景下 UpdateAge 会执行到 fmt.Errorf 函数并打印结构体c,fmt.Errorf会直接调用c结构的 String 方法导致死锁。
type Customer struct { mutex sync.RWMutex id string age int } func (c *Customer) UpdateAge(age int) error { c.mutex.Lock() defer c.mutex.Unlock() if age < 0 { return fmt.Errorf("age should be positive for customer %v", c) } c.age = age return nil } func (c *Customer) String() string { c.mutex.RLock() defer c.mutex.RUnlock() return fmt.Sprintf("id %s, age %d", c.id, c.age) }
解决方案是缩小加锁的范围,只在对临界区的值进行修改的时候才加锁
func (c *Customer) UpdateAge2(age int) error { if age < 0 { return fmt.Errorf("age should be positive for customer %v", c) } c.mutex.Lock() defer c.mutex.Unlock() c.age = age return nil }

9.9 #69: Creating data races with append(使用append函数的时候出现data race)

  • 注意append操作不是线程安全的
notion image

9.10 #70: Using mutexes inaccurately with slices and maps(错误的给slice和map加锁)

当有2个协程同事调用 AddBalance 和 AverageBalance,还会出现data race,因为 AverageBalance 中对 c.balances 只拷贝引用,对slice同理。
type Cache struct { mu sync.RWMutex balances map[string]float64 } func (c *Cache) AddBalance(id string, balance float64) { c.mu.Lock() c.balances[id] = balance c.mu.Unlock() } func (c *Cache) AverageBalance() float64 { c.mu.RLock() balances := c.balances c.mu.RUnlock() sum := 0. for _, balance := range balances { sum += balance } return sum / float64(len(balances)) }

9.11 #71: Misusing sync.WaitGroup(错误使用 sync.WaitGroup)

因为 for 循环创建的3个协程是异步执行,可能执行到wg.Wait的时候有2个协程已经执行成功并退出了,所以v的值可能为2
func main() { wg := sync.WaitGroup{} var v uint64 for i := 0; i < 3; i++ { go func() { wg.Add(1) atomic.AddUint64(&v, 1) wg.Done() }() } wg.Wait() fmt.Println(v) }
解决方案一
func main() { wg := sync.WaitGroup{} var v uint64 wg.Add(3) for i := 0; i < 3; i++ { go func() { atomic.AddUint64(&v, 1) wg.Done() }() } wg.Wait() fmt.Println(v) }
解决方案二
func main() { wg := sync.WaitGroup{} var v uint64 for i := 0; i < 3; i++ { wg.Add(1) go func() { atomic.AddUint64(&v, 1) wg.Done() }() } wg.Wait() fmt.Println(v) }

9.12 #72: Forgetting about sync.Cond(别忘记条件变量sync.Cond)

func main() { type Donation struct { cond *sync.Cond balance int } donation := &Donation{ cond: sync.NewCond(&sync.Mutex{}), } // Listener goroutines f := func(goal int) { donation.cond.L.Lock() for donation.balance < goal { donation.cond.Wait() } fmt.Printf("%d$ goal reached\n", donation.balance) donation.cond.L.Unlock() } go f(10) go f(15) // Updater goroutine for { time.Sleep(time.Second) donation.cond.L.Lock() donation.balance++ donation.cond.L.Unlock() donation.cond.Broadcast() } }
使用Cond可以避免CPU空转的情况。

9.13 #73: Not using errgroup(使用 errgroup 采集多个协程执行结果)

notion image
使用 golang.org/x/sync/errgroup 采集多个协程的错误, 但是 errorgroup限制比较多,需要函数签名符合func() error {}
notion image
k8s 中提供了集合多种错误的功能,可以参考 k8s.io/apimachinery/pkg/util/errors/errors.go:35

9.14 #74: Copying a sync type(同步类型的值不能被拷贝)

notion image
以下类型均不能被拷贝
  • sync.Cond
  • sync.Map
  • sync.Mutex
  • sync.RWMutex
  • sync.Once
  • sync.Pool
  • sync.WaitGroup

10 The standard library

10.1 #75: Providing a wrong time duration(记住标准库的时间单位)

  • 设置时间的时候还是建议使用time.Duration类型,不要直接用数字
func listing1() { ticker := time.NewTicker(1000) for { select { case <-ticker.C: fmt.Println("tick") } } } func listing2() { ticker := time.NewTicker(time.Microsecond) for { select { case <-ticker.C: fmt.Println("tick") } } }

10.2 #76: time.After and memory leaks (使用 time.After 可能会导致内存溢出)

每次调用 time.After 时使用大约200字节的内存。但是只有在指定的时间到达的时候才会GC,如果在1小时内,频繁的调用 time.After 会导致内存爆炸。
func consumer1(ch <-chan Event) { for { select { case event := <-ch: handle(event) case <-time.After(time.Hour): log.Println("warning: no messages received") } } } // 解决方案一 func consumer2(ch <-chan Event) { for { ctx, cancel := context.WithTimeout(context.Background(), time.Hour) select { case event := <-ch: cancel() handle(event) case <-ctx.Done(): log.Println("warning: no messages received") } } } // 解决方案二,创建一次 Timer 每次循环都会重置时间 func consumer3(ch <-chan Event) { timerDuration := 1 * time.Hour timer := time.NewTimer(timerDuration) for { timer.Reset(timerDuration) select { case event := <-ch: handle(event) case <-timer.C: log.Println("warning: no messages received") } } }

10.3 #77: Common JSON-handling mistakes(常见的 JSON 处理错误)

Unexpected behavior due to type embedding
如果类型实现了 Marshaler 接口,在调用 json.Marshal 的时候,会直接调用 MarshalJSON 方法。
type Marshaler interface { MarshalJSON() ([]byte, error) }
// Event1 内嵌了 time.Time 类型,会自动实现 time.Time 的所有方法 // time.Time 实现了 Marshaler 接口,所以 Marshal Event1 的时候会直接调用 // time.Time 的 MarshalJSON 方法 type Event1 struct { ID int time.Time } func listing1() error { event := Event1{ ID: 1234, Time: time.Now(), } b, err := json.Marshal(event) if err != nil { return err } // "2021-05-18T21:15:08.381652+02:00" fmt.Println(string(b)) return nil } // 解决方案一:不使用内嵌类型,给 time.Time 定义一个成员名字 type Event2 struct { ID int Time time.Time } func listing2() error { event := Event2{ ID: 1234, Time: time.Now(), } b, err := json.Marshal(event) if err != nil { return err } fmt.Println(string(b)) return nil } type Event3 struct { ID int time.Time } // 解决方案二:给 Event 类型实现 MarshalJSON 方法 func (e Event3) MarshalJSON() ([]byte, error) { return json.Marshal( struct { ID int Time time.Time }{ ID: e.ID, Time: e.Time, }, ) } func listing3() error { event := Event3{ ID: 1234, Time: time.Now(), } b, err := json.Marshal(event) if err != nil { return err } fmt.Println(string(b)) return nil }
JSON and the monotonic clock
now := time.Now() encodeNow, _ := json.Marshal(now) decodeNow := time.Time{} json.Unmarshal(encodeNow, &decodeNow) fmt.Println(now) // 2018-10-26 16:04:55.230121766 +0800 CST m=+0.000520419 fmt.Println(decodeNow) // 2018-10-26 16:04:55.230121766 +0800 CST
可以看到,经过JSON转码之后,Time结构体会被表示成不带Monotonic Clock的字符串,丢失了Monotonic Clock信息,而将字符串转码回Time结构时,自然也就和转码之前的不一样了。同样的情况,也发生在数据库存储中,存储到数据库里的Time结构和从数据库取出来的也是不一样的。
  • 可以使用 Time.Equal() 对比时间是否相关,这里判断相等不会包含单调时间
  • 使用 Time.Truncate() 函数排除单调时钟
    • notion image
Map of any
当把json解析到 map[string]any 类型,会出现数字类型解析错误的情况
func listing1() error { b := getMessage() var m map[string]any err := json.Unmarshal(b, &m) if err != nil { return err } return nil } func getMessage() []byte { return nil } // getMessage 函数返回如下json,解析后 id 类型为 float64 // { // "id": 32, // "name": "foo" // }
解决方案: 使用 json.Decoder 来代替 json.Unmarshal 方法
decoder := json.NewDecoder(bytes.NewReader(getMessage())) decoder.UseNumber() var m map[string]any decoder.Decode(&personFromJSON)
这种方法首先创建了一个 jsonDecoder,然后调用了 UseNumber 方法,从文档中可以知道,使用 UseNumber 方法后,json 包会将数字转换成一个内置的 Number 类型(而不是 float64),这个 Number 类型提供了转换为 int64、float64 等多个方法。

10.4 #78: Common SQL mistakes(常见的 SQL 错误)

Forgetting that sql.Open doesn’t necessarily establish connections to a database
💡
sql.Open 可能只是验证其参数而不创建与数据库的连接
如果我们要确保使用 sql.Open 的函数也保证底层数据库可访问,我们应该使用Ping方法:
func listing1() error { db, err := sql.Open("mysql", dsn) if err != nil { return err } // ping强制建立连接,确保数据源名称有效并且数据库可访问 if err := db.Ping(); err != nil { return err } _ = db return nil }
Forgetting about connections pooling
sql.Open 返回一个sql.DB结构。这个结构不表示单个数据库连接,而是表示连接池。
notion image
notion image
Not using prepared statements
使用 Prepare 方法创建 prepared statements ,提升查询性能,避免重复解析 SQL 带来的开销
func listing1(db *sql.DB, id string) error { stmt, err := db.Prepare("SELECT * FROM ORDER WHERE ID = ?") if err != nil { return err } rows, err := stmt.Query(id) if err != nil { return err } _ = rows return nil }
Mishandling null values
func listing3(db *sql.DB, id string) error { rows, err := db.Query("SELECT DEP, AGE FROM EMP WHERE ID = ?", id) if err != nil { return err } // Defer closing rows var ( // 使用 ql.NullString 类型,可以匹配在查询遇到NULL值的情况 department sql.NullString age int ) for rows.Next() { err := rows.Scan(&department, &age) if err != nil { return err } // ... } return nil }
Not handling row iteration errors
for rows .Next() {} 循环可能会因为没有查询到值或者遇到错误退出,所以退出后要调用 rows.Err() 看是否是正常退出
func get2(ctx context.Context, db *sql.DB, id string) (string, int, error) { rows, err := db.QueryContext(ctx, "SELECT DEP, AGE FROM EMP WHERE ID = ?", id) if err != nil { return "", 0, err } defer func() { err := rows.Close() if err != nil { log.Printf("failed to close rows: %v\n", err) } }() var ( department string age int ) for rows.Next() { err := rows.Scan(&department, &age) if err != nil { return "", 0, err } } if err := rows.Err(); err != nil { return "", 0, err } return department, age, nil }

10.5 #79: Not closing transient resources(没有及时关闭临时资源)

10.5.1 HTTP body
notion image
需要注意的点:
  • 如果你没有读取Respose.Body的内容,那么默认的 http transport 会直接关闭连接
  • 如果你读取了Body的内容,下次连接可以直接复用
在高并发的场景下,建议你使用长连接,可以调用 io.Copy(io.Discard, resp.Body) 读取Body的内容。
func (h handler) getStatusCode2(body io.Reader) (int, error) { resp, err := h.client.Post(h.url, "application/json", body) if err != nil { return 0, err } defer func() { err := resp.Body.Close() if err != nil { log.Printf("failed to close response: %v\n", err) } }() _, _ = io.Copy(io.Discard, resp.Body) return resp.StatusCode, nil }
10.5.2 sql.Rows
notion image
10.5.3 os.File
写入操作是异步的,所以对写入的文件进行close操作,可能会遇到在buffer内的数据没有写到磁盘的错误,所以在close的时候如果遇到错误要及时上报。
func writeToFile1(filename string, content []byte) (err error) { f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, os.ModeAppend) if err != nil { return err } defer func() { closeErr := f.Close() if err == nil { err = closeErr } }() _, err = f.Write(content) return }
但是如果使用了Sync调用可以同步的把数据写入磁盘,所以调用Close方法的时候也可以不用在意错误,因为数据已经正常写入。
notion image

10.6 #80: Forgetting the return statement after replying to an HTTP request(在发送完响应后忘记及时返回http handler函数)

func handler(w http.ResponseWriter, req *http.Request) { err := foo(req) if err != nil { http.Error(w, "foo", http.StatusInternalServerError) // 记得这里就及时返回 } _, _ = w.Write([]byte("all good")) w.WriteHeader(http.StatusCreated) }

10.7 #81: Using the default HTTP client and server(生产环境不要直接使用默认的Http客户端和服务端)

http.Client
直接使用标准库的里的client和server可能会存在问题,没有完备的配置项,可能会导致生产问题,首先就是直接使用 http.Client 发送请求,没有配置超时时间,这在生产环境是绝对不允许的。
client := &http.Client{} resp, err := client.Get("https://golang.org/")
notion image
notion image
关于默认HTTP Client 还要记住他是如何处理连接的。缺省情况下,HTTP Client 会维持一个连接池。客户端在请求的时候可以重用连接(你也可以通过设置 htthttp.Transport.DisableKeepAlivestrue 来禁用)。
有一个额外的超时来指定空闲连接在连接池中保留多长时间:http.Transfer.IdleConnTimeout
这值的默认值为90s,意味着这个连接可以在90s内都能给其他请求复用。
http.Transport.MaxIdleConns 用于配置连接池的最大数量,这个值默认为100。其中 http.Transport.MaxIdleConnsPerHost 用于限制每个host的连接池数量,默认为2,表示如果我们对同一个host触发100次请求,只用2个请求保留在连接池中,如果我们再触发100次请求,那么我们还要再重新创建98次新的连接,这个配置对请求响应的影响也极大。
💡
突然想到自己之前手写的压测脚本,qps一直上不去,应该就是直接裸用 http.Client的锅
所以我们要明确一些参数的含义,帮助我们生产上线
http.Server
notion image
notion image
服务端同样可以保持长连接,配置长连接的最长超时时间 IdleTimeout , 如果 http.Server.IdleTimeout 没有配置就会和 http.Server .ReadTimeout 保持一致,如果都没设置,就会在结束请求后就立马结束连接。
s := &http.Server{ // ... IdleTimeout: time.Second, }
💡
在生产环境,有时候为了避免服务断流,会把keep-alive给关闭

11 Testing

11.1 #82: Not categorizing tests(没有对测试类型分类)

不同类型的测试:单元测试、集成测试、E2E测试各自的执行时间和个数都有很大差距,如下的测试金字塔显示了测试的占比,各种测试的执行时间也呈反比。这一节介绍了一些方法提醒开发者,不同类型的测试,需要明确区分,并且最好独立执行,可以提升开发效率。
notion image
  • 使用环境变量分类
    • func TestInsert2(t *testing.T) { if os.Getenv("INTEGRATION") != "true" { t.Skip("skipping integration test") } // ...
  • Short mode,执行go test的时候加上 -short 选项,可以选择性的执行耗时短的测试
    • func TestLongRunning(t *testing.T) { if testing.Short() { t.Skip("skipping long-running test") } // ... } // % go test -short -v . // === RUN TestLongRunning // foo_test.go:9: skipping long-running test // --- SKIP: TestLongRunning (0.00s) // PASS // ok foo 0.174s

11.2 #83: Not enabling the -race flag(没开启 -race flag来检验并发冲突)

开启 -race 之后性能和内存占用都有影响,所以生产环境不建议开启(废话。。)
go test -race ./...

11.3 #84: Not using test execution modes(没有使用test执行模式:并行 or shuffle )

  • The parallel flag 开启并行模式执行测试
    • // 并行执行测试,并行最大个数 16 go test -parallel 16 .
  • The -shuffle flag 开启 shuffle 模式执行测试
    • go test -shuffle=on -v
      notion image

11.4 #85: Not using table-driven tests(使用 table-driven 的模式编写测试)

11.5 #86: Sleeping in unit tests(在测试代码中包含sleep逻辑,导致出现flaky测试)

依赖等待一段时间直到特定的逻辑执行完成的方式会因为时间设置问题导致出现不稳定的测试,这种情况,可以通过指定多次重试或者使用同步的方法来避免直接调用 time.sleep 来等待。
可以使用 testify 或者 Gomega 中的 Eventually 方法

11.6 #87: Not dealing with the time API efficiently(没有正确的处理时间问题)

一些函数处理逻辑和超时相关的,如果在编写测试的时候依赖实时的时间,但测试可能没有立即执行,导致代码没有按照期望的行为运行。比如下面的例子,想要过滤掉超时的事件,如果代码执行到 cache.GetAll() 的时候耗费了一些时间,原本希望缓存2个事件,却一个也没缓存。
func TestCache_TrimOlderThan(t *testing.T) { events := []Event{ {Timestamp: time.Now().Add(-20 * time.Millisecond)}, {Timestamp: time.Now().Add(-10 * time.Millisecond)}, {Timestamp: time.Now().Add(10 * time.Millisecond)}, } cache := &Cache{} cache.Add(events) cache.TrimOlderThan(15 * time.Millisecond) got := cache.GetAll() expected := 2 if len(got) != expected { t.Fatalf("expected %d, got %d", expected, len(got)) } }
这类测试可以使用确切的时间来代替实时获取时间
func TestCache_TrimOlderThan(t *testing.T) { events := []Event{ {Timestamp: parseTime(t, "2020-01-01T12:00:00.04Z")}, {Timestamp: parseTime(t, "2020-01-01T12:00:00.05Z")}, {Timestamp: parseTime(t, "2020-01-01T12:00:00.06Z")}, } cache := &Cache{now: func() time.Time { return parseTime(t, "2020-01-01T12:00:00.06Z") }} cache.Add(events) cache.TrimOlderThan(15 * time.Millisecond) // ... }

11.7 #88: Not using testing utility packages(没有使用内置的工具包,比如httptest、iotest)

略,此处就不讲解包的使用了,知道在mock http 和 io 操作的时候可以使用这2个内置的工具包

11.8 #89: Writing inaccurate benchmarks(编写了不正确的BencMark)

Not resetting or pausing the timer
在具体测量某一段函数性能时,一些SetUp操作可能比较耗时会影响测量结果。调用 ResetTimer 函数可以重置一些Bench数据。
notion image
每次迭代都会有一些耗时动作,可以调用 b.StopTimer()b.StartTimer() 暂停和启动BenchMark计量
notion image
Making wrong assumptions about micro-benchmarks
在一些做一些 micro-benchmark 的时候,如果不多次进行基准测试很容易就得出错误的结论
func BenchmarkAtomicStoreInt32(b *testing.B) { var v int32 for i := 0; i < b.N; i++ { atomic.StoreInt32(&v, 1) } } func BenchmarkAtomicStoreInt64(b *testing.B) { var v int64 for i := 0; i < b.N; i++ { atomic.StoreInt64(&v, 1) } }
下图是执行2次BenchMark的结果
notion image
解决方案:对于 micro-benchmark 需要进行多次 BenchMark,可以利用 benchstat 统计BenchMark的结果进行均值计算。
notion image
Not being careful about compiler optimizations
golang的内联优化会导致我们的测试函数被优化,执行结果不符合我们的预期(效果更好)
cmd/compile: SSA compiler removes code in benchmarks
Updated Oct 25, 2019
notion image
💡
go test增加-gcflags="-m"参数,-m表示打印编译器做出的优化决定。可以看到是否做了内联优化,如果有 inlining call to xxx 函数 的字样就是做了优化
  • 执行go test的时候,增加-gcfloags="-l"参数,-l表示禁用编译器的内联优化。
  • 使用//go:noinline 编译器指令(compiler directive),编译器在编译时会识别到这个指令,不做内联优化。
    • //go:noinline func add(a int, b int) int { return a + b }
Being fooled by the observer effect
在物理学中,观测者效应是观测行为对被观测系统的扰动。这种影响也可以在基准测试中看到,并可能导致对结果的错误假设。
// 我们想实现一个接收int64元素矩阵的函数。这个矩阵有固定数量的512列,我们想计算前八列的总和 func calculateSum512(s [][512]int64) int64 { var sum int64 for i := 0; i < len(s); i++ { for j := 0; j < 8; j++ { sum += s[i][j] } } return sum } // 为了优化,我们还想确定更改列数是否有影响,因此我们还实现了第二个包含513列的函数。 func calculateSum513(s [][513]int64) int64 { var sum int64 for i := 0; i < len(s); i++ { for j := 0; j < 8; j++ { sum += s[i][j] } } return sum }
BenchMark后的结果很出乎意料,513*513矩阵的计算更加高效,原因和CPU Cache命中有很高的关系。
因为BenchMark事先创建了一个矩阵并重复计算,再加上缓存命中的影响更加加重了测试差距,解决方案就是每次迭代都创建新的矩阵
notion image
关于 513*513矩阵的计算更加高效 的原理可以参考 #91 Not understanding CPU caches
notion image

11.9 #90: Not exploring all the Go testing features(没有利用 Go testing的特性)

  • 使用 coverprofile flag 查看代码覆盖率
    • // 获得测试覆盖率文件 go test -coverprofile=coverage.out ./... // 可视化展示覆盖率 go tool cover -html=coverage.out
  • 把测试文件可以放在_test包中, 测试文件可以放在不同的包中
    • package counter import "sync/atomic" var count uint64 func Inc() uint64 { atomic.AddUint64(&count, 1) return count }
      package counter_test import ( "testing" counter "myapp/counter" ) func TestCount(t *testing.T) { if counter.Inc() != 1 { t.Errorf("expected 1") } }
  • Setup and teardown 搭建和卸载环境
    • 调用 t.Cleanup 注册一个闭包函数做清理工作,可以在测试结束后被调用来清理环境。
      func TestMySQLIntegration(t *testing.T) { // ... db := createConnection(t, "tcp(localhost:3306)/db") // ... } func createConnection(t *testing.T, dsn string) *sql.DB { db, err := sql.Open("mysql", dsn) if err != nil { t.FailNow() } t.Cleanup( func() { _ = db.Close() }) return db }

12 Optimizations

12.1 #91: Not understanding CPU caches(利用缓存加速代码执行速度)

关于CPU Cache部分不属于GO专属的知识,这里就不细讲了
💡
cache line 是固定大小的连续内存段,通常为64字节(8个int64变量)。
notion image
Slice of structs vs. struct of slices
notion image
Cache placement policy(关于#89出现问题的解释)
// 我们想实现一个接收int64元素矩阵的函数。这个矩阵有固定数量的512列,我们想计算前八列的总和 func calculateSum512(s [][512]int64) int64 { var sum int64 for i := 0; i < len(s); i++ { for j := 0; j < 8; j++ { sum += s[i][j] } } return sum } // 为了优化,我们还想确定更改列数是否有影响,因此我们还实现了第二个包含513列的函数。 func calculateSum513(s [][513]int64) int64 { var sum int64 for i := 0; i < len(s); i++ { for j := 0; j < 8; j++ { sum += s[i][j] } } return sum }
notion image
notion image
notion image
现在回到真实场景,L1D的大小为32KB,一行Cache line大小为64byte,这样就有512(32KB/64byte)行Cache line,按照8路组相连的规则,会被分为64(512行/8)组。
1000*512的int64矩阵内存也按照Cache Line大小划分,因为一行Cache line大小为64byte,一行可以包含8(64byte/8)个元素,512个元素需要64(512/8)行Cache Line大小的内存。
可以算出矩阵一行的内存块正好被分到L1D的64个组中,这样迭代完第一行之后,后续的行数都会分别被缓存到对应的组里,当迭代次数超过8次后就会出现缓存冲突的问题,然后就会一直导致Cache Miss。

12.2 #92: Writing concurrent code that leads to false sharing(编写并发代码的时候,注意避免伪共享)

创建2个协程,并发对一个结构体的内的2个成员写入,看起来都是在更改独立的内存应该不会出现同步写,但是因为Result1的2个成员会分配到一行Cache Line中。
假设每个协程都在独立的CPU上运算,L1D会加载同一行内存到Cache Line中,CPU运算时候也是先写回到Cache Line中,所以每个CPU在运算时候为了保证内存一致性,在写入时是使用同步写的模式
type Input struct { a int64 b int64 } type Result1 struct { sumA int64 sumB int64 } func count1(inputs []Input) Result1 { wg := sync.WaitGroup{} wg.Add(2) result := Result1{} go func() { for i := 0; i < len(inputs); i++ { result.sumA += inputs[i].a } wg.Done() }() go func() { for i := 0; i < len(inputs); i++ { result.sumB += inputs[i].b } wg.Done() }() wg.Wait() return result }
notion image
解决方案:填充结构体让2个成员处于不同的Cache Line中,计算性能明显提升
notion image

12.3 #93: Not taking into account instruction-level parallelism(没有考虑到CPU指令并行优化)

本节讲解了CPU指令优化和分支预测的知识,通过增加指令的并发数和缩短指令步数来提升执行效率,感兴趣的可以自行查阅书籍,这里就不做记录。
💡
关于这部分涉及到计算机体系结构,感兴趣的自行查阅相关知识,个人认为如果考虑到这层优化程度可能就不一定要用GO来实现功能了

12.4 #94: Not being aware of data alignment(没有数据对齐的意识)

GO对以下类型有默认的数据对齐行为(如下类型值的地址等于值大小的倍数):
  • byte, uint8, int8: 1 byte
  • uint16, int16: 2 bytes
  • uint32, int32, float32: 4 bytes
  • uint64, int64, float64, complex64: 8 bytes
  • complex128: 16 bytes
notion image
type Foo1 struct { b1 byte i int64 b2 byte } func sum1(foos []Foo1) int64 { var s int64 for i := 0; i < len(foos); i++ { s += foos[i].i } return s } type Foo2 struct { i int64 b1 byte b2 byte } func sum2(foos []Foo2) int64 { var s int64 for i := 0; i < len(foos); i++ { s += foos[i].i } return s }
notion image
💡
其实还是内存压缩带来的缓存命中率的提高

12.5 #95: Not understanding stack vs. heap(不理解堆栈和逃逸分析)

都是老生常谈了,就不细说了。
// -gcflags "-m=2" 可以帮助查看是否有逃逸 $ go build -gcflags "-m=2" ... ./main.go:12:2: z escapes to heap:
以下是变量可以逃逸到堆的情况:
  • 全局变量,因为多个goroutine可以访问它们。
  • 发送到通道的指针:
    • type Foo struct{ s string } ch := make(chan *Foo, 1) foo := &Foo{s: "x"} ch <- foo
  • 被发送到通道的指针引用的变量
    • type Foo struct{ s *string } ch := make(chan Foo, 1) s := "x" bar := Foo{s: &s} ch <- bar
  • 如果局部变量太大而无法放入栈
  • 如果局部变量的大小未知。例如,s := make([]int, 10) 可能不会逃逸到堆,但s := make([]int, n) 会,因为它的大小基于一个变量。
  • 如果切片的底层数据因为调用append被重新分配
  • 函数的形参是any类型,那么形参也会逃逸

12.6 #96: Not knowing how to reduce allocations(减少内存分配)

本书已经讲解了很多种优化内存的方式:
  • #39 使用strings.Builder拼接字符串
  • #40 避免不必要的string和[]byte类型转换
  • #21和#27给slice和map预先分配内存
  • #94 用更好的内存分配方式减少内存占用
本节会提出新的几种减少内存分配的方式:
Compiler optimizations
notion image
sync.Pool
Go 语言从 1.3 版本开始提供了对象重用的机制,即 sync.Poolsync.Pool 是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。sync.Pool 用于存储那些被分配了但是没有被使用,而未来可能会使用的值。这样就可以不用再次经过内存分配,可直接复用已有对象,减轻 GC 的压力,从而提升系统的性能。
sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。
notion image

12.7 #97: Not relying on inlining(忘记依赖 inlining 编译优化)

内联有两个主要好处。首先,它消除了函数调用的开销(即使自Go 1.17和基于寄存器的调用约定以来开销已经减轻)。其次,它允许编译器进行进一步的优化。例如,在内联函数后,编译器可以决定把一些逃逸的变量放在堆上。
notion image
如果内联是交由编译器实现,那么开发者应该如何利用内联来优化代码呢?这里我们可以利用 Mid-stack inlining 技术,他是关于把一个调用其他函数的函数做内联。
notion image
由于中间堆栈内联的支持,作为 Go 开发者,我们现在可以使用快速路径内联来优化应用程序。让我们看一个具体的例子sync.Mutex 实现:
notion image
由于这一变化,Lock方法可以内联。好处是,尚未锁定的互斥锁现在不会产生调用函数的开销(速度提高了约5%)。互斥锁已经锁定时的慢速路径没有改变。以前它需要一个函数调用来执行这个逻辑;它仍然是一个函数调用这次是lockSlow。

12.8 #98: Not using Go diagnostics tooling(使用Go诊断工具)

这一部分内容还是比较多的,就不适合整理归纳了,建议直接阅读原文。
x-devonthink-item://3C9E4AE0-AECC-4ED8-ADDA-624D653B338D?page=356

12.9 #99: Not understanding how the GC works(不理解GC是如何工作的)

Go 语言垃圾收集器的实现原理
7.2 垃圾收集器 # 各位读者朋友,很高兴大家通过本博客学习 Go 语言,感谢一路相伴!《Go语言设计与实现》的纸质版图书已经上架京东,有需要的朋友请点击 链接 购买。 我们在上一节中详细介绍了 Go 语言内存分配器的设计与实现原理,分析了运行时内存管理组件之间的关系以及不同类型对象的分配,然而编程语言的内存管理系统除了负责堆内存的分配之外,它还需要负责回收不再使用的对象和内存空间,这部分职责是由本节即将介绍的垃圾收集器完成的。 在几乎所有的现代编程语言中,垃圾收集器都是一个复杂的系统,为了在不影响用户程序的情况下回收废弃的内存需要付出非常多的努力,Java 的垃圾收集机制是一个很好的例子,Java 8 中包含线性、并发、并行标记清除和 G1 四个垃圾收集器1,想要理解它们的工作原理和实现细节需要花费很多的精力。 本节会详细介绍 Go 语言运行时系统中垃圾收集器的设计与实现原理,我们不仅会讨论常见的垃圾收集机制、从 Go 语言的 v1.0 版本开始分析其演进过程,还会深入源代码分析垃圾收集器的工作原理。 7.2.1 设计原理 # 今天的编程语言通常会使用手动和自动两种方式管理内存,C、C++ 以及 Rust 等编程语言使用手动的方式管理内存2,工程师需要主动申请或者释放内存;而 Python、Ruby、Java 和 Go 等语言使用自动的内存管理系统,一般都是垃圾收集机制,不过 Objective-C 却选择了自动引用计数3,虽然引用计数也是自动的内存管理机制,但是我们在这里不会详细介绍它,本节的重点还是垃圾收集。 相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。 图 7-21 内存管理的组件 在上图中,用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。我们在这一节中将详细介绍 Go 语言垃圾收集中涉及的关键理论,帮助我们更好地理解本节剩下的内容。 标记清除 # 标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段: 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象; 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表; 如下图所示,内存空间中包含多个对象,我们从根对象出发依次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,即 A、C 和 D 三个对象,剩余的 B、E 和 F 三个对象因为从根节点不可达,所以会被当做垃圾:
垃圾回收的优化问题
GC 的优化问题 # 13. GC 关注的指标有哪些? # Go 的 GC 被设计为成比例触发、大部分工作与赋值器并发、不分代、无内存移动且会主动向操作系统归还申请的内存。因此最主要关注的、能够影响赋值器的性能指标有: CPU 利用率:回收算法会在多大程度上拖慢程序?有时候,这个是通过回收占用的 CPU 时间与其它 CPU 时间的百分比来描述的。 GC 停顿时间:回收器会造成多长时间的停顿?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。 GC 停顿频率:回收器造成的停顿频率是怎样的?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。 GC 可扩展性:当堆内存变大时,垃圾回收器的性能如何?但大部分的程序可能并不一定关心这个问题。 14. Go 的 GC 如何调优? # Go 的 GC 被设计为极致简洁,与较为成熟的 Java GC 的数十个可控参数相比,严格意义上来讲,Go 可供用户调整的参数只有 GOGC 环境变量。当我们谈论 GC 调优时,通常是指减少用户代码对 GC 产生的压力,这一方面包含了减少用户代码分配内存的数量(即对程序的代码行为进行调优),另一方面包含了最小化 Go 的 GC 对 CPU 的使用率(即调整 GOGC)。 GC 的调优是在特定场景下产生的,并非所有程序都需要针对 GC 进行调优。只有那些对执行延迟非常敏感、 当 GC 的开销成为程序性能瓶颈的程序,才需要针对 GC 进行性能调优,几乎不存在于实际开发中 99% 的情况。 除此之外,Go 的 GC 也仍然有一定的可改进的空间,也有部分 GC 造成的问题,目前仍属于 Open Problem。
比较重要的问题是,每次GC是何时发生?与Java等其他语言相比,Go 配置仍然相当简单。它依赖于一个环境变量:GOGC。该变量定义自上次GC触发另一个GC之前的堆增长百分比:默认值为100%。
假设一次GC刚刚被触发,当前堆大小为128 MB。如果 GOGC=100,则当堆大小达到256 MB时触发下一次GC。
默认情况下,每次堆大小翻倍时都会执行GC。此外,如果在过去2分钟内没有执行GC,Go将强制运行一次。
notion image
notion image
如果我们知道堆峰值,我们可以使用一个强制分配大量内存的技巧来提高堆的稳定性(降低GC频率)。例如,我们可以使用main. go中的全局变量强制分配1 GB内存
var min = make([]byte, 1_000_000_000) // 1 GB
如果GOGC保持在100, Go只会在堆达到2 GB时触发GC。这应该会减少所有用户连接时触发的GC周期数,从而减少对平均延迟的影响。
我们可能认为当堆大小减小时,这种方法将浪费大量内存。但事实并非如此。在大多数操作系统上,分配这个 min 变量不会使我们的应用程序消耗1 GB内存。调用make会导致系统调用mmap(),而mmap调用会懒分配内存。

12.10 #100: Not understanding the impacts of running Go in Docker and Kubernetes(GO应用在k8s环境下运行会遇到错误分配GOMAXPROCS的场景)

如果k8s运行的环境不是安全容器,进程读到的全局CPU核数和宿主机一致,会导致错误配置了GOMAXPROCS的值和宿主机一样,GO默认开启的协程个数就会远超容器实际运行环境提供的CPU个数,导致协程频繁的调度切换程序运行时间被拖慢。
解决方案:使用 automaxprocs 包来配置GOMAXPROCS
automaxprocs
uber-goUpdated Mar 26, 2023
💡
但是现在大部分生产环境的容器运行时都是安全容器,隔离性更强不会出现错误配置GOMAXPROCS的情况
 

Loading Comments...