这是我纯手写的《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 = 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并没有被初始化,就会导致程序出错。
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中的互斥锁
无论用channel还是用互斥锁,都需要损耗更多性能用于同步和保证并发安全,因为原本数据的修改由并发变成时串行。
为什么使用channel的耗时比使用互斥锁耗时多这多,因为数据在多个goroutine中传递代价要远大于简单的上一个锁,而且这个差距放大一亿倍后尤为明显。
Go语言是更倾向于用CSP模式的并发(也就是使用channel)来替代传统的基于共享变量的并发,Go(和Go的发明者)认为“不要使用共享数据来通信;而是使用通信来共享数据”,这句话进一步体现了channel在go中的重要性。
当然这不意味这我们不需要学习传统的并发机制,还是那句话不同的技术适用于不同的应用场景,在一些场景下用传统的并发机制比channel更合适或者逻辑的实现更简单。就像上面的例子,用channel也能做到数据安全,但是程序的设计就因为用了channel而复杂了很多。
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),它比互斥锁更加高效。
读写锁分为:读锁和写锁
因此读写锁也对应四个方法:
下面还是分别用读写锁的读锁和写锁分别实现上面的count++
var countGuard sync.RWMutex
和var 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
不是说读写锁比互斥锁性能要高吗,那为什么上面的测试是互斥锁执行时间短呢? 别急,关注我,跟着我学习,为了防止篇幅过长,后面我会写文章再细讲