Go: 关于锁(mutex)的正确使用姿势

前言

最近踩了一个锁的坑,所以在这里简单记录一些 Go 中关于锁(mutex)使用的一些正确姿势或注意事项。

尽量减少锁的持有时间

尽量减少锁的持有时间,毕竟使用锁是有代价的,通过减少锁的持有时间来减轻这个代价:

  • 细化锁的粒度。通过细化锁的粒度来减少锁的持有时间以及避免在持有锁操作的时候做各种耗时的操作。
  • 不要在持有锁的时候做 IO 操作。尽量只通过持有锁来保护 IO 操作需要的资源而不是 IO 操作本身:
func doSomething() {
    m.Lock()
    item := ...
    http.Get()  // 各种耗时的 IO 操作
    m.Unlock()
}

// 改为
func doSomething() {
    m.Lock()
    item := ...
    m.Unlock()

    http.Get()
}

善用 defer 来确保在函数内正确释放了锁

尤其是在那些内部有好几个通过 if err != nil 判断来提前返回的函数中,通过 defer 可以确保不会遗漏释放锁操作,避免出现死锁问题,以及避免函数内非预期的 panic 导致死锁的问题:

func doSomething() {
    m.Lock()
    defer m.Unlock()

    err := ...
    if err != nil {
        return
    }

    err = ...
    if err != nil {
        return
    }

    ...
    return
}

不过使用 defer 的时候也要注意别因为习惯性的 defer m.Unlock() 导致无意中在持有锁的时候做了 IO 操作,出现了非预期的持有锁时间太长的问题。

// 非预期的在持有锁期间做 IO 操作
func doSomething() {
    m.Lock()
    defer m.Unlock()

    item := ...
    http.Get()  // 各种耗时的 IO 操作
}

在适当时候使用 RWMutex

当确定操作不会修改保护的资源时,可以使用 RWMutex 来减少锁等待时间(不同的 goroutine 可以同时持有 RLock, 但是 Lock 限制了只能有一个 goroutine 持有 Lock):

func nickName() string {
    rw.RLock()
    defer rw.RUnlock()

    return name
}

func SetName(s string) string {
    rw.Lock()
    defer rw.Unlock()

    name = s
}

copy 结构体操作可能导致非预期的死锁

copy 结构体时,如果结构体中有锁的话,记得重新初始化一个锁对象,否则会出现非预期的死锁:

package main

import (
    "fmt"
    "sync"
)

type User struct {
    sync.Mutex

    name string
}

func main() {
    u1 := &User{name: "test"}
    u1.Lock()
    defer u1.Unlock()

    tmp := *u1
    u2 := &tmp
    // u2.Mutex = sync.Mutex{} // 没有这一行就会死锁

    fmt.Printf("%#p\n", u1)
    fmt.Printf("%#p\n", u2)

    u2.Lock()
    defer u2.Unlock()
}
$ go run main.go
c00000a080
c00000a0a0
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0xc00000a0a4, 0x0)
    /usr/local/Cellar/go/1.11/libexec/src/runtime/sema.go:71 +0x3d
sync.(*Mutex).Lock(0xc00000a0a0)
    /usr/local/Cellar/go/1.11/libexec/src/sync/mutex.go:134 +0xff
main.main()
    /Users/xxx/tmp/golang/main.go:26 +0x17f
exit status 2

文档中也有类似的警告:

A Mutex must not be copied after first use

https://godoc.org/sync#Mutex

build/test 时使用 -race 参数以便运行时检测数据竞争问题

可以在执行 go build 或 go test 时增加一个 -race 参数来开启数据竞争检测功能,通过这种方式来实现在本地开发环境/CI/测试环境阶段发现程序中可能存在的数据竞争问题:

package main

import (
    "fmt"
    "sync"
)

type Todo struct {
    sync.Mutex

    tasks []string
}

func (t *Todo) do() {
    for _, task := range t.tasks {
        fmt.Println(task)
    }
}

func (t *Todo) Add(task string) {
    t.Lock()
    defer t.Unlock()

    t.tasks = append(t.tasks, task)
}

func main() {
    t := &Todo{}

    for i := 0; i < 2; i++ {
        go t.Add(fmt.Sprintf("%d", i))
    }
    for i := 0; i < 2; i++ {
        t.do()
    }
}

-race 参数可以开启数据竞争检测(详见: Data Race Detector - The Go Programming Language ):

$ go build -race -o main .
$
$ ./main
==================
WARNING: DATA RACE
Read at 0x00c0000a0048 by main goroutine:
  main.(*Todo).do()
      /Users/xxx/tmp/golang/race/main.go:15 +0x42
  main.main()
      /Users/xxx/tmp/golang/race/main.go:34 +0x154

Previous write at 0x00c0000a0048 by goroutine 6:
  main.(*Todo).Add()
      /Users/xxx/tmp/golang/race/main.go:24 +0x11d

Goroutine 6 (finished) created at:
  main.main()
      /Users/xxx/tmp/golang/race/main.go:31 +0x127
==================
0
==================
WARNING: DATA RACE
Read at 0x00c0000b0010 by main goroutine:
  main.(*Todo).do()
      /Users/xxx/tmp/golang/race/main.go:15 +0x85
  main.main()
      /Users/xxx/tmp/golang/race/main.go:34 +0x154

Previous write at 0x00c0000b0010 by goroutine 7:
  main.(*Todo).Add()
      /Users/xxx/tmp/golang/race/main.go:24 +0xe3

Goroutine 7 (finished) created at:
  main.main()
      /Users/xxx/tmp/golang/race/main.go:31 +0x127
==================
1
0
1
Found 2 data race(s)

使用 go-deadlock 检测死锁或锁等待问题

上面说的在持有锁的时候做 IO 操作或其他非预期的耗时超时的问题,一方面需要在写程序的时候注意一下,另一方面也有可能是无意中代入进去的(比如上面提到的习惯性 defer 导致的)。对于那些无意中代入进去的锁等待的问题人为的去 review 的话通常很难发现,此时就需要用工具来检测了。恰好有一个叫 go-deadlock 的工具可以实现这个功能。

package main

import (
    "net/http"
    "time"

    sync "github.com/sasha-s/go-deadlock"
)

var mu sync.Mutex
var url = "http://baidu.com:90"

func do() {
    mu.Lock()
    defer mu.Unlock()

    u := url
    http.Get(u)  // 非预期的在持有锁期间做 IO 操作,导致锁等待时间变长
}

func main() {
    // 检测超过 100 ms 的锁等待
    sync.Opts.DeadlockTimeout = time.Millisecond * 100

    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            do()
        }()
    }

    wg.Wait()
}

执行结果:

$ go run main.go
POTENTIAL DEADLOCK:
Previous place where the lock was grabbed
goroutine 36 lock 0x1483b90
main.go:14 main.do { mu.Lock() } <<<<<
main.go:30 main.main.func1 { do() }

Have been trying to lock it again for more than 100ms
goroutine 35 lock 0x1483b90
main.go:14 main.do { mu.Lock() } <<<<<
main.go:30 main.main.func1 { do() }

Here is what goroutine 36 doing now
goroutine 36 [select]:
net/http.(*Transport).getConn(0x14616c0, 0xc00015e150, 0x0, 0x128adb3, 0x4, 0xc000014100, 0xc, 0x0, 0x0, 0xc0000559e8)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/transport.go:1004 +0x58e
net/http.(*Transport).roundTrip(0x14616c0, 0xc000160000, 0x203000, 0xc000055c90, 0x11d823a)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/transport.go:451 +0x690
net/http.(*Transport).RoundTrip(0x14616c0, 0xc000160000, 0x14616c0, 0x0, 0x0)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/roundtrip.go:17 +0x35
net/http.send(0xc000160000, 0x12c78a0, 0x14616c0, 0x0, 0x0, 0x0, 0xc00000e030, 0x1708000, 0xc000055d20, 0x1)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:250 +0x14b
net/http.(*Client).send(0x1466200, 0xc000160000, 0x0, 0x0, 0x0, 0xc00000e030, 0x0, 0x1, 0x0)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:174 +0xfa
net/http.(*Client).do(0x1466200, 0xc000160000, 0x0, 0x0, 0x0)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:641 +0x2a8
net/http.(*Client).Do(0x1466200, 0xc000160000, 0x128adb3, 0x13, 0x0)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:509 +0x35
net/http.(*Client).Get(0x1466200, 0x128adb3, 0x13, 0xc0000220c0, 0x12412c0, 0xc000055f80)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:398 +0x9d
net/http.Get(0x128adb3, 0x13, 0x1483b90, 0x0, 0xc000114fb8)
    /usr/local/Cellar/go/1.11/libexec/src/net/http/client.go:370 +0x41
main.do()
    /Users/xxx/tmp/golang/deadlock/main.go:18 +0x75
main.main.func1(0xc00009c3f4)
    /Users/xxx/tmp/golang/deadlock/main.go:30 +0x48
created by main.main
    /Users/xxx/tmp/golang/deadlock/main.go:28 +0x83

exit status 2

通过上面的输出可以知道 goroutine 36 持有锁的时间过长导致其他 goroutine 获取锁的等待时间超过了 100 ms ,并且 goroutine 36 在持有锁期间正在做 18 行的 http 操作。可以看到 go-deadlock 在优化锁等待时间方面有很大的帮助,可以帮助我们及时发现异常的锁使用姿势。

实现 tryLock 功能

一般 Lock() 如果拿不到锁的话,会一直阻塞在那里,在某些场景下这个功能不是我们所期望的结果,我们可能希望程序在一定时间内无法获取到锁的话就做其他操作或者直接返回失败:比如在一个 http server 中,处理请求时因为锁等待时间太长导致客户端大量超时,引发客户端重连以及服务端 goroutine 数量持续增长(虽然客户端超时了,但是处理请求的 goroutine 还在继续处理已超时的请求并且阻塞在了获取锁的地方,然后客户端重连又加重了这个问题,表现就是处理请求的 goroutine 数量直线上升)。这个时候我们就需要有一个类似 tryLock 的功能,在发现短时间内无法获取到锁的时候直接返回失败的响应,防止问题进一步加重(Fail Fast)。

关于 tryLock 这个功能的介绍可以参考 Java 中 tryLock 的介绍,至于 Go 中如何实现 tryLock 可以参考 为 Go Mutex 实现 TryLock 方法 | 鸟窝

改为使用 channel

有些时候可能使用 channel 会更符合需求,对于这些更适合 channel 的场景可以改为使用 channel 而不是 lock (可以参考 Share Memory By Communicating - The Go Blog 这篇文章),合适的场景选择合适的方法即可,既不需要畏惧 channel 也不必畏惧 lock 。

总结

目前能想到的就是这些注意事项了,欢迎纠正和补充。


Comments

comments powered by Disqus