Menu
CoddyTech

线程安全的结构体设计

本课属于 Coddy GO 学习旅程的“面向对象编程”单元。第 65 课,共 107 课。

现在你已经了解了互斥锁和等待组,让我们把它们结合起来,设计出可以由多个 goroutine 同时安全使用的结构体。线程安全的结构体会在其方法中封装同步操作,因此调用方不必担心加锁。

模式很简单:在你的结构体中嵌入一个互斥锁,并在每个访问共享状态的方法中加锁:

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

注意,即使是只读的 Value() 方法也会锁定互斥锁。如果不这样做,一个 goroutine 可能会在另一个 goroutine 写入时读取,从而导致数据竞争。如果读取远多于写入,可以改用 sync.RWMutex,并在读取时调用 RLock()。

一个关键的设计原则:将互斥锁设为私有。使用小写字段名(mu),可以防止外部代码直接访问它。所有同步操作都通过你的方法进行,让你能完全掌控线程安全。

对于包含多个字段的结构体,使用同一个互斥锁保护所有相关字段,以确保状态一致:

type Account struct {
    mu      sync.Mutex
    balance int
    history []string
}

func (a *Account) Deposit(amount int) {
    a.mu.Lock()
    defer a.mu.Unlock()
    a.balance += amount
    a.history = append(a.history, fmt.Sprintf("+%d", amount))
}

balance 和 history 都会以原子方式更新:没有任何 goroutine 能观察到一个已改变而另一个未改变的不一致状态。

challenge icon

挑战

简单

让我们构建一个线程安全的银行账户系统,演示如何在结构体方法内部妥善封装同步机制。你的账户会安全地处理并发存款、取款和余额查询,而不会向调用者暴露任何锁的细节。

你会将代码分散到两个文件中:

  • account.go:定义你的线程安全银行账户。

    创建一个 BankAccount 结构体,其中嵌入 sync.Mutex,包含一个 balance 字段(int),以及一个 transactions 切片,用字符串记录所有成功的操作。

    实现以下方法:

    • NewBankAccount(initial int) *BankAccount:创建一个初始余额为给定值、且 transactions 切片为空的新账户
    • Deposit(amount int):将金额加到余额中,并将交易记录为 +[amount]
    • Withdraw(amount int) bool:如果资金充足,则扣除金额,记录 -[amount],并返回 true。否则返回 false,且不做任何修改
    • Balance() int:返回当前余额
    • History() []string:返回交易记录切片的副本

    每个访问结构体字段的方法都必须锁定互斥锁,以确保线程安全。使用 defer 解锁。将互斥锁和所有字段设为未导出(小写),这样外部代码就必须使用你的方法。

  • main.go:处理银行操作,并演示你的线程安全账户。

    读取初始余额,然后读取操作数量。对于每项操作,读取操作类型(deposit、withdraw 或 balance);对于存款和取款,还要读取金额。

    输出每项操作的结果:

    • deposit:输出 Deposited [amount], Balance: [new balance]
    • withdraw:如果成功,输出 Withdrew [amount], Balance: [new balance];否则输出 Withdrawal failed: insufficient funds
    • balance:输出 Current balance: [balance]

    所有操作完成后,输出交易记录,每条记录单独占一行,且只有第一条记录前带有 History: 。

输入内容如下:

  • 第 1 行:初始余额(整数)
  • 第 2 行:操作数量(整数)
  • 后续各行:每项操作的类型(deposit、withdraw 或 balance);对于存款和取款,金额在下一行提供

例如,给定以下输入:

100
5
deposit
50
balance
withdraw
30
withdraw
200
balance

你的输出应为:

Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30

这里的关键原则是,所有同步操作都封装在你的 BankAccount 方法内部。调用者只需使用 Deposit()、Withdraw() 和 Balance(),完全不必考虑锁。你的结构体会在内部处理线程安全。

亲手试试

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"
)

func main() {
	reader := bufio.NewReader(os.Stdin)

	// 读取初始余额
	initialStr, _ := reader.ReadString('\n')
	initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))

	// 读取操作次数
	numOpsStr, _ := reader.ReadString('\n')
	numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))

	// 创建银行账户
	account := NewBankAccount(initial)

	// 处理每次操作
	for i := 0; i < numOps; i++ {
		opType, _ := reader.ReadString('\n')
		opType = strings.TrimSpace(opType)

		switch opType {
		case "deposit":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO:调用 Deposit 并打印结果
			// 格式:"Deposited [amount], Balance: [new balance]"

		case "withdraw":
			amountStr, _ := reader.ReadString('\n')
			amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
			// TODO:调用 Withdraw 并打印相应结果
			// 如果成功:"Withdrew [amount], Balance: [new balance]"
			// 如果失败:"Withdrawal failed: insufficient funds"
			_ = amount // 实现后删除这一行

		case "balance":
			// TODO:调用 Balance 并打印结果
			// 格式:"Current balance: [balance]"
		}
	}

	// TODO:打印交易记录
	// 第一条记录前应加上 "History: "
	// 后续记录应各占一行,不加前缀
}
quiz icon自我测试

这节课包含一个小测验。开始学习即可作答,并记录你的进度。

面向对象编程中的所有课

自己练习: Go 在线编译器