
作为 Linux 内核的核心执行单元,内核线程承担着内存管理、进程调度、IO 处理等关键底层工作,其运行状态直接决定了操作系统的稳定性与性能。很多开发者对内核线程的认知停留在表面,难以吃透其底层实现机制与调度核心逻辑,面对线程休眠、调度异常等问题时往往无从下手。
本文围绕“吃透 Linux 内核线程”核心,从初识入门到底层拆解,逐步讲解内核线程的核心原理、运行机制与调度逻辑,再到休眠常见问题答疑,层层递进、由浅入深。无需复杂的前置知识,跟着节奏就能理清内核线程的本质,掌握其运行与调度的核心要点,轻松应对日常开发与排查中的相关问题。
一、初识内核线程
面试题写作模版在深入探讨内核线程之前,让我们先回顾一下进程和线程的基本概念。进程是操作系统进行资源分配的基本单位,每个进程都有自己独立的地址空间、代码、数据和文件描述符等资源。这就好比一个独立的工作室,拥有自己的办公场地、设备和员工,与其他工作室相互隔离,互不干扰。例如,当你打开一个浏览器,它就是一个进程,拥有自己独立的内存空间来存储网页数据、渲染引擎等资源。
而线程则是进程内的一个执行单元,是 CPU 调度的基本单位 。一个进程可以包含多个线程,这些线程共享进程的资源,就像工作室里的不同员工,他们在同一个办公场地,使用相同的设备,共同完成进程的任务。比如浏览器进程中,可能有负责页面渲染的线程、处理网络请求的线程、管理用户输入的线程等,它们共享浏览器进程的内存空间和文件描述符等资源。
线程作为轻量级进程,与进程相比,具有更低的创建和销毁开销,以及更快的上下文切换速度。这使得线程在处理一些需要频繁创建和销毁执行单元的场景中,具有明显的优势。例如,在一个高并发的 Web 服务器中,使用线程来处理每个客户端请求,可以大大提高服务器的响应速度和吞吐量。
在内核中,每个线程都有一个对应的 task_struct 结构体,它就像是线程的 “身份证”,记录了线程的各种信息 。这个结构体非常复杂,包含了许多成员,其中一些关键成员对于理解内核线程的运行机制至关重要。首先是 pid(进程标识符),它是线程的唯一标识,用于在内核中区分不同的线程。就像每个人的身份证号码一样,独一无二。通过 pid,内核可以方便地对线程进行管理和调度。
state(状态)成员表示线程当前的状态,比如运行(TASK_RUNNING)、睡眠(TASK_INTERRUPTIBLE 或 TASK\UNINTERRUPTIBLE)、停止(\_TASK_STOPPED)等。这些状态反映了线程在不同时刻的运行情况,帮助内核决定何时调度该线程。
mm 指针也是一个重要的成员,它指向线程所属进程的内存管理结构体 mm_struct 。对于普通进程来说,mm 指针指向的 mm_struct 包含了该进程的用户地址空间信息,包括代码段、数据段、堆和栈等。然而,对于内核线程来说,mm 指针为 NULL 。这是因为内核线程运行在内核空间,不需要独立的用户内存,它们直接访问内核地址空间。这就好比一个只在公司内部工作的员工,不需要自己独立的办公场地,直接使用公司的公共资源即可。mm 指针为 NULL 使得内核线程的内存管理更加简单高效,减少了内存开销。
堆栈是内核线程运行时不可或缺的一部分,它就像一个工作栈,用于存储线程在执行过程中的局部变量、函数调用参数和返回地址等信息 。内核线程栈的空间通常是在内核线程创建时分配的,大小一般是固定的,比如在 x86 架构上,通常是 8KB。
当内核线程调用一个函数时,它会将函数的参数和返回地址压入栈中,然后跳转到函数的入口地址开始执行。在函数执行过程中,局部变量也会被分配在栈上。当函数执行完毕后,栈顶指针会恢复到函数调用前的位置,函数的返回地址被弹出,线程继续执行下一条指令。
在中断处理时,堆栈同样起着重要的作用。当中断发生时,内核会将当前线程的寄存器状态等信息压入栈中,然后跳转到中断处理函数执行。中断处理完毕后,再从栈中恢复线程的寄存器状态,继续执行被中断的线程。
与用户线程栈相比,内核线程栈具有更高的特权级别,它可以访问内核空间的所有内存。而用户线程栈只能访问用户空间的内存,一旦访问内核空间,就会引发段错误。这是因为内核线程运行在内核态,拥有更高的权限,而用户线程运行在用户态,权限受到限制。
上下文切换是指内核在不同线程之间进行切换,将 CPU 的控制权从一个线程转移到另一个线程的过程 。这就好比一场接力赛,不同的线程就像接力赛中的运动员,在不同的时刻接过 CPU 的控制权,继续执行任务。
在上下文切换过程中,内核需要保存当前线程的上下文信息,包括寄存器的值、程序计数器(PC)、栈指针(SP)等,以便在将来某个时刻能够恢复该线程的执行。同时,内核会加载下一个要执行线程的上下文信息,将其寄存器的值、程序计数器和栈指针等设置为合适的值,然后开始执行该线程。
具体来说,当内核决定切换线程时,它会执行以下步骤:首先,将当前线程的寄存器值保存到该线程的内核栈中;然后,将程序计数器和栈指针的值也保存到内核栈中;接着,从调度队列中选择下一个要执行的线程;之后,从新线程的内核栈中恢复寄存器的值、程序计数器和栈指针;最后,CPU 开始执行新线程的代码。
上下文切换的开销主要包括保存和恢复上下文信息的时间开销,以及 CPU 缓存失效带来的性能损失。由于上下文切换需要保存和恢复大量的寄存器信息,并且切换后新线程访问的数据可能不在 CPU 缓存中,导致缓存失效,需要从内存中重新读取数据,这都会降低系统的性能。因此,在设计操作系统时,需要尽量减少不必要的上下文切换,提高系统的整体性能。
二、内核线程的原理剖析
面试题写作模版在内核线程的运行过程中,有几个关键的数据结构起着至关重要的作用,其中 struct thread_info、struct task_struct 和内核栈尤为关键。
struct thread_info 主要存放着进程 / 线程的基本信息 ,它和进程 / 线程的内核栈存放在内核空间里的一段 2 倍页长空间中。其中,thread_info 结构存放在地址段的末尾,其余空间作为内核栈,就像一个房间,一部分区域用来存放物品信息(thread_info),另一部分区域用来存放物品(内核栈)。这个结构体中包含一个指向 struct task_struct 类型的指针 task,通过这个指针,thread_info 与 task_struct 建立了紧密的联系,就像房间信息与房间主人的联系一样。
struct task_struct 也叫做任务描述符,它可以说是内核中进程或线程的 “身份证”,包含了进程或线程的众多重要信息,如进程 ID(pid)、线程组 ID(tgid)、指向内核栈的指针(stack)、内存管理相关信息(mm 和 active_mm)、文件系统信息(fs)、打开文件信息(files)等。这些信息就像是一个人的身份信息、工作信息、资产信息等,全面地描述了进程或线程的状态和属性 。而内核栈则是线程执行函数调用时用来存储局部变量、函数参数、返回地址等信息的地方,它就像是一个临时的储物箱,线程在执行过程中需要用到的各种临时数据都存放在这里。
这几个数据结构相互关联,共同构成了内核线程运行的基础,就像一个精密的机器,各个零部件相互配合,才能保证机器的正常运转。
(1)线程创建:内核线程的创建是一个复杂而有序的过程,涉及到内核的多个层面和众多的数据结构操作。以 Linux 内核为例,创建内核线程主要由 kthreadd 内核线程来完成,kthreadd 就像是一个专门负责孵化新线程的 “孵化中心”,时刻准备着接收创建新线程的任务。
当有创建内核线程的需求时,首先会通过__kthread_create_on_node () 函数将内核线程创建请求数据放入 kthread_create_list 列表,这个列表就像是一个任务清单,所有待创建的内核线程请求都被记录在这里。之后,kthreadd 线程会不断检查这个列表。当发现有新的创建请求时,kthreadd 会从列表中取出一个请求,然后调用 create_kthread () 函数,而 create_kthread () 函数又会通过 kernel_thread () -> _do_fork () 这样的调用链来真正创建内核线程。
在这个过程中,内核需要为新线程分配一系列重要的资源,其中关键的一步就是创建和初始化线程控制块(TCB),在 Linux 内核中对应的就是 task_struct 结构体。这个结构体就像是线程的 “个人档案”,里面记录了线程运行所需的各种关键信息,比如线程的状态、优先级、所归属的进程组、打开的文件描述符等,这些信息对于线程的正常运行和内核的有效管理至关重要。
与进程创建相比,内核线程创建有其独特之处。进程创建时,通常会复制父进程的大部分资源,包括地址空间、文件系统信息等,就像孩子继承了父母的大量 “家产”。而内核线程创建时,它共享内核空间的资源,不需要像进程那样复制庞大的用户空间资源,就像是在一个共享的 “大仓库” 里直接获取所需资源,所以创建过程相对更轻量级,开销也更小。并且,进程创建往往伴随着新的可执行程序的加载,就像是一个全新的 “项目” 开始运作;而内核线程通常是在内核中执行特定的任务函数,没有可执行程序的加载过程,更像是在已有的 “项目框架” 内执行特定的子任务。内核线程创建代码示例:
#include <linux/module.h>#include <linux/kthread.h>#include <linux/delay.h>// 定义线程执行函数staticintmy_kthread_func(void *data);// 定义线程结构体指针static struct task_struct *my_kthread_task = NULL;staticint __init kthread_demo_init(void){// 创建并启动内核线程// 参数 1:线程执行函数 参数 2:传递给线程的参数 参数 3:线程名称 my_kthread_task = kthread_run(my_kthread_func, NULL, "my_demo_kthread");if (IS_ERR(my_kthread_task)) { printk(KERN_ERR "内核线程创建失败\n");return -1; } printk(KERN_INFO "内核线程创建成功\n");return0;}// 线程主执行函数staticintmy_kthread_func(void *data){// 在线程创建完成后打印信息 printk(KERN_INFO "内核线程已启动,开始运行\n");// 线程主循环while (!kthread_should_stop()) {// 执行业务逻辑 printk(KERN_INFO "内核线程正常工作中\n"); msleep(1000); } printk(KERN_INFO "内核线程准备退出\n");return0;}(2)线程运行:内核线程被创建并唤醒后,就会进入运行阶段,开始执行它的使命。它首先会进入内核线程公共入口 kthread (),在这里进行一些初始化和准备工作,就像是运动员在比赛前要进行热身和准备装备。之后,kthread () 会调用内核线程真正的入口函数 xxx_kthread_entry (),线程开始执行具体的任务代码,就像运动员正式踏上赛场开始比赛。
在运行过程中,内核线程的状态会不断变化。它可能处于就绪态,等待 CPU 的调度,就像运动员在候场区等待上场;当 CPU 调度到它时,就会进入运行态,开始执行任务,如同运动员在赛场上奋力拼搏。在多处理器环境下,内核线程的调度执行方式更加复杂和灵活。
内核调度器会根据线程的优先级、任务类型以及系统负载等多种因素,将不同的内核线程分配到不同的处理器核心上执行,以充分利用多核处理器的性能优势,就像教练会根据运动员的特长和比赛情况,安排他们在不同的赛道或项目中比赛。
并且,为了保证系统的公平性和高效性,调度器会在适当的时候进行线程切换,让不同的线程都有机会获得 CPU 资源,避免某个线程长时间占用 CPU 而导致其他线程饥饿,就像比赛中会按照规则轮流让不同的运动员上场比赛。内核线程运行逻辑代码示例:
// 线程主函数(支持正常退出)staticintmy_kthread_func(void *data){ printk(KERN_INFO "内核线程启动成功\n");// 循环检查终止信号while (!kthread_should_stop()) { printk(KERN_INFO "线程工作中...\n"); msleep(1000); }// 线程退出前的资源清理 printk(KERN_INFO "内核线程开始清理资源并退出\n");return0;}// 模块退出时停止线程// 线程内部检查泊停逻辑staticintmy_kthread_func(void *data){while (!kthread_should_stop()) {// 检查是否收到泊停指令if (kthread_should_park()) { printk(KERN_INFO "线程进入泊停状态\n");// 执行自我泊停 kthread_parkme();// 被唤醒后继续执行 printk(KERN_INFO "线程退出泊停,恢复运行\n"); } printk(KERN_INFO "线程正常运行\n"); msleep(1000); }return0;}// 外部调用泊停线程// 内核线程运行态完整逻辑staticintmy_kthread_func(void *data){// 允许线程接收信号 allow_signal(SIGKILL); printk(KERN_INFO "内核线程进入运行态\n");// 运行状态主循环while (!kthread_should_stop()) {// 检查是否需要泊停if (kthread_should_park()) {// 进入泊停状态 kthread_parkme(); printk(KERN_INFO "线程已恢复运行\n"); }// 正常业务逻辑 printk(KERN_INFO "运行中:执行任务处理\n");// 调度让出 CPU,避免占用资源 msleep_interruptible(1000); }return0;}(3)线程休眠泊停:泊停(PARK)是内核线程生命周期中的一个特殊状态,它就像是线程的 “暂停休息” 状态。当系统需要暂停某个内核线程的执行,但又不想终止它时,就会让线程进入泊停状态。例如,当某个内核线程正在等待某个特定事件的发生,而这个事件短时间内不会出现时,将线程泊停可以避免它占用 CPU 资源,提高系统的整体效率,就像运动员在比赛间隙,如果暂时没有自己的比赛项目,就会在场边休息等待,避免无谓的消耗体力。
实现线程泊停主要通过 kthread_park () 函数,这个函数会发出泊停请求,即设置 KTHREAD_SHOULD_PARK 标记。内核线程在运行过程中,会在一些特定的检查点检查这个标记。比如在内核线程公共入口 kthread () 中,如果检查到泊停请求,就会执行实际的泊停动作;在内核线程入口函数中,也会通过 kthread_should_park () 函数来检查是否有泊停请求。当线程检测到泊停请求后,会将自己的状态设置为泊停状态,暂停执行,释放 CPU 资源,进入 “休息” 状态。
当需要恢复被泊停的内核线程时,会调用 kthread_unpark () 函数,它会清除 KTHREAD_SHOULD_PARK 标记,并唤醒线程,让线程重新进入就绪态,等待 CPU 调度,就像教练通知休息的运动员准备再次上场比赛。内核线程 PARK / UNPARK 代码示例:
// 线程主函数(支持正常退出)staticintmy_kthread_func(void *data){ printk(KERN_INFO "内核线程启动成功\n");// 循环检查终止信号while (!kthread_should_stop()) { printk(KERN_INFO "线程工作中...\n"); msleep(1000); }// 线程退出前的资源清理 printk(KERN_INFO "内核线程开始清理资源并退出\n");return0;}// 模块退出时停止线程// 线程内部检查泊停逻辑staticintmy_kthread_func(void *data){while (!kthread_should_stop()) {// 检查是否收到泊停指令if (kthread_should_park()) { printk(KERN_INFO "线程进入泊停状态\n");// 执行自我泊停 kthread_parkme();// 被唤醒后继续执行 printk(KERN_INFO "线程退出泊停,恢复运行\n"); } printk(KERN_INFO "线程正常运行\n"); msleep(1000); }return0;}// 外部调用泊停线程voidpark_my_kthread(void){if (my_kthread_task && !IS_ERR(my_kthread_task)) { kthread_park(my_kthread_task); printk(KERN_INFO "已发送线程泊停指令\n"); }}// 外部调用恢复线程voidunpark_my_kthread(void){if (my_kthread_task && !IS_ERR(my_kthread_task)) { kthread_unpark(my_kthread_task); printk(KERN_INFO "已恢复线程运行\n"); }}(4)线程终止销毁:内核线程在完成自己的使命或者因为某些特殊原因,会进入终止阶段。比如当线程执行完它的任务函数,或者接收到终止信号时,就会触发终止流程。在 Linux 内核中,通常是通过 kthread_stop () 函数来终止内核线程。这个函数会设置 kthread_should_stop 标志,线程在运行过程中会不断检查这个标志。当发现标志被设置后,线程就会知道自己需要终止执行,就像运动员完成比赛任务或者接到教练的结束指令后,会停止比赛。
线程终止时,内核需要进行一系列资源的释放和回收工作。首先,线程所占用的内核栈、线程控制块(task_struct)等内核资源会被释放,就像运动员比赛结束后,归还借用的比赛装备和场地。如果线程在运行过程中打开了一些文件描述符或者占用了其他系统资源,这些资源也会被关闭和释放,以保证系统资源的有效管理和回收,避免资源泄漏,就像比赛结束后清理场地,归还所有借用的物品。并且,线程的退出信息会被传递给它的父线程或者相关的监控机制,以便系统了解线程的终止情况,就像运动员比赛结束后向教练汇报比赛结果。内核线程终止代码示例:
// 线程主函数(支持正常退出)staticintmy_kthread_func(void *data){ printk(KERN_INFO "内核线程启动成功\n");// 循环检查终止信号while (!kthread_should_stop()) { printk(KERN_INFO "线程工作中...\n"); msleep(1000); }// 线程退出前的资源清理 printk(KERN_INFO "内核线程开始清理资源并退出\n");return0;}// 模块退出时停止线程staticvoid __exit kthread_demo_exit(void){if (my_kthread_task && !IS_ERR(my_kthread_task)) {// 终止内核线程 kthread_stop(my_kthread_task); printk(KERN_INFO "内核线程已成功终止\n"); } printk(KERN_INFO "内核模块已卸载\n");}// 注册模块入口与出口module_init(kthread_demo_init);module_exit(kthread_demo_exit);MODULE_LICENSE("GPL");MODULE_DESCRIPTION("Linux 内核线程生命周期完整示例");MODULE_AUTHOR("Linux Kernel Demo");内核线程的调度策略是操作系统实现高效多任务处理的关键环节,它决定了哪个内核线程能够在何时获得 CPU 的使用权。内核调度器就像是一个公正的 “交通指挥官”,采用多种策略来合理地调度内核线程,确保系统的高效运行。
其中,时间片轮转调度策略是一种常见的方式。在这种策略下,每个内核线程被分配一个固定的时间片,当线程的时间片用完后,调度器会将其放回就绪队列的末尾,等待下一次调度,就像一场接力赛,每个选手跑一段固定的距离后,就把接力棒交给下一个选手。这种策略保证了每个线程都有机会执行,避免了某个线程长时间占用 CPU,从而提高了系统的公平性和响应性。
优先级调度策略也是内核调度器常用的方法。调度器会根据线程的优先级来决定调度顺序,优先级高的线程优先获得 CPU 资源,就像在机场,头等舱和商务舱的乘客可以优先登机一样。线程的优先级可以根据多种因素确定,比如线程的类型(如实时线程通常具有较高的优先级)、线程的任务紧急程度、线程的 CPU 使用情况等。在 Linux 系统中,线程的优先级范围是 0 - 99,数值越大优先级越高。调度器会综合考虑这些因素,动态地调整线程的优先级,以确保系统的关键任务能够及时得到处理 。
除了时间片轮转和优先级调度策略外,内核调度器还会考虑其他因素,如线程的等待时间、CPU 的负载情况等。对于等待时间较长的线程,调度器会适当提高其优先级,使其有更多机会获得 CPU 资源,就像在排队等待服务时,等待时间久的顾客会被优先照顾一样。而当 CPU 负载较高时,调度器会更加倾向于调度那些 CPU 使用量较低的线程,以平衡系统的负载,提高 CPU 的利用率 。
三、内核线程的核心运行机制
面试题写作模版当内核线程进行上下文切换时,就好比一位演员在舞台上换装。内核需要小心翼翼地保存当前线程的各种状态,包括寄存器中的数据,这些数据就像是演员身上的配饰,记录着当前线程的工作进度和各种临时数据;还有程序计数器,它就像是演员的剧本页码提示器,指示着线程下一条要执行的指令位置。
具体来说,在 x86 架构中,常见的通用寄存器如 EAX、EBX、ECX 等都需要被保存起来。当切换到新的线程时,内核又要像一位熟练的造型师,将新线程的这些状态信息加载到 CPU 中,让新线程能够顺利地继续执行。这个过程涉及到硬件和软件的协同工作,硬件负责执行保存和加载操作,而软件(内核)则负责管理和调度这些操作的时机和顺序。
上下文切换虽然是必要的,但它也带来了一定的开销。每次上下文切换都需要消耗一定的 CPU 时间,就像演员换装需要花费时间,会让演出暂停一会儿。在主流的 x86 - 64 Linux 系统上,一次完整的上下文切换(包括用户态→内核态→用户态)通常耗时 1 - 5 微秒。虽然这个时间看起来很短,但如果线程切换频繁,比如在高并发短任务场景中,每毫秒发生几百次切换,那么 CPU 就会将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,从而大大缩短了真正运行线程的任务时间,导致系统性能下降 。
当多个内核线程访问共享资源时,就像多个顾客同时想要使用银行的同一个保险柜,为了保证数据的一致性和避免竞态条件,就需要用到同步与互斥机制。
锁机制是最常用的手段之一,其中互斥锁就像是保险柜的唯一钥匙,同一时间只允许一个线程持有这把 “钥匙”,从而访问共享资源,其他试图获取这把 “钥匙”(已加锁的互斥锁)的线程就会被阻塞,直到当前线程用完资源并释放 “钥匙”(解锁互斥锁)。自旋锁则有所不同,它就像是顾客在银行柜台前排队等待使用保险柜,当一个线程发现共享资源被占用时,它不会像互斥锁那样进入睡眠等待状态,而是在原地不断地检查锁是否被释放,就像顾客一直在柜台前询问是否可以使用保险柜。这种方式适用于锁被占用时间较短的情况,因为如果锁被占用时间过长,线程一直自旋会浪费 CPU 资源 。不了解自旋锁,请参考这篇《自旋锁解析》。
信号量可以看作是银行里不同等级保险柜的有限数量的钥匙,它允许多个线程同时访问共享资源,但访问的线程数量不能超过信号量的上限。比如,一个信号量的初始值为 3,就表示最多可以有 3 个线程同时持有对应的 “钥匙”,访问共享资源。条件变量则像是银行的通知服务,它允许线程在满足特定条件时才访问共享资源。例如,当银行的某个保险柜有新的重要文件存入时,通过条件变量通知相关线程,这些线程才可以去访问这个保险柜 。不了解信号量,请参考这篇《不懂 信号量与完成量,别说你吃透 Linux 内核同步》。
在计算机系统中,硬件中断就像是紧急电话,随时可能打断内核线程的正常执行。当硬件中断发生时,内核会立即停下手中的工作,就像消防员听到火警铃声后,会立刻放下手头的事情,去处理紧急情况。内核首先会保存当前线程的上下文,然后跳转到相应的中断处理程序。中断处理程序就像是专业的消防员,迅速处理硬件设备发出的紧急请求,比如处理磁盘 I/O 完成的通知、网络数据包的到达等。
中断处理与内核线程有着密切的关系。在一些情况下,中断处理的工作可能比较耗时,如果在中断处理程序中直接完成这些工作,会导致其他中断被长时间阻塞,影响系统的响应性能。因此,内核引入了软中断和 tasklet 机制,它们基于内核线程实现,就像是消防员的后援团队。当中断处理程序完成紧急的、关键的工作后,会将一些耗时的操作交给软中断或 tasklet,由它们在合适的时机继续处理,这样可以避免中断处理时间过长对系统性能的影响 。
例如,当网络设备接收到一个数据包时,会产生一个硬件中断。内核的中断处理程序首先会快速地将数据包从网络设备的缓冲区读取到内存中,然后将后续的数据包解析、协议处理等工作交给软中断或 tasklet 来处理,这样可以让内核尽快返回去处理其他任务,提高系统的整体性能 。
四、内核线程的调度管理
面试题写作模版内核调度线程的核心目标是最大化 CPU 利用率,同时保证线程执行的公平性和响应性。为了实现这一目标,内核主要依赖两个关键机制:时间片轮转和优先级调度 。
时间片轮转机制是为了保证公平性而设计的。由于 CPU 的执行速度极快,内核会给每个处于就绪状态的线程分配一个 “时间片”,例如 10ms。当线程用完这个时间片后,内核会暂停它的执行,并切换到下一个线程。这个切换过程被称为 “上下文切换”。时间片的长度设置十分关键,如果太短,会导致上下文切换过于频繁,消耗大量的 CPU 资源,就像一个人频繁地在不同任务之间切换,效率会很低;如果太长,又会导致线程响应变慢,比如高优先级的线程可能要等待很久才能得到执行机会 。
优先级调度机制则是为了保证响应性。内核会为每个线程分配一个优先级,在调度时,遵循两个原则:高优先级线程优先获取时间片,甚至可以抢占低优先级线程正在使用的 CPU;而相同优先级的线程,则按照时间片轮转的方式执行。不过,不同操作系统对于优先级的映射规则有所不同,例如在 Linux 内核中,线程优先级范围是 0 - 99,数值越大优先级越高,而 Java 线程的优先级(1 - 10)会被 JVM 映射成 Linux 内核的线程优先级,但并非直接一一对应,这就导致在 Java 中设置优先级 10 的线程,不一定比优先级 1 的线程先执行 。
内核线程的调度过程是一个复杂而有序的流程。以 Linux 内核为例,调度器的核心是 CFS(Completely Fair Scheduler),从 2.6.23 版本开始成为默认调度器 。
当一个内核线程需要被调度时,首先会有调度触发事件。调度可能在以下几种情况下触发:一是线程主动放弃 CPU,比如调用 sched_yield () 函数;二是线程进入睡眠状态,例如等待 I/O 操作完成;三是时钟中断(tick)发生时,内核会检查是否需要进行调度 。调度触发场景代码示例:
// 1. 线程主动放弃 CPU(sched_yield()调用)staticintmy_kthread_func(void *data){while (!kthread_should_stop()) {// 执行部分任务后,主动让出 CPU,让其他线程执行 sched_yield(); printk(KERN_INFO "线程主动放弃 CPU,等待下次调度\n"); msleep(500); }return0;}// 2. 线程进入睡眠状态(等待 I/O,触发调度)// 等待 I/O 完成,线程睡眠,触发调度器切换其他线程wait_event_interruptible(io_wait_queue, io_completed); // 3. 时钟中断触发调度(内核源码片段,简化版)// 时钟中断处理函数中,检查是否需要调度voidtick_handle_periodic(struct clock_event_device *dev){ update_process_times(user_mode(get_irq_regs()));// 检查调度时机,触发调度 scheduler_tick(); }调度的主要入口是 schedule () 函数,定义在 kernel/sched/core.c 中。schedule () 函数会调用__schedule () 函数,__schedule () 函数是核心调度函数。在这个函数中,首先会获取当前运行的线程(prev)和当前的运行队列(rq),然后通过调度类选择下一个要运行的线程(next) 。调度核心函数调用链路代码示例:
// 调度入口函数 schedule()(kernel/sched/core.c 简化版)asmlinkage __visible void __sched schedule(void){ struct task_struct *prev, *next; struct rq *rq;int cpu; cpu = smp_processor_id(); rq = cpu_rq(cpu); // 获取当前 CPU 的运行队列 rq prev = current; // 获取当前正在运行的线程(prev)// 调用核心调度函数 __schedule() __schedule(false);}// 核心调度函数 __schedule()(简化版,保留核心逻辑)staticvoid __sched __schedule(bool preempt){ struct task_struct *prev, *next; struct rq *rq; prev = current; rq = cpu_rq(smp_processor_id());// 1. 释放当前线程的 CPU 资源 clear_tsk_need_resched(prev);// 2. 选择下一个要运行的线程(next) next = pick_next_task(rq, prev, &rf);// 3. 执行上下文切换(如果 prev 和 next 不是同一个线程)if (likely(prev != next)) { context_switch(rq, prev, next); // 上下文切换核心函数 }}选择下一个线程的过程由 pick_next_task () 函数完成,它会遍历调度类,选择下一个要运行的线程。对于 CFS 调度器来说,pick_next_task () 会调用 pick_next_task_fair () 函数,选择虚拟运行时间(vruntime)最小的线程。vruntime 是 CFS 调度器实现公平调度的关键,它表示一个线程在 CPU 上的虚拟运行时间,vruntime 较小的线程说明它之前占用 CPU 的时间相对较少,因此会优先获得执行机会 。CFS 选择下一个线程代码示例:
// pick_next_task() 简化版(遍历调度类,选择 next 线程)static struct task_struct *pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf){const struct sched_class *class; struct task_struct *p;// 遍历调度类,CFS 调度类会被优先调用 for_each_class(class) { p = class->pick_next_task(rq);if (p)return p; }return NULL;}// CFS 调度器选择下一个线程(pick_next_task_fair 简化版)static struct task_struct *pick_next_task_fair(struct rq *rq){ struct cfs_rq *cfs_rq = &rq->cfs; struct sched_entity *se; struct task_struct *p;// 选择 vruntime 最小的调度实体(对应内核线程) se = __pick_next_entity(cfs_rq); p = container_of(se, struct task_struct, se);// 更新 CFS 调度队列状态 update_curr(cfs_rq);return p;}// 简化版:获取 vruntime 最小的调度实体static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq){// 红黑树的最左节点,就是 vruntime 最小的线程(CFS 用红黑树管理线程)return rb_entry(cfs_rq->rb_leftmost, struct sched_entity, run_node);}当确定了下一个要运行的线程后,就会进行上下文切换。上下文切换由 context_switch () 函数负责,它主要完成两个关键操作:一是切换地址空间,将当前线程(prev)的 active_mm 切换为下一个线程(next)的 mm;二是切换寄存器状态,通过 switch_to 函数将当前线程的寄存器状态保存,并加载下一个线程的寄存器状态 。上下文切换核心代码示例:
// 上下文切换核心函数 context_switch()static __always_inline struct rq *context_switch(struct rq *rq, struct task_struct *prev, struct task_struct *next){ struct mm_struct *mm, *oldmm;// 1. 切换地址空间 mm = next->mm; oldmm = prev->active_mm;// 切换线程的地址空间(内核线程共享内核地址空间,此处逻辑简化)if (!mm) { next->active_mm = oldmm; atomic_inc(&oldmm->mm_count); } else { switch_mm(oldmm, mm, next); // 切换地址空间 }// 2. 切换寄存器状态(核心:保存 prev 线程状态,加载 next 线程状态) switch_to(prev, next, prev);return finish_task_switch(prev);}// switch_to 汇编示意(ARM 架构简化版,核心逻辑)// 保存 prev 线程的寄存器到栈,加载 next 线程的寄存器asm volatile("str lr, [%0, #TASK_LR]\n" // 保存 lr 寄存器"str sp, [%0, #TASK_SP]\n" // 保存 sp 寄存器"ldr lr, [%1, #TASK_LR]\n" // 加载 next 的 lr 寄存器"ldr sp, [%1, #TASK_SP]\n" // 加载 next 的 sp 寄存器 : : "r" (prev), "r" (next) : "memory");在调度过程中,线程的状态也会发生转换。例如,当线程主动放弃 CPU 或者时间片用完时,会从运行状态转换为就绪状态;当线程等待某个事件(如 I/O 操作完成)时,会从运行状态转换为阻塞状态,当事件发生后,又会从阻塞状态转换为就绪状态,等待内核再次调度执行 。内核线程实战代码示例:
staticintmy_kthread_func(void *data){ struct wait_queue_head io_wait; init_waitqueue_head(&io_wait); bool io_completed = false;while (!kthread_should_stop()) { printk(KERN_INFO "线程处于运行状态,执行任务\n");// 1. 等待 I/O 事件,线程从运行态→阻塞态(触发调度) printk(KERN_INFO "等待 I/O 完成,线程进入阻塞态\n"); wait_event_interruptible(io_wait, io_completed); // 2. I/O 完成,线程从阻塞态→就绪态,等待调度 printk(KERN_INFO "I/O 完成,线程进入就绪态,等待调度\n");// 3. 主动放弃 CPU,从运行态→就绪态(触发调度) sched_yield(); printk(KERN_INFO "主动放弃 CPU,线程回到就绪态\n"); }return0;}内核线程的调度过程是一个高度优化、复杂而又有序的过程,通过合理的调度策略和机制,保证了系统中各个线程能够高效、公平地使用 CPU 资源,为整个操作系统的稳定运行提供了坚实的保障。
内核线程与普通进程在调度上存在多方面的显著区别,这些区别源于它们不同的地址空间和功能特性。
在地址空间切换方面,普通进程拥有独立的用户空间和内核空间。当普通进程进行上下文切换时,不仅要切换内核栈和寄存器等硬件上下文,还需要切换页表,以切换到新进程的地址空间 。这是因为不同进程的用户空间相互隔离,进程切换后需要访问新的用户空间数据和代码。例如,当一个进程从用户态切换到内核态执行系统调用,然后又返回用户态时,需要恢复其原来的用户空间页表基址(CR3 寄存器) 。而内核线程没有独立的用户空间,始终运行在内核态,其地址空间就是内核地址空间 。在上下文切换时,内核线程无需切换页表,因为它们始终访问的是同一内核地址空间,这大大减少了地址空间切换的开销 。
调度开销上,内核线程的调度开销相对较小。由于内核线程不需要进行用户空间的相关操作,如页表切换、TLB(Translation Lookaside Buffer)刷新等,所以在上下文切换时,其开销主要集中在内核栈和寄存器的切换 。而普通进程的上下文切换开销除了内核部分,还涉及用户空间的复杂操作,开销较大。例如,当进程 A 切换到进程 B 时,需要保存进程 A 的用户空间状态,加载进程 B 的用户空间状态,这个过程涉及大量的内存访问和寄存器操作 。
信号处理方面,普通进程需要处理各种用户态信号,如 SIGTERM、SIGINT 等 。当进程接收到信号时,需要进入信号处理流程,保存当前上下文,执行信号处理函数,然后再恢复上下文继续执行 。而内核线程默认屏蔽所有信号,不需要处理用户态信号,这使得内核线程在执行过程中更加专注,避免了信号处理带来的额外开销和复杂性 。
这些差异在实际应用场景中有着重要影响。例如,在服务器应用中,大量的用户进程需要处理各种业务逻辑和用户请求,它们需要独立的用户空间来运行应用程序代码和存储数据 。而内核线程则负责一些后台的基础设施任务,如内存管理、设备驱动等 。由于内核线程调度开销小,可以更高效地执行这些任务,保证系统的稳定性和性能 。以数据库服务器为例,普通进程负责处理用户的数据库查询请求,而内核线程则负责管理数据库的缓存、磁盘 I/O 等底层操作,两者协同工作,确保数据库系统的高效运行 。
五、内核线程休眠常见问题
面试题写作模版内核线程的休眠,简单来说,就是让线程暂时停止执行,释放 CPU 资源,进入一种等待状态 。从底层原理来看,当内核线程调用如 sleep、msleep 等休眠函数时,会触发一系列复杂的操作。
以 Linux 内核为例,这些休眠函数最终会调用 schedule 函数 。schedule 函数是内核调度器的核心函数,它的主要作用是选择一个合适的线程来运行。当一个线程调用 schedule 函数进入休眠时,它会将自己的状态从运行状态(TASK_RUNNING)设置为睡眠状态,如可中断睡眠状态(TASK_INTERRUPTIBLE)或不可中断睡眠状态(TASK_UNINTERRUPTIBLE) 。在这个过程中,内核会将该线程从运行队列中移除,并将其放入对应的等待队列中。等待队列是内核中用于管理等待特定事件的线程的数据结构,每个等待队列都与一个特定的事件相关联,比如等待磁盘 I/O 完成、等待信号量等。
这里涉及到一个关键的数据结构 ——task_struct ,它是内核中描述线程的结构体,包含了线程的各种信息,如线程的状态、优先级、堆栈指针、打开的文件描述符等。当线程进入休眠时,内核会修改 task_struct 中的状态字段,记录线程的睡眠原因和等待的事件等信息。
线程休眠对系统资源分配和其他线程运行有着重要的影响。当一个内核线程进入休眠时,它会释放占用的 CPU 资源,使得 CPU 可以调度其他处于就绪状态的线程运行 。这在多线程环境中,对于提高系统的整体效率和并发处理能力非常关键。
但是,如果线程不合理地休眠,比如休眠时间过长,可能会导致一些问题。一方面,它可能会使某些需要及时处理的任务得不到执行,从而影响系统的响应性能。比如在一个实时操作系统中,如果负责处理关键传感器数据的内核线程长时间休眠,可能会导致数据丢失或处理不及时,影响整个系统的稳定性和可靠性。另一方面,过多的线程同时休眠,可能会导致系统中处于就绪状态的线程过少,使得 CPU 利用率降低,造成资源浪费。
(1)休眠时间设置不当:在实际编程中,经常会遇到休眠时间设置不合理的情况。例如,在一个轮询任务中,如果设置的休眠时间过短,线程会频繁地被唤醒和调度,增加系统开销;而如果休眠时间过长,又可能导致任务处理不及时。假设我们有一个简单的监控系统,通过内核线程定时检查某个文件是否被修改,代码如下:
#include <linux/module.h>#include <linux/kthread.h>#include <linux/delay.h>static struct task_struct *monitor_thread;staticintmonitor_function(void *data){while (!kthread_should_stop()) {// 检查文件是否被修改的逻辑// ...// 休眠 100 毫秒,这里休眠时间设置可能不合理 msleep(100); }return0;}intinit_module(void){ monitor_thread = kthread_run(monitor_function, NULL, "monitor_thread");if (IS_ERR(monitor_thread)) { printk(KERN_ERR "Failed to create monitor thread\n");return PTR_ERR(monitor_thread); }return0;}voidcleanup_module(void){ kthread_stop(monitor_thread);}MODULE_LICENSE("GPL");如果这个监控任务需要更及时地响应文件修改,100 毫秒的休眠时间可能过长,我们可以根据实际需求缩短休眠时间,比如改为 10 毫秒:msleep(10); 。相反,如果这个任务对及时性要求不高,且频繁检查会消耗过多资源,那么可以适当延长休眠时间。
(2)休眠导致死锁:另一个常见的问题是休眠导致死锁。当多个线程之间存在资源竞争和依赖关系时,如果在持有资源的情况下进入休眠,而其他线程又在等待该资源,就可能引发死锁。假设有两个内核线程 thread1 和 thread2,它们分别持有信号量 sem1 和 sem2,并且互相等待对方释放信号量,代码示例如下:
#include <linux/module.h>#include <linux/kthread.h>#include <linux/semaphore.h>static struct task_struct *thread1, *thread2;static struct semaphore sem1, sem2;staticintthread1_function(void *data){while (!kthread_should_stop()) {// 获取 sem1 down(&sem1);// 模拟一些操作// ...// 尝试获取 sem2,但此时 thread2 持有 sem2,且 thread2 也在等待 sem1,导致死锁 down(&sem2);// 释放 sem2 up(&sem2);// 释放 sem1 up(&sem1);// 休眠一段时间 msleep(100); }return0;}staticintthread2_function(void *data){while (!kthread_should_stop()) {// 获取 sem2 down(&sem2);// 模拟一些操作// ...// 尝试获取 sem1,但此时 thread1 持有 sem1,且 thread1 也在等待 sem2,导致死锁 down(&sem1);// 释放 sem1 up(&sem1);// 释放 sem2 up(&sem2);// 休眠一段时间 msleep(100); }return0;}intinit_module(void){ sema_init(&sem1, 1); sema_init(&sem2, 1); thread1 = kthread_run(thread1_function, NULL, "thread1");if (IS_ERR(thread1)) { printk(KERN_ERR "Failed to create thread1\n");return PTR_ERR(thread1); } thread2 = kthread_run(thread2_function, NULL, "thread2");if (IS_ERR(thread2)) { printk(KERN_ERR "Failed to create thread2\n"); kthread_stop(thread1);return PTR_ERR(thread2); }return0;}voidcleanup_module(void){ kthread_stop(thread1); kthread_stop(thread2);}MODULE_LICENSE("GPL");为了避免这种死锁情况,我们需要合理设计线程之间的资源获取和释放顺序,确保不会出现循环等待的情况。比如,可以规定所有线程按照相同的顺序获取信号量,先获取 sem1,再获取 sem2,这样就能有效避免死锁的发生。
六、kswapd 内存回收异常排查
面试题写作模版某嵌入式 Linux 系统(内核版本 5.10,ARM 架构),运行过程中频繁出现 “内存分配卡顿” 现象,进程偶尔阻塞数秒,查看系统日志发现大量 direct reclaim 相关打印(page allocation failure: order:2, mode:0x2040dc0)。经初步排查,判断是 kswapd 后台内存回收未正常工作,导致系统内存水位低于 WMARK_MIN,触发阻塞式直接内存回收,影响系统稳定性。
系统空闲内存持续走低,即使无高内存占用进程,也未触发 kswapd 主动回收;进程调用 kmalloc 分配内存时,偶尔阻塞 3-5 秒,日志打印直接内存回收信息;查看 kswapd 线程状态,发现其长期处于休眠状态,未被正常唤醒;查看内存水位线,发现空闲内存已低于 WMARK_LOW,但 kswapd 未启动回收。
首先通过系统命令,确认 kswapd 状态和内存水位,定位异常核心:
# 查看 kswapd 线程状态(每个内存 zone 对应一个 kswapd)ps aux | grep kswapd# 查看系统内存水位线cat /proc/zoneinfo | grep -A10 "DMA" | grep -E "min|low|high"通过调试发现,该系统内核定制时,误修改了 zone_watermark_ok 函数的水位判断逻辑,放宽了水位校验条件,导致即使空闲内存低于 WMARK_LOW,也无法触发 kswapd 唤醒,最终触发直接内存回收,导致进程阻塞。修复内核代码,还原正确的水位判断逻辑即可解决问题:
// 修复后的 zone_watermark_ok 函数staticintzone_watermark_ok(struct zone *zone, unsigned int order, unsigned long mark,int classzone_idx, int alloc_flags){ unsigned long free = zone_page_state(zone, NR_FREE_PAGES); unsigned long cma_free = zone_page_state(zone, NR_FREE_CMA_PAGES); free += cma_free;// 恢复标准水位判定规则if (free >= mark + (1UL << order) - 1)return1;return0;}重新编译烧写内核后,可通过命令持续监控内存水位与 kswapd 运行状态:
watch -n 1"cat /proc/meminfo | grep MemFree; ps aux | grep kswapd"正常运行时,空闲内存低于 WMARK_LOW 会自动唤醒 kswapd 执行后台回收,内存回升至 WMARK_HIGH 后 kswapd 自动休眠,系统不再出现 direct reclaim 日志,内存分配无卡顿阻塞现象。
本案例核心是 kswapd 唤醒逻辑异常,因内核定制修改导致水位判断错误,无法触发后台内存回收,进而引发阻塞式直接内存回收。kswapd 的核心作用是维持系统内存水位稳定在安全区间,依靠三条内存水位线做阈值判定,保障内存分配效率。排查这类内存卡顿问题时,可借助 /proc/zoneinfo、/proc/meminfo 查看内存基线,结合内核源码水位检测与 kswapd 唤醒流程定位根因,整套命令与代码逻辑可直接用于嵌入式 Linux 线上内存异常排查与内核学习。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐