首页 > 系统相关 >内存屏障踩坑

内存屏障踩坑

时间:2023-04-03 09:23:59浏览次数:44  
标签:负载 task rq 队列 屏障 内存 自旋 cpu

内存屏障踩坑

最近为了给linux系统装上一个新的scheduler,连续一周在熬夜看linux的内核源码。打算等有时间出一个详细的教程怎么搞这类东西作为存档,也要再学习一下。但是这不是今天的主题,今天的主题是一个非常坑爹的bug。

在linux内核模块中,调度器为了提高性能,在每次进行调度的时候,除了会使用各个scheduler class自己提供的pick_next_task方法之外,还会做一些负载均衡的工作。如果我们启用了SMP,也就是Symmetric Multi-Processor,那么在每次调度的时候,还会调用balance方法,这个方法会在每个cpu上找到一个负载最低的cpu,然后将这个cpu上的一个task迁移到负载最高的cpu上。这个方法的实现在kernel/sched/中.

因为是每次调度的时候都会使用,所以这个方法的安全和性能对于整个系统来说都非常重要。假如我们在某些地方写了死锁,那么在一些并发量比较低的场景可能根本不会形成环路等待条件,就算形成了,也一般不会有什么严重后果,但是如果这个方法出了问题,那么就会导致整个系统的负载不均衡,甚至死锁,这个问题就非常严重了。(哭了,这个方法写崩对于系统是有不可逆的损伤的,我重装了至少5次Kernel)

然后在写balance函数的时候,我们观看了一下实施调度器rt.c里面的实现,大概程序可以分为以下几步

  • 先从外部环境解锁当前执行队列
  • 然后判断有没有需要负载均衡的cpu
  • 如果没有,那么就直接返回,如果有,那么就先拿到负载最高的cpu的执行队列的自旋锁,还得同时拿到本队列的自旋锁
  • 那么就从负载最高的cpu上拿出一个task,然后放到负载最低的cpu的对应的执行队列上,cpu设置mask一下
  • 最后再把自旋锁都解锁,再给外部环境加锁
static inline void balance_xx(struct rq *rq, struct task_struct *p, struct rq_flag *rf)
{
    外部环境加锁();
    
    判断有没有过载cpu();

    if (没有需要负载均衡的cpu)
        return;

    负载最高的cpu的执行队列的自旋锁();
    本队列的自旋锁();

    从负载最高的cpu上拿出一个task();

    本队列的自旋锁解锁();
    负载最高的cpu的执行队列的自旋锁解锁();

    外部环境解锁();
}

比如我们可以看到rt.c里面的实现,这个实现是比较简单的,因为rt的调度器是不会有负载均衡的,所以直接返回就好了。

static void balance_rt(struct rq *rq, struct task_struct *p, struct rq_flag *rf)
{
    if (task_on_rq_queued(p) && need_pull_rt_task(rq)) {
        rq_unpin_lock(rq, rf);
        pull_rt_task(rq);
        rq_repin_lock(rq, rf);
    }
}

这么来的,我们于是也写了个类似的逻辑。

但是,诡异的事情发生了,我们写出来的东西能跑,但不完全能跑。大概10次里面有一次可以正常启动,其他的都会卡在启动的时候,然后我们就开始了一段漫长的debug之旅。多长呢?大概让我这周一整周都干到了凌晨三点。

首先判断是死锁,但是问题来了

  • 既然死锁,为什么偶尔能全部开机?
  • 如果开机的时候会死锁,为什么开机之后系统表现完全正常?

带着这样的疑问我们毫无头绪得看了三天,人都快疯了。然后直到我们选择了重构。

为了解决问题,我队友把上面的函数拆成了pull_task, need_pull_task和balance三个函数。

我选择了直接用手添加spinlock,其他什么也没改,就好了!! 我和队友的程序几乎在同时间恢复正常了。

这就很让人迷惑了,为什么之前不行,但这样就可以了呢?我们的代码逻辑完全一样啊,为什么会出现这样的问题呢?

然后最后总结了一下,发现问题可能出在代码重排上。

因为我们的编译器能够利用的寄存器是有限的,所以在编译的时候,编译器会对代码进行重排,以便能够更好的利用寄存器。但是这个重排是有可能会改变程序的执行顺序的(可这是自旋锁啊!)。虽然我记得代码重排应该至少保证a的读在a的写前面,但是这个重排是有可能会改变程序的执行顺序的。所以我们的代码可能会变成这样


    外部环境加锁();
    
    判断有没有过载cpu();

    if (没有需要负载均衡的cpu)
        return;
    负载最高的cpu的执行队列的自旋锁解锁();
    负载最高的cpu的执行队列的自旋锁();
    从负载最高的cpu上拿出一个task();
    本队列的自旋锁解锁();
    本队列的自旋锁();
    
    

    外部环境解锁();
    

结论

这样的话,就会出现问题了,从负载最高的cpu上拿出一个task() 因为只涉及几个cpu的bitmask,所以可能触发了某些神秘的机制,导致我们还没有加锁的时候就解锁了,或者让我们的两个锁形成了环路等待条件,所以g掉了。而且这个二进制指令一旦被正确生成,就不会再改变了,所以这也解释了我们为什么有些时候运气好开了机,然后程序完全正常,完全没有崩溃。有的开到一般就g了,有的直接冲坏了kernel。

那为什么内核里面的代码不会出现这样的问题呢?

因为代码被放到了两个函数里面,如队友1和rt的代码所作,而且rt里面的加锁其实是放在两个函数和一个for里面去做的,这样的高级控制语句使得编译器不太会对代码进行重排,所以就不会出现这样的问题了。(不确定是不是在某一处加了内存屏障)

如何解决这个问题呢?我们可以在代码里加上内存屏障,来阻止编译器对代码进行重排。但是这个内存屏障会带来一些性能损失,所以我们选择了重构。比如我们可以把代码重构成这样

static inline void balance_xx(struct rq *rq, struct task_struct *p, struct rq_flag *rf)
{
    外部环境加锁();
    
    判断有没有过载cpu();

    if (没有需要负载均衡的cpu)
        return;

    负载最高的cpu的执行队列的自旋锁();
    本队列的自旋锁();

    smp_wmb(); // 内存屏障,阻止编译器对代码进行重排
    从负载最高的cpu上拿出一个task();

    本队列的自旋锁解锁();
    负载最高的cpu的执行队列的自旋锁解锁();

    外部环境解锁();
}

这样的话,就能保证代码的执行顺序了。

标签:负载,task,rq,队列,屏障,内存,自旋,cpu
From: https://www.cnblogs.com/tiany7/p/17282076.html

相关文章

  • 从 JDK 9 到 19,认识一个新的 Java 形态(内存篇)
    前言在JDK9之前,Java基本上平均每三年出一个版本。但是自从2017年9月份推出JDK9到现在,Java开始了疯狂更新的模式,基本上保持了每年两个大版本的节奏。从2017年至今,已经发布了一个版本到了JDK19。其中包括了两个LTS版本(JDK11与JDK17)。除了版本更新节奏明显加快之......
  • Demo03 数据类型 类型转换 内存溢出
    关键字数据类型java是强类型语言要求变量的使用要严格符合规定,所有变量都要先定义后才能使用 Java的数据类型分为两大类基本类型(primitivetype)引用类型(referencetype)  publicclassDemo02{   publicstaticvoidmain(String[]args){   ......
  • 【Java 并发】【五】volatile怎么通过内存屏障保证可见性和有序性
    1 前言这节我们就来看看volatile怎么通过内存屏障保证可见性和有序性。2  保证可见性volatile修饰的变量,在每个读操作(load操作)之前都加上Load屏障,强制从主内存读取最新的数据。每次在assign赋值后面,加上Store屏障,强制将数据刷新到主内存。以volatileintx=0;线程A、B进行......
  • Elasticsearch 学习-Elasticsearch优化,硬件选择,分片策略,写入优化,内存设置,重要配置
    Elasticsearch学习-Elasticsearch优化,硬件选择,分片策略,写入优化,内存设置,重要配置6.1硬件选择Elasticsearch的基础是Lucene,所有的索引和文档数据是存储在本地的磁盘中,具体的路径可在ES的配置文件../config/elasticsearch.yml中配置,如下:#----------------------------......
  • 单片机的内存分配你了解多少呢?
    单片机开发也是嵌入式开发中的一个大群体,有许多的的人是进行单片机逻辑开发的,也有些人是单片机+嵌入式实时操作系统,当然也有单片机+linux+人工智能技术的。当然,不管你是什么样的组合方式,只要你最终开发的产品中有使用到MCU,进行程序开发时,都应该会涉及到内存的分配问题。只要是开发......
  • Python内存管理
    Python内存管理的三个阶段:1.引用计数引用计数是Python内存管理的第一道防线。当一个对象被引用时,Python会为其分配一段内存,并将其引用计数设置为1。当对象被多次引用时,其引用计数会逐渐增加。当一个对象不再被引用时,Python将其引用计数减少1。当一个对象的引用计数变为......
  • Redis——内存淘汰策略
    一、缓存耗尽的原因1、每台机器的内存是一定的2、key未设置过期时间key不设置过期时间则在内存中一直存在,直到我们明确删除它。3、过度或不合理的持久化无论RDB快照或者AOF日志,都会在内存和磁盘中反复操作,需要一定的内存空间。4、不及时清理过期缓存有时过期缓存依旧存在,主......
  • 一维数组内存分析
    Java虚拟机的内存划分为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。区域名称作用虚拟机栈用于存储正在执行的每个Java方法的局部变量表等。局部变量表存放了编译期可知长度<br/>的各种基本数据类型、对......
  • 什么是内存泄漏?哪些情况造成内存泄露?
    内存泄漏是指:一块被分配的内存既不能使用又不能回收,直到浏览器进程结束;以下列举内存泄漏的情况: <body>  <divclass="main">   <divclass="test">天</div>   <divclass="item">天</div>   <divclass="item">向<......
  • 一个对象的内存布局是怎么样的?
      「1.对象头」:对象头又分为 「MarkWord」 和 「ClassPointer」 两部分。「MarkWord」:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位,gc记录信息等等。「ClassPointer」:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占......