WIP:《100 Go Mistakes and How to Avoid Them》之并发实践篇

2023-07-12 ⏳2.9分钟(1.1千字)

9.1 传递了不合适的context

我们在代码中经常会向下传递context,但是有时会产生bug。比如我们在httpserver中想要异步传输一条topic信息。

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(r.Context(), response)
        // Do something with err
    }()
    writeResponse(response)
}

当客户端关闭连接或者返回response给客户端时,ctx会canceled。这就有可能导致异步消息提前终止。所以这样情况,我们不能无脑传递ctx,我们需要:

err := publish(context.Background(), response)

9.2 创建了一个协程却并不知道什么时候关闭

在Go中,创建协程非常简单,但如果我们不设定什么时候停止这个协程,就会造成资源泄漏。协程的初始内存是2KB,可以缩扩容。如果当一个协程持有了数据库资源或者网络socket,那么它的泄漏同样会造成资源泄漏。我们可以看以下的一个例子:

func main() {
    newWatcher()
    // Run the application
}
type watcher struct { /* Some resources */ }

func newWatcher() {
    w := watcher{}
    go w.watch()
}

我们需要给watcher增加关闭资源的方法,然后在程序退出前执行:

w := newWatcher()
defer w.close()

func (w watcher) close() {
    // Close the resources
}

总而言之,我们开启一个协程就应该知道什么情况下去关闭。

9.3 协程使用迭代变量不仔细

错误操作协程和迭代变量非常常见:

s := []int{1, 2, 3}
for _, i := range s {
    go func() {
        fmt.Print(i)
    }()
}

比如以上的例子,你可能认为输出的结果是123,当实际是223或者有时是333。闭包创建协程,变量读的是地址,可能内容已经改变了。我们可以改为:

for _, i := range s {
    val := i
    go func() {
        fmt.Print(val)
}() }

或者:

for _, i := range s {
    go func(val int) {
        fmt.Print(val)
    }(i) 
}

9.4 使用select和channels时认为确定行为

我们可以看下例子:

for {
    select {
        case v := <-messageCh:
            fmt.Println(v)
        case <-disconnectCh:
            fmt.Println("disconnection, return")
            return
    } 
}

for i := 0; i < 10; i++ {
    messageCh <- i
}
disconnectCh <- struct{}{}

我们得到的结果是:

0
1
2
3
4
disconnection, return

为什么不是10条?我们可以看下go对于select语句的文档:

If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.

当case语句同时满足时,select会随机选择分支。如果我们想要满足上述的实现,我们可以:

9.5 不使用通知类型的channel

channel通常作为协程中传递信号的一种通讯方式。那我们仅仅是为了通知而非传递数据时,我们应该使用chan struct{},并不占用存储空间。

var s struct{}
fmt.Println(unsafe.Sizeof(s))
0

这种类型可以明确的让接收者清楚不需要理会message的内容。 ### 9.6 不使用nil channel 值为nil的管道其实很有用,我们可以了解下什么是nil channel,什么时候我们可以使用。 ### 9.7 不理解channel长度我们使用channel的时候需要考虑2个问题:

带缓冲区代表了异步操作,数据会放入缓冲区。而不带缓冲区或者缓冲区已满,则会变成同步操作,如果没有接收/发送方则会堵塞。当使用带缓冲区的时候,我们应该如何设置缓冲区大小呢?

如何设置缓冲区大小本身就是CPU与Memory之间的平衡点。

9.8 忘记字符串格式化时的边界情况

9.9 append的时候产生 data races

我们看下在什么情况下append会产生数据竞争。

s := make([]int, 1)

go func() {
    s1 := append(s, 1)
    fmt.Println(s1)
}()

go func() {
    s2 := append(s, 1)
    fmt.Println(s2)
}()

你认为上述例子有么?答案是没有。因为在上述情况下,slice的lengthcapacity都是1,append操作会申请新的内存块。如果改成s := make([]int, 0, 1)就会产生数据竞争了。这种情况要避免就需要使用副本copy去操作:

s := make([]int, 0, 1)

go func() {
    sCopy := make([]int, len(s), cap(s))
    copy(sCopy, s)
    s1 := append(sCopy, 1)
    fmt.Println(s1)
}()

go func() {
    sCopy := make([]int, len(s), cap(s))
    copy(sCopy, s)
    s2 := append(sCopy, 1)
    fmt.Println(s2)
}()

9.10 在slices和maps中错误的使用锁

9.11 错用sync.WaitGroup

sync.WaitGroup是用来等待X个操作完成的机制。配合Add(int)Done()去增减内部计数器,Wait()会阻塞直到计数器归零。下面有一个例子,大家可以看看有什么问题:

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)

结果是个不确定的值,原因是我们调用wg.Add(1)不应该在协程内部,这样会导致wg.Wait()直接非堵塞,然后读取了v.正确处理方式应该是:

for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
        // ...
    }() 
}

9.12 不知道sync.Cond

9.13 不使用errgroup

Go提供了errgroup包,可以执行多个协程,从多个协程中聚合错误。使用方式:

$ go get golang.org/x/sync/errgroup
func handler(ctx context.Context, circles []Circle) ([]Result, error) {     
    results := make([]Result, len(circles))
    g, ctx := errgroup.WithContext(ctx)

    for i, circle := range circles {
        i := i
        circle := circle
        g.Go(func() error {
            result, err := foo(ctx, circle)
            if err != nil {
                return err 
            }
            results[i] = result
            return nil 
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

g.Go接收一个闭包函数func() error. g.Wait当遇到error会抛出,否则就会等待所有协程完成。另一个优势就是当一个协程抛错,errgroup.WithContext生成的ctx就会接收到canceled,其他协程也会结束,无需继续等待。

9.14 copy了一个sync类型

sync包提供了一些基础的同步工具比如:mutexeswait groups。他们都有一个铁律想要遵守:不能copy. 比较常见的错误就是方法用了值接收,比如:

type Counter struct {
    mu       sync.Mutex
    counters map[string]int
}

func NewCounter() Counter {
    return Counter{counters: map[string]int{}}
}

func (c Counter) Increment(name string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.counters[name]++
}

解决方案就是改成指针接收:

func (c *Counter) Increment(name string) {
    // Same code
}

或者把锁改成指针:

type Counter struct {
    mu       *sync.Mutex
    counters map[string]int
}