美文网首页
GO基础学习(12)内存重排

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

作者: 温岭夹糕 | 来源:发表于2023-05-06 22:57 被阅读0次

    写在开头

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

    往期回顾

    • 基础章节
    1. 基本数据类型
    2. slice/map/array
    3. 结构体
    4. 接口
    5. nil
    6. 函数
    • GMP章节
    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
    • L1在核内(store buffer),存放指令核数据
    • L2在核内(cache),高速缓存,比L1更大
    • L3在核外,缓存队列,所有CPU共享一个L3

    三级缓存如何保证数据一致性?这里只是简单总结下写协议(实际很复杂):
    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. 曹大博客

    相关文章

      网友评论

          本文标题:GO基础学习(12)内存重排

          本文链接:https://www.haomeiwen.com/subject/uwqhsdtx.html