GO基础学习(12)内存重排

2023-05-06  本文已影响0人  温岭夹糕

写在开头

非原创,知识搬运工/整合工,仅用于自己学习,本文是对曹大内存重拍文章的阅读

往期回顾

  1. 基本数据类型
  2. slice/map/array
  3. 结构体
  4. 接口
  5. nil
  6. 函数
  1. GO调度器
  2. GMP介绍
  3. 调度器初始化
  4. 循环调度

带着问题去阅读

  1. 什么情况下会发生内存重排
  2. 内存重排的概念
  3. 内存重排的本质
  4. CPU的缓存策略是什么
  5. 如何避免内存重排

由问题引出主题

demo1

func TestProblem1(t *testing.T) {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
    // 10 10 10 10 10
    time.Sleep(2 * time.Second)
}

这里相信在学过之前的函数和GMP后都能分析出输出10的原因(runtime.GOMAXPROCS<1表示设置与核数相同的线程数量):

  1. 设置最大线程数为1,即一个P处理G
  2. 循环生成G,但是一个G的生成并不代表立即执行(要等待调度)
  3. 所有G生成完后,最后生成的G加入到p.runnext最先被执行,其余因为runnext已经被占用按顺序入队(runqput函数规则)
  4. go1.13版本time会额外生成一个G,但是之后就不会了,这里模拟阻塞
  5. 执行G时i不是参数,也不是函数局部变量,从括号外层层往上找,此时循环执行完,i=10,因此全部为10

修改一下demo1为demo2

func TestProblem2(t *testing.T) {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10; i++ {
        i := i
        go func() {
            fmt.Println(i)
        }()
    }
    // 9 0 1 2 3
    time.Sleep(2 * time.Second)
}

这里因为i与循环的i不是同一个,因此为这样输出
上面例子是对GMP的一个简单复习,这里因为是模拟单核的缘故没有存在数据竞争,那我们再模拟多核呢?
demo3

func TestProblem3(t *testing.T) {
    var x int
    threads := runtime.GOMAXPROCS(0)
    fmt.Println("threads = ", threads)
    for i := 0; i < 8; i++ {
        go func() {
            for {
                x++
            }
        }()
    }
    time.Sleep(2 * time.Second)
    fmt.Println("x =", x)
}

最终结果输出

threads =  8
x = 0

为什么,不是应该存在数据竞争,x的值应该是一个大于0随机数的呀,不可能G都没执行吧,这个情况就要引入今天的主题内存重排

1.内存重排

内存重排指内存的读/写指令重排

一看到指令就想到CPU的汇编代码了,又要头疼。
我们引用曹大博客中的例子再来详细体验下内存重排

var x, y int
//G1
go func() {
    x = 1 // A1
    print(y) // A2
}()
//G2
go func() {
    y = 1                   // A3
    print(x) // A4
}()
image.png

多核情况下G1和G2是等价的,那么就会出现下面几种我们认为合理的情况:

  1. 执行顺序:a1-2-3-4,输出 01
  2. 执行顺序:a3-4-1-2,输出 01
  3. 执行顺序:a1-3-2-4,输出 11
  4. 执行顺序:a1-2-4-2,输出 11
    还有几意外的情况就是输出先于赋值执行的:
  5. 执行顺序:a2-4-1-3,输出 00
  6. 执行顺序:a1-4-3-2,输出 10
相比而言,意外情况的第二种更可能发生,也就是上面的demo3 image.png

那么我们就有如下猜想:众所周知,用户的代码最终会转为CPU指令,CPU的设计者为了榨干CPU无所不用,那为了提高CPU读写内存的效率(写指令更耗时),会不会对读写指令进行了重新排序?
实际上不是指令排序引起的,是CPU的缓存引起的,我们要从CPU的发展架构讲起

1.1CPU架构的变迁

CPU1.0


image.png

每次操作直接操作内存
CPU2.0


image.png
每个核中加入了高速缓存cache,写数据访问流程:
1.先去高速缓存中查看有无,有则直接修改cache数据

2.没有就去内存中读
3.内存读完后写入高速缓存
但是同时也带来了新问题,访问共享数据如上文的x和y,如何保证每个核上的cache数据一致呢,特别是共享数据x和y,那就是引入缓存一致协议,即如果是内存写的操作就需要广播,让其他核也一起更新,等都更新完后才返回。
这种协议是在牺牲性能的代价上换取数据的完整性
CPU3.0天降猛男(三级缓存策略)


image.png

三级缓存如何保证数据一致性?这里只是简单总结下写协议(实际很复杂):
1.cache中无数据则直接写入cache,无需广播
2.cache有数据写入L3,然后直接返回

  1. L3异步同步数据
    所以就会出现这种情况,缓存数据还没同步到其他核,该旧数据就被读取了,称为脏读,读取的旧数据为脏数据(读数据是先从cache中读,不在再从内存读,cache中的数据有4种状态)
    回到上文的xy代码


    image.png
    当a1执行后,不需要等待x=1进入L3就可以执行a2代码了,因为两个核是同时执行的G2也这样 image.png
    神奇的情况发生了(此时两个核都分别没有y数据和x数据,读取时需要从内存读),a2和a4都从内存中读,但是L3还没更新,读取到了脏数据0
    demo3是因为在循环中不断的更新x,不断刷新缓存,让L3来不及异步更新

总结与展望

内存重排实际是多线程情况下,CPU核缓存未更新导致的诡异现象,解决办法就是实验锁。但锁会带来性能问题,为降低影响,需要减小锁的粒度,并且不在互斥区放入耗时长的操作

参考

  1. 曹大谈内存
  2. CPU三级缓存秘密
  3. CPU数据一致性
  4. 一文讲明白内存重排
  5. 曹大博客
上一篇下一篇

猜你喜欢

热点阅读