当前位置: 首页 > news >正文

【Linux内核设计与实现】第三章——进程管理05

文章目录

  • 12. 处理遗留问题
    • 12.1. 问题 1 的分析
    • 12.2. 回顾 fork()
    • 12.3. copy_thread() & kernel_clone()
    • 12.4. 问题 2 的分析
    • 12.5. 验证问题 2 的分析
    • 12.6. 小结
  • 13. 线程
  • 14. 创建线程
    • 14.1. clone 系统调用(用户级线程)
    • 14.2. 内核线程
    • 14.3. 创建内核线程
      • 14.3.1. kthread_create
      • 14.3.2. kthread_run
      • 14.3.3. kthread_create_on_node
      • 14.3.4. __kthread_create_on_node
        • 14.3.4.1. 分配并初始化 kthread_create_info
        • 14.3.4.2. 加入创建队列并唤醒 kthreadd
        • 14.3.4.3. 等待线程创建完成
      • 14.3.5 用于创建内核线程的管理线程 kthreadd
      • 14.3.6. create_kthread
      • 14.3.7. kernel_thread
        • 14.3.7.1. 参数说明
        • 14.3.7.2. 构造 kernel_clone_args
        • 14.3.7.3. 调用 kernel_clone(🌟🌟🌟🌟🌟)
      • 14.3.8. kthread 入口
        • 14.3.8.1. 入口参数与初始化
        • 14.3.8.2. 线程结构体初始化
        • 14.3.8.3. 调度策略与同步
        • 14.3.8.4. NUMA 亲和性
        • 14.3.8.5. 线程主循环与入口函数调用
    • 14.4. 内核线程创建的典型用法
    • 14.5 小结
  • 15. 第三章总结
  • #上一篇
  • #下一篇

[注]:本篇文章与上一篇紧密相关,若未阅读上一篇请移步上一篇阅读,在文末可以找到上一篇链接。

12. 处理遗留问题

  在之前提到了两个问题分别是:

  • 用户态程序是如何判断 fork() 函数的两个不同的返回值,fork() 是如何实现返回两个不同的返回值这样的机制?
  • 父进程在子进程 exit() 退出执行后,在什么时机调用 wait() 释放子进程的所有资源?也就是说,父进程怎么知道子进程什么时候退出运行了。

12.1. 问题 1 的分析

  在分析问题之前首先来观察问题是如何产生的,来看一段再平常不过的 fork() 函数使用示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程路径printf("This is the child process.\n");} else if (pid > 0) {// 父进程路径printf("This is the parent process. Child PID: %d\n", pid);} else {// 错误处理perror("fork failed");}return 0;
}

编译并执行这段代码得到的输出如下:

$ ./a.out
This is the parent process. Child PID: 135134
This is the child process.

  这段程序所作的事情也很简单,就是在主进程中使用 fork() 函数开始创建子进程,接着根据 fork() 函数的返回值来判断:

  • 返回值等于 0:则当前为子进程;
  • 返回值大于 0:则当前为主进程(父进程),这时的返回值为子进程的 PID
  • 返回值小于 0:则说明进程创建失败。

  代码的处理逻辑很简单,但是应该如何理解通过一个返回值判断不同多种状态,且进入了多条分支这件事呢?说白了就是,fork() 函数的返回值保存进 pid 变量,首先在当前进程中,这个变量只能保存一个确切的值,那么这里是如何又执行了 if 条件中的代码,同时也执行了 else if 条件中的代码呢?带着这个问题我们回到之前所梳理 fork() 函数工作过程中。

12.2. 回顾 fork()

在这里插入图片描述

  根据上面的函数调用图简要的回顾以下 fork() 函数的工作流程,其核心部分就是 copy_process() 函数了,该函数会为进程分配具体的描述符内存(task_struct),并作相关的初始化工作,其原理就是利用当前进程的信息拷贝作为一个新的进程存在,而在这其中较为关键的一步就是调用 copy_thread() 函数拷贝父进程的上下文(内核栈、寄存器等)。

// Linux Kernel 6.15.0-rc2
// PATH: kernel/fork.c
// Function:copy_process()retval = copy_thread(p, args);if (retval)goto bad_fork_cleanup_io;

12.3. copy_thread() & kernel_clone()

// Linux Kernel 6.15.0-rc2
// PATH: arch/x86/kernel/process.c
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{...// 复制父进程的寄存器上下文到子进程。*childregs = *current_pt_regs();childregs->ax = 0;if (sp)childregs->sp = sp;...
}

  copy_thread 函数会为子进程拷贝父进程的信息,其中包含父进程寄存器状态,current_pt_regs 函数便是这个作用,而当拷贝完父进程寄存器后,紧接着将 AX 寄存器的值修改为 0。而在 x86_64 架构中,AX 寄存器正是作为返回值寄存器来使用,从此也就注定子进程给的返回值将是 0

// Linux Kernel 6.15.0-rc2
// PATH: kernel/fork.c
pid_t kernel_clone(struct kernel_clone_args *args)
{...// 获取子进程 pid 结构体pid = get_task_pid(p, PIDTYPE_PID);// 获取子进程 pid 值nr = pid_vnr(pid);...put_pid(pid);return nr;
}

  而在 kernel_clone 函数中则最终将获取子进程的 pid 作为返回值,这也就是为什么父进程需要用返回值大于零来判断了。也就是说,当父进程执行完 copy_thread 函数后,在内存中存在了一个除了 ax 寄存器值不同外其它和自己一模一样的进程,当然也包含 pc 寄存器状态也被复制到子进程,当父进程执行到了 wake_up_new_task 函数时,子进程将会从当时拷贝的 pc 处投入运行,这时候也就是在当前父进程更早的代码处执行(copy_thread 阶段)。提到这一点,再回头看一下用户程序代码。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程路径printf("This is the child process.\n");} else if (pid > 0) {// 父进程路径printf("This is the parent process. Child PID: %d\n", pid);} else {// 错误处理perror("fork failed");}return 0;
}
$ ./a.out
This is the parent process. Child PID: 135134
This is the child process.

  在代码中分明是先判断 pid == 0 是否是子进程,后判断是否是父进程,假设父子进程 fork() 结束后是从同位置开始运行,那么按照代码逻辑应该是先打印子进程打输出而不是父进程的输出。但是时机编译并执行这段代码得到的输出则是先去执行 else if 中父进程的打印,而后执行子进程的打印,这更进一步的验证了笔者的分析,即子进程的运行时机是在父进程更早的指令处开始执行。下图展示了笔者对整个分析过程中内存中的变化,以及父子进程执行时机的指令顺序和函数返回值。好的,那么问题 1 到这里也就回答完毕了。

在这里插入图片描述

12.4. 问题 2 的分析

  子进程如何通知父进程自己结束运行了呢?其实这句话是否在哪里见过,没错,在分析 exit 系统调用时,其中 do_exit 函数调用了一个 exit_notify 的函数,当时笔者讲该函数会通知父进程当前进程已退出,那么具体来看看是如何通知,以及通知了什么。

// Linux Kernel 6.15.0-rc2
// PATH: kernel/exit.cstatic void exit_notify(struct task_struct *tsk, int group_dead)
{bool autoreap;struct task_struct *p, *n;LIST_HEAD(dead);// 加写锁保护全局进程链表,防止并发修改。write_lock_irq(&tasklist_lock);// 将当前进程的所有子进程重新指定新父进程(如 init 或子收养者),并处理 ptrace 相关的子进程。如果有需要释放的子进程,加入 dead 链表。forget_original_parent(tsk, &dead);// 如果整个线程组都已退出,检查是否有孤儿进程组需要发送 SIGHUP/SIGCONT 信号。if (group_dead)kill_orphaned_pgrp(tsk->group_leader, NULL);// 将当前进程的 exit_state 设为 EXIT_ZOMBIE,表示进程已退出,等待父进程回收。tsk->exit_state = EXIT_ZOMBIE;if (unlikely(tsk->ptrace)) {int sig = thread_group_leader(tsk) &&thread_group_empty(tsk) &&!ptrace_reparented(tsk) ?tsk->exit_signal : SIGCHLD;autoreap = do_notify_parent(tsk, sig);} else if (thread_group_leader(tsk)) {autoreap = thread_group_empty(tsk) &&do_notify_parent(tsk, tsk->exit_signal);} else {autoreap = true;/* untraced sub-thread */do_notify_pidfd(tsk);}// 如果父进程不关心该进程(autoreap),将 exit_state 设为 EXIT_DEAD,并把该进程加入 dead 链表,稍后释放。if (autoreap) {tsk->exit_state = EXIT_DEAD;list_add(&tsk->ptrace_entry, &dead);}/* mt-exec, de_thread() is waiting for group leader */// 如果有线程在等待 group leader 退出(如多线程 exec),唤醒等待的线程。if (unlikely(tsk->signal->notify_count < 0))wake_up_process(tsk->signal->group_exec_task);// 释放全局进程链表的写锁。write_unlock_irq(&tasklist_lock);// 遍历 dead 链表,删除每个进程的 ptrace_entry,并调用 release_task 彻底释放其资源(包括内核栈和 task_struct)。list_for_each_entry_safe(p, n, &dead, ptrace_entry) {list_del_init(&p->ptrace_entry);release_task(p);}
}

主要来看中间这段代码。

	if (unlikely(tsk->ptrace)) {int sig = thread_group_leader(tsk) &&thread_group_empty(tsk) &&!ptrace_reparented(tsk) ?tsk->exit_signal : SIGCHLD;autoreap = do_notify_parent(tsk, sig);} else if (thread_group_leader(tsk)) {autoreap = thread_group_empty(tsk) &&do_notify_parent(tsk, tsk->exit_signal);} else {autoreap = true;/* untraced sub-thread */do_notify_pidfd(tsk);}
  • 通知父进程
    • 被 ptrace 跟踪:如果当前进程被 ptrace 跟踪,选择合适的信号(通常是 SIGCHLDexit_signal),调用 do_notify_parent 通知父进程,并判断是否需要自动回收(autoreap)。
    • 线程组组长:如果是程组组长,且线程组已空,通知父进程,并判断是否需要自动回收。
    • 普通子线程:直接设置 autoreap = true,并通知 pidfd(如果有)。

  也就是说,无论如何都会通知父进程一个信号,要么是 SIGCHLD,要么就是 tsk->exit_signal。那么就来看 exit_signal 是什么吧,从其实追查到 exit 系统调用的定义位置并没有发现对进程 exit_signal 的赋值位置,从进程创建后就不会主动改变 exit_signal 的值。那么就直接追查到进程创建时。

// Linux Kernel 6.15.0-rc2
// PATH: kernel/fork.c
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMUstruct kernel_clone_args args = {.exit_signal = SIGCHLD,};return kernel_clone(&args);
#else/* can not support in nommu mode */return -EINVAL;
#endif
}
#endif

  很好,我们在 fork 系统调用中看到了为 exit_signal 赋值的过程,原来这里给的信号也是 SIGCHLD,那就不用多说什么了,在 exit_notify 时发送给父进程的信号毫无疑问就是 SIGCHLD。也就是说,当父进程收到了 SIGCHLD 信号就表示子进程正在执行 do_exit 函数中的 exit_notify 函数向自己发送信号系统通知父进程自己退出运行了。

在这里插入图片描述

12.5. 验证问题 2 的分析

  根据上面的分析,也就清楚了在进程退出后会向父进程发送 SIGCHLD 信号,那么也就是当父进程收到 SIGCHLD 信号时就可以调用 wait 主动释放子进程了。接下来用一段代码来验证这件事:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>void sigchld_handler(int signo) {int status;pid_t pid;// 使用 waitpid 处理所有已终止的子进程while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {printf("[Get:SIGCHLD]子进程 %d 已终止\n", pid);}
}int main() {struct sigaction sa;sa.sa_handler = sigchld_handler;sigemptyset(&sa.sa_mask);sa.sa_flags = SA_RESTART; // 确保被中断的系统调用自动重启sigaction(SIGCHLD, &sa, NULL);pid_t pid = fork();if (pid == 0) {// 子进程printf("子进程运行中...\n");sleep(2);exit(0);} else if (pid > 0) {// 父进程printf("父进程等待子进程终止...\n");pause(); // 等待信号} else {perror("fork");exit(1);}return 0;
}

编译并执行上面代码将会得到这样的输出:

$ ./a.out
父进程等待子进程终止...
子进程运行中...
[Get:SIGCHLD]子进程 142052 已终止

  可以看到子进程结束后,父进程的确收到了 SIGCHLD 信号,由此父进程就可以通过该信号了解子进程是否退出运行了。

12.6. 小结

  那么到此两个遗留问题也就分析结束了,有关《Linux 内核设计与实现》第三章内容中进程部分也就基本被我们剖析的差不多了。是的,还有一个点,那就是线程,让我们继续来学习吧。

13. 线程

  线程机制是现代编程技术中常用的一种抽象概念。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术(concurrent programming),在多处理器系统上,它也能保证真正的并行处理(parallelism)。Linux 实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux 把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。

  上述线程机制的实现与 Microsoft Windows 或是 Sun Solaris 等操作系统的实现差异非常大。这些系统都在内核中提供了专门支持线程的机制(这些系统常常把线程称作轻量级进程(lightweight processes))。“轻量级进程”这种叫法本身就概括了 Linux 在此处与其他系统的差异。在其他的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于 Linux 来说,它只是一种进程间共享资源的手段( Linux 的进程本身就够轻量级了)。举个例子来说,假如我们有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux 仅仅创建四个进程并分配四个普通的 task_sturct 结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。

14. 创建线程

  通常我们在用户空间创建线程时都会用到一个接口函数 pthread_create 来创建用户级线程,而这个函数是 glibc 中提供给我们的一个封装好的库函数,而真正创建线程的系统调用是 clonepthread_create 也是使用 clone 系统调用创建线程。

[注]:这里笔者特意强调 用户级线程,因为后面还会有内核线程的概念。

14.1. clone 系统调用(用户级线程)

// Linux Kernel 6.15.0-rc2
// PATH: kernel/fork.c
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,int __user *, parent_tidptr,int __user *, child_tidptr,unsigned long, tls)
{struct kernel_clone_args args = {.flags		= (lower_32_bits(clone_flags) & ~CSIGNAL),.pidfd		= parent_tidptr,.child_tid	= child_tidptr,.parent_tid	= parent_tidptr,.exit_signal	= (lower_32_bits(clone_flags) & CSIGNAL),.stack		= newsp,.tls		= tls,};return kernel_clone(&args);
}

  实际上你发现了,clone 系统调用仅仅只是设置一个 kernel_clone_args 结构体传入给 kernel_clone 去执行。欸~ 发现了吧~ 没错,这是我们熟悉的 kernel_clone 函数,剩下的步骤就和之前创建进程的过程一模一样了。这里不同的就是在 clone 系统调用传入的参数中 clone_flags 通常会指定更多的共享资源(如 CLONE_VMCLONE_FSCLONE_FILESCLONE_THREAD 等),而 fork 函数则是不共享资源。

特性forkclone
资源共享子进程与父进程不共享资源。通过 clone_flags 指定共享的资源。
灵活性固定行为,无法定制资源共享方式。非常灵活,可以指定共享的资源类型。
创建线程不支持创建线程,只能创建独立的进程。可以通过 CLONE_THREAD 创建线程。
退出信号默认发送 SIGCHLD 信号给父进程。可以通过 clone_flags 指定退出信号。
系统调用参数无参数,固定行为。参数丰富,可以指定栈地址、TLS 等。
使用场景创建独立的子进程。创建线程或共享资源的子进程。

  而这里你应该也会发现,在使用 clone 系统调用时,其实并没有传入线程的入口函数,事实上用于管理用户级线程启动、入口函数的工作都在 pthread_create 中实现好了,换句话说,也就是 glibc 库帮我们实现了这一步骤,因此此处就无需多虑。

14.2. 内核线程

  内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的 mm 指针被设置为 NULL)。它们只在内核空间运
行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。Linux 确实会把一些任务交给内核线程去做,像 flushksofirgd 这些任务就是明显的例子。在装有 Linux 系统的机器上运行 ps -ef 命令,你可以看到内核线程,有很多!这些线程在系统启动时由另外一些内核线程创建。实际上,内核线程也只能由其他内核线程创建。

  kthread_create 是 Linux 内核中用于创建内核线程的高级接口。内核是通过从kthreadd 内核进程中衍生出所有新的内核线程来自动处理这一点的。

14.3. 创建内核线程

14.3.1. kthread_create

  kthread_createLinux 内核用于创建内核线程的常用接口,其作用是创建一个新的内核线程,并返回对应的 task_struct 指针,但不会立即启动该线程。如果需要唤醒线程则需要调用 wake_up_process

// Linux Kernel 6.15.0-rc2
// PATH: include/linux/kthread.h
#define kthread_create(threadfn, data, namefmt, arg...) \kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)
  • threadfn:新线程启动后要执行的函数指针(入口函数)。
  • data:传递给入口函数的参数。
  • namefmt/arg…:线程名的格式字符串和参数(类似 printf)。

14.3.2. kthread_run

  正如上面所说,kthread_create 会创建一个内核线程,但并不会立刻执行,而需要手动唤醒,内核当然也有另一个接口函数 kthread_run 用于创建一个线程并立即执行它。其实就是对 kthread_createwake_up_process 的一个封装。

// Linux Kernel 6.15.0-rc2
// PATH: include/linux/kthread.h
#define kthread_run(threadfn, data, namefmt, ...)			   \
({									   \struct task_struct *__k						   \= kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \if (!IS_ERR(__k))						   \wake_up_process(__k);					   \__k;								   \
})

14.3.3. kthread_create_on_node

// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),void *data, int node,const char namefmt[],...)
{struct task_struct *task;va_list args;va_start(args, namefmt);task = __kthread_create_on_node(threadfn, data, node, namefmt, args);va_end(args);return task;
}
  • threadfn:新线程启动后要执行的函数指针(入口函数)。
  • data:传递给入口函数的参数。
  • nodeNUMA 节点,决定线程栈等资源分配在哪个节点。
  • namefmt:线程名格式字符串(可变参数)。

可以看到该函数的实际工作是由 __kthread_create_on_node 完成,最终返回创建好的 task_struct

14.3.4. __kthread_create_on_node

// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
struct task_struct *__kthread_create_on_node(int (*threadfn)(void *data),void *data, int node,const char namefmt[],va_list args)
{DECLARE_COMPLETION_ONSTACK(done);struct task_struct *task;struct kthread_create_info *create = kmalloc(sizeof(*create),GFP_KERNEL);if (!create)return ERR_PTR(-ENOMEM);create->threadfn = threadfn;create->data = data;create->node = node;create->done = &done;create->full_name = kvasprintf(GFP_KERNEL, namefmt, args);if (!create->full_name) {task = ERR_PTR(-ENOMEM);goto free_create;}spin_lock(&kthread_create_lock);//将新线程的创建信息排队,等待 kthreadd 内核线程处理。list_add_tail(&create->list, &kthread_create_list);spin_unlock(&kthread_create_lock);// 唤醒 kthreadd 内核线程wake_up_process(kthreadd_task);/** Wait for completion in killable state, for I might be chosen by* the OOM killer while kthreadd is trying to allocate memory for* new kernel thread.*/// 等待 kthreadd 创建完成这个内核线程if (unlikely(wait_for_completion_killable(&done))) {/** If I was killed by a fatal signal before kthreadd (or new* kernel thread) calls complete(), leave the cleanup of this* structure to that thread.*/if (xchg(&create->done, NULL))return ERR_PTR(-EINTR);/** kthreadd (or new kernel thread) will call complete()* shortly.*/wait_for_completion(&done);}// 获得创建完成的内核线程的 tasktask = create->result;
free_create:kfree(create);return task;
}
14.3.4.1. 分配并初始化 kthread_create_info
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: __kthread_create_on_node
struct kthread_create_info *create = kmalloc(sizeof(*create), GFP_KERNEL);
create->threadfn = threadfn;
create->data = data;
create->node = node;
create->done = &done;
create->full_name = kvasprintf(GFP_KERNEL, namefmt, args);

分配并初始化一个 kthread_create_info 结构体,保存入口函数、参数、节点、线程名等信息。

14.3.4.2. 加入创建队列并唤醒 kthreadd
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: __kthread_create_on_node
spin_lock(&kthread_create_lock);
list_add_tail(&create->list, &kthread_create_list);
spin_unlock(&kthread_create_lock);wake_up_process(kthreadd_task);
  • 将创建请求加入全局队列 kthread_create_list
  • 唤醒内核线程 kthreadd_task,由它来实际创建新线程。
14.3.4.3. 等待线程创建完成
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: __kthread_create_on_node
wait_for_completion_killable(&done);
task = create->result;
kfree(create);
return task;
  • 当前调用者阻塞等待,直到新线程创建完成(通过 completion 机制)。

14.3.5 用于创建内核线程的管理线程 kthreadd

  kthreadd_task 是定义在 kernel/kthread.c 中的一个全局变量,用于指向 kthreadd,而 kthreadd 是内核线程的管理线程,是所有内核线程的“工厂”,负责统一、安全地创建内核线程。其本身是在内核启动早期由 kernel_start 通过 kernel_thread 创建得到。

// Linux Kernel 6.15.0-rc2
// PATH: init/main.c
start_kernel()│└─> rest_init()│└─> kernel_thread(kthreadd,...)

kthreadd_task 全局变量的赋值位置也是在 rest_init 函数中:

// Linux Kernel 6.15.0-rc2
// PATH: init/main.c
static noinline void __ref __noreturn rest_init(void)
{...pid = kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES);rcu_read_lock();kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);rcu_read_unlock();...
}

虽然现在还没有说明,不过我们也知道每个线程都会有一个入口函数,而 kthreadd 线程的入口函数就是 kthreadd 函数。

// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
int kthreadd(void *unused)
{static const char comm[TASK_COMM_LEN] = "kthreadd";struct task_struct *tsk = current;/* Setup a clean context for our children to inherit. */set_task_comm(tsk, comm);ignore_signals(tsk);set_cpus_allowed_ptr(tsk, housekeeping_cpumask(HK_TYPE_KTHREAD));set_mems_allowed(node_states[N_MEMORY]);current->flags |= PF_NOFREEZE;cgroup_init_kthreadd();for (;;) {set_current_state(TASK_INTERRUPTIBLE);if (list_empty(&kthread_create_list))schedule();__set_current_state(TASK_RUNNING);spin_lock(&kthread_create_lock);while (!list_empty(&kthread_create_list)) {struct kthread_create_info *create;create = list_entry(kthread_create_list.next,struct kthread_create_info, list);list_del_init(&create->list);spin_unlock(&kthread_create_lock);// 创建内核线程// kthreadd 内核线程会调用 create_kthread() 函数create_kthread(create);spin_lock(&kthread_create_lock);}spin_unlock(&kthread_create_lock);}return 0;
}

kthreadd 线程的函数体就是这里的 kthreadd 函数,其中定义的死循环部分(作为守护线程一直运行)就作为被创建之后的主要工作内容了。它会首先将自己设置为 TASK_INTERRUPTIBLE,遍历 kthread_create_list,如果由存在等待被创建的内核线程则调用 create_kthread(create) 创建新线程,若没有将会调用 schedule 切出自己保持睡眠状态以节省 CPU 资源。

14.3.6. create_kthread

// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
static void create_kthread(struct kthread_create_info *create)
{int pid;#ifdef CONFIG_NUMAcurrent->pref_node_fork = create->node;
#endif// 使用 kernel_thread 创建线程,入口函数为 kthread。/* We want our own signal handler (we take no signals by default). */pid = kernel_thread(kthread, create, create->full_name,CLONE_FS | CLONE_FILES | SIGCHLD);if (pid < 0) {/* Release the structure when caller killed by a fatal signal. */struct completion *done = xchg(&create->done, NULL);kfree(create->full_name);if (!done) {kfree(create);return;}create->result = ERR_PTR(pid);complete(done);}
}

create_kthread 负责实际分配和启动内核线程,设置 NUMA 亲和性、线程名、入口函数和参数。通过 kernel_thread 创建一个新线程:

  • 新线程的入口函数为 kthread,参数为 create(包含用户指定的 threadfndata、线程名等)。
  • 线程名用于调试和管理。
  • 线程继承文件系统和文件描述符(CLONE_FS | CLONE_FILES),并在退出时发送 SIGCHLD

14.3.7. kernel_thread

kernel_threadLinux 内核用于创建内核线程的标准接口。

// Linux Kernel 6.15.0-rc2
// PATH: kernel/fork.c
pid_t kernel_thread(int (*fn)(void *), void *arg, const char *name,unsigned long flags)
{struct kernel_clone_args args = {.flags		= ((lower_32_bits(flags) | CLONE_VM |CLONE_UNTRACED) & ~CSIGNAL),.exit_signal	= (lower_32_bits(flags) & CSIGNAL),.fn		= fn,.fn_arg		= arg,.name		= name,.kthread	= 1,};return kernel_clone(&args);
}
14.3.7.1. 参数说明
  • fn:新线程启动后要执行的入口函数。
  • arg:传递给入口函数的参数。
  • name:线程名(可选)。
  • flagsclone 标志(如调度、资源继承等)。

14.3.7.2. 构造 kernel_clone_args
  • 设置 flags,强制包含 CLONE_VM | CLONE_UNTRACED,去除 CSIGNAL 位。
  • 设置 exit_signal,通常为 0(内核线程不需要信号退出)。
  • 设置入口函数 fn 和参数 fn_arg
  • 设置线程名和 kthread 标志。
14.3.7.3. 调用 kernel_clone(🌟🌟🌟🌟🌟)
  • kernel_clone 是内核统一的进程/线程创建接口。
  • 其内部会调用 copy_process,并最终调用 copy_thread
  • 事实上在创建新线程或进程时,内核都会为其设置好“返回地址”为 ret_from_fork_asm(汇编入口)。
  • copy_thread 中,检测到 args->fn 非空(即为内核线程),会调用 kthread_frame_init,把入口函数和参数写入新线程的内核栈帧。
// Linux Kernel 6.15.0-rc2
// PATH: arch/x86/kernel/process.c
// Function: copy_thread...frame->ret_addr = (unsigned long) ret_from_fork_asm;...
/* Kernel thread ? */if (unlikely(p->flags & PF_KTHREAD)) {p->thread.pkru = pkru_get_init_value();memset(childregs, 0, sizeof(struct pt_regs));kthread_frame_init(frame, args->fn, args->fn_arg);return 0;}...
  • 新线程或进程被调度后,ret_from_fork_asm(汇编入口),它最终会通过 call ret_from_fork 跳转到 C 函数 ret_from_fork,从 ret_from_fork 进入。
// Linux Kernel 6.15.0-rc2
// PATH: arch/x86/entry/entry_64.S
SYM_CODE_START(ret_from_fork_asm)/** This is the start of the kernel stack; even through there's a* register set at the top, the regset isn't necessarily coherent* (consider kthreads) and one cannot unwind further.** This ensures stack unwinds of kernel threads terminate in a known* good state.*/UNWIND_HINT_END_OF_STACKANNOTATE_NOENDBR // copy_threadCALL_DEPTH_ACCOUNTmovq	%rax, %rdi		/* prev */movq	%rsp, %rsi		/* regs */movq	%rbx, %rdx		/* fn */movq	%r12, %rcx		/* fn_arg */call	ret_from_fork/** Set the stack state to what is expected for the target function* -- at this point the register set should be a valid user set* and unwind should work normally.*/UNWIND_HINT_REGS#ifdef CONFIG_X86_FREDALTERNATIVE "jmp swapgs_restore_regs_and_return_to_usermode", \"jmp asm_fred_exit_user", X86_FEATURE_FRED
#elsejmp	swapgs_restore_regs_and_return_to_usermode
#endif
SYM_CODE_END(ret_from_fork_asm)
  • ret_from_fork 检查 fn 是否非空(也就是判断是否是内核线程),如果是,则直接调用 fn(fn_arg),即执行你指定的内核线程入口函数(内核默认指定的 kthread 函数)。
// Linux Kernel 6.15.0-rc2
// PATH: arch/x86/kernel/process.c
__visible void ret_from_fork(struct task_struct *prev, struct pt_regs *regs,int (*fn)(void *), void *fn_arg)
{schedule_tail(prev);/* Is this a kernel thread? */if (unlikely(fn)) {fn(fn_arg);/** A kernel thread is allowed to return here after successfully* calling kernel_execve().  Exit to userspace to complete the* execve() syscall.*/regs->ax = 0;}syscall_exit_to_user_mode(regs);
}

事实上这里有一个细节地方,笔者在进程创建阶段忽略了,那就是这里的 syscall_exit_to_user_mode 函数。

创建进程时

  • 对于普通进程(不是内核线程),fnNULL,所以不会进入 if (unlikely(fn)) 分支。只会执行 syscall_exit_to_user_mode
  • 这一步会根据 regs(新进程的 pt_regs,已经在 copy_thread 阶段设置好)恢复用户空间的寄存器上下文,并跳转回用户空间,继续执行用户代码。而这里返回到用户空间的位置也就是新进程从父进程 fork 时的用户空间指令位置继续执行。

创建内核线程时

  • 对于内核线程,fn 非空,直接调用你传入的入口函数(如果是使用 kthread_create 创建,那么这里将为默认的 kthread 函数,最终这个函数会去调用我们指定的线程入口函数)。
  • 而此时的 syscall_exit_to_user_mode 就没有任何意义了,对于内核线程,由于它们没有用户空间上下文(regs->ipregs->sp 都为 0),所以这个函数不会真正跳转到用户空间。内核线程的入口函数一旦返回,线程就会退出(通常调用 do_exit()),不会进入用户空间。

14.3.8. kthread 入口

  • 新线程启动后,通过 ret_from_fork 将会执行 kthread(create),从 create 结构体中取出 threadfndata,并最终调用 threadfn(data),即我们指定的入口函数。
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
static int kthread(void *_create)
{static const struct sched_param param = { .sched_priority = 0 };/* Copy data: it's on kthread's stack */struct kthread_create_info *create = _create;int (*threadfn)(void *data) = create->threadfn;void *data = create->data;struct completion *done;struct kthread *self;int ret;// 将当前线程的 task_struct 转换为 kthread 结构self = to_kthread(current);// xchg 用于交换 create->done 的值,确保同步完成。/* Release the structure when caller killed by a fatal signal. */done = xchg(&create->done, NULL);// 如果 done 为 NULL,说明创建线程的调用者已被杀死,线程直接退出。if (!done) {kfree(create->full_name);kfree(create);kthread_exit(-EINTR);}self->full_name = create->full_name;self->threadfn = threadfn;self->data = data;/** The new thread inherited kthreadd's priority and CPU mask. Reset* back to default in case they have been changed.*/// 设置调度策略为 SCHED_NORMAL,优先级为 0。sched_setscheduler_nocheck(current, SCHED_NORMAL, &param);/* OK, tell user we're spawned, wait for stop or wakeup */__set_current_state(TASK_UNINTERRUPTIBLE);create->result = current;/** Thread is going to call schedule(), do not preempt it,* or the creator may spend more time in wait_task_inactive().*/// 禁用内核抢占,确保当前线程不会被调度器切换出去。preempt_disable();// 调用 complete 完成创建线程的信号量,通知创建线程已完成。// 这里的 done 是一个信号量,表示线程创建完成。complete(done);// 调用 schedule_preempt_disabled 让出 CPU,当前线程睡眠,等待被唤醒。schedule_preempt_disabled();// 恢复内核抢占,允许调度器正常运行。preempt_enable();self->started = 1;if (!(current->flags & PF_NO_SETAFFINITY) && !self->preferred_affinity)kthread_affine_node();  // 调用 kthread_affine_node 将线程绑定到 NUMA 节点。ret = -EINTR;// 如果线程未设置 KTHREAD_SHOULD_STOP 标志,则调用用户指定的线程函数 threadfn(data)。if (!test_bit(KTHREAD_SHOULD_STOP, &self->flags)) {cgroup_kthread_ready();__kthread_parkme(self);ret = threadfn(data);}// 用 kthread_exit 退出线程,并将返回值传递给 kthread_stop。kthread_exit(ret);
}
14.3.8.1. 入口参数与初始化
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: kthreadstatic const struct sched_param param = { .sched_priority = 0 };struct kthread_create_info *create = _create;int (*threadfn)(void *data) = create->threadfn;void *data = create->data;struct completion *done;struct kthread *self;int ret;
  • _createkthread_create_info 结构体,包含入口函数、参数、线程名等。
  • 取出用户指定的入口函数 threadfn 和参数 data

14.3.8.2. 线程结构体初始化
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: kthreadself = to_kthread(current);done = xchg(&create->done, NULL);if (!done) {kfree(create->full_name);kfree(create);kthread_exit(-EINTR);}self->full_name = create->full_name;self->threadfn = threadfn;self->data = data;
  • 将线程名、入口函数、参数等保存到当前线程的 struct kthread 结构体。
  • 如果 doneNULL,说明创建者线程已被杀死,直接退出。
14.3.8.3. 调度策略与同步
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: kthreadsched_setscheduler_nocheck(current, SCHED_NORMAL, &param);__set_current_state(TASK_UNINTERRUPTIBLE);create->result = current;preempt_disable();complete(done);schedule_preempt_disabled();preempt_enable();self->started = 1;
  • 设置调度策略为普通优先级。
  • 设置当前线程为不可中断睡眠,通知创建者线程(通过 complete(done))线程已创建完成。
  • 让出 CPU,内核线程主动进入睡眠(schedule_preempt_disabled()),等待被唤醒。
  • 唤醒后,标记线程已启动。
14.3.8.4. NUMA 亲和性
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: kthreadif (!(current->flags & PF_NO_SETAFFINITY) && !self->preferred_affinity)kthread_affine_node();
  • 如果没有特殊亲和性,绑定到指定 NUMA 节点。
14.3.8.5. 线程主循环与入口函数调用
// Linux Kernel 6.15.0-rc2
// PATH: kernel/kthread.c
// Function: kthreadret = -EINTR;if (!test_bit(KTHREAD_SHOULD_STOP, &self->flags)) {cgroup_kthread_ready();__kthread_parkme(self);ret = threadfn(data);}kthread_exit(ret);
}
  • 如果没有设置停止标志,调用 cgroup_kthread_ready()cgroup 相关准备。
  • 调用 __kthread_parkme(self),如果线程需要 park,会进入 park 状态等待唤醒。
  • 调用用户指定的入口函数 threadfn(data),并将返回值作为线程退出码。(这里所执行的 threadfn 就是在创建内核线程时我们所指定的入口函数了。)
  • 调用 kthread_exit(ret) 退出线程,kthread_exit(ret) 会唤醒等待 kthread_stop() 的线程,并调用 do_exit() 彻底退出。

14.4. 内核线程创建的典型用法

static int my_thread_func(void *data) {// 线程主循环while (!kthread_should_stop()) {// do work...}return 0;
}struct task_struct *tsk = kthread_create(my_thread_func, NULL, "my_kthread");
wake_up_process(tsk); // 启动线程

或直接:

struct task_struct *tsk = kthread_run(my_thread_func, NULL, "my_kthread");

14.5 小结

  • kthread_create 创建一个内核线程,设置入口函数和参数,但不会自动启动。
  • 线程启动后自动执行你指定的函数,返回时线程结束。
  • 推荐用 kthread_run 创建并立即启动线程。

  使用 kthread_create 这个相对高级的封装接口去创建内核线程并不会主动运行,而需要等待被唤醒后才可运行,而需要创建后立刻执行可以使用 kthread_run 来创建,二者最终都会依靠唤醒 threadd 线程来创建新的内核线程,而 threadd 线程会调用 create_kthread 函数构造并初始化参数结构体(其中传入的入口函数为 kthread),再去调用 kernel_clone 我们熟知的创建进程和线程的函数。

  这里有一个比较细节的地方不知道各位有没有发现产生疑问的,那就是我们知道通过 kthread_create 创建出来的线程不会主动运行,那是因为在 kthread 函数中会将当前线程状态改为 TASK_UNINTERRUPTIBLE,然后主动让出 CPU 进入睡眠状态,等待被唤醒。而这时候在 kernel_clone 函数中,创建完线程后,也就是执行完 copy_process 创建出新线程后会继续执行 wake_up_new_task 函数,我们都知道这个函数是用来唤醒线程或进程的,那么这时候为什么最终创建出来的线程还是睡眠状态并没有被唤醒呢?

  实际上,如果有读者会有这样的疑问的话,那其实是说明并没有完全理解整个创建的工作流程导致的,这其中有着一定的顺序关系。也就是说当 kernel_clone 函数通过 copy_process 函数创建出线程后,这时候仅仅只是在内存中将它分配并初始化出来,并不能直接投入运行,也就是说线程并不能通过 kernel_clone 设置的返回入口 ret_from_fork 去执行。而当执行到 kernel_clonewake_up_new_task 时候,这才让该线程有了执行能力,CPU 去调度它运行,从 ret_from_fork 入口执行,先是调用 kthread 函数,而 kthread 函数会讲线程的入口函数设置为 kthreadfn 也就是最开始调用函数时候由我们主动传入的自定义函数,在设置完成后由将当前线程状态设置为 TASK_UNINTERRUPTIBLE 不可被中断状态,接着就调用 schedule_preempt_disabled 让出 CPU,进入睡眠状态。这时候这个线程也就完成了所有的创建步骤,当我们主动调用 wake_up_process 函数时它才会恢复运行,去执行我们指定的函数,执行完成后将会调用 kthread_exit 完成退出,当然它也是利用 do_exit 函数实现的。

在这里插入图片描述

[注]:由于之前已经详细画过 kernel_clone 函数,这里就简要画出关键步骤。

15. 第三章总结

  不知不觉间我们已经完成整个第三章内容的学习,在本章中,我们学习了操作系统中的核心概念–进程。我们也讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系。然后,讨论了Linux 如何存放和表示进程(用 task_stnuctthread_info),如何创建进程(通过 fork()),实际上最终是 kernel_clone()),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过 wait() 系统调用族),以及进程最终如何消亡(强制或自地调用 exit()))。进程是一个非常基础、非常关键的抽象概念,位于每一种现代操作系统的核心位置,也是我们拥有操作系统(用来运行程序)的最终原因。第 4 章讨论进程调度,内核以这种微妙而有趣的方式来决定哪个进程运行,何时运行,以何种顺序运行。

#上一篇

《【Linux内核设计与实现】第三章——进程管理04》

#下一篇

《【Linux内核设计与实现】第四章——进程调度01》

http://www.xdnf.cn/news/195769.html

相关文章:

  • SSO单点登录
  • 通过DeepSeek大语言模型控制panda机械臂,听懂人话,拟人性回答。智能机械臂助手又进一步啦
  • 大模型在肝硬化腹水风险预测及临床方案制定中的应用研究
  • AWS虚拟专用网络全解析:从基础到高级实践
  • 【Spark入门】Spark架构解析:组件与运行机制深度剖析
  • vim粘贴代码格式错乱 排版错乱 缩进错乱 解决方案
  • 【软件工程】需求分析详解
  • 24体育NBA足球直播M28模板体育赛事直播源码
  • 介绍下Nginx的作用与请求转发机制
  • Windows操作系统核心知识解析
  • C++ 表达式求值优先级、结合律与求值顺序(五十九)
  • 关于https请求丢字符串导致收到报文解密失败问题
  • 第二章:Agent System
  • RestRequest ,newtonsoft解析
  • 大模型(LLMs)强化学习—— PPO
  • 【angular19】入门基础教程(一):项目的搭建与启动
  • 如何查看电脑电池使用情况
  • 北京市延庆区“禅苑茶事“非遗项目挂牌及茶事院正式启用
  • Adobe Lightroom Classic v14.3.0.8 一款专业的数字摄影后期处理软件
  • 测试反馈陷入死循环?5大策略拆解新旧Bug难题
  • if consteval
  • 多模态大型模型,实现以人为中心的精细视频理解
  • [原创](现代Delphi 12指南):[macOS 64bit App开发]: 跨平台开发同样支持retain()引用计数器处理.
  • 【氮化镓】质子辐照对 GaN-on-GaN PiN 二极管电导调制的影响
  • 后端Web实战之登录认证,JWT令牌,过滤器Filter,拦截器Interceptor一篇文章so easy!!!
  • 【python】-基础语法1
  • 颖儿生活提案:用海信璀璨505U6真空冰箱重建都市鲜食自由
  • 蓝桥杯 3. 压缩字符串
  • 树莓派5+edge-tts 语音合成并进行播放测试
  • EtherCAT转EtherNet/IP网关CEI-382实现罗克韦尔PLC与和利时伺服电机通讯