调度器初始化

Kernel initialization. Part 8.

调度器初始化

这是 Linux 内核初始化过程章节的第八部分,在上一部分中我们停在了 setup_nr_cpu_ids 函数处。

本部分的重点是调度器的初始化。但在开始学习调度器的初始化过程之前,我们需要完成一些准备工作。接下来是在 init/main.c 中的 setup_per_cpu_areas 函数,该函数为 percpu 变量设置内存区域。更多相关信息您可以在关于 Per-CPU 变量 的专门章节中阅读。在 percpu 区域启动并运行后,下一步是 smp_prepare_boot_cpu 函数。

smp_prepare_boot_cpu 函数为对称多处理做一些准备工作。由于此函数是针对特定架构的,它位于 arch/x86/include/asm/smp.h Linux 内核头文件中。让我们看看这个函数的定义:

static inline void smp_prepare_boot_cpu(void)
{
         smp_ops.smp_prepare_boot_cpu();
}

可以看到,这里实际上调用了 smp_ops 结构体的 smp_prepare_boot_cpu 回调函数。如果我们查看 arch/x86/kernel/smp.c 源文件中该结构体实例的定义,会发现 smp_prepare_boot_cpu 展开为对 native_smp_prepare_boot_cpu 函数的调用:

struct smp_ops smp_ops = {
    ...
    ...
    ...
    smp_prepare_boot_cpu = native_smp_prepare_boot_cpu,
    ...
    ...
    ...
}
EXPORT_SYMBOL_GPL(smp_ops);

函数 native_smp_prepare_boot_cpu 形式如下:

void __init native_smp_prepare_boot_cpu(void)
{
        int me = smp_processor_id();
        switch_to_new_gdt(me);
        cpumask_set_cpu(me, cpu_callout_mask);
        per_cpu(cpu_state, me) = CPU_ONLINE;
}

该函数主要执行以下操作:首先通过 smp_processor_id 函数获取当前 CPU id(此时为引导处理器 BSP,其 id 为 0)。关于 smp_processor_id 的工作原理,此处不再赘述,因为我们已在内核入口点部分详细分析过。获取处理器 id 后,函数通过 switch_to_new_gdt 为指定 CPU 重新加载全局描述符表 GDT

void switch_to_new_gdt(int cpu)
{
        struct desc_ptr gdt_descr;

        gdt_descr.address = (long)get_cpu_gdt_table(cpu);
        gdt_descr.size = GDT_SIZE - 1;
        load_gdt(&gdt_descr);
        load_percpu_segment(cpu);
}

这里的 gdt_descr 变量表示指向 GDT 描述符的指针(desc_ptr 结构定义已在早期中断和异常处理部分见过)。我们通过指定 id 获取对应 CPUGDT 描述符地址和大小。其中 GDT_SIZE 的值为 256,或者更准确地说:

#define GDT_SIZE (GDT_ENTRIES * 8)

而描述符的地址将通过 get_cpu_gdt_table 函数获取:

static inline struct desc_struct *get_cpu_gdt_table(unsigned int cpu)
{
        return per_cpu(gdt_page, cpu).gdt;
}

get_cpu_gdt_table 函数通过 per_cpu 宏来获取指定 CPU 编号(本例中为 ID 0 的 BSP)对应的 gdt_page percpu 变量值。

您可能会问:既然我们能访问 gdt_page 这个 percpu 变量,它是在哪里定义的呢?实际上我们在本书前文已经见过它。如果您阅读过本章第一部分,会记得我们在 arch/x86/kernel/head_64.S 中看到过 gdt_page 的定义:

early_gdt_descr:
	.word	GDT_ENTRIES*8-1
early_gdt_descr_base:
	.quad	INIT_PER_CPU_VAR(gdt_page)

如果我们查看链接器脚本,可以看到它被放在 __per_cpu_load 符号之后:

#define INIT_PER_CPU(x) init_per_cpu__##x = x + __per_cpu_load
INIT_PER_CPU(gdt_page);

并且在 arch/x86/kernel/cpu/common.c 中完成了对 gdt_page 的初始化:

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
#ifdef CONFIG_X86_64
	[GDT_ENTRY_KERNEL32_CS]		= GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
	[GDT_ENTRY_KERNEL_CS]		= GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
	[GDT_ENTRY_KERNEL_DS]		= GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER32_CS]	= GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER_DS]	= GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
	[GDT_ENTRY_DEFAULT_USER_CS]	= GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
    ...
    ...
    ...

关于 percpu 变量的更多细节,您可以在 Per-CPU 变量章节中阅读。当我们获取到 GDT 描述符的地址和大小后,通过 load_gdt 函数重新加载 GDT,该函数实际执行 lgdt 指令,并通过以下函数加载 percpu_segment

void load_percpu_segment(int cpu) {
    loadsegment(gs, 0);
    wrmsrl(MSR_GS_BASE, (unsigned long)per_cpu(irq_stack_union.gs_base, cpu));
    load_stack_canary_segment();
}

percpu 区域的基地址必须存放在 gs 寄存器(x86 架构下也可能是 fs 寄存器)中,因此我们使用 loadsegment 宏并传入 gs。接下来的步骤会写入 IRQ 栈的基地址,并设置栈 canary(仅针对 x86_32)。在加载新的 GDT 后,我们会用当前 CPU 填充 cpu_callout_mask 位图,并通过设置当前处理器的 cpu_state percpu 变量为 CPU_ONLINE 来标记 CPU 状态为在线。

cpumask_set_cpu(me, cpu_callout_mask);
per_cpu(cpu_state, me) = CPU_ONLINE;

那么,什么是 cpu_callout_mask 位图?在初始化引导处理器(x86 架构中第一个启动的处理器)后,多处理器系统中的其他处理器被称为次级处理器。Linux 内核使用以下两个位掩码:

  • cpu_callout_mask

  • cpu_callin_mask

当 BSP 初始化完成后,它会更新 cpu_callout_mask 来指示接下来可以初始化哪个次级处理器。所有其他次级处理器在初始化前会检查引导处理器设置的 cpu_callout_mask 位图。只有当引导处理器在 cpu_callout_mask 中标记了某个次级处理器后,该次级处理器才会继续其剩余的初始化工作。完成初始化流程后,该次级处理器会在 cpu_callin_mask 中设置自己的位。当引导处理器检测到当前次级处理器在 cpu_callin_mask 中的位被置位后,就会对剩余的次级处理器重复相同的初始化流程。简而言之,其工作原理如我所描述,但我们将在关于 SMP(对称多处理)的章节中看到更多细节。

至此,我们已完成所有的 SMP 启动准备工作。

Zonelists 构建

接下来,我们会看到 build_all_zonelists 函数的调用。该函数设置了内存分配时优先从哪些内存区域(zone)获取的顺序。我们稍后会解释什么是 zone 以及这个顺序的含义。首先让我们看看 Linux 内核如何管理物理内存。物理内存被划分为称为节点(nodes)的存储单元。如果您的硬件不支持 NUMA(非统一内存访问架构),您将只会看到一个节点:

$ cat /sys/devices/system/node/node0/numastat
numa_hit 72452442
numa_miss 0
numa_foreign 0
interleave_hit 12925
local_node 72452442
other_node 0

在 Linux 内核中,每个 node 都由 struct pglist_data 结构体表示,其又被划分为若干特殊的区块,称为内存区域(zones)。每个 zone 在内核中由 zone struct 表示,并属于以下类型之一(通过 zone_type 枚举定义):

  • ZONE_DMA - 0-16MB 内存区域;

  • ZONE_DMA32 - 32 位 DMA 设备专用区域(仅能访问 4GB 以下内存);

  • ZONE_NORMAL - x86_64 架构上 4GB 以上普通内存区域;

  • ZONE_HIGHMEM - 在 x86_64 架构上不存在(仅用于 32 位系统);

  • ZONE_MOVABLE - 包含可移动页面的特殊区域。

可以通过以下方式获取 zone 的详细信息:

$ cat /proc/zoneinfo
Node 0, zone      DMA
  pages free     3975
        min      3
        low      3
        ...
        ...
Node 0, zone    DMA32
  pages free     694163
        min      875
        low      1093
        ...
        ...
Node 0, zone   Normal
  pages free     2529995
        min      3146
        low      3932
        ...
        ...

如前所述,所有内存节点在内核中均通过 pglist_data(即 pg_data_t)结构体描述,该结构定义于 include/linux/mmzone.hmm/page_alloc.c 中的 build_all_zonelists 函数会构建一个有序的 zonelist(包含 DMADMA32NORMALHIGH_MEMORYMOVABLE 等不同内存区域),用于指定当所选 zonenode 无法满足分配请求时应尝试访问的备用区域/节点顺序。关于 NUMA 和多处理器系统的更多细节,将在后续专题章节深入讨论。

调度器初始化前的剩余步骤

在深入探讨 Linux 内核调度器初始化过程之前,我们需要完成几项准备工作。首先是位于 mm/page_alloc.c 中的 page_alloc_init 函数。这个函数看似简单:

void __init page_alloc_init(void)
{
    int ret;

    ret = cpuhp_setup_state_nocalls(CPUHP_PAGE_ALLOC_DEAD,
                                    "mm/page_alloc:dead", NULL,
                                    page_alloc_cpu_dead);
    WARN_ON(ret < 0);
}

该函数为 CPU 热插拔状态 CPUHP_PAGE_ALLOC_DEAD 设置 startupteardown 回调函数(第二和第三个参数)。当然,这个函数的实现取决于内核配置选项 CONFIG_HOTPLUG_CPU ——若启用该选项,系统将根据各个 CPU 的热插拔状态为其设置相应的回调函数。CPU 热插拔机制是一个复杂主题,本书将不做详细探讨。

完成此函数后,我们能在初始化输出中看到内核命令行信息:

kernel command line

接下来会调用几个处理 Linux 内核命令行的函数,包括 parse_early_paramparse_args。您可能记得我们在内核初始化章节的第六部分已经见过 parse_early_param 的调用,那么为什么这里需要再次调用呢?原因很简单:之前是在架构特定代码(如 x86_64)中调用的,但并非所有架构都会调用此函数。而这里调用 parse_args 是为了解析和处理非早期的命令行参数。

随后我们可以看到来自 kernel/jump_label.cjump_label_init 函数调用,它用于初始化跳转标签(jump label)机制。

接着是 setup_log_buf 函数调用,该函数设置 printk 日志缓冲区。我们在 Linux 内核初始化流程的第七部分已经分析过这个函数。

PID 哈希初始化

接下来是 pidhash_init 函数。如您所知,每个进程都会被分配一个唯一的编号,称为进程标识符(Process Identification Number,简称 PID)。内核会为通过 fork 或 clone 生成的每个新进程自动分配一个唯一的 PID 值。PID 的管理主要围绕两个特殊数据结构展开:struct pidstruct upid。第一个结构表示内核中 PID 信息。第二个结构表示特定命名空间可见的 PID 信息。所有 PID 实例都存储在专门的哈希表中:

static struct hlist_head *pid_hash;

该哈希表用于通过数字 PID 值查找对应的 pid 实例。因此,pidhash_init 函数负责初始化这个哈希表。在 pidhash_init 函数的开头,我们可以看到调用了 alloc_large_system_hash 函数:

pid_hash = alloc_large_system_hash("PID", sizeof(*pid_hash), 0, 18,
                                   HASH_EARLY | HASH_SMALL,
                                   &pidhash_shift, NULL,
                                   0, 4096);

pid_hash 哈希表的元素数量取决于 RAM 内存配置,其范围介于 $2^4$ 到 $2^{12}$ 之间。pidhash_init 函数会计算所需大小并分配存储空间(此处使用的是 hlist 结构——与双向链表类似,但在 struct hlist_head 中仅包含单个指针)。如果传递 HASH_EARLY 标志时(如当前场景),alloc_large_system_hash 函数使用 memblock_virt_alloc_nopanic 分配大型系统哈希表,否则改用 __vmalloc 进行分配。

执行结果可通过 dmesg 命令查看:

$ dmesg | grep hash
[    0.000000] PID hash table entries: 4096 (order: 3, 32768 bytes)
...
...
...

以上就是调度器初始化前的所有准备工作。剩余需要执行的函数包括:

  • vfs_caches_init_early 执行虚拟文件系统(VFS)的早期初始化(详细分析将在 VFS 专题章节展开);

  • sort_main_extable 对内核内置的异常表项(位于 __start___ex_table__stop___ex_table 之间)进行排序;

  • trap_init 初始化陷阱处理程序(后两个函数将在中断相关章节详细探讨)。

调度器初始化前最后一步是通过 init/main.c 中的 mm_init 函数完成内存管理器的初始化。我们可以看到,该函数负责初始化 Linux 内核内存管理器各个部分:

page_ext_init_flatmem();
mem_init();
kmem_cache_init();
percpu_init_late();
pgtable_init();
vmalloc_init();

首先是 page_ext_init_flatmem 函数,其功能取决于内核配置选项 CONFIG_SPARSEMEM,主要用于初始化每页的扩展数据处理。mem_init 负责释放所有 bootmem 内存,kmem_cache_init 初始化内核缓存,percpu_init_latepercpu 内存块替换为 SLUB 分配器分配的内存块,pgtable_init 初始化 page->ptl 内核缓存,而 vmalloc_init 则用于初始化 vmalloc 机制。请注意,我们不会深入探讨这些函数和概念的具体细节,但您可以在 Linux 内核内存管理章节中详细了解它们。

至此,准备工作已全部完成。接下来,我们可以开始探讨 scheduler(调度器)的初始化过程。

调度器初始化

现在,我们来到本部分的核心目标——任务调度器的初始化。需要再次说明的是(正如我已多次强调),这里不会完整解释调度器的全部内容,后续会有专门的章节来详细介绍。这里主要描述最先初始化的调度机制。让我们开始吧。

当前我们关注的是位于 kernel/sched/core.c 内核源代码文件中的 sched_init 函数,顾名思义,它负责初始化调度器。让我们开始深入这个函数,尝试理解调度器是如何初始化的。在 sched_init 函数的开头,我们可以看到以下调用:

sched_clock_init();

sched_clock_init 是一个相当简单的函数,其主要功能是设置 sched_clock_init 变量:

void sched_clock_init(void)
{
	sched_clock_running = 1;
}

该变量将在后续流程中使用。接下来的步骤是初始化 waitqueues 数组:

for (i = 0; i < WAIT_TABLE_SIZE; i++)
	init_waitqueue_head(bit_wait_table + i);

其中 bit_wait_table 定义如下:

#define WAIT_TABLE_BITS 8
#define WAIT_TABLE_SIZE (1 << WAIT_TABLE_BITS)
static wait_queue_head_t bit_wait_table[WAIT_TABLE_SIZE] __cacheline_aligned;

bit_wait_table 是一个等待队列数组,用于根据特定位的值来等待/唤醒进程。初始化 waitqueues 数组后,下一步是计算为 root_task_group 分配的内存大小。可以看到,这个大小取决于以下两个内核配置选项:

#ifdef CONFIG_FAIR_GROUP_SCHED
         alloc_size += 2 * nr_cpu_ids * sizeof(void **);
#endif
#ifdef CONFIG_RT_GROUP_SCHED
         alloc_size += 2 * nr_cpu_ids * sizeof(void **);
#endif
  • CONFIG_FAIR_GROUP_SCHED

  • CONFIG_RT_GROUP_SCHED

这两个选项提供了两种不同的调度模型。根据文档说明,当前使用的调度器——完全公平调度器(CFS)采用了一个简洁的设计理念。它将进程调度建模为理想的多任务处理器,其中每个可运行进程都能获得 $\displaystyle\frac 1 n$ 的处理器时间(n 代表可运行进程数量)。该调度器遵循特定的规则集,这些规则决定了何时以及如何选择新进程运行,被称为调度策略

完全公平调度器支持以下普通(即非实时)调度策略:

  • SCHED_NORMAL;

  • SCHED_BATCH;

  • SCHED_IDLE.

SCHED_NORMAL 用于普通应用程序,每个进程占用的 CPU 时间主要由 nice 值决定;SCHED_BATCH 用于 100% 非交互式任务;而 SCHED_IDLE 仅在处理器没有其他任务可运行时才会调度。

对于时间敏感的实时应用,内核也支持两种策略:SCHED_FIFOSCHED_RR。了解 Linux 内核调度器的读者会知道其采用模块化设计,这意味着它支持不同算法来调度不同类型的进程。这种模块化通常称为调度类(scheduler classes),这些模块封装了调度策略细节,由调度器核心统一处理而无需了解具体实现。

现在回到代码,我们关注两个配置选项:CONFIG_FAIR_GROUP_SCHEDCONFIG_RT_GROUP_SCHED。虽然调度器调度的基本单位是单个任务或线程,但进程并非唯一可调度实体。这两个选项均提供了对组调度的支持——前者针对完全公平调度器策略,后者则对应实时策略。

简单来说,组调度是一种功能,允许我们将一组任务视为单个任务进行调度。例如,如果你创建一个包含两个任务的组,从内核的角度来看,这个组就像一个普通任务。当组被调度时,调度器会从该组中选择一个任务在组内进行调度。这种机制让我们能够构建层级结构并管理它们的资源。虽然调度的最小单位是进程,但 Linux 内核调度器在底层并不直接使用 task_struct 结构,而是通过专门的 sched_entity 结构作为调度单元。

因此,当前的目标是计算为根任务组的 sched_entity 分配的空间大小,我们通过以下两种情况进行计算:

#ifdef CONFIG_FAIR_GROUP_SCHED
         alloc_size += 2 * nr_cpu_ids * sizeof(void **);
#endif
#ifdef CONFIG_RT_GROUP_SCHED
         alloc_size += 2 * nr_cpu_ids * sizeof(void **);
#endif

第一种情况是启用了完全公平调度器的任务组调度功能,第二种情况则是针对实时调度器的相同用途。这里我们计算的大小等于指针大小乘以系统中的 CPU 数量,再乘以 2。需要乘以 2 是因为我们要为以下两项分配空间:

  • 调度实体结构;

  • runqueue

计算完大小后,我们通过 kzalloc 函数分配空间,并在此设置 sched_entityrunqueues 的指针:

ptr = (unsigned long)kzalloc(alloc_size, GFP_NOWAIT);

#ifdef CONFIG_FAIR_GROUP_SCHED
        root_task_group.se = (struct sched_entity **)ptr;
        ptr += nr_cpu_ids * sizeof(void **);

        root_task_group.cfs_rq = (struct cfs_rq **)ptr;
        ptr += nr_cpu_ids * sizeof(void **);
#endif
#ifdef CONFIG_RT_GROUP_SCHED
		root_task_group.rt_se = (struct sched_rt_entity **)ptr;
		ptr += nr_cpu_ids * sizeof(void **);

		root_task_group.rt_rq = (struct rt_rq **)ptr;
		ptr += nr_cpu_ids * sizeof(void **);

#endif

如前所述,Linux 的组调度机制支持层级结构定义。这类层级结构的根节点是 root_runqueuetask_group 任务组结构体。该结构体包含多个字段,但目前我们重点关注 sert_secfs_rqrt_rq 这四个成员:

其中 sert_sesched_entity 结构体的实例,该结构定义于 include/linux/sched.h 头文件,是调度器的基本调度单元。

struct task_group {
    ...
    ...
    struct sched_entity **se;
    struct cfs_rq **cfs_rq;
    ...
    ...
}

cfs_rqrt_rq 表示运行队列(run queues)。运行队列是一种特殊的 per-cpu 结构,Linux 内核调度器用它来存储 active 线程,换句话说,这些线程集合中的线程可能会被调度器选中运行。

空间分配完成后,下一步是初始化 real-time(实时)和 deadline(截止时间)任务的 CPU 带宽:

init_rt_bandwidth(&def_rt_bandwidth,
                  global_rt_period(), global_rt_runtime());
init_dl_bandwidth(&def_dl_bandwidth,
                  global_rt_period(), global_rt_runtime());

所有任务组都必须能够依赖预设的 CPU 时间配额。以下两个结构体:def_rt_bandwidthdef_dl_bandwidth,分别表示实时任务和截止时间任务的默认带宽配置。虽然这些结构体的具体定义目前并不重要,但我们重点关注以下两个参数值:

  • sched_rt_period_us;

  • sched_rt_runtime_us.

第一个参数表示周期长度,第二个参数表示在 sched_rt_period_us 周期内为 real-time 任务分配的时间量(quantum)。这些参数的全局默认值可以在以下位置查看:

$ cat /proc/sys/kernel/sched_rt_period_us
1000000

$ cat /proc/sys/kernel/sched_rt_runtime_us
950000

与任务组相关的参数可通过 <cgroup>/cpu.rt_period_us<cgroup>/cpu.rt_runtime_us 进行配置。由于此时尚未挂载任何文件系统,def_rt_bandwidthdef_dl_bandwidth 将使用 global_rt_periodglobal_rt_runtime 函数返回的默认值进行初始化。

至此已完成 real-timedeadline 任务的带宽设置。接下来,根据是否启用 SMP,我们将初始化 root domain

#ifdef CONFIG_SMP
	init_defrootdomain();
#endif

实时调度器需要全局资源来做出调度决策。但随着 CPU 数量增加,可扩展性瓶颈就会出现。引入根域(root domains)概念正是为了提升可扩展性并避免此类瓶颈。调度器不再需要遍历所有运行队列,而是通过 root_domain 结构获取可以推送/拉取实时任务的 CPU 信息。该结构定义于 kernel/sched/sched.h 头文件,主要跟踪可用于进程迁移的 CPU 集合。

完成根域初始化后,我们像之前一样为根任务组的实时任务初始化带宽控制:

#ifdef CONFIG_RT_GROUP_SCHED
	init_rt_bandwidth(&root_task_group.rt_bandwidth,
			global_rt_period(), global_rt_runtime());
#endif

并使用相同的默认值进行初始化。

接下来,根据内核配置选项 CONFIG_CGROUP_SCHED 的设置,我们会为 task_group 结构分配 slab 缓存,并初始化根任务组的 siblingschildren 链表。根据内核文档说明,CONFIG_CGROUP_SCHED 选项的作用是:

允许通过 cgroup 伪文件系统创建任意任务组,并控制分配给每个任务组的 CPU 带宽。

完成链表初始化后,我们可以看到调用了 autogroup_init 函数:

#ifdef CONFIG_CGROUP_SCHED
         list_add(&root_task_group.list, &task_groups);
         INIT_LIST_HEAD(&root_task_group.children);
         INIT_LIST_HEAD(&root_task_group.siblings);
         autogroup_init(&init_task);
#endif

该函数用于初始化自动进程组调度机制。autogroup 特性的作用是通过 setsid 系统调用创建新会话时,自动创建并填充新的任务组。

完成此操作后,我们会遍历所有 possible CPU(您可能记得 possible CPU 存储在 cpu_possible_mask 位图中,表示系统中可能存在的所有 CPU),并为每个 possible CPU 初始化运行队列:

for_each_possible_cpu(i) {
    struct rq *rq;
    ...
    ...
    ...

Linux 内核中的 rq 结构体定义于 kernel/sched/sched.h。如之前所述,运行队列是调度过程中的核心数据结构,调度器通过它决定下一个要运行的任务。该结构体包含众多字段,我们不会在此逐一说明,而是在后续实际使用时具体分析。

在为每个 CPU 的运行队列完成默认值初始化后,我们需要设置系统中第一个任务的负载权重load weight):

set_load_weight(&init_task);

首先我们来理解什么是进程的负载权重。如果查看 sched_entity 结构的定义,会发现它起始于 load 字段:

struct sched_entity {
	struct load_weight		load;
    ...
    ...
    ...
}

load weight 结构仅包含两个字段,分别表示调度实体的实际权重及其优化计算的固定倒数:

struct load_weight {
	unsigned long	weight;
	u32				inv_weight;
};

您可能知道系统中每个进程都有优先级priority)。更高的优先级意味着可以获得更多的运行时间。进程的负载权重实际上反映了该进程优先级与其时间片配额之间的关系。每个进程都包含以下三个与优先级相关的字段:

struct task_struct {
...
...
...
	int				prio;
	int				static_prio;
	int				normal_prio;
...
...
...
}

第一个是动态优先级dynamic priority),它基于进程的静态优先级和交互性,在进程生命周期内不可更改。static_prio 包含初始优先级,即您可能熟知的 nice 值。除非用户主动修改,否则内核不会改变这个值。最后一个 normal_priority 虽然也基于 static_prio 的值,但同时还取决于进程的调度策略。

set_load_weight 函数的主要目标是为 init 任务初始化 load_weight 字段:

static void set_load_weight(struct task_struct *p)
{
	int prio = p->static_prio - MAX_RT_PRIO;
	struct load_weight *load = &p->se.load;

	if (idle_policy(p->policy)) {
		load->weight = scale_load(WEIGHT_IDLEPRIO);
		load->inv_weight = WMULT_IDLEPRIO;
		return;
	}

	load->weight = scale_load(sched_prio_to_weight[prio]);
	load->inv_weight = sched_prio_to_wmult[prio];
}

可以看到,我们根据 init 任务的初始 static_prio 值计算出初始 prio,并将其作为 sched_prio_to_weightsched_prio_to_wmult 数组的索引来设置 weightinv_weight 值。这两个数组包含了基于优先级值的负载权重。对于 idle 进程,我们设置最小负载权重。

至此,我们已完成 Linux 内核调度器的初始化过程。最后的步骤包括:将当前进程(即首个 init 进程)设为 idle 状态(当 CPU 没有其他进程可运行时执行),计算下一次 CPU 负载计算的时间周期,以及初始化 fair 调度类:

__init void init_sched_fair_class(void)
{
#ifdef CONFIG_SMP
	open_softirq(SCHED_SOFTIRQ, run_rebalance_domains);
#endif
}

这里我们注册了一个软中断,该中断将调用 run_rebalance_domains 处理程序。当 SCHED_SOFTIRQ 被触发时,run_rebalance 函数会被调用来重新平衡当前 CPU 的运行队列。

sched_init 函数的最后两个步骤是初始化调度器统计信息和设置 scheduler_running 变量:

scheduler_running = 1;

至此,Linux 内核调度器已完成初始化。当然,我们在此跳过了许多细节和解释,因为需要先理解 Linux 内核中各种概念(如进程和进程组、运行队列、RCU 等)的工作原理。不过我们已经对调度器初始化过程有了基本了解,更详细的内容将在专门讨论调度器的独立章节中深入分析。

总结

这是关于 Linux 内核初始化过程的第八部分的结尾。在本部分中,我们探讨了调度器的初始化流程,并将在下一部分继续深入 Linux 内核初始化过程,重点分析 RCU 机制及其他核心组件的初始化工作。

如果您有任何疑问或建议,请通过 Twitter 或者 email 与我联系,或者创建一个 issue

Please note that English is not my first language, And I am really sorry for any inconvenience. If you find any mistakes please send me PR to linux-insides.

链接

最后更新于