Linux内核笔记-进程调度

现代操作系统都可以同时运行若干进程,对于单核CPU,给定时刻只会有一个进程运行,CPU快速的在各个进程中切换,给用户以多个进程同时运行的错觉,对于多核CPU,可以真正并发运行多个进程,取决于CPU的数目。

内核在各个进程切换过程中,必须做到进程之间不能互相干扰,而且需要CPU时间必须在各种应用程序中尽可能公平的共享,进程管理和调度有两个主要任务:

  • 内核决定各个进程分配多长时间,何时切换到下一个进程;
  • 进程从A切换到B时,需要确保进程B的执行环境与上一次撤销其处理资源时完全相同;

这两个任务称之为调度器的内核子系统的职责。

进程生命周期

进程不是总是可以立即运行,有时必须等待外部信号,在信号发生时进程无法运行。当调度器在进程之间切换时,必须知道系统中每个进程的状态。进程可能有以下几种状态:

  • 运行:进程正在运行
  • 等待:进程能够运行,但没有分到时间片,调度器下一次可以选择此进程
  • 睡眠:进程正在睡眠无法运行,正在等待外部事件
  • 僵尸:进程资源已经释放,但还保留进程表中的项
  • 终止:进程退出

Linux进程管理还需另外两种状态选项:用户状态和核心态。用户态受到各种限制,内核态却有无上的权利。进程通常处于用户状态,只能访问自身数据,无法干扰其他进程。如果进程想要访问系统数据或者功能,必须切换到核心态,用户态切换到核心态有两种方法,第一种是系统调用(系统调用)[/reading/linux-kernel-system-call.html],另一种是通过中断。内核的抢占调度模型建立了一个层次结构,用户判断哪些进程状态可以由其他状态抢占。

  • 普通进程总是可能被抢占
  • 如果进程处于核心态并在处理系统调用,那么其他进程是无法抢占,但中断可以终止系统调用
  • 中断可以暂停出于用户状态和核心态的进程,中断有最高优先级

内核有一个内核抢占的选项,支持紧急情况下切换到另一个进程,甚至当前处于核心态执行系统调用。

进程表示

Linux内核涉及进程和进程所有算法都围绕一个名为 task_struct 的数据结构建立,该结构定义在 include/sched.h 中,这是系统中重要的一个结构。

task_struct 结构复杂,要想都搞清楚着实不易,但该结构可以分为各个部分,每个部分表示进程的不同方面:

  • 状态和执行信息:如待定信号,进程ID(pid),父进程及其他有关进程的指针,优先级和程序执行有关的信息
  • 有关分配的虚拟内存信息
  • 进程身份凭据,如用户ID,组ID及权限
  • 使用的文件包含程序代码的二进制文件,以及进程所处理的所有文件的文件系统信息
  • 线程信息记录该进程特定于CPU的运行时间数据
  • 在与其他应用程序协作时所需的进程间通信有关的信息
  • 该进程所用的信号处理程序,用于响应到来的信号

task_struct 的许多成员并非简单类型变量,而是其他数据结构的指针。下面介绍一些结构的重要数据成员。

当前状态 state

state指定了当前状态,可以使用

  • TASK_RUNNING:进程处于可运行状态,这并不意味着实际分配了CPU,进程可能会一直等到调度器选中它
  • TASK_INTERRUPTIBLE:针对等待某事件或其他资源的睡眠进程设置的。在内核发送信号给进程表明事件已经发生,进程状态变为 TASK_RUNNING
  • TASK_UNINTERRUPTIBLE:用于因内核指示而停用的睡眠进程,他们不能由外部信号唤醒,只能由内核亲自唤醒
  • TASK_STOPPED:表示进程特意停止运行,例如由调度器暂停
  • TASK_TRACED:本来不是进程状态,用于从停止的进程中,将当前被调试的那些与常规的进程区分开

下面常量可以用于struct task_struct进程状态字段,也可以用于exit_state字段,后者明确地用于退出进程

  • EXIT_ZOMBIE:僵尸进程
  • EXIT_DEAD:状态则是指wait系统调用已经发出,而进程完全从系统移除之前的状态,只有多个线程对同一个进程发出wait调用时,才有意义

资源限制 rlimit

Linux提供资源限制功能,该机制利用了task_struct中的 rlim 数组,数组项类型为 struct rlimit

struct rlimit {
    unsigned long   rlim_cur;
    unsigned long   rlim_max;
};

上述定义可以用于多种不同的资源类型:

  • rlim_cur:是进程当前资源限制,也称之为软限制
  • rlim_max:是该限制的最大容许值,称之为硬限制

系统调用setrlimit来增减当前限制,但不能超过rlim_max指定的值。rlim数组中的位置标志了受限资源的类型,这也是内核需要定义的处理器常数,将资源与位置关联起来的原因。如果某一类资源没有限制,则将rlim_max设置为RLIM_INFINITY。以下是几种宏定义:

常数 语义
RLIMIT_CPU 按毫秒计算的最大CPU时间
RLIMIT_FSIZE 允许的最大文件长度
RLIMIT_DATA 数据段的最大长度
RLIMIT_STACK (用户态)栈最大长度
RLIMIT_CORE 内存转储文件最大长度
RLIMIT_NOFILE 打开文件最大数目
RLIMIT_SIGPENDING 待决信号的最大数目
RLIMIT_MSGQUEUE 信息队列最大数目

proc文件系统中,可以从文件/proc/self/limits查看当前rlimit限制。

进程类型

典型的UNIX进程包括:由二进制代码组成的应用程序、单线程、分配给应用程序的一组资源。新进程是由forkexec系统调用完成的。

  • fork 生成当前进程的一个相同副本,成为子进程。
  • exec 从一个可执行文件加载另一个应用程序,来代替当前运行的进程。
  • clone 工作原理基本与fork相同,但新进程不是独立于父进程的,可以与其共享某些资源,如父进程的内存数据等。

clone 用于实现线程,但仅仅系统调用还不能做到这点,还需要用户空间库才能提供完整实现,如Linuxthreads和Next Generation Posix Threads等。

命名空间

命名空间提供了虚拟化的一种轻量级形式,传统上,在Linux以及其他衍生的UINX扁蹄中,许多资源是全局管理的,例如系统中的所有进程按照惯例都是通过PID标识的,这意味着内核必须管理一个全局的PID列表,而且,所有调用者通过uname系统调用返回的系统相关信息都是相同的。用户的管理方式相同,即各个用户是通过一个全局唯一的UID号标识。

有些情况下,如提供Web主机的提供商给每个用户提供Linux计算机全部访问权限,包括root权限在内,传统上,要为每个用户准备一台计算机,代价很高,使用KVM虚拟机是另一种办法,但资源分配做的不是很好,计算机各个用户都需要一个独立的内核,以及一份完全安装好的用户层应用。

命名空间提供了一种不同的解决方案,所需资源较少,命令空间只使用一个内核,前述的所有全局资源都通过命令空间抽象起来,这使得可以将一组进程放置到容器中,各个容器彼此隔离,隔离可以使得容器的成员与其他容器毫无关系,但也可以通过允许容器进程一定的共享,来降低容器之间的间隔。例如,容器可以设置为使用自身的PID集合,但仍然与其他容器共享部分文件系统。

Linux 系统对简单形式的命令空间的支持已经有很长时间了,主要是chroot系统调用,该方法可以将进程限制到文件系统的某一部分。因而是一种简单的命名空间机制,但真正的命名空间能够控制的功能远超文件系统视图。

新命令空间可以用两种方法创建:

  • fork 或 clone 系统调用创建新进程时,有特定选项可以控制是与父进程共享命名空间,还是建立新的命名空间
  • unshare 系统调用将进程的某些部分从父进程分离,其中也包括命名空间

在通过上述两种方式从父进程命名空间分离后,从该进程角度看,改变全局属性不会传播到父进程命名空间,而父进程修改也不会传播到子进程。

命名空间的实现需要两个部分:每个子系统的命名空间结构,将此前所有的全局组建包装的命名空间中;将给定进程关联到所属各个命名空间的机制。

子系统此前的全局属性现在封装到命名空间中,每个进程关联到一个选定的命名空间,每个可以感知命名空间的内核子系统都必须提供一个数据结构,将所有通过命名空间形式提供的对象集中起来。struct nsproxy用户汇集指向特定于子系统的命名空间包装器的指针:

struct nsproxy {
    atomic_t count;
    struct uts_namespace *uts_ns;
    struct ipc_namespace *ipc_ns;
    struct mnt_namespace *mnt_ns;
    struct pid_namespace *pid_ns;
    struct net           *net_ns;
};

当前内核的以下范围可以感知到命名空间:

  1. UTS(UNIX Timesharing System)命名空间包含了运行内核的名称、版本、底层体系结构类型等信息
  2. 保存在struct ipc_namespace 中的所有与进程间通信IPC有关信息
  3. 已经装载的文件系统的视图,在struct mnt_namespace中给出
  4. 有关进程ID的信息,由struct pid_namespace提供
  5. struct net_ns 包含所有网络相关的命名空间参数

task_struct 结构成员 struct nsproxy *nsproxy保存关联到自身的命名空间视图。因为使用指针,多个进程可以共享一组名字空间,这样,修改给定的命名空间,对所有属于该命名空间的进程都是可见的。

init_nsproxy定义了初始的全局命名空间,其中维护了指向各子系统初始的命名空间对象的指针:

struct nsproxy init_nsproxy = {
        .count  = ATOMIC_INIT(1),
        .uts_ns = &init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
        .ipc_ns = &init_ipc_ns,
#endif
        .mnt_ns = NULL,
        .pid_ns = &init_pid_ns,
#ifdef CONFIG_NET
        .net_ns = &init_net,
#endif
};

进程ID号

UNIX进程总会分配一个号码用于在命令空间中唯一的标志它们,该号码被称作PID。用fork和clone产生的每个进程都会由内核自动地分配唯一的新的PID值。

进程除了PID这个值还有其他的ID,有下列几种可能的类型:

  • 处于某个线程组中的所有统一的线程组ID(TGID),如果进程没有使用线程,则PID和TGID相同,线程组中的主进程被称作组长。通过clone组建的所有线程task_structgroup_leader成员,会指向组长的task_struct
  • 独立进程可以组成进程组(setpgrp系统调用),进程组成员的task_struct成员pgrp属性都相同,即组长PID。
  • 几个进程组可以合并成一个会话。会话中所有进程都有相同的会话ID,保存在task_structsession成员中。SID可以使用setsid系统调用设置。

进程关系

除了源于ID连接的关系外,内核还负责管理建立在UNIX进程创建模型之上的家族关系:

  • 如果进程A分支形成进程B,进程A称之为父进程二进程B是子进程
  • 如果进程B再次分支建立进程C,进程A和进程C之间称之为祖孙关系
  • 如果进程A分支若干次形成B1,B2… 各个B进程之间成为兄弟进程

task_struct 数据结构提供了两个链表表头,用于实现这些关系:

struct task_struct {
...
    struct list_head children;
    struct list_head sibling;
...
};

进程管理相关的系统调用

进程复制

Linux内核提供三个函数复制进程:

  • fork:这是重量级调用,因为它提供了一个完整副本,然后作为子进程执行,为减少调用相关的工作量,Linux使用了写时复制(copy-on-write)技术
  • vfork:类似fork,但并不创建父进程数据副本,相反,父子进程之间共享数据,这节省了大量CPU时间,vfork设计用于子进程执行execve系统调用加载新程序的情况,但由于fork函数实现了写时复制功能,所以vfork在性能方面不再有优势,应避免使用
  • clone 产生线程,可以对父子进程之间的共享、复制进行精确控制

写时复制

内核使用写时复制技术,以防止在fork执行时将父进程的所有数据复制到子进程,该技术利用了下述事实,进程通常只使用了其内存页的一小部分,在调用fork时,内核通常会复制父进程的每个内存页,这样有两个不好的负面效应:

  • 使用了大量的内存
  • 复制操作耗费很长时间

如果应用程序在进程复制之后使用exec立即加载程序,那么负面效应会更严重。这意味着,此前复制操作是完全多余的。内核可以使用技巧来规避这个问题,并不复制整个地址空间,而是复制其页表,这样建立了虚拟地址和物理内存页之间的联系。fork之后父子进程地址空间指向相同的物理内存页。

这时父子进程不能修改彼此的页,这也是两个进程页表对其标志了只读的原因。如果两个进程只读内存页,二者共享空间不会有问题,只要有一个进程试图向复制的内存页写入,处理器会向内核报告缺页异常。内核查看额外的内存管理数据结构,如果是COW页,内核会创建当前进程的副本。这里的实现需要了解内存管理方面的知识。

COW机制使得内核尽可能延迟内存页复制,当然大多数情况下,可能都不需要复制,节省了大量的时间。

执行系统调用

fork,vfork,clone系统调用的入口分别是sys_fork,sys_vfork,sys_clone函数。其定义依赖于具体的体系结构。上述函数任务是从处理器寄存器中提取用户空间提供的信息,调用体系结构无关的do_fork函数,后者负责进程复制。函数原型如下:

// kernel/fork.c
long do_fork(unsigned long clone_flags,
                unsigned long stack_start,
                unsigned long stack_size,
                int __user *parent_tidptr,
                int __user *child_tidptr)

参数如下:

  • clone_flags 是一个标志集合,用来指定控制复制过程中的一些属性
  • start_stack 是用户状态下栈的起始地址
  • regs 是一个指向寄存器集合的指针,其中以原始形式保存了调用参数。该参数使用的数据类型是特定于体系结构的struct pt_regs
  • stack_size 是用户状态下栈的大小,通常没有必要,设置为0
  • parent_tidptrchild_tidptr 是指向用户空间中地址的两个指针,分别指向父子进程的TID,NPTL(Native Posix Threads Library)库的线程实现需要这两个参数。

不同的fork变体,主要是通过标志集合区分,多数体系结构上,典型的fork调用实现方式于IA-32处理器相同:

// arch/x86/kernel/process.c
int sys_fork(struct pt_regs *regs)
{
    return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}

使用的唯一标志是 SIGCHLD,这意味着在子进程终止后发送 SIGCHLD 信号通知父进程。如果do_fork成功,则新建进程的PID作为系统调用的结果返回,否则返回负值错误码。

sys_vfork的实现与sys_fork只是略微不同,前者使用了额外的标志CLONE_VFORKCLONE_VM

sys_clone的实现方式与上述调用相似,差别在于do_fork调用如下:

// arch/x86/kernel/process_32.c
long sys_clone(unsigned long clone_flags, unsigned long newsp,
            void __user *parent_tid, void __user *child_tid, struct pt_regs *regs)
{
    if (!newsp)
        newsp = regs->sp;
    return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
}

标志不再是硬编码,可以通过各个寄存器参数传递到系统调用。而且,也不再复制父进程的栈,可以指定新的栈地址。

do_fork 的实现

所有三个fork机制最终都调用了do_fork,其代码流程如下:

do_fork
    |
    |----- copy_process
    |----- 确定PID
    |----- 初始化vfork的完成处理程序
    |----- wake_up_new_task
    |----- 是否设置了CLONE_VFORK 标志 -> wait_for_completion

do_fork以调用copy_process开始,后者执行生成新进程的实际工作,根据指定标志重用父进程的数据,子进程生成之后,内核必须执行以下收尾操作:

进程复制do_fork实现如下:

copy_process
    |
    |----- 检查标志
    |----- dup_task_struct
    |----- 检查资源限制
    |----- 初始化 task_struct
    |----- sched_fork
    |----- 复制进程各个部分
                |----- copy_semundo
                |----- copy_files
                |----- copy_fs
                |----- copy_sighand
                |----- copy_signal
                |----- copy_mm
                |----- copy_namespaces
                |----- copy_thread
    |----- 设置各个 ID、进程关系

复制进程的行为受到相当多的标志的控制,clone(2) 的手册详细讲述了这些标志,标志可以参考手册。该函数也需要做一些错误判断,Linux函数有时候会在成功的时候返回结构体指针,失败的情况下返回错误码,但函数只能返回一个值,所以Linux做了一个特殊处理,虽然一般而言指针可以指向内存任何位置,而Linux支持的每个体系结构的虚拟地址从0到4Kib的区域,该区域没有任何有意义的信息,因此内核可以重用此地址范围来编码错误。

当检查完标志后,使用dup_task_struct来建立父进程task_struct的副本,父进程的task_struct实例只有一个成员不同,新进程分配了一个新的核心态,即task_struct->stack。通常栈和thread_info一同保存在一个联合之中,thread_info保存了线程所需的所有特定于处理器的底层信息。

内核线程

内核线程是直接由内核本身启动的进程,内核线程实际上是将内核函数委托给独立的进程,内核线程经常称之为守护线程,用于执行下列任务:

  • 周期性的修改内存页与页来源块设备同步
  • 如果内存页很少使用,写入交换区
  • 管理延迟动作
  • 实现文件系统的事务日志

基本上,有两种类型的内核线程:

  • 线程启动后一直等待,直至内核请求线程执行某一特定操作
  • 线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预期的限制时采取行动。

调用kernel_thread函数可启动一个内核线程,定义是特定于体系结构的,但原型总是相同的。

int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)

产生的线程将执行用fn指针传递的函数,用arg指定的参数自动传个fn,flags可指定CLONE标志。kernel_thread第一个任务是构建一个pt_regs实例,对其中的寄存器指定适当的值,这与普通的fork类似,接下来调用do_fork函数。

p = do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0 &regs, 0, NULL, NULL);

内核线程可以用两种方法实现,古老的方法内核中一些地方仍然在使用该方法,将一个函数直接传递给kernel_thread,该函数接下来负责帮助内核调用daemonize以转换为守护进程:

  1. 该函数从内核线程释放其父进程的所有资源,不然这些资源会一直锁定到线程结束。这是不可取的,因为守护线程通常运行到系统关机,因为守护进程只操作内核地址区域。
  2. daemonize 阻塞信号的接收。
  3. 将init用作守护进程的父进程。

内核线程的现代方法是辅助函数kthread_create

struct task_struct * kthread_create(int (*threadfn)(void *data),
                void *data,
                const char namefmt[],
                ...);

改函数创建一个新的内核线程,其名称由namefmt给出,最初该线程是停止的,需要使用wake_up_process启动它,此后,会调用threadfn给出的线程函数,而data则作为参数。

另一个备选方案是宏kthread_run,它会调用kthread_create创建新线程,但立即唤醒它,还可使用kthread_create_cpu代替kthread_create创建内核线程,使之绑定到特定的CPU。

内核线程会出现在系统进程列表中,在ps的输出中由放括号包围,以便与普通进程区分。

cppbreak@ThinkPad:~$ ps fax
  PID TTY      STAT   TIME COMMAND
    2 ?        S      0:00 [kthreadd]
    3 ?        S      0:00  \_ [ksoftirqd/0]
    6 ?        S      0:02  \_ [migration/0]
    7 ?        S      0:00  \_ [watchdog/0]
    8 ?        S      0:00  \_ [migration/1]
    9 ?        S      0:00  \_ [kworker/1:0]
   10 ?        S      0:00  \_ [ksoftirqd/1]
   12 ?        S      0:00  \_ [watchdog/1]
   13 ?        S      0:01  \_ [migration/2]

启动新程序 execve

Linux 通过用新代码替换现存程序,即可启动新程序。Linux提供的 execve 系统调用可实现此功能。该函数的入口点是体系相关的sys_execve 函数,该函数把具体的工作委托给do_execve实现具体功能。

int do_execve(const char *filename,
                const char __user *const __user *__argv,
                const char __user *const __user *__envp,
                struct pt_regs *regs)

这里不仅用参数传递了寄存器集合和可执行文件的名称filename,而且还传递了指向程序的参数和环境指针。这里argv和envp都是指针数组,而且指向用户空间,__user注释允许自动化工具检测是否所有处理都正确。

do_execve 代码执行流程如下:

do_execv
    |----- 打开可执行文件
    |----- bprm_init
                |----- mm_alloc
                |----- init_new_context
                |----- __bprm_mm_init
    |----- prepare_binprm
    |----- 复制环境和参数数组内容
    |----- search_binary_handler

首先要打开可执行文件,换言之,内核找到相关的inode并生成一个文件描述符,用于寻址文件。

bprm_init接下来处理若干管理性任务:mm_alloc生成一个新的mm_struct实例来管理进程地址空间。init_new_context是一个特定于体系结构的函数,用于初始化该实例,而__bprm_mm_init则建立初始化的栈。

新进程的各个参数,例如euid,egid,参数列表,环境等等,这里会合并成一个类型为linux_binprm的结构,prepare_binprm用于提供一些父进程相关的值,特别是 UID 和 GID。

Linux 支持可执行文件的各种不同组织格式,标准格式是ELF。Linux还支持其他不同的二进制格式,通过函数search_binary_handlerdo_execve结束时查找一种适当的二进制格式。各种格式根据不同特点(文件开始时的“魔数”)识别。二进制格式处理程序执行以下操作。

  1. 释放原进程所使用的所有资源
  2. 将应用程序映射到虚拟地址空间中。
    • text段包含程序的可执行代码,start_codeend_code指定该段在地址空间中驻留的区域
    • 预先初始化的数据,位于start_dataend_data之间,映射自可执行文件对应段
    • 堆用户动态内存分配,置于虚拟地址空间中,start_brkbrk指定了其边界
    • 栈的位置由start_stack定义,大多数计算机的栈都是自动向下增长
    • 程序的参数和环境也映射到虚拟地址空间中,分别位于arg_startarg_end之间,以及env_startenv_end之间
  3. 设置进程的指令指针和其他特定于体系结构的寄存器,以便在调度器选择该进程时开始执行程序的main函数

Linux内核中,每种二进制格式都表示为下列数据结构的一个实例:

struct linux_binfmt {
        struct list_head lh;
        struct module *module;
        int (*load_binary)(struct linux_binprm *, struct  pt_regs * regs);
        int (*load_shlib)(struct file *);
        int (*core_dump)(struct coredump_params *cprm);
        unsigned long min_coredump;     /* minimal dump size */
};

linux支持的部分二进制格式有flat_format, script_format, misc_format, elf_format, elf_fdpic_format, irix_format, som_format, aout_format。每种二进制格式必须提供下面三个函数:

  1. load_binary 用于加载普通程序
  2. load_shlib 用于加载共享库,即动态库
  3. core_dump 用于程序错误的情况下输出内存转储

每种格式首先必须用register_binfmt向内核注册。该函数的目的是向一个链表增加一个新的二进制格式,该链表的表头是fs/exec.c中的全局变量formatslinux_binfmt实例通过其next成员彼此连接起来。

退出进程

进程必须用exit系统调用终止,这使得内核有机会将该进程使用的资源放回系统。该调用的入口点是 sys_exit 函数,需要一个错误码作为其参数,以便退出进程,最终调用do_exit函数实现,该函数实现就是将各个引用计数器减1。如果引用计数器归0就将相应的内存区域返还给内存管理模块。

调度器的实现

内存中保存了对每个进程的唯一描述,通过若干结构与其他进程连接起来,调度器任务便是程序之间共享CPU时间,创造并执行的错觉,该任务分为两个不同部分:一个涉及调度策略,另一个涉及上下文切换。

概观

内核必须提供一种方法,在进程之间尽可能公平地共享CPU时间,而同时又要考虑不同的任务优先级。schedule函数是理解调度操作的起点,该函数定义在kernel/sched.c中,是内核代码最常用的函数之一。

完全公平调度类

实时调度类

调度器增强

Built with Hugo
主题 StackJimmy 设计