本文代码基于linux 4.19.195 笔者最近遇到了一个workqueue导致性能问题,引发了笔者对workqueue机制的探索和思考。 简单的抽象后,问题是这样的: 一共有两个进程,假设称之为a进程和b进程。a进程在等待b进程完成一些工作,b进程在完成工作后会调用相关接口通知a进程。b进程完成工作的流程的最末尾的位置,有如下两个关键步骤:
- 首先调用schedule_work_on触发一个work 1,b进程只需要触发即可,无需等待work 1完成;
- 然后再调用work_on_cpu(本质上也是schedule_work_on)完成触发一个work 2,然后等待work 2完成后再返回。
在之前的代码里,是没有步骤1的,性能是正常的,耗时约5s。 在近期的开发里,把步骤1加入后,发现性能下降了3倍,耗时15s。
触发了该问题后,先抓了正常代码和异常代码的on-cpu火焰图,发现火焰图长的几乎一模一样。看来,并不是代码执行的时候有性能问题。 其次,check了一下work 1的代码,发现work 1的代码是cpu消耗型,但是在循环里有cond_resched(),也就是说,按理不会一直占着cpu不放,即使是和其他进程抢着cpu使用,那也不至于多花10s的时间。 思考了一下,会不会是这样呢:work 1和work 2都在队列里排队,然后work1先执行,但是一直没有返回,然后导致了work 2一直得不到执行,从而造成了性能的下降。 那么,抓了一下top的输出,发现,每次a进程成功返回的时候,基本和work 1退出的时间吻合。 但是,想了想,还是说不通,因为,workqueue有着自己的管理机制,在任务多的时候,是会通过创建多个kworker去执行的。
static int worker_thread(void *__worker)
{
***
if (unlikely(!may_start_working(pool)) && manage_workers(worker))
goto recheck;
WARN_ON_ONCE(!list_empty(&worker->scheduled));
worker_clr_flags(worker, WORKER_PREP | WORKER_REBOUND);
do {
struct work_struct *work =
list_first_entry(&pool->worklist,
struct work_struct, entry);
pool->watchdog_ts = jiffies;
if (likely(!(*work_data_bits(work) & WORK_STRUCT_LINKED))) {
process_one_work(worker, work);
if (unlikely(!list_empty(&worker->scheduled)))
process_scheduled_works(worker);
} else {
move_linked_works(work, &worker->scheduled, NULL);
process_scheduled_works(worker);
}
} while (keep_working(pool));
***
}
may_start_working()会判断pool中是否有idle状态工作线程。如果没有,那么会调用manage_workers()创建一些工作线程,在manage_workers()函数中会把这些kworker线程状态设置成idle。也就是说,workqueue这个系统,除了正在执行的kworker线程,至少会在该work_pool保留一个idle的kworker线程。当然,workqueue模块也不会放任idle的kworker数量不断增长,这个机制可以查看idle_worker_timeout函数,不过,根据too_many_workers()函数的逻辑,感觉一般情况下至少会保留2个idle的kworker
static bool manage_workers(struct worker *worker)
{
struct worker_pool *pool = worker->pool;
if (pool->flags & POOL_MANAGER_ACTIVE)
return false;
pool->flags |= POOL_MANAGER_ACTIVE;
pool->manager = worker;
maybe_create_worker(pool);
pool->manager = NULL;
pool->flags &= ~POOL_MANAGER_ACTIVE;
wake_up(&wq_manager_wait);
return true;
}
理论上走不通了,那就继续复现抓线索。发现,在a进程等待的第5秒到第15秒中,一直有一个进程处于D状态,而且,这个进程就是b进程,并且,调用栈就是在等待work 2的完成。 看来,上面分析的没有错,确实就是work 1和work 2都在队列上,然后work 1一直没执行完,从而导致的性能下降。 看来,我对workqueue的机制还是没有理解透彻,继续翻看代码。 我们插入work的时候,调用的是schedule_work_on()函数,这个函数最终会调用到insert_work()函数
static void insert_work(struct pool_workqueue *pwq, struct work_struct *work,
struct list_head *head, unsigned int extra_flags)
{
struct worker_pool *pool = pwq->pool;
set_work_pwq(work, pwq, extra_flags);
list_add_tail(&work->entry, head);
get_pwq(pwq);
smp_mb();
if (__need_more_worker(pool))
wake_up_worker(pool);
}
注意最后两行代码,从字面上来看,插入这个work到队列里面后,先通过函数__need_more_worker(pool)判断是否需要一个新的kworker来处理他,如果需要,则调用wake_up_worker(pool)函数完成kworker的唤醒操作。 显然,在这个问题中,__need_more_worker(pool)函数返回的值是false。
static bool __need_more_worker(struct worker_pool *pool)
{
return !atomic_read(&pool->nr_running);
}
看来,如果这个worker_pool里有kworker在跑,在insert_work()时就不会唤醒idle的kworker来处理下一个在排队的work。这样的策略,在这个问题的场景下,就会导致性能问题。这个策略,其实,也不是说不合理,毕竟,有些work只是不适合在中断里处理,从而才放到workqueue里面来做,并不是说每个work都会处理很长的时间。从而,这样的策略,能够减少kworker被唤醒的次数,从而可以为系统减压,当然,带来的结果就是潜在的效率的下降。感觉,这个策略是一个折中的策略,毕竟,如果唤醒一个kworker,起来做了那么多准备,最后只是执行几行代码这个work就退出了的话,确实也太浪费cpu资源了。 好吧,到此为止至少问题是分析清除了,那么该如何解决呢? schedule_work_on函数使用的是system_wq这个workqueue,那我们自己调用create_workqueue()创建一个新的workqueue能不能解决呢? 答案是不行。因为基于workqueue的设计理念(这里就不讲了,请自行百度),create_workqueue()创建一个新的workqueue最后大概率也是会和system_wq用同一个work_pool,这样的话问题依然存在。 看了下代码,发现用WQ_CPU_INTENSIVE这个flag创建一个workqueue是最合适的,这个flag创建的workqueue,其work会在单独一个kworker上执行,而不会和其他work使用同一个kworker线程,详情见process_one_work函数中的cpu_intensive临时变量。 嗯,改代码,验证,性能问题成功fix。
趁周末时间,再次了解了一下workqueue调度的思想:
如果有 work 需要处理,保持一个 running 状态的 worker 处理,不多也不少。 但是这里有一个问题如果 work 是 CPU 密集型的,它虽然也没有进入 suspend 状态,但是会长时间的占用 CPU,让后续的 work 阻塞太长时间。 为了解决这个问题,CMWQ 设计了 WQ_CPU_INTENSIVE,如果一个 wq 声明自己是 CPU_INTENSIVE,则让当前 worker 脱离动态调度,像是进入了 suspend 状态,那么 CMWQ 会创建新的 worker,后续的 work 会得到执行
看来,workqueue确实不愿意使用多个kworker线程处理work,而是依靠一个kworker一个个的把work处理完。
|