Linux 内核可以看作一个服务进程 (管理软硬件资源,响应用户进程的种种合理以及不合理的请求)。

这是因为内核需要并行多个执行流,需要防止阻塞。内核线程可以理解为内核的分身,一个分身处理一项特定任务,内核线程调度由内核负责,一个内核线程阻塞不影响其他内核线程, 内核线程是调度的基本单位

作为对比,用户线程可以运行在用户态和内核态,内核线程只在内核态。

它只是用大于 PAGE_OFFSET 的地址空间。

Linux 的进程和线程

对于 Linux 来讲,所有的线程都当作进程来实现,因为没有单独为线程定义特定的调度算法,也没有单独为线程定义特定的数据结构(所有的线程或进程的核心数据结构都是 task_struct)。

对于一个进程,相当于是它含有一个线程,就是它自身。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。

进程拥有自己的地址空间,所以每个进程都有自己的页表。而线程却没有,只能和其它线程共享某一个地址空间和同一份页表。这个区别的 根本原因 是,在进程 / 线程创建时,因是否拷贝当前进程的地址空间还是共享当前进程的地址空间,而使得指定的参数不同而导致的。

具体地说,进程和线程的创建都是执行 clone 系统调用进行的。而 clone 系统调用会执行 do_fork 内核函数,而它则又会调用 copy_process 内核函数来完成。主要包括如下操作:

在调用 copy_process 的过程中,会创建并拷贝当前进程的 task_stuct,同时还会创建属于子进程的 thread_info 结构以及内核栈。
此后,会为创建好的 task_stuct 指定一个新的 pid(在 task_struct 结构体中)。
然后根据传递给 clone 的参数标志,来选择拷贝还是共享打开的文件,文件系统信息,信号处理函数,进程地址空间等。这就是进程和线程不一样地方的本质所在。

task_struct

每个进程或线程都有三个数据结构,分别是 struct thread_info, struct task_struct 和 内核栈。

注意,虽然线程与主线程共享地址空间,但是线程也是有自己独立的内核栈的。

thread_info 对象中存放的进程 / 线程的基本信息,它和这个进程 / 线程的内核栈存放在内核空间里的一段 2 倍页长的空间中。其中 thread_info 结构存放在低地址段的末尾,其余空间用作内核栈。内核使用 伙伴系统 为每个进程 / 线程分配这块空间。

thread_info 结构体中有一个 struct task_struct *task,task 指向的就是这个进程或线程相关的 task_struct 对象(也在内核空间中),这个对象叫做进程描述符(叫做任务描述符更为贴切,因为每个线程也都有自己的 task_struct)。内核使用 slab 分配器为每个进程 / 线程分配这块空间。

2018-01-10-1

内核线程

内核线程是直接由内核本身启动的进程。 内核线程实际上是将内核函数委托给独立的进程 ,它与内核中的其他进程并行执行。内核线程经常被称之为内核守护进程。 所有的内核线程共享内核地址空间 (对于 32 位系统来说,就是 3-4GB 的虚拟地址空间),所以也共享同一份内核页表。这也是为什么叫内核线程,而不叫内核进程的原因。

内核线程执行的任务有:

  • 周期性地将修改的内存页与页来源块设备同步
  • 如果内存页很少使用,则写入交换区
  • 管理延时动作, 如2号进程接手内核进程的创建
  • 实现文件系统的事务日志

内核线程主要有两种类型:

  1. 线程启动后一直等待,直至内核请求线程执行某一特定操作。

  2. 线程启动后按周期性间隔运行,检测特定资源的使用,在用量超出或低于预置的限制时采取行动。

内核线程由内核自身生成,其特点在于

  1. 它们在 CPU 的管态执行,而不是用户态。
  2. 它们只可以访问虚拟地址空间的内核部分(高于 TASK_SIZE 的所有地址),但不能访问用户空间

系统在正式启动内核时,会执行 start_kernel 函数。在这个函数中,会自动创建一个进程,名为 init_task。其 PID 为 0,运行在内核态中。然后开始执行一系列初始化。

init 内核线程

init_task 在执行 rest_init 函数时,会执行 kernel_thread 创建 init 内核线程。它的 PID 为 1,用来完成内核空间初始化。

在内核空间完成初始化后,会调用 exceve 执行 init 可执行程序 (/sbin/init)。之后,init 内核线程变成了一个普通的进程,运行在用户空间中。

init 内核线程没有地址空间,且它的 task_struct 对象中的 mm 为 NULL。因此,执行 exceve 会使这个 mm 指向一个 mm_struct,而不会影响到 init_task 进程的地址空间。 也正因为此,init 在转变为进程后,其 PID 没变,仍为 1。

创建完 init 内核线程后,init_task 进程演变为 idle 进程(PID 仍为 0)。

之后,init 进程再根据再启动其它系统进程 (/etc/init.d 目录下的各个可执行文件)。

kthreadd 内核线程

init_task 进程演变为 idle 进程后,idle 进程会执行 kernel_thread 来创建 kthreadd 内核线程(仍然在 rest_init 函数中)。它的 PID 为 2,用来创建并管理其它内核线程(用 kthread_create, kthread_run, kthread_stop 等内核函数)。

系统中有很多内核守护进程 (线程),可以通过:

1
ps -efj

进行查看,其中带有 [] 号的就属于内核守护进程。它们的祖先都是这个 kthreadd 内核线程。

主内核页全局目录

内核维持着一组自己使用的页表,也即主内核页全局目录。当内核在初始化完成后,其存放在 swapper_pg_dir 中,而且所有的普通进程和内核线程就不再使用它了。

内核线程访问页表

active_mm

对于内核线程,虽然它的 task_struct 中的 mm 为 NULL,但是它仍然需要访问内核空间,因此需要知道关于内核空间映射到物理内存的页表。然而不再使用 swapper_pg_dir,因此只能另外想法解决。

由于所有的普通进程的页全局目录中的后面部分为主内核页全局目录,因此内核线程只需要使用某个普通进程的页全局目录就可以了。

在 Linux 中,task_struct 中还有一个很重要的元素为 active_mm,它主要就是用于内核线程访问主内核页全局目录。

对于普通进程来说,task_struct 中的 mmactive_mm 指向的是同一片区域; 然而对内核线程来说,task_struct 中的 mm 为 NULL,active_mm 指向的是前一个普通进程的 mm_struct 对象。

mm_users/mm_count

但是这样还是不行,因为如果因为前一个普通进程退出了而导致它的 mm_struct 对象也被释放了,则内核线程就访问不到了。

为此,mm_struct 对象维护了一个计数器 mm_count,专门用来对引用这个 mm_struct 对象的自身及内核线程进行计数。初始时为 1,表示普通进程本身引用了它自己的 mm_struct 对象。只有当这个引用计数为 0 时,才会真正释放这个 mm_struct 对象。

另外,mm_struct 中还定义了一个 mm_users 计数器,它主要是用来对共享地址空间的线程计数。事实上,就是这个主线程所在线程组中线程的总个数。初始时为 1。

内核线程的退出

线程一旦启动起来后,会一直运行,除非该线程主动调用 do_exit 函数,或者其他的进程调用 kthread_stop 函数,结束线程的运行。

1
int kthread_stop(struct task_struct *thread);

kthread_stop () 通过发送信号给线程。

如果线程函数正在处理一个非常重要的任务,它不会被中断的。当然如果线程函数永远不返回并且不检查信号,它将永远都不会停止。

在执行 kthread_stop 的时候,目标线程必须没有退出,否则会 Oops。原因很容易理解,当目标线程退出的时候,其对应的 task 结构也变得无效,kthread_stop 引用该无效 task 结构就会出错。

为了避免这种情况,需要确保线程没有退出,其方法如代码中所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
thread_func ()
{
//do your work here
//wait to exit
while(!thread_could_stop ())
{
wait ();
}
}

exit_code ()
{
kthread_stop (_task); // 发信号给 task,通知其可以退出了
}

这种退出机制很温和,一切尽在 thread_func () 的掌控之中,线程在退出时可以从容地释放资源,而不是莫名其妙地被人 “暗杀”。