
本文详解如何用 go 的 goroutine 和 channel 模拟双向银行账户转账,并通过 sync.mutex 和 sync.waitgroup 解决竞态条件与 goroutine 同步问题,确保余额守恒。
本文详解如何用 go 的 goroutine 和 channel 模拟双向银行账户转账,并通过 sync.mutex 和 sync.waitgroup 解决竞态条件与 goroutine 同步问题,确保余额守恒。
在 Go 中实现并发安全的银行转账系统,核心挑战不在“如何启动 goroutine”,而在于正确协调状态访问与执行时序。原始代码看似逻辑清晰:每个 Transfer 启动两个监听 goroutine(源账户接收负额、目标账户接收正额),并通过 channel 逐单位发送微支付。但运行结果中反复出现 ±1 的余额偏差,说明存在未被完全覆盖的并发缺陷。
? 根本问题分析
原始代码存在两类并发隐患:
-
数据竞争(Data Race):
Account.Balance被多个 goroutine(不同Listen函数)无保护地读写。即使为Listen()加了Mutex,若最终余额统计(fooEndBalance += ...)未同步访问,仍会读到中间态值; -
时序竞态(Timing Race):主 goroutine 在从
statuschannel 接收完 50 条完成消息后,立即关闭 channel 并开始计算最终余额,但此时部分Listengoroutine 可能尚未处理完所有 channel 数据(尤其是close(channel)后,range循环虽会退出,但defer或调度延迟可能导致最后几笔加法未落库)。
✅ 提示:
go run -race main.go可精准定位所有竞态点——它不仅会报Balance读写冲突,还会标记status关闭后仍尝试发送的潜在 panic。
✅ 正确实现:Mutex + WaitGroup + Channel 守护
以下是修复后的关键结构与逻辑(精简核心):
type Account struct {
sync.Mutex
Owner string
Number int
Balance int
}
func (a *Account) Add(amount int) {
a.Lock()
a.Balance += amount
a.Unlock()
}
func (a *Account) GetBalance() int {
a.Lock()
b := a.Balance
a.Unlock()
return b
}
func (t Transfer) Execute(done chan<- struct{}) {
const PAYMENT = 1
srcCh, tgtCh := make(chan int), make(chan int)
// 启动监听 goroutine,并用 WaitGroup 确保它们结束
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); t.Source.listen(srcCh) }()
go func() { defer wg.Done(); t.Target.listen(tgtCh) }()
// 发送全部微支付
for paid := 0; paid < t.Amount; paid += PAYMENT {
srcCh <- -PAYMENT
tgtCh <- +PAYMENT
}
close(srcCh)
close(tgtCh)
// 等待监听 goroutine 处理完毕
wg.Wait()
done <- struct{}{}
}
// listen 方法内部直接调用 Add,避免裸露 Balance
func (a *Account) listen(ch <-chan int) {
for amount := range ch {
a.Add(amount)
}
}? 主函数同步改造(关键!)
func main() {
const ACCOUNTS = 25
const TRANSFERS = ACCOUNTS * 2
const AMOUNT = 5000
const BALANCE = 9000
// 初始化账户(略)
fooAccounts := make([]*Account, ACCOUNTS)
barAccounts := make([]*Account, ACCOUNTS)
for i := 0; i < ACCOUNTS; i++ {
fooAccounts[i] = NewAccount("foo", i+1, BALANCE)
barAccounts[i] = NewAccount("bar", i+1, BALANCE)
}
// 构建转账任务
transfers := make([]*Transfer, 0, TRANSFERS)
for i := 0; i < ACCOUNTS; i++ {
transfers = append(transfers, NewTransfer(fooAccounts[i], barAccounts[i], AMOUNT))
transfers = append(transfers, NewTransfer(barAccounts[i], fooAccounts[i], AMOUNT))
}
// 启动所有转账,并用 WaitGroup 等待全部完成
var wg sync.WaitGroup
done := make(chan struct{}, len(transfers)) // 缓冲 channel 避免阻塞
for _, t := range transfers {
wg.Add(1)
go func(tr *Transfer) {
defer wg.Done()
tr.Execute(done)
}(t)
}
// 启动 goroutine 收集完成信号(可选:用于日志)
go func() {
for i := 0; i < len(transfers); i++ {
<-done
fmt.Printf("%2d. transfer done\n", i+1)
}
}()
// ⚠️ 等待所有转账 goroutine 真正结束
wg.Wait()
close(done)
// ✅ 此时所有 Add() 已完成,可安全读取余额
fooEndBalance, barEndBalance := 0, 0
for _, acc := range fooAccounts {
fooEndBalance += acc.GetBalance()
}
for _, acc := range barAccounts {
barEndBalance += acc.GetBalance()
}
fmt.Printf("Start: foo: %6d, bar: %6d\n", ACCOUNTS*BALANCE, ACCOUNTS*BALANCE)
fmt.Printf(" End: foo: %6d, bar: %6d\n", fooEndBalance, barEndBalance)
}? 关键要点总结
-
Mutex 必须覆盖所有 Balance 访问:包括
Add()、GetBalance(),且不可遗漏任何路径(如日志打印、调试输出); -
WaitGroup 替代 channel 计数:
statuschannel 仅适合通知,不能作为“执行完成”的唯一依据;wg.Wait()才是强同步保证; -
避免过早关闭资源:
close(status)后仍可能有 goroutine 尝试发送——使用带缓冲的done chan struct{}更健壮; -
打印 Bug 也会导致误判:原代码中
fmt.Printf("... foo: %d, bar: %d\n", foo, foo)重复打印 foo,掩盖了真实差异,务必校验输出逻辑。
通过以上重构,程序将 100% 保持资金守恒:起始总余额 = 结束总余额,且每次运行结果确定。这正是 Go 并发模型的精髓——用组合原语(goroutine + channel + sync)显式表达协作,而非依赖隐式调度假设。

















