Linux 的 workqueue 机制浅析

简介: ## Introworkqueue 是 Linux 中非常重要的一种异步执行的机制,本文对该机制的各种概念,以及 work 的并行度进行分析,以帮助我们更好地**使用**这一机制;对 workqueue 机制并不陌生的读者也可以直接跳到第四节,即 "Concurrency" 小节,了解 workqueue 机制中 work 的并行度以 v2.6.36 为界,workqueue 存在两个不

Intro

workqueue 是 Linux 中非常重要的一种异步执行的机制,本文对该机制的各种概念,以及 work 的并行度进行分析,以帮助我们更好地使用这一机制;对 workqueue 机制并不陌生的读者也可以直接跳到第四节,即 "Concurrency" 小节,了解 workqueue 机制中 work 的并行度

以 v2.6.36 为界,workqueue 存在两个不同的实现,在此之前的实现我们称为 old-style,之后的即所谓的 CMWQ (Concurrency Managed Workqueue)

old-style

在介绍 CMWQ 之前其实绕不开 old-style workqueue,正是因为 old-style workqueue 存在一系列的问题,才有了后来的 CMWQ

在介绍 old-style workqueue 的问题之前,其实有必要简单介绍一下 old-style workqueue 中 singlethread workqueue 和 multi-thread workqueue 的概念,其中前者整个 workqueue 只有一个 worker,而后者每个 workqueue 有一组 worker

issue 1

首先 old-style workqueue 会创建出大量的 worker,即使其中的大部分可能都无所事事。对于 multi-thread workqueue 来说,每个 workqueue 都分配有一组 per-CPU worker,因而当系统中 workqueue 的数量很多时,就需要消耗大量的 PID 资源,同时大量的 worker 线程也会严重影响系统性能

issue 2

其次,old-style workqueue 很容易发生阻塞。对于 singlethread workqueue,所有 CPU 共享一个 worker,当 worker 处理其中一个 work 阻塞时,之后等待的所有 work 都会阻塞在那里而得不到处理;对于 multi-thread workqueue,每个 CPU 都有一个绑定的 per-CPU worker,同一个 CPU 上的 work 共享一个 worker,因而在执行过程中当其中一个 work 阻塞时,该 CPU 上的其他 work 也必须等待

issue 3

以上描述的 old-style workqueue 容易发生阻塞的问题,还可能会导致死锁问题。例如 work A 依赖于 work B,当这两个 work 都调度到同一个 worker 上时,如果 work A 先于 work B 执行,那么 work A 就会因为依赖于 work B 而进入阻塞,此时整个 worker 都进入阻塞状态,因而也调度不了 worker B,从而造成死锁

CMWQ

Old-style workqueue 在创建 workqueue 的过程中同时创建对应的 worker,此时 workqueue 与对应的 worker 具有严格的对应关系即二元性,这也是以上问题 1 的原因

CMWQ 为了解决这一问题,将 workqueue 与对应的 worker 相分离,由 worker-pool 管理和调度 worker,此时 workqueue 与 worker-pool 是彼此分离的。CMWQ 在初始化时只是创建有限数量的 worker-pool,worker 的调度全部由 worker-pool 管理,之后创建的所有的 workqueue 都共用这些全局的 worker-pool。由于 worker-pool 的数量是有限的,而每个 thread pool 中 worker 的数量也是严格控制的,因而就可以解决问题 1 中 worker 数量过多的问题

此外问题 2 的本质是由于 old-style workqueue 中虽然有 thread pool 的概念,但是这个 thread pool 并不是弹性的,即 thread pool 中 worker 的数量都是定死的,例如 multi-thread workqueue 对应的 thread pool 中 worker 的数量就是 CPU 的数量,singlethread workqueue 对应的 thread pool 中就只有一个 worker,当其中的任一个 worker 阻塞时,thread pool 中都不会再动态创建一个新的 worker 补上

问题 3 实际是问题 2 导致的,只要解决了问题 2,问题 3 自然也就不复存在

CMWQ 中 workqueue 与 worker-pool 是彼此分离的,worker 的调度全部由 worker-pool 管理,因而为了解决问题 2,CMWQ 中 worker-pool 是弹性可伸缩的,即当 worker 陷入阻塞时,worker-pool 会立即创建一个新的 worker 补上,以运行之后的 work

之前介绍过,CMWQ 中 workqueue 与 worker-pool 是彼此分离的,这样 workqueue 与 worker-pool 就是多对多的关系,即一个 workqueue 实际对应多个 worker pool,而一个 worker pool 也负责处理多个 workqueue 提交的 work,因而此时就需要 per-pool workqueue 数据结构来建立 workqueue 与 worker pool 之间的映射关系,即此时每个 (workqueue, worker pool) pair 都有一个对应的 per-pool workqueue

Worker Pool

CMWQ 中 workqueue 与 worker-pool 是彼此分离的,此时 worker-pool 都是全局的,所有 workqueue 会共用这些全局的 worker-pool,在系统初始化时会预先创建或者是在之后动态创建 worker-pool,之后 workqueue 创建过程中会为 workqueue 挑选对应的 worker-pool,即建立两者之间的绑定关系

worker pool 分为 bound worker-pool 与 unbound worker-pool 两种

unbound worker-pool

unbound worker-pool 是指不与单个 CPU 相绑定的 worker-pool,其中的 worker 可以在多个 CPU 上运行,这些 worker 具体可以在哪些 CPU 上运行,是由 unbound worker-pool 的 workqueue_attrs 属性来描述的

每个 worker-pool 都有一个对应的 struct workqueue_attrs 结构来描述其属性

struct workqueue_attrs {
    /* @nice: nice level */
    int nice;

    /* @cpumask: allowed CPUs */
    cpumask_var_t cpumask;
    ...
};

@nice 描述该 worker-pool 的 worker 的 nice 值

@cpumask 描述该 worker-pool 中的 worker 只能在该 cpumask 描述的 CPU 子集上运行,这是通过设置 worker 的 CPU affinity 实现的,至于 worker 最终是在 cpumask 中的哪一个 CPU 上运行的,则是由 scheduler 决定的

@nice 与 @cpumask 的组合多种多样,因而系统中就可能存在多个 unbound worker-pool,内核使用全局的 @unbound_pool_hash hash table 组织系统中的所有 unbound worker-pool

unbound worker-pool 是按需创建的,只有当前需要特定 workqueue attrs 属性对应的 worker-pool,同时 hash table 中不存在该属性对应的 worker-pool 时,才会创建该 worker-pool

bound worker-pool

bound worker-pool 是 per-CPU 的 worker-pool,即每个 CPU 上都有一个对应的 worker-pool,这些 worker-pool 中的 worker 只能在当前对应的 CPU 上运行

与 unbound worker-pool 按需创建不同,bound worker-pool 由系统静态定义,在系统初始化阶段会初始化这些 bound worker-pool

static DEFINE_PER_CPU_SHARED_ALIGNED(struct worker_pool [NR_STD_WORKER_POOLS], cpu_worker_pools);

NR_STD_WORKER_POOLS    = 2,    /* # standard pools per cpu */

每个 CPU 实际上维护两个 bound worker pool,即 normal worker-pool 与 high priority worker-pool,两者的区别是,前者管理的 worker 的 nice 为默认的 0,而后者管理的 worker 的 nice 为 HIGHPRI_NICE_LEVEL 即 -20,因而后者管理的 worker 在调度时具有更高的优先级

bound worker-pool 同样由 workqueue_attrs 来描述其属性,例如对于 CPU 0 上的 high priority worker-pool,就具有如下 workqueue_attrs

  • @cpumask 的值为 0x1,描述该 worker pool 中的 worker 只能在 CPU 0 上运行
  • @nice 的值就为 -20

Workqueue

创建 workqueue 的接口为

alloc_workqueue(fmt, flags, max_active, args...)

@flags 与 @max_active 这两个参数的组合,就描述了该 workqueue 中的 work 在调度、执行时的特性

bound workqueue

alloc_workqueue() 中 @flags 参数不包含 WQ_UNBOUND 标志时,说明此时创建的是 bound workqueue

bound workqueue 创建过程中,会为其挑选对应的 bound worker-pool,即 normal worker-pool 或 high priority worker-pool,具体是由 workqueue 自身的优先级决定的

  • high priority workqueue (@flags 参数包含 WQ_HIGHPRI 标志) 会与每个 CPU 上的 high priority worker-pool 相联系
  • normal workqueue 会与每个 CPU 上的 normal worker-pool 相联系

workqueue 中 per-CPU 的变量 @cpu_pwqs 就描述了该 workqueue 对应的一组 per-CPU worker-pool

struct workqueue_struct {
    struct pool_workqueue __percpu *cpu_pwqs;
    ...
}
    per-CPU @cpu_pwqs   +------------------------+      +---------------------+
                +-----> | pool workqueue (CPU 0) | ---> | worker pool (CPU 0) |
                |       +------------------------+      +---------------------+
+-----------+   |
| workqueue | --+
+-----------+   |
                |       +------------------------+      +---------------------+
                +-----> | pool workqueue (CPU N) | ---> | worker pool (CPU N) |
                        +------------------------+      +---------------------+

提交 work 的入口为 queue_work_on(),表示当前需要将 @wq 中的 @work 提交给 @cpu 上的 worker-pool 处理

bool queue_work_on(int cpu, struct workqueue_struct *wq, struct work_struct *work)

select CPU for bound work

此时将 @work 提交给具体哪个 CPU 上的 worker-pool 处理,具体有以下两个候选

  1. @cpu 参数对应的 bound worker-pool
  2. @work->data 中保存的上一次处理该 work 的 bound worker-pool

具体选择以上的那一个候选,是由以下算法决定的

  • 如果当前是第一次处理该 work,即 work->data 字段为空,那么就选择 @cpu 参数对应的 bound worker-pool,即在 @cpu 参数描述的 CPU 上运行
  • 否则 @work->data 就保存了上一次处理该 work 的 bound worker-pool,此时

    • 如果 @work->data 指向的 worker-pool 当前存在 worker 正在运行这个 work,那么就会让当前这个 work 由 @work->data 指向的 worker-pool 运行,即在 @work->data 参数指向的 CPU 上运行
    • 否则还是选择 @cpu 参数对应的 bound worker-pool,即在 @cpu 参数描述的 CPU 上运行

所谓的 work 添加到 worker-pool 的过程,实际上就是将该 work 提交到对应的 worker-pool 的 @worklist 链表中去,并唤醒该 worker-pool 的 worker,之后 worker 被唤醒后就会处理 worklist 链表中的 work

select CPU for unbound work

以上描述的算法中,@cpu 参数描述了将 @work 提交给哪一个 worker-pool 的其中一个候选,实际上 @cpu 参数的值还可以是 WORK_CPU_UNBOUND,表示当前提交的 @work 实际上是 unbound 的,此时就需要挑选出一个 CPU 出来处理这个 work,具体有以下策略

默认情况下,会选择当前 CPU,即调用 queue_work_on() 时的 CPU,但是前提是当前 CPU 在 @wq_unbound_cpumask 内,@wq_unbound_cpumask 全局参数描述了当前系统中所有 unbound worker pool 默认的 CPU affinity (即 workqueue_attr->cpumask),用户可以通过 /sys/devices/virtual/workqueue/cpumask 修改该参数的值,该参数也适用于 bound workqueue 中 unbound work 的处理

如果当前 CPU 不在 @wq_unbound_cpumask 内,就会回退到按照 round-robin 算法在 @wq_unbound_cpumask 参数描述的一组 CPU 中选出一个 CPU

上文提到的 round-robin 算法,实际上就是在 @wq_unbound_cpumask 参数描述的一组 CPU 中,按照 round-robin 算法选出一个 CPU,当然如果系统中 CONFIG_DEBUG_WQ_FORCE_RR_CPU 配置项是开启的,那么在一开始就会直接使用 round-robin 算法在 @wq_unbound_cpumask 参数描述的一组 CPU 中选出一个 CPU,而不是选择当前 CPU;但是如果 @wq_unbound_cpumask 参数本身是空的,那么还是会回退到使用当前的 CPU

以上描述的算法只是选出 @cpu 参数对应的一个 worker-pool 候选,之后还是会和上一小节中介绍的算法那样,和 @work->data 指向的另一个 worker-pool 候选一起,选出最终将该 work 提交到哪个 CPU 上运行

unbound workqueue

alloc_workqueue() 的 flags 参数包含 WQ_UNBOUND 标志时,说明此时创建的是 unbound workqueue,即该 workqueue 中的 work 会提交给 unbound worker-pool 处理

unbound workqueue 创建过程中需要为其挑选对应的 unbound worker-pool,由于系统中可以存在多个 unbound worker-pool,因而通常使用 struct workqueue_attrs 来描述该 unbound workqueue 所需要的 worker-pool 的属性,这其中会在全局的 @unbound_pool_hash hash table 中寻找与该 workqueue attrs 匹配的 unbound worker-pool,若 hash table 中尚不存在匹配的 worker-pool,则根据该 workqueue attrs 创建一个新的 worker-pool

standared unbound workqueue in one NUMA-node

alloc_workqueue() 中 flags 参数包含 WQ_UNBOUND 标志 (但不包含 __WQ_ORDERED 标志) 时,说明此时创建的是 standared unbound workqueue

此时只考虑系统中只包含一个 NUMA node 的情况,那么该 workqueue 只需要与一个 worker-pool 相联系

+-----------+  numa_pwq_tbl[0]  +----------------+      +-----------------------------------+
| workqueue | ----------------> | pool workqueue | ---> | worker pool (@wq_unbound_cpumask) |
+-----------+                   +----------------+      +-----------------------------------+

在调用 alloc_workqueue() 创建 standared unbound workqueue 时,需要为创建的 unbound workqueue 寻找匹配的 worker-pool,这一过程中使用的 workqueue attrs 实际上为

  • @cpumask 字段默认为 @wq_unbound_cpumask,该参数描述了 unbound worker pool 默认的 CPU affinity,用户可以通过 /sys/devices/virtual/workqueue/cpumask 修改该参数的值
  • @nice 字段由 workqueue 本身的优先级决定,若 workqueue 的 flags 参数包含 WQ_HIGHPRI 标志,则 @nice 字段即为 -20,否则为 0

由于此时 standared unbound workqueue 只对应于一个 worker-pool,因而 queue_work_on() 提交 work 时,该 work 总是提交给这个唯一的 worker-pool

standared unbound workqueue in multi NUMA-node

alloc_workqueue() 中 flags 参数包含 WQ_UNBOUND 标志 (但不包含 __WQ_ORDERED 标志) 时,说明此时创建的是 standared unbound workqueue

考虑系统中存在多个 NUMA node 的情况,如果还是和之前一样,一个 workqueue 对应一个 worker pool,那么这个 worker pool 对应的 cpumask 参数就应该是 all_possible_cpu,即该 worker pool 下的 worker 可以在所有 NUMA node 的所有 CPU 下运行,这样就会带来一个问题,即 worker 可能被调度器调度到 NUMA node 2 的某个 CPU 上运行,而当初是 NUMA node 1 的某个 CPU 调用的 queue_work_on() 提交了这个 work,即这个 @work 数据大概率是存储在 NUMA node 1 上的,此时这个 worker 运行时就需要跨 NUMA node 访问 @work 数据,从而带来性能的下降

解决办法就是让 worker pool 是 per-NUMA node 的,即让一个 unbound workqueue 对应多个 worker pool,每个 NUMA node 一个 worker pool,其中的每个 worker pool 的 cpumask 参数只覆盖一个 NUMA node,即每个 per-NUMA-node worker-pool 的 worker 只能在该 NUMA node 对应的 CPU 上运行,之后 queue_work_on() 提交 work 的时候,实际上是将该 work 提交给当前 CPU 所在的 NUMA node 对应的那个 worker-pool 处理,这样 @work 数据是存储在当前 CPU 所在的 NUMA node 的,而实际执行的 worker 也是在同一个 NUMA node 上运行的,这样就不会再存在跨 NUMA node 的情况

此时一个 unbound workqueue 就对应于多个 worker pool

      numa_pwq_tbl[0]   +-------------------------+      +----------------------+
                +-----> | pool workqueue (node 0) | ---> | worker pool (node 0) |
                |       +-------------------------+      +----------------------+
+-----------+   |
| workqueue | --+
+-----------+   |
                |       +-------------------------+      +----------------------+
                +-----> | pool workqueue (node N) | ---> | worker pool (node N) |
      numa_pwq_tbl[N]   +-------------------------+      +----------------------+
select per-NUMA-node worker-pool

在之后调用 queue_work_on() 提交 work 的时候,需要将 work 提交给对应的 worker-pool,此时需要从该 workqueue 对应的多个 worker-pool 中挑选出一个 worker-pool,这里的算法实际上是和 bound workqueue 中介绍的算法基本一致,这里候选的 worker-pool 有两个

  1. @cpu 参数对应的 bound worker-pool,如果 @cpu 参数为 WORK_CPU_UNBOUND,就会选择当前的 CPU 或按照 round-robin 算法从 @wq_unbound_cpumask 参数描述的一组 CPU 中选出一个 CPU
  2. @work->data 中保存的上一次处理该 work 的 bound worker-pool

实际上无论是 bound workqueue,还是 single NUMA node 下的 standared unbound workqueue,还是 multi NUMA node 下的 standared unbound workqueue,都是使用这一套算法挑选由哪个 worker-pool 处理当前这个 work,其差异主要在步骤 1 这里,其他步骤基本一致

步骤 1 中在确定对应的 CPU 之后,需要将该 CPU 映射为对应的 worker-pool,这里因为 pool workqueue 结构实现了 workqueue 与 worker-pool 之间的联系,而 pool workqueue 结构与 worker-pool 是一对一的,因而以上问题就转化为了根据 CPU 获取其对应的 pool workqueue 结构

CPU
    - bound workqueue
        pwq = per_cpu_ptr(wq->cpu_pwqs, cpu);

    - standared unbound workqueue (single NUMA node)
        pwq = wq->numa_pwq_tbl[0]

    - standared unbound workqueue (multi NUMA node)
        pwq = wq->numa_pwq_tbl[cpu_to_node(cpu)]

对于 bound workqueue 来说,worker-pool 是 per-CPU 的,每个 CPU 一个 worker-pool,workqueue的 per-CPU 变量 @cpu_pwqs 就描述了该 workqueue 对应的所有 worker-pool,此时就是获取该 CPU 对应的那个 per-CPU worker-pool

对于 single NUMA node 下的 standared unbound workqueue 来说,(对于单个 workqueue 来说) 整个系统范围内只有一个 worker-pool,一个 workqueue 就只对应于一个 worker-pool,wq->numa_pwq_tbl[0] 就存储了这个 worker-pool,因而此时只需要直接返回 wq->numa_pwq_tbl[0] 指向的这个 worker-pool 就好

而对于 multi NUMA node 下的 standared unbound workqueue 来说,worker-pool 是 per-NUMA node 的,此时 wq->numa_pwq_tbl[] 数组存储了该 workqueue 对应的一组 worker-pool,numa_pwq_tbl[0] 就指向 NUMA node 0 对应的 worker-pool,numa_pwq_tbl[1] 就指向 NUMA node 1 对应的 worker-pool,以此类推...... 因而此时只需要根据 CPU 确定该 CPU 所在的 NUMA node number,wq->numa_pwq_tbl[node] 就是我们寻找的 worker-pool

example

例如 NUMA 系统中

  • node 0 包含 CPU A、CPU B
  • node 1 包含 CPU C、CPU D
  • node 2 包含 CPU E、CPU F
  • node 3 包含 CPU G、CPU H

那么在调用 alloc_workqueue() 创建 standared unbound workqueue 时,在为创建的 unbound workqueue 寻找匹配的 worker-pool 的过程中,使用的 workqueue attrs 的 cpumask 字段默认为 @wq_unbound_cpumask

那么此时

  • 为 node 0 创建一个 unbound worker-pool,其 cpumask 为 CPU A/B,即其中的 worker 只能在 CPU A、CPU B 上运行
  • 为 node 1 创建一个 unbound worker-pool,其 cpumask 为 CPU C/D,即其中的 worker 只能在 CPU C、CPU D 上运行
  • 为 node 2 创建一个 unbound worker-pool,其 cpumask 为 CPU E/F,即其中的 worker 只能在 CPU E、CPU F 上运行
  • 为 node 3 创建一个 unbound worker-pool,其 cpumask 为 CPU G/H,即其中的 worker 只能在 CPU G、CPU H 上运行

之后当调用 queue_work_on() 提交 work 时,在 CPU A 上调用 queue_work_on() 时就会将 work 提交到 node 0 对应的 worker-pool,在 CPU C 上调用 queue_work_on() 时就会将 work 提交到 node 1 对应的 worker-pool,以此类推

select default worker-pool

考虑以下这种情况,alloc_workqueue() 创建 standared unbound workqueue 时,@wq_unbound_cpumask 参数实际上限制了 workqueue->cpumask 的值,@wq_unbound_cpumask 参数的默认值为 all_possible_cpu,因而默认情况下每个 NUMA node 确实都有一个 worker-pool

但是用户是可以修改 @wq_unbound_cpumask 参数值的,考虑以上 NUMA 系统中,如果 @wq_unbound_cpumask 参数的值修改为 CPU A、CPU C,即之后创建的 standard unbound workqueue 中的 work 只能在 CPU A、CPU C 中运行,此时该 standared unbound workqueue 与对应的 worker-pool 的关系为

  • 首先创建一个 default worker-pool,其 cpumask 参数即为该 standard unbound workqueue 的 workqueue attr 的 cpumask,即 @wq_unbound_cpumask 参数的值,即 CPU A、CPU C,也就是说 default worker-pool 的 worker 可以在 CPU A、CPU C 上运行
  • 为 node 0 创建一个 unbound worker-pool,其 cpumask 为 CPU A,即其中的 worker 只能在 CPU A 上运行,之后在 node 0 即 CPU A、CPU B 上调用 queue_work_on() 时,work 都会提交到 node 0 对应的 worker-pool
  • 同理,为 node 1 创建一个 unbound worker-pool,其 cpumask 为 CPU C,即其中的 worker 只能在 CPU C 上运行,之后在 node 1 即 CPU C、CPU D 上调用 queue_work_on() 时,work 都会提交到 node 0 对应的 worker-pool
  • 由于该 workqueue 不能在 node 2 上运行,因而在 node 2 即 CPU E、CPU F 上调用 queue_work_on() 时,work 都会提交到 default worker-pool,这些 work 会在 CPU A、CPU C 上运行
  • 同理,在 node 3 即 CPU G、CPU H 上调用 queue_work_on() 时,work 都会提交到 default worker-pool,这些 work 会在 CPU A、CPU C 上运行
           @dfl_pwq     +-------------------------+      +-------------------------------------------+
                + ----> | default pool workqueue  | ---> | default worker pool (@wq_unbound_cpumask) |
                |       +-------------------------+      +-------------------------------------------+       
                |
                |       +-------------------------+      +----------------------+
numa_pwq_tbl[0] +-----> | pool workqueue (node 0) | ---> | worker pool (node 0) |
                |       +-------------------------+      +----------------------+
+-----------+   |
| workqueue | --+
+-----------+   |
                |       +-------------------------+      +----------------------+
                +-----> | default pool workqueue  | ---> |         ...          |
      numa_pwq_tbl[N]   +-------------------------+      +----------------------+

ordered unbound workqueue

alloc_workqueue() 中 flags 参数同时包含 WQ_UNBOUND|__WQ_ORDERED 标志时,说明此时创建的是 ordered unbound workqueue

ordered unbound workqueue 与 standared unbound workqueue 的区别是,在 multi NUMA node 情况下,后者可能与多个 unbound worker pool 相联系,而前者只与一个 unbound worker pool 相联系,此时该 worker-pool 的 cpumask 即为 @wq_unbound_cpumask,即默认可以在系统中任意一个 CPU 上运行

而当系统中只有一个 NUMA node 时,ordered unbound workqueue 实际就等同于 standared unbound workqueue

ordered unbound workqueue 提交 work 的逻辑与之前 bound workqueue 中提交 work 的逻辑相类似,只是此时该 work 总是提交给唯一的 worker-pool

           @dfl_pwq     +-------------------------+      +-------------------------------------------+
                + ----> | default pool workqueue  | ---> | default worker pool (@wq_unbound_cpumask) |
                |       +-------------------------+      +-------------------------------------------+       
                |
                |       +-------------------------+      +----------------------+
numa_pwq_tbl[0] +-----> | default pool workqueue  | ---> |         ...          |
                |       +-------------------------+      +----------------------+
+-----------+   |
| workqueue | --+
+-----------+   |
                |       +-------------------------+      +----------------------+
                +-----> | default pool workqueue  | ---> |         ...          |
      numa_pwq_tbl[N]   +-------------------------+      +----------------------+

Concurrency

以下两个因素会影响 work 的并发度

concurrency for one workqueue

对于一个 workqueue 来说,max_active 参数描述了该 workqueue 中有多少个 work 可以并行执行,即该参数决定了 workqueue 的并行度
如果 max_active 为 1,那么该 workqueue 中的 work 只能串行执行,即一个 work 执行完了,才能执行该 workqueue 中的下一个 work

queue_work_on() 中,在上述确定了将 work 提交给哪个 worker-pool 之后,就会将该 work 添加到该 worker-pool 的 @worklist 链表

但是在细节上 alloc_workqueue() 传入的 @max_active 参数会影响以上行为,@max_active 参数描述了每个 worker-pool 的 @worklist 链表中可以缓存的来自该 workqueue 的 work 数量

后面会介绍到,同一个 work 不能重复添加到一个 worker pool 的 worklist 中,因而 @max_active 参数实际描述了每个 worker-pool 的 worklist 链表中可以缓存的来自该 workqueue 的不同 work 的数量

对于特定 worker-pool,其 worklist 链表当前已经缓存了多少个来自该 workqueue 的 work 的统计量保存在对应的 pool workqueue 中,因而

  • 从 workqueue 的视角来看,@max_active 参数对于各个 worker-pool 是独立统计的

例如对于 bound workqueue 来说,CPU A 上的 bound worker-pool 的 @worklist 链表可以缓存最多 @max_active 个来自该 workqueue 的 work,同时 CPU B 上的 bound worker-pool 的 worklist 链表也可以缓存最多 @max_active 个来自该 workqueue 的 work

  • 从 worker-pool 的视角来看,@max_active 参数对于各个 workqueue 是独立统计的

例如对于 bound worker-pool 来说,该 bound worker-pool 的 @worklist 链表可以缓存最多 @max_active (workqueue 1 的 @max_active 参数) 个来自 workqueue 1 的 work,同时该 @worklist 链表还可以缓存最多 @max_active (workqueue 2 的 @max_active 参数) 个来自 workqueue 2 的 work

当 alloc_workqueue() 传入的 @max_active 为 0 时将使用其默认配置,即 WQ_DFL_ACTIVE 即 256

但是 @max_active 参数的值也存在上限

  • 对于 bounded workqueue,max active work 的最大值为 WQ_MAX_ACTIVE 即 512
  • 对于 unbounded workqueue,max active work 的最大值为 WQ_UNBOUND_MAX_ACTIVE,即 512 与 4 * num_possible_cpus() 中的最大值

queue_work_on() 调用过程中会将 work 添加到对应的 worker-pool 的 @worklist 链表,此时若对应的 worker-pool 中缓存的来自当前 workqueue 的 work 数量已经达到 @max_active 上限,那么会将当前提交的 work 缓存到对应的 pool workqueue 的 @delayed_works 链表中,之后该 worker-pool 中的 worker 在每次处理完成一个 work 的时候,若对应的 @delayed_works 链表不为空,则会将 delayed_works 链表中的一个 pending work 添加到该 worker-pool 的 @worklist 链表中

因为 worker 只会处理 worklist 链表中的 work,而不会处理 delayed_works 链表中的 work,因而 @max_active 参数可以有效地控制 workqueue 的并行度

concurrency of worker pool

对于 (per-CPU) bound worker pool 来说,每个 worker pool 在同一时刻最多只有一个 running worker (这里 "running worker" 的概念指正在处理 work 的 worker)

对于 (per-NUMA-node) unbound worker pool 来说,每个 worker pool 在同一时刻可以有多个 running worker,只要 worklist 中有 pending work,就会创建新的 running worker;基本上 worklist 中的每个 pending work 都会唤醒一个 running worker

worker_thread
    # get one work from worklist
    process_one_work
        wake_up_worker // wake up another idle worker
                         (transit to running worker soon)
                         to process remained pending work
    work->func()

concurrency for one work

同一个 work 在 system-wide 不会发生并发,即同一个 work 在 system-wide 同时只能由一个 worker 执行,因而多次提交的同一个 work 会串行执行
  1. 首先同一个 work 在 system-wide 同时只能挂载到一个 worker-pool,即一个 work 在某一时刻不可能存在于多个 worker pool

CMWQ 通过 work->data 的 WORK_STRUCT_PENDING_BIT bit 来确保一个 work 同时只能挂载到一个 worker-pool

首先 queue_work_on() 中会设置 work->data 的 WORK_STRUCT_PENDING_BIT bit,并将 work 添加到对应 worker pool 的 worklist 中;下次再对同一个 work 调用 queue_work_on(),检查到该 work 的 WORK_STRUCT_PENDING_BIT bit 当前已经被置位,即该 work 之前已经被添加到当前的 worker-pool 中,则函数直接返回,从而确保一个 work 同时只能挂载到一个 worker-pool

queue_work_on
    if !test_and_set_bit(WORK_STRUCT_PENDING_BIT, ...):
        # add this work into worklist

worker 在处理 work 的时候,会将 work 从 worklist 链表中移除,之后清除 work 的 WORK_STRUCT_PENDING_BIT bit,最后执行 work 的任务

worker_thread
    # get one work from worklist
    process_one_work
        # delete this work from worklist
        set_work_pool_and_clear_pending // clear WORK_STRUCT_PENDING_BIT bit
        work->func()

注意上述时序中,有可能存在 worker 清除 WORK_STRUCT_PENDING_BIT bit 后,还没来得及执行 work->func() 的时候,又有进程对该 work 执行 queue_work_on() 操作,此时该 work 再次被添加到 worker pool 的 worklist 中

PROCESS 1                           PROCESS 2
===========                         ===========
queue_work_on                       worker_thread
  # insert work into worklist
  # set WORK_STRUCT_PENDING_BIT
                                      # process_one_work
                                        # delete this work from worklist
                                        clear WORK_STRUCT_PENDING_BIT bit
queue_work_on
  # insert work into worklist
  # set WORK_STRUCT_PENDING_BIT
                                        work->func()

这里插一句题外话,process_one_work() 中为什么不能在 work->func() 执行结束后再 clear WORK_STRUCT_PENDING_BIT bit 呢?如果这样的话,就不会发生上述的时序了。这是因为如果在 work->func() 执行结束后再 clear WORK_STRUCT_PENDING_BIT bit,考虑以下时序

PROCESS 1                           PROCESS 2
===========                         ===========
                                   worker_thread
                                    # process_one_work
                                      work->func()
                                        ...
queue_work_on                            ...
# since WORK_STRUCT_PENDING_BIT set
# i.e. this work is still in worklist
                                      # delete this work from worklist
                                      clear WORK_STRUCT_PENDING_BIT bit

这里 worker 在执行 work->func() 的过程中,其他进程调用 queue_work_on() 提交 work,此时看到这个 work 还在 worklist 中,因而马上返回;但是此时 work->func() 可能已经执行到了一半,可能并不会处理到刚刚 queue_work_on() 提交的任务

那么是否有可能两次 queue_work_on() 的时候这个 work 被添加到两个不同的 worker pool 呢,即第一次 queue_work_on() 的时候 work 添加到一个 worker pool,而第二次 queue_work_on() 的时候 work 添加到另一个 worker pool ?答案是并不会,CMWQ 会确保前后两次 queue_work_on() 调用将 work 添加到同一个 worker pool,但前提是 "这个 work 不会重复提交给另一个 workqueue (No one queues the work item to another workqueue)"

queue_work_on() 将 work 添加到 worker pool 的 worklist 的时候,会在 work->data 中保存对应的 pool workqueue

下一次再调用 queue_work_on() 的时候,work->data 中保存的 pool workqueue 就描述了这个 work 上一次被添加到哪个 worker pool,此时如果检查到上一次 queue_work_on() 提交的任务还在执行中 (即发生了上述时序),那么就会将这个 work 提交到与上一次 queue_work_on() 一样的 worker pool,从而确保两次 queue_work_on() 提交到同一个 worker pool

queue_work_on
    __queue_work
        last_pool = get_work_pool(work)
        worker = find_worker_executing_work(last_pool, work);
        if (worker && worker->current_pwq->wq == wq):
            pwq = worker->current_pwq;
  1. 其次同一个 work (在一个 worker pool 中) 不会被多个 worker 并发处理

上面介绍了同一个 work 在同一时刻只能挂载到一个 worker-pool,但是考虑到之前描述的时序,上一次提交的 work->func() 还在运行过程中,这个 work 就有可能被再次添加到 worklist 中,这种情况下有没有可能再起一个新的 worker 处理第二次提交的 work,从而导致两个 worker 并发处理同一个 work 呢?

PROCESS 1                           PROCESS 2
===========                         ===========
queue_work_on                       worker_thread
  # insert work into worklist
  # set WORK_STRUCT_PENDING_BIT
                                      # process_one_work
                                        # delete this work from worklist
                                        clear WORK_STRUCT_PENDING_BIT bit
queue_work_on
  # insert work into worklist
  # set WORK_STRUCT_PENDING_BIT
                                        work->func()

答案是不会

1) 对于 bound worker pool 来说

由于每个 bound worker pool 同一时刻只能有一个 running worker,此时不会有其他 running worker 来执行第二次提交的 work,从而与当前的 running worker 形成并发

实际上当前 running worker 处理完第一次提交的 work 之后,才会重新从 worklist 中取出第二次提交的 work 进行处理,从而确保这个 work 在 system-wide 不会并发

2) 对于 unbound worker pool 来说

虽然 unbound worker pool 在同一时刻可以有多个 running worker,但是会对这种情况作特殊处理

PROCESS 1                           PROCESS 2
===========                         ===========
worker_thread                       worker_thread
  process_one_work
    work->func()
      ...
                                      process_one_work
                                        collision = find_worker_executing_work(pool, work);
                                        if collision:
                                          move_linked_works // delete work from worklist
                                                              and insert into collision worker's @scheduled list
                                          return

      # when finish processing this work
      if !list_empty(&worker->scheduled):
        process_scheduled_works(worker)                                   

新起的 running worker 在尝试处理第二次提交的 work 的时候,会发现这个 work 正在被其他 worker (collision worker) 处理过程中,那么此时只是将这个 work 添加到 collision worker 的 @scheduled 链表之后立即返回

等到 collision worker 处理完第一次提交的 work 之后,才会处理 @scheduled 链表中第一次提交的 work,从而确保这个 work 在 system-wide 不会并发

ordered workqueue

alloc_ordered_workqueue() 用于创建 ordered unbound workqueue,又称为 single thread workqueue,此时

  • @flags 标志位包含 WQ_UNBOUND | __WQ_ORDERED 标志
  • @max_active 参数为 1

ordered unbound workqueue 中的 work 严格按照顺序串行执行,注意这里指的是 workqueue 中的不同 work 之间也是严格串行执行的

我们知道 __WQ_ORDERED 描述的 ordered workqueue 只会与一个 worker-pool 相联系,那为什么还需要设置 @max_active 参数为 1 呢?

假设 workqueue 中存在两个不同的 work,即 work A 与 work B,work A 先于 work B 提交到 workqueue,为了达到“work 严格按照顺序串行执行”的要求,必须等待 work A 完成之后才能开始 work B 的处理

若该 workqueue 的 @max_active 参数大于 1,那么 work A 与 work B 都会添加到对应 worker-pool 的 @worklist 链表中,之后该 worker-pool 的 worker thread 在处理 work A 进入阻塞时,该 worker-pool 检查到 @worklist 链表不为空,因而会创建一个新的 worker thread 以处理 work B,此时 work A 还没有完成就开始了 work B 的处理

而当 workqueue 的 @max_active 参数为 1 时,work A 添加到对应 worker-pool 的 @worklist 链表之后,在将 work B 提交给 worker-pool 的时候,由于该 worker-pool 中 @worklist 链表中缓存的来自该 workqueue 的 work 数量已经达到 @max_active 上限,因而此时会将 work B 缓存到 pool workqueue 的 @delayed_works 链表

之后该 worker-pool 的 worker thread 处理 work A 的过程中进入阻塞时,若 @worklist 链表已经为空,则此时不会再创建新的 worker thread(尽管此时 delayed_works 链表不为空);而若 @worklist 链表不为空即含有来自其他 workqueue 的 pending work,那么此时虽然会创建一个新的 worker thread,但是新创建的 worker thread 只负责处理 @worklist 链表中的 pending work,而不会处理 pool workqueue 的 @delayed_works 链表中的 work。之后等到处理 work A 的 worker thread 从阻塞中恢复并处理完成 work A 之后,会检查 work A 对应的 pool workqueue 的 @delayed_works 链表,当检查到该链表不为空时,就会处理该链表中的 work B,从而满足了严格按照顺序串行执行的要求

相关文章
|
1月前
|
缓存 Linux 开发者
Linux内核中的并发控制机制
本文深入探讨了Linux操作系统中用于管理多线程和进程的并发控制的关键技术,包括原子操作、锁机制、自旋锁、互斥量以及信号量。通过详细分析这些技术的原理和应用,旨在为读者提供一个关于如何有效利用Linux内核提供的并发控制工具以优化系统性能和稳定性的综合视角。
|
9天前
|
存储 编译器 Linux
动态链接的魔法:Linux下动态链接库机制探讨
本文将深入探讨Linux系统中的动态链接库机制,这其中包括但不限于全局符号介入、延迟绑定以及地址无关代码等内容。
113 16
|
17天前
|
监控 算法 Linux
Linux内核锁机制深度剖析与实践优化####
本文作为一篇技术性文章,深入探讨了Linux操作系统内核中锁机制的工作原理、类型及其在并发控制中的应用,旨在为开发者提供关于如何有效利用这些工具来提升系统性能和稳定性的见解。不同于常规摘要的概述性质,本文将直接通过具体案例分析,展示在不同场景下选择合适的锁策略对于解决竞争条件、死锁问题的重要性,以及如何根据实际需求调整锁的粒度以达到最佳效果,为读者呈现一份实用性强的实践指南。 ####
|
21天前
|
消息中间件 安全 Linux
深入探索Linux操作系统的内核机制
本文旨在为读者提供一个关于Linux操作系统内核机制的全面解析。通过探讨Linux内核的设计哲学、核心组件、以及其如何高效地管理硬件资源和系统操作,本文揭示了Linux之所以成为众多开发者和组织首选操作系统的原因。不同于常规摘要,此处我们不涉及具体代码或技术细节,而是从宏观的角度审视Linux内核的架构和功能,为对Linux感兴趣的读者提供一个高层次的理解框架。
|
28天前
|
算法 Linux 开发者
Linux内核中的锁机制:保障并发控制的艺术####
本文深入探讨了Linux操作系统内核中实现的多种锁机制,包括自旋锁、互斥锁、读写锁等,旨在揭示这些同步原语如何高效地解决资源竞争问题,保证系统的稳定性和性能。通过分析不同锁机制的工作原理及应用场景,本文为开发者提供了在高并发环境下进行有效并发控制的实用指南。 ####
|
1月前
|
缓存 Linux 开发者
Linux内核中的并发控制机制:深入理解与应用####
【10月更文挑战第21天】 本文旨在为读者提供一个全面的指南,探讨Linux操作系统中用于实现多线程和进程间同步的关键技术——并发控制机制。通过剖析互斥锁、自旋锁、读写锁等核心概念及其在实际场景中的应用,本文将帮助开发者更好地理解和运用这些工具来构建高效且稳定的应用程序。 ####
41 5
|
1月前
|
Linux 数据库
Linux内核中的锁机制:保障并发操作的数据一致性####
【10月更文挑战第29天】 在多线程编程中,确保数据一致性和防止竞争条件是至关重要的。本文将深入探讨Linux操作系统中实现的几种关键锁机制,包括自旋锁、互斥锁和读写锁等。通过分析这些锁的设计原理和使用场景,帮助读者理解如何在实际应用中选择合适的锁机制以优化系统性能和稳定性。 ####
59 6
|
1月前
|
消息中间件 存储 Linux
|
29天前
|
安全 Linux 数据安全/隐私保护
深入探索Linux操作系统的多用户管理机制
【10月更文挑战第21天】 本文将详细解析Linux操作系统中的多用户管理机制,包括用户账户的创建与管理、权限控制以及用户组的概念和应用。通过具体实例和命令操作,帮助读者理解并掌握Linux在多用户环境下如何实现有效的资源分配和安全管理。
|
7月前
|
存储 Linux C语言
Linux:冯·诺依曼结构 & OS管理机制
Linux:冯·诺依曼结构 & OS管理机制
181 0
下一篇
DataWorks