Linux中的中断处理机制[七]--任务工厂workqueue机制(1)

adtxl
2023-04-26 / 0 评论 / 208 阅读 / 正在检测是否收录...

转载自https://zhuanlan.zhihu.com/p/91106844, 作者兰新宇

image5a53a8133802c81e.png

1. workqueue

Linux 中的 workqueue 机制是中断底半部的一种实现,同时因为其支持用一个线程处理多个小任务,有利于降低资源开销,因此也成为一种通用的任务异步处理的手段。

进入 workqueue 队列处理的任务(work item)在代码中由 "work_struct " 结构体表示:

struct work_struct {
    struct list_head entry;
    work_func_t func;
    atomic_long_t data;
};

其中,"entry" 表示其所挂载的队列节点,"func" 就是要执行的任务的入口函数。而 "data" 表示的意义就比较丰富了:最后的 4 个 bits 是作为 "flags" 标志位使用的,中间的 4 个 bits 是用于 flush 功能的 "color"(flush 的功能是在销毁 workqueue 队列之前,等待 workqueue 队列上的任务都处理完成)。

剩下的 bits 在不同的场景下有不同的含义(相当于 C 语言里的 "union"),它可以指向 work item 所在的 workqueue 队列的地址,由于低 8 位被挪作他用,因此要求 workqueue 队列的地址是按照 256 字节对齐的。它还可以表示处理 work item 的 worker 线程所在的 pool 的 ID(关于 pool 将在本文的后半部分介绍)。

这种在一个 C 语言变量里塞入不同类型数据的方法在 Linux 的代码实现中不难见到,在目前的 workqueue 机制中,"flags" 和 "color" 所需的 bits 都较少,单独使用整形变量去表示确实会增加一定的内存消耗。但这种牺牲可读性的做法也被一些内核开发者认为是比较 "ugly" 的。

image36784760930b9f85.png

为了充分利用 locality,通常选择将处理 hardirq 的 CPU 作为该 hardirq 对应的 workqueue 底半部的执行CPU,在早期 Linux 的实现中,每个 CPU 对应一个 workqueue 队列,并且每个 CPU 上只有一个 worker 线程来处理这个 workqueue 队列,也就是说 workqueue 队列和 worker 线程都是 per-CPU 的,且一一对应。

imagec7d366fc9cb90499.png

让我们看看这种设计存在什么问题。

假设现在一个work item(设为w0)被添加到了workqueue队列上。w0需要运行5ms后休眠10ms,接着再运行5ms。在w0开始运行5ms和10ms后,另外两个work items(设为w1和w2)也分别加入了workqueue队列,w1和w2都是需要运行5ms,然后再休眠10ms(该示例来自内核Documentation/core-api/workqueue.rst文档)。

因为只有1个worker线程,所以即便在执行某个work item的时候休眠,其他的work item也得不到执行,因此将这3个work item执行完毕将总共需要50ms的时间。

image7a472a9d42dc4343.png

假设现在一个CPU上有2个worker线程,分别为worker 1和worker 2,那么整个执行时间将缩短到35ms:

image51e314e8a52b9260.png

如果一个CPU上有3个worker线程,执行时间将进一步缩短到25ms:

image3b455b96b5403dc3.png

2. cmwq

这种在一个CPU上运行多个worker线程的做法,就是 v2.6.36 版本引入的,也是现在Linux内核所采用的concurrency managed workqueue,简称 cmwq。一个CPU上是不可能“同时”运行多个线程的,所以这里的名称是concurrency(并发),而不是parallelism(并行)。

显然,设置合适的worker线程数目是很关键的,多了浪费资源,少了又不能充分利用CPU。大体的原则就是:如果现在一个CPU上的所有worker线程都进入了睡眠状态,但workqueue队列上还有未处理的work item,那么就再启动一个worker线程。

一个CPU上同一优先级的所有worker线程(优先级的概念见下文)共同构成了一个 worker pool(此概念由内核v3.8 引入),我们可能比较熟悉memory pool,当需要内存时,就从空余的memory pool中去获取,同样地,当workqueue上有work item待处理时,我们就从worker pool里挑选一个空闲的worker线程来服务这个work item。

worker pool在代码中由 "worker_pool" 结构体表示:

struct worker_pool {
    int            cpu;        /* the associated cpu */
    int            id;        /* pool ID */

    struct list_head    worklist;    /* list of pending works */

    struct list_head    idle_list;    /* list of idle workers */
    DECLARE_HASHTABLE(busy_hash, 6);        /* hash of busy workers */
    ...
}

如果一个worker正在处理work item,那么它就是busy的状态,将挂载在busy workers组成的6阶的hash表上。既然是hash表,那么就需要key,充当这个key的是正在被处理的work item的内存地址。

如果一个worker没有处理work item,那么它就是idle的状态,将挂载在idle workers组成的链表上。

前面说过,有未处理的work item,内核就会启动一个新的worker线程,以提高效率。有创建就有消亡,当现在空闲的worker线程过多的时候,就需要销毁一部分worker线程,以节省CPU资源。就像一家公司,在项目紧张,人员不足的时候需要招人,在项目不足,人员过剩的时候可能就会裁员。

这种动态的资源伸缩,就有点“云”的味道了,即根据负载的变化,动态地扩容和缩容,目标是用尽可能少的资源,来完成尽可能多的事。至于保留多少空闲线程可以取得较理想的平衡,则涉及到一个颇为复杂的算法,在此就不展开了。

imaged992e1173bd080cc.png

worker 线程在代码中由"worker" 结构体表示:

struct worker {
    struct worker_pool     *pool;        /* the associated pool */
    union {
        struct list_head  entry;    /* while idle */
        struct hlist_node hentry;    /* while busy */
    };

    struct work_struct    *current_work;      /* work being processed */
    work_func_t         current_func;      /* current_work's fn */
    struct task_struct    *task;          /* worker task */

    struct pool_workqueue    *current_pwq;     /* current_work's pwq */
        ...
}

其中,"pool"是这个worker线程所在的worker pool,根据worker线程所处的状态,它要么在idle worker组成的空闲链表中,要么在busy worker组成的hash表中。

"current_work"和"current_func"分别是worker线程正在处理的work item和其对应的入口函数。既然worker线程是一个内核线程,那么不管它是idle,还是busy的,都会对应一个task_struct(由"task"表示)。

imagef5e66739ce177faf.png

"current_pwq"指向被服务的work item对应的workqueue队列,关于workqueue队列的介绍,以及它与worker pool之间的交互,将在下文讲解。

0

评论 (0)

取消