本文是 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:
- 你需要在错误上增加一些上下文
- 你需要把错误封装在另一个错误内
GO支持如下几种包装error的方式:
- 自定义Error结构体
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} } }
- 使用 fmt.Errorf 方法,⚠️ %w 和 %v 有不同的执行效果
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) }
如果保留源错误的信息,会有潜在的耦合信息,因为接收方需要直接函数实现的细节,需要知道被包装的错误是什么类型,所以没有特殊需求在使用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(多次处理错误)
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的时候把错误值赋值给返回结果,向上传递错误。
// 一种解决方式是使用命名结果,把错误信息通过命名结果返回 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(认为并发就一定快)
给出了归并排序的例子,一个串行版本和一个并发版本
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(不清楚什么时候用锁什么时候用通道)
锁用来保护临界区,通道就用来消息传递和事件通知
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 }
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)
为了能够异步执行写入消息队列的动作,又能继承来自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 执行时机是随机的
解决方案可以是使用for-select嵌套:
9.5 #65: Not using notification channels(chan struct{} 可以用于通知事件)
// struct{} 不占用内存,经常用于通知场景 chan struct{}
9.6 #66: Not using nil channels(利用nil的channel读写都会block的特性)
- GO对于close的channel仍然能够读取到0值,所以需要使用
v, open := <-ch
语法中的open来判断channel是否被close
解决方案:通过
v, open := <-ch
语法判断 chan 是否被close,避免读取0值,close之后的channel置为nil,避免for循环空转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操作不是线程安全的
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 采集多个协程执行结果)
使用 golang.org/x/sync/errgroup 采集多个协程的错误, 但是 errorgroup限制比较多,需要函数签名符合
func() error {}
k8s 中提供了集合多种错误的功能,可以参考 k8s.io/apimachinery/pkg/util/errors/errors.go:35
9.14 #74: Copying a sync type(同步类型的值不能被拷贝)
以下类型均不能被拷贝
- 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() 函数排除单调时钟
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结构。这个结构不表示单个数据库连接,而是表示连接池。
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
需要注意的点:
- 如果你没有读取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
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方法的时候也可以不用在意错误,因为数据已经正常写入。
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/")
关于默认HTTP Client 还要记住他是如何处理连接的。缺省情况下,HTTP Client 会维持一个连接池。客户端在请求的时候可以重用连接(你也可以通过设置 htt
http.Transport.DisableKeepAlives
为 true
来禁用)。有一个额外的超时来指定空闲连接在连接池中保留多长时间:
http.Transfer.IdleConnTimeout
这值的默认值为90s,意味着这个连接可以在90s内都能给其他请求复用。
http.Transport.MaxIdleConns
用于配置连接池的最大数量,这个值默认为100。其中 http.Transport.MaxIdleConnsPerHost
用于限制每个host的连接池数量,默认为2,表示如果我们对同一个host触发100次请求,只用2个请求保留在连接池中,如果我们再触发100次请求,那么我们还要再重新创建98次新的连接,这个配置对请求响应的影响也极大。突然想到自己之前手写的压测脚本,qps一直上不去,应该就是直接裸用 http.Client的锅
所以我们要明确一些参数的含义,帮助我们生产上线
http.Server
服务端同样可以保持长连接,配置长连接的最长超时时间
IdleTimeout
, 如果 http.Server.IdleTimeout
没有配置就会和 http.Server .ReadTimeout
保持一致,如果都没设置,就会在结束请求后就立马结束连接。s := &http.Server{ // ... IdleTimeout: time.Second, }
在生产环境,有时候为了避免服务断流,会把keep-alive给关闭
11 Testing
11.1 #82: Not categorizing tests(没有对测试类型分类)
不同类型的测试:单元测试、集成测试、E2E测试各自的执行时间和个数都有很大差距,如下的测试金字塔显示了测试的占比,各种测试的执行时间也呈反比。这一节介绍了一些方法提醒开发者,不同类型的测试,需要明确区分,并且最好独立执行,可以提升开发效率。
- 使用
go:build
tag来给测试归类,用法可参考:
//go:build integration // +build integration package db import ( "os" "testing" ) func TestInsert1(t *testing.T) { // ... }
- 使用环境变量分类
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
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数据。每次迭代都会有一些耗时动作,可以调用
b.StopTimer()
和 b.StartTimer()
暂停和启动BenchMark计量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的结果
解决方案:对于 micro-benchmark 需要进行多次 BenchMark,可以利用
benchstat
统计BenchMark的结果进行均值计算。Not being careful about compiler optimizations
golang的内联优化会导致我们的测试函数被优化,执行结果不符合我们的预期(效果更好)
cmd/compile: SSA compiler removes code in benchmarks
Updated Oct 25, 2019
给
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事先创建了一个矩阵并重复计算,再加上缓存命中的影响更加加重了测试差距,解决方案就是每次迭代都创建新的矩阵
关于 513*513矩阵的计算更加高效 的原理可以参考 #91 Not understanding CPU caches
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变量)。
Slice of structs vs. struct of slices
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 }
现在回到真实场景,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 }
解决方案:填充结构体让2个成员处于不同的Cache Line中,计算性能明显提升
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
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 }
其实还是内存压缩带来的缓存命中率的提高
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
sync.Pool
Go 语言从 1.3 版本开始提供了对象重用的机制,即
sync.Pool
。sync.Pool
是可伸缩的,同时也是并发安全的,其大小仅受限于内存的大小。sync.Pool
用于存储那些被分配了但是没有被使用,而未来可能会使用的值。这样就可以不用再次经过内存分配,可直接复用已有对象,减轻 GC 的压力,从而提升系统的性能。sync.Pool
的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。12.7 #97: Not relying on inlining(忘记依赖 inlining 编译优化)
内联有两个主要好处。首先,它消除了函数调用的开销(即使自Go 1.17和基于寄存器的调用约定以来开销已经减轻)。其次,它允许编译器进行进一步的优化。例如,在内联函数后,编译器可以决定把一些逃逸的变量放在堆上。
如果内联是交由编译器实现,那么开发者应该如何利用内联来优化代码呢?这里我们可以利用 Mid-stack inlining 技术,他是关于把一个调用其他函数的函数做内联。
由于中间堆栈内联的支持,作为 Go 开发者,我们现在可以使用快速路径内联来优化应用程序。让我们看一个具体的例子sync.Mutex 实现:
由于这一变化,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是如何工作的)
比较重要的问题是,每次GC是何时发生?与Java等其他语言相比,Go 配置仍然相当简单。它依赖于一个环境变量:GOGC。该变量定义自上次GC触发另一个GC之前的堆增长百分比:默认值为100%。
假设一次GC刚刚被触发,当前堆大小为128 MB。如果 GOGC=100,则当堆大小达到256 MB时触发下一次GC。
默认情况下,每次堆大小翻倍时都会执行GC。此外,如果在过去2分钟内没有执行GC,Go将强制运行一次。
如果我们知道堆峰值,我们可以使用一个强制分配大量内存的技巧来提高堆的稳定性(降低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-go • Updated Mar 26, 2023
但是现在大部分生产环境的容器运行时都是安全容器,隔离性更强不会出现错误配置GOMAXPROCS的情况
Loading Comments...