31《Go语言入门》共享变量的并发(互斥锁)

这是我纯手写的《Go语言入门》,手把手教你入门Go。源码+文章,看了你就会🥴!
文章中所有的代码我都放到了github.com/GanZhiXiong/go_learning这个仓库中!
看文章时,对照仓库中代码学习效果更佳哦!

前言

前面我们有说到Go并发的主要方式:
基于CSP模型的并发(通过channel实现) 和 基于共享变量的并发。Go的主要并发方式是前者,当然后者也是不可或缺的处理并发的传统同步机制。

首先我们要知道什么叫共享变量:
如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

多个线程或者协程并发的对一个变量进行修改,会出现数据不一致的问题,这是因为多个并发体竞争同一个资源导致的。

以最经典的存钱取钱的例子,下面的例子有2个goroutine并发的多次对存款变量进行修改:

// 两个goroutine并发多次对存款变量进行修改
// 当循环次数为一亿的时候,就会出现每次打印最终余额不一致的问题
// 耗时大概0.23秒
func TestSharedVariablesConcurrentlyTest(t *testing.T) {
	waiting := make(chan struct{})

	go func() {
		// 循环一亿次
		for i := 0; i < 100000000; i++ {
			deposit++
		}
		waiting <- struct{}{}
	}()

	for i := 0; i < 100000000; i++ {
		deposit--
	}

	<-waiting
	t.Log(deposit)
}

代码中使用了两个协程分别循环一亿次同时修改一个变量,我们可以看到打印的deposit每次都不一样,而实际我们期望的存款是0元。

面对多个协程同时修改同一变量,并发竞争导致的数据不一致的问题怎么解决呢?

线程安全三大特性

并发程序要正确地执行,必须要保证其具备原子性、可见性以及有序性
只要有一个没有被保证,就有可能会导致程序运行不正确。
线程不安全的情况,在程序编译阶段、测试阶段,甚至投产使用阶段,并不一定能发现,因为受到当时的CPU调度顺序、线程个数、指令重排的影响,而偶然触发程序不正确的运行。

原子性

原子性即一个操作或多个操作,要么全部执行并且执行的过程不会被任何因素打断(同一时间只有一个线程对共享变量的操作),要么都不执行。

比较典型的例如:

  • A向B转账1000元,需要进行两个操作,一个是A账户减1000元,B账户加1000元。如果这两个操作任何一个出现了问题,都不能保证A和B的财产安全。
  • A和B同时向C转1000元,C的余额是2000元。A向C转账1000元,C的余额应该是3000元,如果在将3000元写到C的账号之前,B向C转账了1000元,将3000元(C的余额2000元+B转的1000元)写入C的账户后,A的操作继续进行,将3000元写入C的账户。这就有问题了,因为C的余额为3000元,而不是正确预期的的4000元。
  • 多个线程同时对一个共享变量n++100次,如果n的初始值为0,n最后的值应该是100。其实n++实际包含了三个操作:1、读取变量n的值;2、对n进行加1的操作;3、将加1后的值赋值给变量n。
    如果n为10时,线程a和线程b同时在读取n的值,因为同时读的话,n都是10,n++后n都是11,等于两个线程执行完成后n的值是11,而不是预期的12

我们看下实际代码使用中,下面哪些是原子性,哪些是非原子性:

a = true // 原子性
a = 1 // 原子性
a = b // 非原子性,分2个操作,第一读取b的值,第二将b的值赋值给a
a = b + 1 // 非原子性,分3个操作
a++ // 非原子性,分3个操作

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。

CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

比如下面代码中线程1通过高速缓存将10赋值给i,却没有立即写入主内存中,此时线程2执行j = i,那么j的值为0,而不是10。这就是可见性的问题。

//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

有序性

有序性指的是,程序执行的顺序按照代码的先后顺序执行。

指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

这段代码有4个语句,那么可能的一个执行顺序是:
语句2 -> 语句1 -> 语句3 -> 语句4
那么可不可能是这个执行顺序:
语句2 -> 语句1 -> 语句4 -> 语句3。
不可能,因为处理器在进行指令重排序时会考虑指令之间的数据依赖性。

虽然重排序不会影响单个线程内程序执行的结果,但是会影响到多线程并发执行的正确性。
比如:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

怎么解决共享变量协程不安全的问题?

使用channel解决

  1. 新建一个bank包
  2. bank包下新建一个deposit.go文件,一个goroutine用于提供查询余额、存款、取款业务服务(只有一个goroutine修改这个资源从而保证了数据的安全。)
package bank

var Deposit chan int // 存钱或取钱用到的channel
var Search chan int  // 查看余额用到的channel
var amount int       // 余额

func init() {
	Deposit = make(chan int)
	Search = make(chan int)

	// 必须开一个goroutine跑,否者会阻塞init方法,也会阻塞main可执行文件
	go func() {
		for {
			select {
			case money := <-Deposit: // 客户端存钱或取钱时走这个case
				amount += money
			case Search <- amount: // 客户端要查看余额时走这个case
			}
		}
	}()
}
// 多个并发体竞争同一个资源的时候,就会出现数据不一致的问题
// 所以采用一个goroutine来修改余额,使用channel进行协程间通信
// 耗时大概八九十秒
func TestSharedVariablesConcurrentlyTest1(t *testing.T) {
	waiting := make(chan struct{})

	go func() {
		for i := 0; i < 100000000; i++ {
			//t.Log("a")
			bank.Deposit <- 1 // 存一元
		}
		waiting <- struct{}{}
	}()

	for i := 0; i < 100000000; i++ {
		//t.Log("b")
		bank.Deposit <- -1 // 取一元
	}

	<-waiting
	t.Log(<-bank.Search)
}

使用互斥锁解决

如果我想让资源的修改在多个goroutine,但同一时刻值允许一个goroutine修改该资源,使资源的访问和修改不再是竞争而是有序的,那该怎么做呢?
答案是:互斥锁,也就是这篇文章要讲解的一个重要知识点。

// 如果相让资源在多个goroutine中修改呢?
// 那只需要做到同一时刻只允许一个goroutine修改该资源即可
// 那就得使用互斥锁
// 耗时大概3秒
func TestSharedVariablesConcurrentlyTest2(t *testing.T) {
	var amount int
	var mutex *sync.Mutex

	defer func(t time.Time) {
		fmt.Println(float64(time.Now().UnixNano() - t.UnixNano()) / 1e9)
	}(time.Now())

	waiting := make(chan struct{})
	mutex = &sync.Mutex{}

	go func(){
		for i:=0; i < 100000000; i++{
			//t.Log("a")
			mutex.Lock()
			amount++
			mutex.Unlock()
		}
		waiting <- struct{}{}
	}()

	for i:=0; i < 100000000; i++{
		//t.Log("b")
		mutex.Lock()
		amount--
		mutex.Unlock()
	}

	<-waiting
	fmt.Println(amount)		// 最后查看余额
}

先不要纠结sync.Mutex下面我会相信讲解Go中的互斥锁

总结

  • 协程不安全时,耗时大概0.23秒,每次结果都不一样,❌
  • 使用channel解决协程不安全,耗时大概八九十秒,每次结果都是0,✅
  • 使用互斥锁解决协程不安全,耗时大概3秒,每次结果都是0,✅

无论用channel还是用互斥锁,都需要损耗更多性能用于同步和保证并发安全,因为原本数据的修改由并发变成时串行。

为什么使用channel的耗时比使用互斥锁耗时多这多,因为数据在多个goroutine中传递代价要远大于简单的上一个锁,而且这个差距放大一亿倍后尤为明显。

Go语言是更倾向于用CSP模式的并发(也就是使用channel)来替代传统的基于共享变量的并发,Go(和Go的发明者)认为“不要使用共享数据来通信;而是使用通信来共享数据”,这句话进一步体现了channel在go中的重要性。

当然这不意味这我们不需要学习传统的并发机制,还是那句话不同的技术适用于不同的应用场景,在一些场景下用传统的并发机制比channel更合适或者逻辑的实现更简单。就像上面的例子,用channel也能做到数据安全,但是程序的设计就因为用了channel而复杂了很多。

互斥锁(sync.Mutex)

Go语言包中的 sync 包提供了两种锁类型:sync.Mutex(互斥锁) 和 sync.RWMutex(读写锁)。

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个 goroutine 可以访问到共享资源(同一个时刻只有一个线程能够拿到锁)。

我们来看一个例子,多个协程同时执行n++,没有使用互斥锁时,结果是怎么样的?

func TestMutex(t *testing.T) {
	var count int
	var wg sync.WaitGroup
	wg.Add(2)
	for i := 0; i < 2; i++ {
		go func() {
			defer wg.Done()
			for i := 1000000; i > 0; i-- {
				count ++
			}
			//fmt.Println(count)
			t.Log(count)
		}()
	}

	wg.Wait()
	t.Log(count)
	//fmt.Scanf("\n")  //等待子线程全部结束
}
=== RUN   TestMutex
    31_test.go:140: 999373
    31_test.go:140: 1004515
    31_test.go:145: 1004515
--- PASS: TestMutex (0.00s)
PASS

从输出的结果可以看到,n最终的结果并不是2百万,而是比2百万小。很明显这里不符合原子性,具体原因我上面也有写到。

接下来我把代码加上互斥锁,再看下结果如何:

func TestMutex1(t *testing.T) {
	var count int
	var wg sync.WaitGroup
	wg.Add(2)

	var coutGuard sync.Mutex

	for i := 0; i < 2; i++ {
		go func() {
			defer wg.Done()
			for i := 1000000; i > 0; i-- {
				coutGuard.Lock()
				count ++
				coutGuard.Unlock()
			}
			t.Log(count)
		}()
	}

	wg.Wait()
	t.Log(count)
	//fmt.Scanf("\n")  //等待子线程全部结束
}
=== RUN   TestMutex1
    31_test.go:164: 1936897
    31_test.go:164: 2000000
    31_test.go:169: 2000000
--- PASS: TestMutex1 (0.04s)
PASS

这个结果就对了。

读写锁(sync.RWMutex)

在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更加高效。

读写锁分为:读锁和写锁

  • 如果设置了一个写锁,那么其它读的线程以及写的线程都拿不到锁,这个时候,与互斥锁的功能相同
  • 如果设置了一个读锁,那么其它写的线程是拿不到锁的,但是其它读的线程是可以拿到锁

因此读写锁也对应四个方法:

  • Lock 加写锁
  • Unlock 释放写锁
  • RLock 加读锁
  • RUnlock 释放读锁

下面还是分别用读写锁的读锁和写锁分别实现上面的count++

  • 写锁
    其实代码和使用互斥锁唯一的区别就是var countGuard sync.RWMutexvar countGuard sync.Mutex
func TestRWMutex(t *testing.T) {
	var count int
	var wg sync.WaitGroup
	wg.Add(2)

	var countGuard sync.RWMutex

	for i := 0; i < 2; i++ {
		go func() {
			defer wg.Done()
			for i := 1000000; i > 0; i-- {
				countGuard.Lock()
				count ++
				countGuard.Unlock()
			}
			t.Log(count)
		}()
	}

	wg.Wait()
	t.Log(count)
	//fmt.Scanf("\n")  //等待子线程全部结束
}
=== RUN   TestRWMutex
    31_test.go:188: 1979979
    31_test.go:188: 2000000
    31_test.go:193: 2000000
--- PASS: TestRWMutex (0.08s)
PASS
  • 读锁
func TestRWMutex1(t *testing.T) {
	var count int
	var wg sync.WaitGroup
	wg.Add(2)

	var countGuard sync.RWMutex

	for i := 0; i < 2; i++ {
		go func() {
			defer wg.Done()
			for i := 1000000; i > 0; i-- {
				countGuard.RLock()
				count ++
				countGuard.RUnlock()
			}
			t.Log(count)
		}()
	}

	wg.Wait()
	t.Log(count)
	//fmt.Scanf("\n")  //等待子线程全部结束
}

你们先自己想下结果……
代码中使用的是读锁,当一个goroutine获得读锁时,其他goroutine可以获取读锁,但是不能获得写锁,这就会导致两个goroutine读取的count可能会是一样的,最终n++的结果也一样。
所以每次执行的结果都不一样。

=== RUN   TestRWMutex1
    31_test.go:212: 1237935
    31_test.go:212: 1289029
    31_test.go:217: 1289029
--- PASS: TestRWMutex1 (0.11s)
PASS

不是说读写锁比互斥锁性能要高吗,那为什么上面的测试是互斥锁执行时间短呢? 别急,关注我,跟着我学习,为了防止篇幅过长,后面我会写文章再细讲

支持🤟


  • 🎸 [关注❤️我吧],我会持续更新的。
  • 🎸 [点个👍赞吧],码字不易麻烦了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值