美文网首页
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