并发编程中为什么需要加锁

在多线程并发编程过程中,经常会遇到对临界资源的操作逻辑,一般为了操作结果的正确性会使用各种锁来保护临界资源。

并发操作临界资源导致的问题

先看一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"sync"
)

const N = 1000

func main() {
var n int64

wg := sync.WaitGroup{}
wg.Add(N)

for i:=0; i<N; i++{
go func() {
defer wg.Done()
n++ // 第19行代码,变量n记性加1操作
}()
}

wg.Wait()
fmt.Println("n's result is :", n)
}

执行结果:n's result is : 942
现象分析:1000个go协程分别对变量进行加1操作,最终执行结果不是1000。

原因分析

造成现象的原因:

  1. n++不是原子操作
  2. n对于每一个协程来说是全局的临界资源
  3. 1000个协程是并发执行

主要原因分析:

  1. n++为什么不是原子操作
    查看第19行代码的汇编指令,如下所示:
1
2
3
4
5
(/demos/main.go:19)   MOVQ  "".&n+120(SP), AX // 获取变量n的地址存入寄存器AX
(/demos/main.go:19) MOVQ (AX), AX // AX寄存器存储地址的数值存入寄存器AX,即n的值拷贝
(/demos/main.go:19) MOVQ "".&n+120(SP), CX // 获取变量n的地址存入寄存器CX
(/demos/main.go:19) INCQ AX // 对n的值拷贝加一
(/demos/main.go:19) MOVQ AX, (CX) // 把n加一之后的数值存储到变量n的地址空间内

可以看到,简单的一行n++代码对应多条底层汇编指令,而每条汇编指令对应着一个CPU的操作动作。

这些汇编指令归结起来执行了3个操作:

① 获取变量n的值副本存入寄存器

② 对寄存器中n的值副本加一

③ 用寄存器中的值覆盖变量n所在内存地址空间中的值

现象演示

学过计算机微机原理的应该知道,多核CPU的执行过程是并行执行、毫无次序的。

假设当前程序运行在双核CPU之上,这里通过图示来追溯造成问题的原因。

初始状态,内存中变量 n=0。

核心1执行第一条指令,把内存中n的值副本拷贝到寄存器中。

核心2无操作。

核心1执行第二条指令,对寄存器中值+1,核心1寄存器的值此刻为1。

核心2执行第一条指令,把内存中n的值副本拷贝到寄存器中。

核心1执行第三条指令,把寄存器中的值存入变量n所在的内存地址中,覆盖n当前的值0,使等于1。

核心2执行第二条指令,对寄存器中的值+1,核心2寄存器的值此刻为1。

核心1无操作。

核心2执行第三条指令,把寄存器中的值存入变量n所在的内存地址中,覆盖n当前的值1,使等于1。

从上面的操作步骤可以看出,由于多核CPU并发执行指令的次序问题,导致两次加1操作的最终结果为1,而不是2。

同理,文章最开始的程序执行结果不为1000,也是由此导致。

问题解决方案

解决当前问题有两套方案。
方案1:使n++变为原子操作(即底层的三条操作指令 变为 一条指令)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var n int64

wg := sync.WaitGroup{}
wg.Add(N)

for i:=0; i<N; i++{
go func() {
defer wg.Done()
atomic.AddInt64(&n, 1) // 原子操作
}()
}

wg.Wait()
}

方案2:对临界资源加锁保护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
var n int64
mux := sync.Mutex{}

wg := sync.WaitGroup{}
wg.Add(N)

for i:=0; i<N; i++{
go func() {
defer wg.Done()
mux.Lock() // 对临界资源加锁
n++
mux.Unlock() // 对临界资源释放锁
}()
}

wg.Wait()
}

分析:

  1. 方案1使用CPU硬件级别锁
  2. 方案2使用程序级别的锁

总结

在多线程并发编程过程中,经常会遇到对临界资源的操作逻辑,一般为了操作结果的正确性会使用各种锁来保护临界资源。
锁从进程角度来看可以分为两种:

  1. 进程内部锁,多线程对同一进程内的临界资源访问时使用。
  2. 进程外部锁,多个进程对远端临界资源的访问时使用,又叫分布式锁。

通过一段代码引出问题,并通过分析导致问题的原因,引出了锁与原子操作的的使用方法。从而让我们认识到在多线程并发编程过程中使用锁的原因及其重要性。