
最近,我研究了多线程(协程)方面的知识,对于线程安全有一定的心得,于是写下这篇文章来分享一下确保线程安全的集中方法。
我们来设置这样一个场景,我们原来有1个空钱包,然后在同一时刻有1000个人给我们打款,每个人都打1块钱。那么在打款结束后,我们的钱包里面应该有1000元钱,是线程安全的,如果最后钱包中的钱不是1000元,就表明其中可能有数据的丢失或者重复计算,是非线程安全的。
非线程安全
我们先来看一下非线程安全的例子:
package main
import "fmt"
var balance int32 = 0 // 模拟钱包中的钱数
var ch chan struct{} = make(chan struct{})
func main() {
for num := 1; num < 10; num ++ { // 实验的次数
for i := 1; i <= 1000; i++ { // 模拟1000人同时打钱
go add(1)
}
for i := 1; i <= 1000; i++ { // 确保打款全部打完了再执行下面的代码
<-ch
}
fmt.Println(fmt.Sprintf("第%d次实验,结果为%d",num, balance))
balance = 0
}
}
func add(i int32) {
balance = balance + i
ch <- struct{}{}
}
这里我们使用golang语言中的协程模拟此情景。我们把钱包放在公用变量“balance”中,外层的for表示进行实验的次数(一共进行9次), 内层的使用for循环加“go add(1)” 来模拟1000人同时打款。
我们执行一下程序,得到如下结果:

我们发现,9次实验结果,其中有5次钱包的钱都少了,这是为什么呢?
这是因为 “balance = balance + i” 并不是原子操作,这里主要分为三个步骤:

我们希望程序能够按照下面的流程来执行:

A线程以红色表示,B线程以绿色表示,先执行完线程A,再执行线程B。但在实际的运行中会有概率出现下面这种情况:

当发生上面这种情况时,就会出现钱包少钱的情况。 那我们应该如何防止上面这种情况发生呢?这里我介绍4种方法。
使用锁
锁是最常用的线程安全的做法。 锁就好比是一个令牌,所有线程都需要争夺这个令牌,当某个线程争夺到了令牌才能对数据进行操作,操作完了之后需要放回令牌供其他线程继续争夺。

我们接下来看一下具体的代码:
package main
import (
"fmt"
"sync"
)
var balance int32 = 0
var um sync.Mutex
var ch chan struct{} = make(chan struct{})
func main() {
for num := 1; num < 10; num ++ {
for i := 1; i <= 1000; i++ {
go add(1)
}
for i := 1; i <= 1000; i++ {
<-ch
}
fmt.Println(fmt.Sprintf("第%d次实验,结果为%d",num, balance))
balance = 0
}
}
func add(i int32) {
um.Lock()
defer um.Unlock()
balance = balance + i
ch <- struct{}{}
}
这个代码和之前那个线程不安全的代码相比就在 “add函数” 中多了两行代码。
um.Lock()
defer um.Unlock()
第一行表示“加锁”,如果线程加锁成功(抢到令牌),则继续执行下去,如果加锁不成功,会阻塞住,直到加锁成功。
第二行表示在函数结束的时候释放锁(放回令牌供其他线程抢)。
我们执行一下该代码获得的结果如下:

CAS(Compare and Swap)
虽然锁可以解决问题,但是因为会阻塞线程,锁 的效率并不高,那有没有效率更高的方法呢?有,我们可以使用 硬件同步原语。
** 硬件同步原语** 是由计算机硬件提供的一组原子操作,我们比较常用的原语主要是 CAS 和 FAA 这两种。
我们先来看一下 CAS (Compare and Swap),从字面意思来看,就是 "先比较,再交换"。
我们来看一下CAS的代码:
package main
import (
"fmt"
"sync/atomic"
)
var balance int32 = 0
var ch chan struct{} = make(chan struct{})
func main() {
for num := 1; num < 10; num ++ {
for i := 1; i <= 1000; i++ {
go add(1)
}
for i := 1; i <= 1000; i++ {
<-ch
}
fmt.Println(fmt.Sprintf("第%d次实验,结果为%d",num, balance))
balance = 0
}
}
func add(i int32) {
for {
oldBalance := atomic.LoadInt32(&balance)
newBalance := oldBalance + i
if atomic.CompareAndSwapInt32(&balance, oldBalance, newBalance) {
break
}
}
ch <- struct{}{}
}
这个我们主要关注 "add函数" 中的代码:
- 1.我们通过 “atomic.LoadInt32” 原子性的获取钱包中的值,放在oldBalance中。
- 2.我们通过oldBalance和i相加获得新的newBalance。
- 3.我们通过oldbalance和钱包的值对比,如果相同则证明钱包没有被修改,把newBalance赋值给钱包结束,如果不相等则说明钱包已经被别人修改过了,重新执行操作1。
我们接下来执行一下代码:

使用CAS也可以使线程安全,但如果并发过多,需要重复计算,所以此方法比较消耗cpu,一般用在不需要重试这样的场景下。
FAA
我们接下来看一下FAA。
package main
import (
"fmt"
"sync/atomic"
)
var balance int32 = 0
var ch chan struct{} = make(chan struct{})
func main() {
for num := 1; num < 10; num ++ {
for i := 1; i <= 1000; i++ {
go add(1)
}
for i := 1; i <= 1000; i++ {
<-ch
}
fmt.Println(fmt.Sprintf("第%d次实验,结果为%d",num, balance))
balance = 0
}
}
func add(i int32) {
atomic.AddInt32(&balance, i)
ch <- struct{}{}
}
我们可以看到,FAA就是把 "balance = balance + i"改成了"atomic.AddInt32(&balance, i)"。
FAA比CAS更加简单,并发效率也更加的高,但是FAA只能执行简单的加减计算,而更复杂的计算,则需要使用CAS来执行。
channel
在golang语言中,流传着这样一句话:
Don’t communicate by sharing memory; share memory by communicating.
(不要通过共享内存来通信,而应该通过通信来共享内存。
其中通道类型(channel)恰恰是后半句话的完美实现,我们可以利用通道在多个 goroutine 之间传递数据。
接下来我们就使用channel来解决这一个问题:
package main
import (
"fmt"
"time"
)
var balance int32 = 0
var ch chan struct{} = make(chan struct{})
var bch chan int32 = make(chan int32, 10)
func main() {
go sum()
for num := 1; num < 10; num ++ {
for i := 1; i <= 1000; i++ {
go add(1)
}
for i := 1; i <= 1000; i++ {
<-ch
}
time.Sleep(2* time.Millisecond)
fmt.Println(fmt.Sprintf("第%d次实验,结果为%d",num, balance))
balance = 0
}
}
func add(i int32) {
bch <- i
ch <- struct{}{}
}
func sum() {
for {
i := <- bch
balance = balance + i
}
}
上面的程序流程图大体如下:

- 1.启动1000个线程执行add()方法,把1增加到管道中去。
- 2.先启动一个线程,执行sum方法,先从管道中取出一个值,然后计算并赋值给balance,再重复取值在计算的过程,当管道没有值的时候线程会阻塞住。
可以看到,golang中的管道可以认为是一种队列。
小节
今天我们聊了四种线程安全的方法。
- 1.锁最常用,但效率不高。
- 2.CAS使用场景广,但比较消耗cpu,不适合重复计算的场景。
- 3.FAA效率最高的方法,但只能做简单的加减,使用场景相对较小
- 4.channel就是使用一个队列,做异步操作。