Go语言并发编程:互斥锁与读写锁实战详解

半杯拿铁
半杯拿铁
发布时间:2026-10-16 | 6人浏览过
原创

在Go语言高并发场景中,同步原语是保证数据一致性的基石。本文深入解析标准库 sync 包中的互斥锁(Mutex)与读写锁(RWMutex)。通过模拟账户存取款的高频竞争案例,直观展示不加锁时的数据混乱现象,以及如何通过正确的加锁与解锁操作消除竞态条件(Race Condition),从而提升并发程序的安全性。

互斥锁 Mutex 的原理与实践

第1步:理解 Mutex 的核心机制与注意事项

sync.Mutex 是 Go 语言中最基础的同步工具,旨在保护临界区,确保同一时刻只有一个 goroutine 能够执行该段代码。其核心方法为锁定(Lock)与解锁(Unlock)。

在使用过程中,需严格遵循以下安全规范:

  • 禁止重复锁定:若在未解锁的情况下再次调用 Lock,会导致当前 goroutine 阻塞甚至死锁。
  • 配对操作:Lock 与 Unlock 必须成对出现。推荐使用 defer 语句进行解锁,以确保函数正常退出或异常 panic 时都能释放锁。
  • 值类型特性:sync.Mutex 是值类型,若将其实例作为参数传递给其他函数,传递的是副本。修改副本的锁状态不会影响原变量,因此通常建议使用指针传递或全局定义。

第2步:演示 Mutex 解决竞态条件(Race Condition)

为了验证并发安全性,下面编写一个模拟银行操作的程序。程序开启多个子协程同时进行存款和取款操作。我们将分别观察“未加锁”与“加锁”两种情况下的结果差异。

代码实现:**

package main

import (
	"flag"
	"fmt"
	"sync"
)

var (
	mutex   sync.Mutex
	balance int
	protecting uint  // 控制是否加锁的标志位
	sign = make(chan struct{}, 10) // 信号通道,用于等待所有 goroutine 结束
)

// 存钱操作
func deposit(value int) {
	defer func() {
		sign <- struct{}{}
	}()

	if protecting == 1 {
		mutex.Lock()
		defer mutex.Unlock()
	}

	fmt.Printf("余额: %d\n", balance)
	balance += value
	fmt.Printf("存 %d 后的余额: %d\n", value, balance)
	fmt.Println()
}

// 取钱操作
func withdraw(value int) {
	defer func() {
		sign <- struct{}{}
	}()

	if protecting == 1 {
		mutex.Lock()
		defer mutex.Unlock()
	}

	fmt.Printf("余额: %d\n", balance)
	balance -= value
	fmt.Printf("取 %d 后的余额: %d\n", value, balance)
	fmt.Println()
}

func main() {
	// 初始化参数
	flag.UintVar(&protecting, "protecting", 0, "是否加锁:0表示不加锁,1表示加锁")
	flag.Parse()
	balance = 1000 // 初始余额

	// 并发执行5次取款和5次存款
	for i := 0; i < 5; i++ {
		go withdraw(500)
		go deposit(500)
	}

	// 主协程等待所有子协程完成
	for i := 0; i < 10; i++ {
		<-sign
	}
	fmt.Printf("最终当前余额: %d\n", balance)
}

执行结果对比与分析:**

当设置 -protecting=0(不加锁)时:

由于没有加锁保护,多个 goroutine 同时读取并修改 balance,导致输出顺序混乱,部分步骤读取到的是旧值或中间态值。例如可能出现第一次取款后余额显示仍为 1000,随后又被扣除的情况,这是典型的竞态条件,最终余额可能不等于预期的 1000。

当设置 -protecting=1(加锁)时:

此时输出变得井然有序。每一个存/取操作都是原子化的:先锁定,再读取、计算、写入、打印,最后解锁。虽然打印顺序依然取决于调度器,但数据的读写逻辑被严格串行化了。经过 5 次 +500 和 5 次 -500 的操作,最终余额稳定为 1000。

细粒度优化:读写锁 RWMutex

第3步:掌握 RWMutex 的设计思想

虽然 sync.Mutex 解决了安全问题,但在很多业务场景中,“读多写少”是常态。频繁的互斥锁会让所有的读操作也被迫排队,降低了系统吞吐量。sync.RWMutex(读写互斥锁)正是为此而生。它将锁分为两类:

  • 读锁(RLock/RUnlock):允许多个协程同时进入读临界区。
  • 写锁(Lock/Unlock):独占模式,任何写操作都会阻塞后续的读和写。

读写权限矩阵:

操作关系 允许并行?
读锁 vs 读锁 是 (Yes)
读锁 vs 写锁 否 (No)
写锁 vs 写锁 否 (No)

第4步:基于结构体的并发安全实例

我们将前面的例子重构,使用 RWMutex 来封装一个账户结构体。其中查询余额采用读锁,存取款采用写锁。

代码实现:**

package main

import (
	"fmt"
	"sync"
)

// Account 代表一个安全的账户结构
type account struct {
	balance int       // 余额
	num     uint      // 操作次数
	rwMu    *sync.RWMutex // 关联的读写锁
}

var sign = make(chan struct{}, 15) // 配合下面的并发数量

// Check 查看余额:仅需读锁
func (c *account) check() {
	defer func() { sign <- struct{}{} }()
	c.rwMu.RLock()
	defer c.rwMu.RUnlock()
	fmt.Printf("[%d次操作] 当前余额: %d\n", c.num, c.balance)
}

// Deposit 存入金额:需要写锁
func (c *account) deposit(value int) {
	defer func() { sign <- struct{}{} }()
	c.rwMu.Lock()
	defer c.rwMu.Unlock()

	c.num++
	oldBalance := c.balance
	c.balance += value
	fmt.Printf("[存 %d] 余额从 %d 变为 %d\n", value, oldBalance, c.balance)
}

// Withdraw 取出金额:需要写锁
func (c *account) withdraw(value int) {
	defer func() { sign <- struct{}{} }()
	c.rwMu.Lock()
	defer c.rwMu.Unlock()

	c.num++
	oldBalance := c.balance
	c.balance -= value
	fmt.Printf("[取 %d] 余额从 %d 变为 %d\n", value, oldBalance, c.balance)
}

func main() {
	// 初始化为拥有1000元余额的账户
	c := &account{
		balance: 1000,
		rwMu: new(sync.RWMutex),
	}

	// 每个循环内启动:5个取款、5个存款、5个查询
	for i := 0; i < 5; i++ {
		go c.withdraw(500)
		go c.deposit(500)
		go c.check()
	}

	// 等待所有 goroutine 结束 (共 15 个)
	for i := 0; i < 15; i++ {
		<-sign
	}

	fmt.Printf("总计 [%d次操作] 后最终余额: %d\n", c.num, c.balance)
}

执行结果解读:

运行上述代码可以发现,尽管查询操作(check)与存取款操作(deposit/withdraw)可能在时间轴上交错,但由于读写的隔离性,只有在进行写操作时才会阻塞其他读写。只要数据逻辑正确,最终的余额依然是 1000。

总结

在 Go 语言并发编程中,正确处理共享资源是避免 Bug 的关键。选择 sync.Mutex 还是 sync.RWMutex 主要取决于业务场景的性能需求。务必牢记加锁必解锁的原则,利用 defer 能有效降低死锁风险。以上就是 Go 语言并发编程互斥锁与读写锁的详细内容。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表