编程笔记

lifelong learning & practice makes perfect

go|在局部函数中返回sync.RWMutex指针合适吗

sync.RWMutex 的基本特性

  • 值类型: sync.RWMutex 是一个 struct。这意味着如果你直接传递它,你将得到一个副本。对副本的锁定操作不会影响原始的 mutex,这会导致同步失效。
  • 不应在第一次使用后复制: Go 官方文档明确指出,sync.Mutex (以及 sync.RWMutex) 不应在第一次使用(即调用 Lock/UnlockRLock/RUnlock)后进行复制。复制会导致未定义的行为,通常是死锁或 panic。
  • 零值可用: sync.RWMutex{} (零值) 是一个有效的、可直接使用的 mutex。

在局部函数中创建并返回 *sync.RWMutex 的情况

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
"fmt"
"sync"
"time"
)

// 情况 A: 函数内部创建 RWMutex 并返回其指针
func createAndReturnRWMutex() *sync.RWMutex {
var mu sync.RWMutex // 局部变量,Go的逃逸分析会将其分配到堆上
fmt.Println("Mutex created at address:", &mu)
return &mu
}

func main() {
// 获取由 createAndReturnRWMutex 函数创建的 mutex 指针
mu1 := createAndReturnRWMutex()
mu2 := createAndReturnRWMutex() // 再次调用,会得到一个新的 mutex

fmt.Println("Returned mu1 address:", mu1)
fmt.Println("Returned mu2 address:", mu2)

// 使用 mu1
mu1.RLock()
fmt.Println("mu1 RLocked")
go func() {
mu1.Lock() // 这个地方会等待上面的 RLock 释放
fmt.Println("mu1 Locked by goroutine")
time.Sleep(100 * time.Millisecond)
mu1.Unlock()
fmt.Println("mu1 Unlocked by goroutine")
}()
time.Sleep(50 * time.Millisecond) // 等待 goroutine 尝试加锁
mu1.RUnlock()
fmt.Println("mu1 RUnlocked")

// 确保 goroutine 完成
time.Sleep(200 * time.Millisecond)

// 使用 mu2 (与 mu1 独立)
mu2.Lock()
fmt.Println("mu2 Locked")
mu2.Unlock()
fmt.Println("mu2 Unlocked")
}

分析:

  1. 技术上可行: Go 的逃逸分析会识别到局部变量 mu 的地址被返回了函数外部,因此它会被分配到堆上(heap),而不是栈上。所以,返回的指针是有效的,不会指向已被销毁的内存。
  2. 不常见,且通常不是好的设计模式:
    • 封装性差: sync.RWMutex 的核心作用是保护共享数据。它几乎总是作为某个数据结构(struct)的字段而存在,由该数据结构的方法来管理其锁定和解锁,以保护该结构体内部的数据。
    • 职责不清: 如果一个函数仅仅返回一个 *sync.RWMutex,那么这个 mutex 是为了保护什么数据?这个信息并没有被封装起来,使用者需要自己去追踪这个 mutex 的用途,这增加了代码的复杂性和出错的风险。
    • 容易误用: 调用者可能会不清楚这个 mutex 应该保护什么数据,或者在不适当的时候加锁/解锁,导致死锁、竞争条件等问题。
    • 代码可读性差: 相比于一个明确的结构体(例如 SafeCache)包含一个 RWMutex,仅仅返回一个 RWMutex 指针会让代码意图模糊。

在局部函数中返回已存在*sync.RWMutex 指针

另一种情况是,函数不是创建新的 RWMutex,而是返回一个指向已经存在的 RWMutex 的指针,比如从一个全局变量或者传入参数的结构体中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import "sync"

var globalMu sync.RWMutex

type MyContainer struct {
Mu sync.RWMutex
Data int
}

// 情况 B: 返回全局 RWMutex 的指针
func getGlobalRWMutex() *sync.RWMutex {
return &globalMu
}

// 情况 C: 返回结构体字段 RWMutex 的指针
func getContainerRWMutex(c *MyContainer) *sync.RWMutex {
return &c.Mu
}

func main() {
gMu := getGlobalRWMutex()
gMu.Lock()
// ...
gMu.Unlock()

container := &MyContainer{}
cMu := getContainerRWMutex(container)
cMu.RLock()
// ...
cMu.RUnlock()
}

分析:

  • 技术上可行: 同样,返回的指针是有效的。
  • 合适性存疑:
    • 暴露内部实现: 这种做法打破了封装性。一个结构体通常应该通过其方法来提供对数据(包括同步机制)的受控访问,而不是直接暴露其内部的 sync.RWMutex 字段。
    • 耦合性增加: 外部代码直接操作内部 mutex 会增加模块间的耦合,使得结构体的内部实现更难修改。
    • 潜在的API风险: 如果 MyContainer 的设计者希望控制对 Data 的访问方式,而调用者直接使用 c.Mu 进行锁操作,可能会绕过 MyContainer 提供的方法,导致不一致或错误的状态。

推荐做法

在 Go 中,管理 sync.RWMutex惯用和最佳实践是将其嵌入到一个结构体中,并通过该结构体的方法来管理锁定和解锁,从而保护结构体自身的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
"fmt"
"sync"
)

// SafeCounter 是一个线程安全的计数器
type SafeCounter struct {
mu sync.RWMutex // RWMutex 作为结构体的字段
count int
}

// Inc 增加计数器的值
func (sc *SafeCounter) Inc() {
sc.mu.Lock() // 方法内部管理锁
defer sc.mu.Unlock()
sc.count++
}

// Value 返回当前计数器的值
func (sc *SafeCounter) Value() int {
sc.mu.RLock() // 方法内部管理读锁
defer sc.mu.RUnlock()
return sc.count
}

// NewSafeCounter 是 SafeCounter 的构造函数
func NewSafeCounter() *SafeCounter {
// RWMutex 的零值是可用的,无需额外初始化
return &SafeCounter{}
}

func main() {
counter := NewSafeCounter() // 返回的是 *SafeCounter,而不是 *sync.RWMutex

var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Inc() // 通过 SafeCounter 的方法访问计数器,由方法处理同步
}()
}

wg.Wait()
fmt.Println("Final Counter Value:", counter.Value())
}

总结

在Go语言中,一般不推荐在局部函数中创建 sync.RWMutex 并单独返回其指针。这通常表示设计上存在封装性不足的问题。

更推荐的做法是:

  1. sync.RWMutex 作为你希望保护的结构体的字段。
  2. 通过该结构体的方法来封装对数据的访问和 sync.RWMutex 的锁定/解锁操作。
  3. 如果需要提供一个同步的实例,你的构造函数应该返回一个指向该结构体实例的指针(例如 *SafeCounter),而不是单独的 *sync.RWMutex 指针。

这样做可以提高代码的封装性、可读性和健壮性,减少误用的可能性。

欢迎关注我的其它发布渠道