原子性、可见性、有序性 | 青训营笔记

106 阅读3分钟

原子性

定义:一个操作或多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

在多线程并发编程中,由于多个线程可能同时访问同一变量,如果没有保证原子性,就会出现多个线程同时修改同一变量的情况,从而导致数据出现错误,即竞态条件

为了避免竞态条件的发生,我们需要使用原子操作来保证对变量的操作都是原子性的。原子操作在执行过程中不会被中断,具有排他性,即同一时刻只有一个线程可以执行原子操作,从而避免的多个线程同时修改同一变量的情况。

package main
​
import (
    "fmt"
    "sync/atomic"
)
​
func main() {
    var num int32 = 0
    var delta int32 = 1
​
    // 原子性地增加 num 的值
    atomic.AddInt32(&num, delta)
​
    fmt.Println(num) // 输出 1
}

可见性

定义:一个线程对共享变量的修改对于其他线程是可见的。

由于每个线程都有自己的本地缓存(线程读取变量时,先从内存中读取到缓存,并在缓存中修改),所以一个线程对共享变量的修改可能不会立即被其他线程看到,这就可能导致其它线程读取到的是修改前的变量值,而不是修改后的变量值(还在缓存中)。这被称为脏读

下面是一个使用 Mutex 保证可见性的例子:

package main
​
import (
    "fmt"
    "sync"
)
​
var num int
var mutex sync.Mutex
​
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mutex.Lock()
            num++
            mutex.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println(num) // 输出 10
}

锁的机制会带来一定的开销(并行:多人同行的通道,锁:通道只允许单人通过)

有序性

为了优化程序性能,编译器、处理器和运行时会对代码指令进行重排,重排过程依照无拓扑关系的原则可能会对语句进行打乱顺序。

finish := false
func test(){
    ...
    val := Node{} // 语句一,新建一个对象
    finish = true // 语句二
}

在上面的代码中,语句一与语句二无拓扑关系,因此编译器可能会对其重排为:

finish = true // 语句二
val := Node{} // 语句一

那么如果我们对test函数启用多个协程处理,并且存在另一个函数:

func check(){
    if finish{
        ...
    }
}

就可能会出现finish已经为true,但是val还没有新建对象的情况,此时调用val,比如说它有一个内置函数,调用该函数就会出错。

脏读

image-20230515170511849.png

B修改数据,但还未最终提交到数据库,这是A来读了,读到了B修改的数据。但是B发现出错了,进行事务回滚,此时A读到的就是错误的数据。

不可重复读

image-20230515170701260.png

A为了保证读取数据的准确性,前后读取两次判断是否出错。A读完第一次后,B对该数据修改了,A又读了一次,发现读的不一样,这就叫不可重复读

幻读

image-20230515171108166.png

A想插入一条数据。在读读下,A读了两次数据,B在A读第一次后,往数据表里插入了同一条数据,因此,A读了两次,都没发现数据表里有这条数据,于是A也插入这条数据,但是报错了,提示插入失败。这就叫幻读