当多个协程间使用到了相同内存空间中的变量时,有可能出现同时对数据进行读写的情况,这时可能会导致数据不准确,打个比方:

用户A和用户B是一对夫妻,两人共同使用着一张银行卡,卡中余额为50000元。碰巧有一天,用户A正将6000元工资存入银行,在同一时刻用户B支出了500元购买衣物,两笔操作同时发生。

按照正常逻辑,要么是先存入后支出,余额为50000+6000-500=55500,要么是先支出后存入50000-500+6000=55500,这两种方式都不会有问题。但在程序执行的过程中,这两笔操作是不具备原子性的,都要对数据先读后写,分两步才能完成。有可能A的操作刚读出数据还未写入时,B的操作完成了读写,之后A的写操作才完成,这样B的操作就被无效了,余额为50000+6000=56000元;也有可能B的操作刚读出数据还未写入时,A的操作完成了读写,之后B的写操作才完成,这样A的操作就被无效了,余额变为50000-500=49500元。我们可以通过以下例子模拟上述的情况。

例子:多协程并发读写导致数据错误

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// myAccount 表示我的银行账户余额
var myAccount int

// getAccount 模拟读取数据库中我的账户余额
func getAccount() int {
	// 随机休眠0~1秒,模拟从数据库读取数据花费的时间
	time.Sleep(time.Duration((float64)(time.Second) * rand.Float64()))
	
	return myAccount
}

// setAccount 模拟更新数据库中我的账户余额
func setAccount(account int) {
	// 随机休眠0~1秒,模拟将数据写入数据库花费的时间
	time.Sleep(time.Duration((float64)(time.Second) * rand.Float64()))
	
	myAccount = account
}

// trade 模拟对我的账号发起一笔交易,income为正表示收入,为负表示支出
func trade(income int, wg *sync.WaitGroup) {
	// 读取我的账户余额
	account := getAccount()
	// 更新我的账户余额
	setAccount(account + income)
	
	wg.Done()
}

func main() {
	var waitGroup sync.WaitGroup

	for i := 0; i < 10; i++ {
		myAccount = 50000
		// 添加两个计数器
		waitGroup.Add(2)
		// 收入6000元
		go trade(6000, &waitGroup)
		// 支出500元
		go trade(-500, &waitGroup)

		waitGroup.Wait()

		fmt.Println(myAccount)
	}
}

以上代码的运行结果:

56000
55500
56000
56000
49500
56000
49500
56000
49500
49500

为了解决类似问题,程序中处理的过程中需要引入锁机制,用来保证数据并发读写时的原子性。其原理是锁具有互斥性,同一个锁在一个协程中加锁后在另一个协程中是无法加锁的,会一直阻塞直到锁被解开。因此可以在事务开始前加锁,在事务结束后解锁,保证一个时间段内只能有一个事务在进行。

锁可以分很多种,对于分布式系统可以有分布式锁,对于线程可以有线程锁,对于协程也可以有协程锁。

在golang内置的sync库中实现了互斥锁,我们可以通过Mutex类型解决上方的问题。

例子:通过互斥锁保证多协程操作的原子性

package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// myMutex 用于给我的账户交易时加锁
var myMutex sync.Mutex

// myAccount 表示我的银行账户余额
var myAccount int

// getAccount 模拟读取数据库中我的账户余额
func getAccount() int {
	// 随机休眠0~1秒,模拟从数据库读取数据花费的时间
	time.Sleep(time.Duration((float64)(time.Second) * rand.Float64()))

	return myAccount
}

// setAccount 模拟更新数据库中我的账户余额
func setAccount(account int) {
	// 随机休眠0~1秒,模拟将数据写入数据库花费的时间
	time.Sleep(time.Duration((float64)(time.Second) * rand.Float64()))

	myAccount = account
}

// trade 模拟对我的账号发起一笔交易,income为正表示收入,为负表示支出
func trade(income int, wg *sync.WaitGroup) {
	// 交易开始前加锁
	myMutex.Lock()
	// 读取我的账户余额
	account := getAccount()
	// 更新我的账户余额
	setAccount(account + income)
	// 交易结束后解锁
	myMutex.Unlock()
	wg.Done()
}

func main() {
	var waitGroup sync.WaitGroup

	for i := 0; i < 10; i++ {
		myAccount = 50000
		// 添加两个计数器
		waitGroup.Add(2)
		// 收入6000元
		go trade(6000, &waitGroup)
		// 支出500元
		go trade(-500, &waitGroup)

		waitGroup.Wait()

		fmt.Println(myAccount)
	}
}

以上代码的运行结果:

55500
55500
55500
55500
55500
55500
55500
55500
55500
55500

在跨协程间访问同一个资源时,并不是每次都需要加解锁,如果都是只读的情况就没必要加锁,因为数据不会发生变化;而如果读写同时发生或者都是写,那就需要加锁,因为数据的变化有可能在另外的协程中没获取到。

除了互斥锁Mutex外sync包中还提供了读写锁RWMutex类型,对于锁的作用做了进一步的延申。其中包含了两种类型的锁:读锁和写锁。写锁和互斥锁一样,在判断到已经有锁的情况下就无法再锁上;而读锁有所不同,只要不存在写锁,多个读锁可以同时锁上。换而言之,在没有写操作的情况下读操作可以同时发生。通过读写锁可以减少程序因加锁而带来的性能损失。

例子:通过读写锁降低性能损耗

package main

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

// ReadOnly 运行多个加了读锁的任务,count为任务数
func ReadOnly(count int) {
	var rwMutex sync.RWMutex
	var waitGroup sync.WaitGroup

	startTime := time.Now()

	waitGroup.Add(count)
	for i := 0; i < count; i++ {
		go func(wg *sync.WaitGroup) {
			rwMutex.RLock()
			defer rwMutex.RUnlock()
			time.Sleep(time.Second)
			wg.Done()
		}(&waitGroup)
	}
	waitGroup.Wait()

	fmt.Printf("只读耗时:%s\n", time.Now().Sub(startTime))
}

// ReadWrite 运行多个既有读锁又有写锁的任务,count为任务数
func ReadWrite(count int) {
	var rwMutex sync.RWMutex
	var waitGroup sync.WaitGroup

	startTime := time.Now()

	waitGroup.Add(count)
	for i := 0; i < count/2; i++ {
		go func(wg *sync.WaitGroup) {
			rwMutex.RLock()
			defer rwMutex.RUnlock()
			time.Sleep(time.Second)
			wg.Done()
		}(&waitGroup)
		go func(wg *sync.WaitGroup) {
			rwMutex.Lock()
			defer rwMutex.Unlock()
			time.Sleep(time.Second)
			wg.Done()
		}(&waitGroup)
	}
	waitGroup.Wait()

	fmt.Printf("读写耗时:%s\n", time.Now().Sub(startTime))
}

// WriteOnly 运行多个加了写锁的任务,count为任务数
func WriteOnly(count int) {
	var rwMutex sync.RWMutex
	var waitGroup sync.WaitGroup

	startTime := time.Now()

	waitGroup.Add(count)
	for i := 0; i < count; i++ {
		go func(wg *sync.WaitGroup) {
			rwMutex.Lock()
			defer rwMutex.Unlock()
			time.Sleep(time.Second)
			wg.Done()
		}(&waitGroup)
	}
	waitGroup.Wait()

	fmt.Printf("只写耗时:%s\n", time.Now().Sub(startTime))
}

func main() {
	WriteOnly(6)

	ReadWrite(6)

	ReadOnly(6)
}

以上代码的运行结果:

只写耗时:6s
读写耗时:4s
只读耗时:1s

只要不是协程安全的类型,在跨协程使用过程中会发生数据变化的,都应当加锁。加锁时应当以事务为单位加,且不能出现嵌套加锁,否则会出现死锁。

只要事务中出现了写操作,就应当加写锁;只有在事务中不存在写操作时,才加读锁。

最后更新于