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