使用实时调度策略和无锁队列踩坑记录

在linux上开发应用,当有比较高的性能要求或确定性要求时,我们往往会使用实时调度策略,当需要使用队列时,也往往会用到无锁队列。这两者的使用往往能非常明显的提高性能和确定性表现。在某些情况下也会带来问题。本文对一次问题分析过程进行记录。

在分析问题之前,先想清楚两个问题:

问题一:实时调度策略一定优先于普通调度策略,那么会不会存在实时线程得不到调度,而普通线程得到调度的情况?

存在。

linux 调度策略的几点理解_linux调度策略-CSDN博客

在linux中,特别是打了RT-patch的linux内核,实时调度策略(SCHED_FIFO、SCHED_RR)一定会优先于普通调度策略进行调度的,具有绝对的优先级;而实时调度策略,优先级高的又比优先级低的线程被优先调度。也就是说如果有实时线程和普通线程同时需要运行,那么实时线程一定会抢占普通线程。

cat /proc/sys/kernel/sched_rt_runtime_us

950000

linux中很多普通的功能,也是非常重要的,比如ssh,shell等,如果这些功能不能用,那么你想关机也没机会执行。所以linux中通过一个配置来限制实时调度策略最多可以使用的cpu时间,默认情况下是95%,也就是说在1s的时间,实时调度策略最多只能占用950ms的时间,而剩余50ms的时间被普通线程使用。个人认为,95%的比例,在很多场景下都够用了。

那么我们想一种场景,有3个线程,t1、t2、t3,t1的调度策略为SCHED_FIFO、优先级为1,t2的调度策略为SCHED_FIFO、优先级为2,t3的调度策略为SCHED_NORMAL,那么t2优先于t1被调度,t1优先于t3被调度。假如t2陷入了死循环,那么t2就会占用95%的cpu,而剩余的5%的时间会被t3占用,那么t1就得不到调度。t1得不到调度,而t3得到了调度,并不是因为t3的优先级比t1高,而是因为t2已经把实时调度策略的份额完全用光。

问题二:使用无锁队列的try_push和try_pop,线程会在这两个api里死循环吗?

一般情况下,我们会认为try_push中会判断有没有剩余空间来盛放新的元素,如果有,那么就push成功,如果没有就通过返回值来标记,没有空间了,push失败,线程并不会在try_push内部陷入死循环;try_pop同理。

问题分析过程

问题背景

MPMCQueue/include/rigtorp/MPMCQueue.h at master · rigtorp/MPMCQueue · GitHub

使用了MPMCQueue,多生产者,单消费者。队列中都是要执行的任务,也就是函数。

生产者和消费者均配置了实时调度策略,其中生产者的调度策略,根据业务特点,有的优先级大于消费者,有的小于消费者。

生产者通过try_push将元素入队;

消费者通过try_pop将元素出队,代码如下,只要try_pop成功,那么就执行item,否则通过条件变量进行wait。

while (true) {

std::function< void() > item;

if (queue_->try_pop(item)) {

item();

} else {

cond_.wait_for(lock, std::chrono::milliseconds(10), this { return !queue_->empty(); });

}

}

问题现象

消费者线程会偶发占用cpu到95%,因为有参数/proc/sys/kernel/sched_rt_runtime_us限制,所以最多只能占用95%,不会占到100%。

用户线程陷入到了死循环,最先想到的是执行的item中陷入了死循环,通过走查代码,发现不会。

在各个位置增加计数器,看看陷入死循环的时候,哪些计数器在增长,哪些计数器没有再增长,以此来判断哪段代码陷入了死循环,使用另外一个普通线程周期打印各计数器,同时也打印队列中元素的个数。

最后发现,队列中有元素的时候,try_pop返回false,直接进入条件变量wait分支,但是条件变量的wait会先判断队列是否为空,如果不为空那么就不会进入wait,而是会直接返回。

那么问题就是队列不为空,的时候,为什么try_pop会返回false呢?

try_push最终会调用到try_emplace,head是入队的索引,turn表示轮次,偶数表示可写,奇数表示可读,当条件turn(head) * 2 == slot.turn.load(std::memory_order_acquire)成立时,则可写。

cpp 复制代码
  template <typename... Args> bool try_emplace(Args &&...args) noexcept {
    static_assert(std::is_nothrow_constructible<T, Args &&...>::value,
                  "T must be nothrow constructible with Args&&...");
    auto head = head_.load(std::memory_order_acquire);
    for (;;) {
      auto &slot = slots_[idx(head)];
      if (turn(head) * 2 == slot.turn.load(std::memory_order_acquire)) {
        //可写,可入队,首先占住head的位置
        if (head_.compare_exchange_strong(head, head + 1)) {
          //数据填充
          slot.construct(std::forward<Args>(args)...);
          //将turn改为奇数,表示可读,可出队
          //可以看到无锁队列典型的三段式,每种无锁队列实现的方式可能不一样
          //但是基本上都是三段式
          //入队:占空->填充->提交
          //出队:占空->出队->提交
          slot.turn.store(turn(head) * 2 + 1, std::memory_order_release);
          return true;
        }
      } else {
        auto const prevHead = head;
        head = head_.load(std::memory_order_acquire);
        if (head == prevHead) {
          return false;
        }
      }
    }
  }

如果如果生产者线程已经进入分支if (head_.compare_exchange_strong(head, head + 1)) ,但是还没有提交,这个时候就被更高优先级的线程抢占。此时,因为head已经完成了+1操作,那么empty就会返回false,也就是说队列中有元素,不为空。

cpp 复制代码
  ptrdiff_t size() const noexcept {
    // TODO: How can we deal with wrapped queue on 32bit?
    return static_cast<ptrdiff_t>(head_.load(std::memory_order_relaxed) -
                                  tail_.load(std::memory_order_relaxed));
  }

  bool empty() const noexcept { return size() <= 0; }

此时队列不为空,所以可以进行try_pop,而try_pop中条件if (turn(tail) * 2 + 1 == slot.turn.load(std::memory_order_acquire))不成立,所以返回false。所以造成了消费者死循环,那么如果被抢占的生产者优先级比消费者优先级低,那么改生产者永远得不到执行,那么也永远不会提交。即使消费者被比它优先级高的生产者抢占,并且提交成功,那么在当前tail索引下,判断一直是不可读,所以也一直在这里卡着,继续死循环。

cpp 复制代码
  bool try_pop(T &v) noexcept {
    auto tail = tail_.load(std::memory_order_acquire);
    for (;;) {
      auto &slot = slots_[idx(tail)];
      if (turn(tail) * 2 + 1 == slot.turn.load(std::memory_order_acquire)) {
        if (tail_.compare_exchange_strong(tail, tail + 1)) {
          v = slot.move();
          slot.destroy();
          slot.turn.store(turn(tail) * 2 + 2, std::memory_order_release);
          return true;
        }
      } else {
        auto const prevTail = tail;
        tail = tail_.load(std::memory_order_acquire);
        if (tail == prevTail) {
          return false;
        }
      }
    }
  }

该问题的根本原因是empty返回false时,并不能说明队列中一定有元素,因为真正标记有元素的是turn,turn为奇数,才表示元素已经提交了。

1、MPMCQueue用head和turn,tail和turn来维护队列的头和尾;而dpdk中的无锁队列,头和尾均用两个索引来维护,占用一个索引,提交一个索引,判空或者满,通过提交索引来判断。很显然,dpdk这种实现更不易出错。

2、实时调度策略有优点,但是也有缺点,缺点就是,一旦陷入死循环,并且绑核,那么绑到相同核上的低优先级实时任务完全得不到执行。不想普通调度策略,使用完全公平调度算法,不会永远饿死,总会得到执行的机会。

相关推荐
赴生-1 小时前
C++进阶 智能指针
开发语言·c++
AI thought1 小时前
C语言、C++与C#深度研究报告:从底层控制到现代企业级开发的演进
c语言·c++·c·内存管理·编译模型
我命由我123451 小时前
RFID 技术极简理解
java·c语言·c++·嵌入式硬件·物联网·visualstudio·java-ee
格发许可优化管理系统1 小时前
Mentor许可证与其他软件许可证的深度比较
java·大数据·运维·c语言·c++·算法
吃着火锅x唱着歌1 小时前
深度探索C++对象模型 学习笔记 第六章 执行期语意学(1)
c++·笔记·学习
xxwl5852 小时前
工作室小测的部分记录
c++·学习·算法
程序员zgh2 小时前
C++ 万能引用与完美转发
c语言·开发语言·c++·经验分享·学习
智者知已应修善业2 小时前
【51单片机串口通信甲机四个按键模拟四位二进制值发送乙机以十进制显示2位数码管】2024-6-14
c++·经验分享·笔记·算法·51单片机
郝学胜_神的一滴2 小时前
CMake 018:解决头文件编译失效\&VS项目无法展示头文件难题
c++·cmake