Professional Linux Kernel Architecture(一)

基于linux内核2.6.24版本,书籍:Professional Linux Kernel Architecture英文版(可在https://github.com/welldef/os_books.git下载)

1 一些概念

1.1 微内核和单体内核

微内核:只有最基本的功能直接在中央内核(微内核)中实现。所有其他功能都委托给各自独立的进程,这些进程通过通信接口与微内核通信,例如各种文件系统、内存管理等。理论上,这是一种非常优雅的方法,因为各个部分明显相互隔离,这迫使程序员使用“精益”编程技术。这种方法的其他好处是动态可扩展性和在运行时交换重要组件的能力。然而,由于需要额外的CPU时间来支持组件之间的复杂通信,微内核在实践中并没有真正建立起来

单体内核:内核的整个代码(包括其所有子系统,如内存管理、文件系统或设备驱动程序)都打包到一个文件中。每个函数都可以访问内核的所有其他部分

linux使用的是单体内核,但其模块化特性确实在一定程度上借鉴了微内核的理念

1.2 内核组成

进程、任务切换和调度

在Unix下运行的应用程序、服务和其他程序被称为进程。各个进程的地址空间是完全独立的。

linux是多任务系统,多任务系统中,内核将负责进程的切换。既然是多任务系统,则该系统必须给每个单独的进程一种CPU始终可用的错觉。这是通过在CPU资源被回收并且进程被置于空闲状态之前保存进程的所有状态来实现的。当该进程被重新激活时,恢复保存的状态。

内核还必须决定如何分配CPU时间。重要进程被分配了更大的CPU时间份额,而不太重要的进程则被分配了更小的份额。决定哪个进程运行多长时间被称为进程的调度

2 进程管理

进程的状态

  • Running: 进程当前正在执行。
  • Waiting: 进程能够运行,但不允许运行,因为CPU被分配给了另一个进程。如果调度器愿意,它可以在下一次任务切换时选择进程。
  • Sleeping: 进程正在休眠,无法运行,因为它正在等待外部事件。调度程序无法在下一次任务切换时选择进程。
  • zombie: 僵尸状态,指的是子进程已经终止,但是父进程还未调用wait4(wait for)系统调用的临时状态

系统存储所有进程到一个进程表里,处于Sleeping状态的进程被特殊标记出来,以便调度器知道它不能被调度。同时系统中还有几个队列来分组这些Sleeping状态的进程以便在某个事件发生时唤醒这些队列中的进程

有关zombie状态:一个进程终止有两个条件:1 程序必须被另一个进程或用户终止(这通常通过发送SIGTERM或SIGKILL信号来完成,这相当于正常终止进程);2 父进程必须调用wait4(wait for)系统调用,向内核确认父进程已经知晓子进程的终止。这个系统调用允许内核释放子进程预留的资源。;当只有第一个条件(程序已终止)成立而第二个条件(wait4)不成立时,就会出现僵尸进程。一个进程在终止和从进程表中移除其数据之间,总是会短暂地切换到僵尸状态。在某些情况下(例如,如果父进程编程不当且没有发出wait调用),僵尸进程会牢固地停留在进程表中,并一直保持在那里,直到下一次重新启动。这可以通过读取如ps或top等进程工具的输出结果来看到。由于残留数据在内核中占用的空间很小,所以这几乎不是问题。

2.1 抢占式多任务处理

抢占式进程切换是有好处的,抢占式多任务切换对良好的交互行为和低系统延迟是有益的。比如编辑器收到期待已久的键盘输入,即使当前进程正在正常执行,调度器可以决定立即执行编辑器进程。
如果系统处于内核态并且正在处理系统调用,则系统中没有其他进程能够导致CPU时间的占用。调度器必须等待系统调用执行完毕,然后才能选择另一个进程。但是,中断可挂起系统调用。
中断具有最高优先级,因为当中断发生时,必须尽快处理中断。中断可以挂起处理内核态和用户态的进程。

2.2 创建新进程

Linux采用树形结构管理进程之间的关系,称之为进程树,其中每个进程都依赖于一个父进程。内核启动时会初始化1号进程-init进程,init进程作为所有用户进程的根,它负责进一步的系统初始化工作以及显示登录提示等等功能。(新版本的内核1号进程已经变为systemd了)

影响进程树结构的关键因素fork和exec

  • fork:生成当前进程的精确副本,该副本与父进程完全相同,只有PID不同。创建之初共享同一代码段和数据段,当子进程或父进程需要写时(写时复制方式),进程的代码段和数据段才真正分开。
  • exec:在当前进程中执行新的程序实体,旧的内存页被新的内存页替换。

线程(也叫轻量级进程):共享相同的数据(代码段和数据段)和资源,但是执行不同的代码分支(比如java,一个java虚拟机是一个进程,但是我们可以创建多线程,其共享同一java代码,但是执行代码中的不同分支,有时执行同一分支但是可作并发控制措施)。因为有了线程,执行多任务时比创建新进程更快、更简便。使用clone创建线程(轻量级进程)

  • clone:clone()是一个更通用、更灵活的版本,它允许调用者指定哪些资源应该被新进程共享,哪些应该被复制。通过使用clone(),可以创建所谓的线程(轻量级进程),这些线程共享相同的地址空间和其他资源,但每个线程都有自己的执行流(都可参与CPU时间片分配)。
2.2.1 COW(写时复制)技术

传统上,每当一个进程(父进程)通过fork系统调用创建一个新进程(子进程)时,整个进程的地址空间(包括数据、代码等)都需要被复制到子进程中。然而,这种做法非常耗时且效率低下,特别是当进程占用大量内存时。
为了解决这个问题,内核采用了一个巧妙的方法:不是复制整个进程的地址空间,而是仅复制进程的页表(Page Tables)。页表是操作系统用于将进程的虚拟地址空间映射到物理内存中的物理页面上的数据结构。在进程fork时,只复制页表意味着父进程和子进程会共享相同的物理页面,直到它们中的任何一个尝试修改那些页面为止。
当任何一个进程尝试修改共享的物理页面时,会触发一个写时复制(Copy-on-Write, COW)的操作。此时,内核会复制被修改的页面到新的物理内存中,并更新页表,使得父进程和子进程各自指向自己的物理页面副本。这样,只有在需要时才会复制内存,大大提高了fork操作的效率。
进程尝试写入其内存空间中被标记为只读或未分配(即被复制但尚未明确赋予写权限)的页面时,会触发一种称为“页面错误”(Page Fault)的访问错误。这种错误由处理器捕获并报告给操作系统的内核进行处理。操作系统对应的异常处理例程将会被执行,然后执行内存页换入和换出逻辑

2.2.2 系统调用的执行过程

fork、vfork、clone这三个系统调用分别是通过sys_fork、sys_vfork、sys_clone函数调用的。这些函数的实现是与CPU架构强相关的,因为不同架构用户空间和内核空间的传参方式不同。下面以x86架构为例:

asmlinkage int sys_fork(struct pt_regs regs)
{
    return do_fork(SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
}

do_fork原型

long do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr)

它允许用户通过一系列参数定制新进程或线程的行为。下面是对你提供的参数的详细解释:

  • clone_flags:用于指定复制过程的特性。这个参数的低字节(最低8位)通常用来指定子进程终止时发送给父进程的信号编号(不过,在大多数实现中,这一功能并不常用)。而高字节则包含了一系列的标志位,用于控制各种复制行为,比如是否共享内存空间(CLONE_VM)、是否共享文件描述符表(CLONE_FILES)、是否共享信号处理器(CLONE_SIGHAND)等。这些标志位允许在进程创建时实现精细的控制,包括创建轻量级线程(如 POSIX 线程)的能力。
  • stack_start:这是用户栈的起始地址。在创建新进程时,需要为其分配一个新的栈空间,stack_start 就是这个栈空间的起始地址。这个地址通常是在用户空间中分配的一块连续内存区域的起始位置。
  • regs:这是一个指向 pt_regs 结构体的指针,pt_regs 是架构相关的结构体,用于在系统调用或异常发生时保存寄存器的状态。在 do_fork 的上下文中,regs 包含了调用 do_fork 时的寄存器状态,这些状态可能会被复制到新进程中,以便新进程能够继续执行。
  • stack_size:这个参数指定了用户模式栈的大小。然而,在大多数实现中,这个参数并不被直接使用,而是被设置为 0。这是因为 Linux 内核通常会自己管理栈的大小,而不是依赖于这个参数。
  • parent_tidptr 和 child_tidptr:这两个参数是指向用户空间地址的指针,分别用于存储父进程和子进程的线程ID(TID)。这两个参数是 NPTL(Native POSIX Threads Library)线程实现的一部分,允许库在创建线程时获取线程ID,并将其存储在用户指定的位置。这对于线程间的同步和通信非常重要。

do_fork定义

long do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

    p = copy_process(clone_flags, stack_start, regs, stack_size, child_tidptr, NULL);
if (!IS_ERR(p)) { struct completion vfork; nr = (clone_flags & CLONE_NEWPID) ? //是否clone_flags中设置了创建新的PID名称空间,处理逻辑不同 task_pid_nr_ns(p, current->nsproxy->pid_ns) : task_pid_vnr(p); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); }

这里着重说明以下几点:

  • copy_process函数完成实际的子进程的创建,并返回新进程结构的指针,稍后会介绍这个函数
  • 因为涉及到名称空间的存在,如果clone_flags中设置了创建新的PID名称空间,则获取新进程PID的逻辑有所不同,见上面注释

copy_process函数

  • 每种CPU架构基本上都会将虚拟地址0-4kb(至少到4kb)的空间作为预留区域,内核使用这个范围的指针区域来编码错误码
  • 如果返回的指针落到这个范围内,则说明copy_process调用失败了,失败原因则可以由指针指向的数值来决定。
  • ERR_PTR是一个宏,用于进行特定的转换(转换细节还未搞清楚)
  • CLONE_THREAD和CLONE_SIGHAND必须同时传递。意思是线程组必须共享信号量
  • CLONE_VM和CLONE_SIGHAND必须同时传递。意思是如果虚拟地址设置为共享,那么信号处理也必须共享
  • 经过上面的一些校验后,会调用 dup_task_struct 函数进行task_struct结构的复制。
  • 新旧进程只有两处不同:
  • 新进程会被分配一个新的内核栈(task_struct->stack,和thread_info使用同一个union)
static struct task_struct *copy_process(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size, int __user *child_tidptr, struct pid *pid)
{
    struct task_struct *p;

    if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS)) //处理一些异常情况,比如clone_flags不能同时配置为CLONE_NEWNS(创建一个新的名称空间)和CLONE_FS(共享所有文件系统信息)
        return ERR_PTR(-EINVAL);//每种CPU架构基本上都会将虚拟地址0-4kb(至少到4kb)的空间作为预留区域,内核使用这个范围的指针区域来编码错误码

    if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND)) //CLONE_THREAD和CLONE_SIGHAND必须同时传递。意思是线程组必须共享信号量
        return ERR_PTR(-EINVAL);

    if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM)) //如果虚拟地址设置为共享,那么信号处理也必须共享
        return ERR_PTR(-EINVAL);

    p = dup_task_struct(current); //进行实际task_struct结构的拷贝
    if (!p)
        goto fork_out;

    if ((retval = copy_files(clone_flags, p)))
        goto bad_fork_cleanup_semundo;
    if ((retval = copy_signal(clone_flags, p)))
        goto bad_fork_cleanup_sighand;
    if ((retval = copy_mm(clone_flags, p)))
        goto bad_fork_cleanup_signal;
    if ((retval = copy_keys(clone_flags, p)))
        goto bad_fork_cleanup_mm;
    if ((retval = copy_namespaces(clone_flags, p)))
        goto bad_fork_cleanup_keys;

    p->pid = pid_nr(pid);
    p->tgid = p->pid; //线程组id就是创建者的PID
    if (clone_flags & CLONE_THREAD)
        p->tgid = current->tgid;

    //对于线程(传递了CLONE_THREAD参数)来说,线程的parent不是创建者,而是创建者的parent。同理,对创建进程时传递了CLONE_PARENT参数也是如此
    if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) //对于线程来说,线程的parent不是创建者,而是创建者的parent
        p->real_parent = current->real_parent;
    else
        p->real_parent = current;
    p->parent = p->real_parent;

    if (clone_flags & CLONE_THREAD) {
        p->group_leader = current->group_leader; //如果是线程,那么线程的group_leader是当前进程的group_leader
        list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
    }
}

多处调用了copy_xyz函数,用来拷贝或共享内核子系统的相关资源,比如copy_files,如果我们传递了CLONE_FILES标识,那么新进程和旧进程的文件描述符的引用计数都将会增1

static int copy_files(unsigned long clone_flags, struct task_struct * tsk)
{
    struct files_struct *oldf, *newf;
    int error = 0;

    if (clone_flags & CLONE_FILES) {
        atomic_inc(&oldf->count);//旧进程引用计数加1
        goto out;
    }

    /*
     * Note: we may be using current for both targets (See exec.c)
     * This works because we cache current->files (old) as oldf. Don't
     * break this.
     */
    tsk->files = NULL;
    newf = dup_fd(oldf, &error);
    if (!newf)
        goto out;

    tsk->files = newf;//新进程引用计数也加1
    error = 0;
out:
    return error;
}

2.3 Namespace

名称空间可以分离系统资源。可以给系统资源多个视图。

通过名称空间实现容器来创建系统的多个视图,而不是为每个客户设置一台物理机器,每个容器是相互分离的,看起来都像一台运行Linux的机器。这有助于更有效地利用资源。

与KVM等完全虚拟化解决方案相比,名称空间方式实现的容器只需要一个内核在机器上运行,并负责管理所有容器。

通过名称空间的隔离,允许将一组进程放入一个容器中,并且一个容器与其他容器是隔离的。这种隔离可以使得一个容器中的成员与其他容器没有任何联系。然而,也可以通过允许容器共享其生命周期的某些方面来放宽容器之间的隔离。例如,可以设置容器使用自己的PID集,但仍共享文件系统的部分内容。

命名空间本质上创建了系统的不同视图。全局的资源都必须封装在容器数据结构中,名称空间+资源组成的元组才是全局唯一的。

例子:如下图,有3个名称空间,一个命名空间是父命名空间,它产生了两个子命名空间。假设容器被用于单独主机,其中每个容器必须看起来像一台Linux机器。因此,它们中的每一个都有自己的PID为1的初始化任务。两个子命名空间都有一个PID为1的init任务,以及两个PID分别为2和3的进程。由于具有相同值的PID在系统上出现多次,因此这些数字不是全局唯一的。两个子容器感知不到其他容器的存在,但是父容器可以看到子容器中执行的所有进程(它们被映射到父容器的PID号:4-9)

UTS(UNIX Timesharing System)名称空间是namespace的一个子系统,通过它,不同的进程或容器可以拥有独立的主机名和域名信息,从而实现运行环境的隔离。UTS名称空间是扁平化的结构,不同的命名空间之间没有层级关系。每个容器或进程都对应一个自己的UTS名称空间,用于隔离其内核名称、版本和主机名等信息。在容器技术(如Docker)中,UTS名称空间被广泛应用来实现容器的隔离。每个容器都运行在自己的UTS名称空间中,拥有独立的主机名和域名信息,从而实现了轻量级、可移植和可扩展的应用程序打包和部署。

2.3.1 创建名称空间的2种方式
  • fork或clone系统调用创建新进程时,通过传递特殊的选项控制名称空间是否创建或者是否和父进程共享
  • 通过unshare系统调用,将当前进程的一些属性和父进程剥离,其中就包含名称空间属性
2.3.2 命名空间的实现方式如下所示

需要几个特殊的数据结构:nsproxy、uts_namespace、ipc_namespace、mnt_namespace、pid_namespace、user_namespace、net

在task_struct中的定义

struct task_struct {
...
/* namespaces */
struct nsproxy *nsproxy;
...
}

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 user_namespace *user_ns;
    struct net *net_ns;
};
  • struct ipc_namespace UTS 命名空间包含正在运行的内核的名称、版本、底层架构类型等信息。UTS 是 Unix Timesharing System(Unix 分时系统)的缩写。
  • 所有与进程间通信(IPC)相关的信息都存储在 struct ipc_namespace 中。
  • struct mnt_namespace 提供了已挂载文件系统的视图。
  • struct pid_namespace 提供了有关进程标识符的信息。
  • struct user_namespace 用于保存每个用户的信息,这些信息允许限制单个用户的资源使用。
  • struct net_ns 包含所有与网络相关的命名空间参数。

命名空间的支持需要在编译操作系统内核时针对每个命名空间类型进行启用。这意味着开发者可以根据需要选择性地启用或禁用特定类型的命名空间支持。
这种设计允许系统在不同使用场景下进行优化,比如在一个不需要复杂隔离的环境中,可以禁用某些命名空间以节省资源。

尽管命名空间支持是按需启用的,但内核总是包含处理命名空间概念的通用代码和框架。这意味着即使某个特定的命名空间类型没有被启用,内核也能够处理与命名空间相关的逻辑。
这种设计使得内核能够以一种统一的方式处理不同的系统配置,无论是否启用了命名空间。

每个进程都关联了一个默认的命名空间,除非明确指定使用其他命名空间。这个默认命名空间为进程提供了一个全局的、未隔离的环境。
当命名空间支持没有被编译进内核时,所有进程都将在这个默认命名空间中运行,此时所有的属性和资源都是全局的,就像传统的没有命名空间支持的系统一样。
命名空间感知代码可以在任何系统上运行,无论是否启用了命名空间支持。
如果命名空间支持被禁用,这些代码将自动回退到使用默认命名空间,其行为将与没有命名空间的系统相同。

2.3.3 nsproxy的初始化
<kernel/nsproxy.c>
struct nsproxy init_nsproxy = INIT_NSPROXY(init_nsproxy);

其中INIT_NSPROXY在liclude/linux/init_task.h中定义

#define INIT_NSPROXY(nsproxy) {                        \
    .pid_ns        = &init_pid_ns,                    \
    .count        = ATOMIC_INIT(1),                \
    .uts_ns        = &init_uts_ns,                    \
    .mnt_ns        = NULL,                        \
    INIT_NET_NS(net_ns)                                             \
    INIT_IPC_NS(ipc_ns)                        \
    .user_ns    = &init_user_ns,                \
}

上面结构中的UTS名称空间得初始化init_uts_ns

struct uts_namespace init_uts_ns = {
    .kref = {
        .refcount    = ATOMIC_INIT(2),
    },
    .name = {
        .sysname    = UTS_SYSNAME,
        .nodename    = UTS_NODENAME,
        .release    = UTS_RELEASE,
        .version    = UTS_VERSION,
        .machine    = UTS_MACHINE,
        .domainname    = UTS_DOMAINNAME,
    },
};

其中uts_namespace定义如下:

struct uts_namespace {
    struct kref kref;
    struct new_utsname name;
};

new_utsname定义

struct new_utsname {
    char sysname[65];
    char nodename[65];
    char release[65];
    char version[65];
    char machine[65];
    char domainname[65];
};
2.3.4 创建新的UTS名称空间

在Linux内核中,创建一个新的UTS命名空间是进程隔离和资源管理的一个重要方面。也是Docker等容器技术的基础。以下是关于如何创建新的UTS命名空间的详细过程:
1. 调用fork或clone系统调用
当进程需要创建一个新的子进程,并且希望这个子进程拥有独立的UTS命名空间时,它会在调用fork或clone系统调用时设置CLONE_NEWUTS标志。这个标志告诉内核,在创建新进程时,需要为其分配一个新的UTS命名空间。
2. 调用copy_utsname函数
一旦设置了CLONE_NEWUTS标志,内核就会调用copy_utsname函数。这个函数的职责是创建一个当前UTS命名空间的副本,并将这个副本与新创建的进程相关联。具体来说,copy_utsname会执行以下操作:

  • 分配一个新的uts_namespace结构体,用于存储新的UTS命名空间的信息。
  • 从当前进程的UTS命名空间中复制必要的信息到新的uts_namespace结构体中。这些信息可能包括系统名(虽然通常不会更改)、节点名(可能需要根据需要进行修改)等。
  • 将新的uts_namespace结构体的指针安装到当前任务的nsproxy实例中。nsproxy是内核中用于管理命名空间的结构体,它包含了指向各种命名空间的指针。

3. 隔离UTS命名空间
一旦新的UTS命名空间被创建并与新进程相关联,内核就会确保该进程在读取或设置UTS值时总是操作其特定的uts_namespace实例。这意味着,对当前进程所做的UTS更改不会反映到其父进程中,同样,父进程对UTS的更改也不会传播到子进程中。这种隔离机制是实现容器化技术(如Docker)和命名空间隔离的基础。

Linux内核通过copy_utsname函数和CLONE_NEWUTS标志实现了UTS命名空间的创建和隔离。这种机制为进程提供了独立的UTS视图,从而实现了对系统标识信息的精细控制和管理。

2.3.5 User名称空间

一个用户对应一个user_namespace结构体,不同名称空间中的root用户对应的user_namespace实例是不同的

User名称空间的处理和上面提到的UTS名称空间的处理方式相同,当需要一个新的User名称空间时,会生成当前用户命名空间的副本,并将其与当前任务的nsproxy实例相关联。然而,用户命名空间本身的表示稍微复杂一些

struct user_namespace {
    struct kref        kref; //kref是一个引用计数器,用于跟踪user_namespace实例被引用的次数
    struct hlist_head    uidhash_table[UIDHASH_SZ];
    struct user_struct    *root_user; //当前用户的引用计数、打开的文件数、拥有的进程数等资源统计
};

2.4 进程标识

  • PID:每个进程在系统中都有一个唯一的进程ID
  • TGID:线程组由具有相同线程组ID(TGID)的多个进程(或更准确地说是线程)组成。如果一个进程不使用线程(即它是单线程的),那么它的PID和TGID是相同的。
  • pgrp:进程组。进程组中所有进程的task_struct中的pgrp元素都具有相同的值,这个值就是进程组leader的PID
  • Session:会话中的所有进程都有相同的会话ID(SID),这个SID存储在每个进程的task_struct中的session元素里。

2.5 内核线程

内核线程(Kernel threads)是由内核自身直接启动的进程。可以说0号(idel进程)、1号(init进程)、2号(kthreadd进程)进程都是内核线程,除此之外,还有几个常见的内核线程:

kthreadd:
作用:kthreadd是内核中负责管理其他内核线程的父进程。它在系统启动时由0号进程(idle进程)创建,负责调度和管理其他内核线程。
数量:通常只有一个。
migration:
作用:每个处理器核对应一个migration内核线程,主要用于执行进程迁移操作。当进程需要从一个CPU迁移到另一个CPU时,migration线程会负责处理这些迁移请求。
数量:与处理器核的数量相对应。
watchdog:
作用:watchdog线程用于监视系统的运行状态,以防止系统出现死锁或软件故障。如果系统在一定时间内没有响应,watchdog会尝试重启系统。
数量:每个处理器核对应一个watchdog线程。
ksoftirqd:
作用:ksoftirqd线程用于处理软中断。由于硬件中断处理需要快速响应,但中断处理过程中可能需要进行大量计算或等待,因此将部分中断处理任务移交给软中断处理线程ksoftirqd。
数量:通常与处理器核的数量相对应。

 比如,我们看看真实系统中的watchdog线程,可以看到其父进程是2号进程(kthreadd),且其数量和CPU个数对应

$ ps -ef|grep watchdog
root        11     2  0 5月14 ?       00:01:07 [watchdog/0]
root        12     2  0 5月14 ?       00:00:57 [watchdog/1]
root        17     2  0 5月14 ?       00:00:55 [watchdog/2]
root        22     2  0 5月14 ?       00:00:56 [watchdog/3]
root        27     2  0 5月14 ?       00:00:56 [watchdog/4]
root        32     2  0 5月14 ?       00:00:56 [watchdog/5]
root        37     2  0 5月14 ?       00:00:57 [watchdog/6]
root        42     2  0 5月14 ?       00:00:56 [watchdog/7]
root        59     2  0 5月14 ?       00:00:00 [watchdogd]
root      2521     2  0 5月14 ?       00:00:00 [lc_watchdogd]
P_bc_qs+ 21097 15974  0 09:34 pts/0    00:00:00 grep --color=auto watchdog
2.5.1 内核线程的创建

旧方式

内核调用函数kernel_thread创建内核线程。其定义和cpu架构有关,以x86架构为例。

pt_regs是一个结构体,用于保存CPU的寄存器状态。在kernel_thread中,首先会构造一个pt_regs实例,并为其成员设置适当的值,以模拟一个来自系统调用的调用环境。这样做是为了让新线程在启动时能够表现得像是一个从系统调用返回的进程。

然后,调用do_fork函数来实际创建新线程

int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
    struct pt_regs regs;

    memset(&regs, 0, sizeof(regs));

    regs.ebx = (unsigned long) fn;
    regs.edx = (unsigned long) arg;

    regs.xds = __USER_DS;
    regs.xes = __USER_DS;
    regs.xfs = __KERNEL_PERCPU;
    regs.orig_eax = -1;
    regs.eip = (unsigned long) kernel_thread_helper;
    regs.xcs = __KERNEL_CS | get_kernel_rpl();
    regs.eflags = X86_EFLAGS_IF | X86_EFLAGS_SF | X86_EFLAGS_PF | 0x2;

    /* Ok, create the new process.. */
    return do_fork(flags | CLONE_VM | CLONE_UNTRACED, 0, &regs, 0, NULL, NULL);
}

内核线程和用户线程有一个区别,就是task_struct中的mm被设置为NULL

<sched.h>
struct task_struct {
    ...
    struct mm_struct *mm, *active_mm;
    ...
}

mm_struct它代表了进程的内存描述符(Memory Descriptor)。每个进程在Linux中都有一个与之对应的内存描述符,用于管理该进程所拥有的内存资源,包括代码段、数据段、堆栈段以及通过系统调用如 mmap() 分配的内存区域等。

在Linux系统中,TLB(Translation Lookaside Buffer,转换后援缓冲器,或简称为“快表”)是一个关键的硬件组件,它负责加速虚拟地址到物理地址的转换过程。因为不论内核态还是用户态中运行的程序,都会涉及到虚拟地址到物理地址的转换,转换时需要根据页表定位到虚拟地址对应的物理地址,有时需要经过多级映射才能找到物理地址。TLB提供了一个硬件缓存,缓存了最近最常用虚拟地址和物理地址间的映射关系,极大地提高了内存访问的效率。

对于内核线程来说,它们主要在内核空间运行,不直接访问用户空间。然而,内核线程仍然需要访问物理内存,以执行各种内核任务,如系统调用处理、中断处理、设备驱动等。在这些过程中,内核线程会生成虚拟地址,并通过TLB和页表来查找对应的物理地址。值得注意的是,内核线程在地址转换时可能会采用一种称为“懒惰模式”(Lazy Mode)的优化策略。由于内核线程不直接访问用户空间,且Linux系统中所有进程的内核部分页面映射是相同的,因此当内核线程运行时,如果它之前运行的用户进程的TLB条目仍然有效(即没有发生页表更改或进程切换),那么内核线程可以继续使用这些TLB条目,而无需立即刷新TLB。这种策略可以减少不必要的TLB刷新操作,从而提高系统性能。

上面已经提到task_struct中的mm被设置为NULL,它们不访问用户空间,因此自然而然地成为了这种优化策略的应用对象。总结一句话:内核线程执行期间不会进行TLB的更新,因而可以提高系统性能。

那为什么还需要active_mm呢?active_mm指针保存了一个指向当前活动用户进程的mm_struct的指针,该结构体描述了用户空间的虚拟地址空间布局。极特殊情况下,当内核线程需要访问与用户空间相关的数据时,它可以通过active_mm来获取必要的信息

有时,内核可能会以一种特殊的方式创建内核线程,即利用现有的用户进程上下文来创建内核线程。这种情况下,内核线程会继承用户进程的一些资源(尽管这些资源在内核线程的执行过程中通常是不需要的)。为了避免这些资源被长期占用,内核线程需要执行daemonize()函数来释放这些资源,并将自己从用户进程的上下文中完全脱离出来,成为一个真正的内核守护进程(daemon)

daemonize函数执行以下处理

  • 释放用户进程资源:由于内核线程是作为某个用户进程的子进程启动的,但内核线程本身不访问用户空间,因此daemonize会释放该用户进程的所有资源(如内存上下文、文件描述符等)。如果不这样做,这些资源将一直被占用,直到线程结束,这通常是不希望的,因为守护进程通常运行直到系统关闭。
  • 阻塞信号接收:守护进程通常不需要处理来自外部的信号,因此daemonize会修改内核线程的信号掩码,以阻塞所有信号的接收。这有助于确保守护进程的稳定运行,避免不必要的信号干扰。
  • 设置父进程为init:在Linux中,init是所有进程的祖先。通过daemonize,内核线程会将其父进程更改为init(进程ID为1)。这样做有几个好处:首先,它确保了即使在原始父进程结束后,守护进程也能继续运行;其次,它简化了孤儿进程的处理,因为所有孤儿进程最终都会由init进程收养;最后,它有助于系统管理和监控,因为所有守护进程都可以很容易地通过查找init进程的子进程来找到。

新方式

kthread_create 函数是用来创建新的内核线程的。这个函数允许你指定线程的名字(通过namefmt格式化字符串及其后的可变参数)、线程将要执行的函数(threadfn)以及传递给该函数的参数(data)。然而,创建后的线程默认是停止状态,需要通过wake_up_process函数来启动它。kthread_create函数的原型为:

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

另外两个包装函数

  • kthread_run宏:会在创建线程后立即通过wake_up_process唤醒它,无需手动启动。
  • kthread_create_cpu:对于需要将内核线程绑定到特定CPU的情况,可以使用kthread_create_cpu

在ps命令的输出中,内核线程会被放在方括号[]中,以区别于用户空间进程。
如果线程被绑定到了特定CPU,该CPU编号会显示在斜杠之后

$ ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 5月14 ?       01:03:51 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root         2     0  0 5月14 ?       00:00:03 [kthreadd]
root         6     2  0 5月14 ?       00:01:21 [ksoftirqd/0]
root         7     2  0 5月14 ?       00:00:54 [migration/0]
root         8     2  0 5月14 ?       00:00:00 [rcu_bh]
root         9     2  0 5月14 ?       01:03:07 [rcu_sched]
root        10     2  0 5月14 ?       00:00:00 [lru-add-drain]
root        11     2  0 5月14 ?       00:01:07 [watchdog/0]
root        12     2  0 5月14 ?       00:00:57 [watchdog/1]
root        13     2  0 5月14 ?       00:01:05 [migration/1]
root        14     2  0 5月14 ?       00:00:17 [ksoftirqd/1]
root        16     2  0 5月14 ?       00:00:00 [kworker/1:0H]
root        17     2  0 5月14 ?       00:00:55 [watchdog/2]
root        18     2  0 5月14 ?       00:01:02 [migration/2]

2.6 进程调度

如果不考虑实时调度,调度系统需要尽量使得所有任务都尽量公平的被分配CPU使用权。内核使用红黑树结构存储可运行任务。如果您不熟悉红黑树,只需在这里知道,这种数据结构可以有效地管理它所包含的条目,并且查找、插入和删除操作所需的时间只会随着树中存在的进程数量而适度增加。

task_struct中和进程调度相关的字段如下

<sched.h>
struct task_struct {
    ...
    int prio, static_prio, normal_prio; //优先级
    unsigned int rt_priority; //表示一个real-time的优先级,取值范围为0-99
    struct list_head run_list;
    const struct sched_class *sched_class; //是一个指针,任务分类,不同的任务被分配到不同的类别
    struct sched_entity se; //调度器除了可以调度进程外,还可以调度其他实体(比如按照进程组进行调度,这样CPU时会首先在各个进程组之间进行分配,然后各个进程组内部在对时间进行更详细的分配),调度实体用内嵌在task_struct中的sched_entity结构体表示
    unsigned int policy;
    cpumask_t cpus_allowed;
    unsigned int time_slice;
    ...
}
2.6.1 调度类

我们将可调度任务进行归类处理,用sched_class结构体表示

<sched.h>
struct sched_class {
    const struct sched_class *next;
    void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup); //向run queue添加一个新的task
    void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep); //比如任务状态变为不可运行态时,从run queue去除这个task
    void (*yield_task) (struct rq *rq); //当当前进程决定主动放弃CPU使用权时,会触发sched_yield系统调用,进而yield_task函数被调用
    void (*check_preempt_curr) (struct rq *rq, struct task_struct *p);
    struct task_struct * (*pick_next_task) (struct rq *rq);
    void (*put_prev_task) (struct rq *rq, struct task_struct *p);
    void (*set_curr_task) (struct rq *rq);
    void (*task_tick) (struct rq *rq, struct task_struct *p);
    void (*task_new) (struct rq *rq, struct task_struct *p);
};

用户空间程序不会直接和scheduling classes交互,用户空间程序只会识别SCHED_NORMAL, SCHED_BATCH, SCHED_IDLE, SCHED_RR 和 SCHED_FIFO。
其中SCHED_NORMAL, SCHED_BATCH, 和 SCHED_IDLE对应于fair_sched_class,而SCHED_RR 和 SCHED_FIFO 对应于 rt_sched_class。
fair_sched_class 和 rt_sched_class 都是 sched_class 的实例

2.6.2 运行队列Run Queues

每个CPU都有自己的运行队列,每个运行队列有两个子队列cfs(完全公平调度队列)和rt(实时调度队列)
运行队列的定义:

kernel/sched.c
struct rq {
    unsigned long nr_running; //在该CPU运行队列上可运行进程数
    #define CPU_LOAD_IDX_MAX 5
    unsigned long cpu_load[CPU_LOAD_IDX_MAX]; //这个数组用于记录CPU的负载信息,索引值代表不同的时间窗口或采样周期
    ...
    struct load_weight load; //表示当前CPU上所有可运行任务的总体负载权重。负载权重是一个重要的概念,用于公平地分配CPU资源给不同的任务,考虑到任务的优先级和“重量”。
    struct cfs_rq cfs; //表示CFS(Completely Fair Scheduler,完全公平调度器)的运行队列
    struct rt_rq rt; //表示RT(实时)任务的运行队列
    struct task_struct *curr, *idle; //curr指向当前正在CPU上运行的任务(进程或线程)的task_struct结构。idle指向该CPU的空闲任务的task_struct结构。当没有其他任务可以运行时,CPU将运行这个空闲任务
    u64 clock;
    ...
};

在Linux内核的调度器实现中,runqueues 数组包含了系统中每个CPU对应的运行队列(struct rq实例)

kernel/sched.c
static DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
2.6.3 调度实体

因为调度器除了可以调度任务外,还可以调度其他实体,也就是这里我们要讲的内容

<sched.h>
struct sched_entity {
    struct load_weight load; /* 这个字段用于表示实体的负载权重。在负载均衡时,调度器会考虑这个权重来决定哪个实体应该获得更多的 CPU 时间。 */
    struct rb_node run_node; // 是一个红黑树节点的引用。调度器使用它来管理可运行的实体。这个节点使得调度器能够高效地找到下一个应该运行的实体。
    unsigned int on_rq; // 如果 on_rq 被设置,表示该实体是可运行的,并且正在等待 CPU 时间
    u64 exec_start; // 记录了实体当前运行周期的起始时间
    u64 sum_exec_runtime; // 累计了实体从调度器开始跟踪它以来总共的执行时间
    u64 vruntime;
    u64 prev_sum_exec_runtime;
    ...
}

 

posted @ 2024-09-19 15:45  zhenjingcool  阅读(19)  评论(0编辑  收藏  举报