文章目录
Linux内核设计与实现第三章学习笔记
读前先知
- 水平有限,基本照搬原书
- 在文中看到笔者,表示是我的一些拙见之类的
- 如有错误,还望指出
进程
进程就是处于执行期的程序,通常进程还包括一些资源:打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址空间及一个或多个执行线程、存放全局变量的数据段等
实际上,进程就是正在执行的程序代码的实时结果,程序本身并不是进程,进程是处于执行期间的程序以及相关的 资源的总称
执行线程:是在进程中活动的对象
。每个线程都有独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,不是进程。Linux系统线程的实现非常特别:对于进程和线程并不特别区分,对于Linux而言,线程只不过是一种特殊的进程罢了
在Linux系统中,通常调用fork系统调用复制一个现有进程来创建一个全新的进程。调用fork的进程称之为父进程,新产生的进程称之为子进程。该调用结束时,返回两次
,一次回到父进程,一次回到新产生的子进程。现代Linux系统中,fork系统调用实际上是由clone来实现的
最终进程通过exit()系统调用结束,该函数会终结进程并把占用的资源释放掉,父进程可以使用wait查看子进程是否死亡。进程退出后被设置为僵死状态,直到父进程调用wait()或者waitpid()为止
特别的
:Linux内核通常把进程也叫任务(task)
进程描述符及任务结构
内核把进程的列表存放在任务队列的双向循环链表中。链表中的每一项都是类型为task_struct
,称之为进程描述符的结构,该结构定义在<linux/sched.h>中。进程描述符包含了一个具体进程的所有信息,如一些常用的:
- 打开的文件
- 进程的地址空间
- 挂起的信号
- 进程的状态
分配进程描述符
Linux系统通过slab分配器
分配task_struct结构,这样能达到对象复用和缓存着色的目的,2.6内核之前,进程的task_struct被放置在内核栈的末端,这样做是为了快速计算出task_struct地址,笔者记:esp寄存器指向内核堆栈中,当屏蔽掉esp的低13位后,就得到这个两个连续的物理页面
的开头,而这个开头正好是task_struct的开始,从而得到了指向task_struct的指针。
对于现在使用slab分配器动态生成task_struct,只需要在栈底或栈顶创建一个struct thread_info,该结构体里有一个指向进程描述符的指针,在x86上,struct thread_info定义在文件<asm/thread_info.h>中,定义如下:
struct thread_info {
struct task_struct *task; /* main task structure */
struct exec_domain *exec_domain; /* execution domain */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable,
<0 => BUG */
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp; /* ESP of the previous stack in
case of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};
每个任务的thread_info结构在它的内核栈的尾端分配。结构中的task域中存放的是指向该任务实际的task_struct的指针
进程描述符的存放
内核通过唯一的进程标识pid来标识每个进程。pid为pid_t类型,实际为int类型,默认最大值为32768。内核把每个进程的pid存放在它们各自的进程描述符中
这个最大值非常重要,因为它实际就是一个系统允许同时存在的进程的最大数目,这个值越小,转一圈就越快,本来数值大的进程比数值小的进程迟运行,但这样依赖就破坏了这一原则。
在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current
宏查找到当前正在运行进程的进程描述符的速度就尤其重要。x86体系因为寄存器数量不富裕,因此都是通过`屏蔽掉esp的低13位,计算出thread_info的偏移。该操作是通过current_thread_info()函数完成的,代码如下:
static inline struct thread_info *current_thread_info(void)
{
// THREAD_SIZE可能是8KB也可能是4KB
register unsigned long sp asm ("sp");
return (struct thread_info *)(sp & ~(THREAD_SIZE - 1));
}
// 汇编代码
movl $-8192, %eax
andl %esp, %eax
这里假定栈的大小为8KB,当4KB的栈被启用的时候,就要用4096
最后,current再从thread_info的task域中提取并返回task_struct的地址,代码如下:
current_thread_info()->task;
进程的状态
进程描述符的state
描述了进程的当前状态,进程的状态如下:
- TASK_RUNNING:运行态,正在运行或者在运行队列中
等待执行
- TASK_INTERRUPTIBLE:可中断态,进程被阻塞,等待某些条件达成,内核会把进程状态设置为运行。处于此状态的进程也会因为接收到
信号
提前被唤醒并随时准备投入运行 - TASK_UNINTERRUPTIBLE:不可中断态,这个状态和可打断状态相同,但是接收到信号也不会被唤醒或者投入运行
- __TASK_TRACED:被其他进程跟踪的进程,例如通过
ptrace
对调试程序进行跟踪 - __TASK_STOPPED:终止态,进程停止执行,没有投入运行也不能投入运行。通常这种状态发生在接收到
SIGSTOP
、SIGTSTP
、SIGTTIN
、SIGTTOU
等信号的时候。此外,调试期间收到任何信息也会使进程进入这种状态
设置当前进程状态
内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, state)函数,该函数将指定的进程设置为指定的状态。必要的时候,它会设置内存屏障
来强制其他处理器重新排序,实现代码等价于:
task->state = state;
set_current_state(state)的功能的set_task_state(task, state)是相同的
进程家族树
Linux进程之间存在明显的继承关系,所有的进程都是PID为1的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其他相关程序,最终完成系统启动的整个过程
系统中的每个进程必然有一个父进程,相应的,每个进程可以拥有任意个子进程。拥有同一个父进程的所有进程称之为兄弟进程。进程间的关系存放在进程描述符里。每个task_struct都包含一个指向父进程的进程描述符的指针(parent)。还包含一个称之为children的子进程链表,所以,对于当前进程,可以通过以下的代码获得其父进程的进程描述符:
struct task_sturct *my_parent = current->parent;
同样的,可以按照以下方式依次访问子进程:
struct task_struct* task;
struct list_head* list;
list_for_each(list, ¤t->children){
task = list_entry(list, struct task_struct, sibling);
}
init进程的进程描述符是作为init_task静态分配的。
// 查找init_task
struct task_struct *task;
for (task == current; task != &init_task; task = task->parent)
如此可以看出,可以从这种继承体系和双向链表中查找到你需要的任何一个进程,如:
// 给定一个进程,查找到下一个进程
list_entry(task->tasks.next, struct task_struct, tasks);
// 给定一个进程,查找到前一个进程
list_entry(task->tasks.prev, struct task_struct, tasks);
进程创建
fork()通过拷贝
当前进程创建一个子进程,子进程与父进程的区别仅仅在于
- PID:每个进程唯一
- PPID
- 某些资源和统计量:挂起的信号
exec()函数负责读取可执行文件并将其载入地址空间开始运行。
写时拷贝
传统的fork直接复制所有资源,如果子进程立马调用exev族函数,那么之前的拷贝就前功尽弃,所以才有了写时复制。写时拷贝:是一种推迟甚至避免拷贝数据的技术,内核并不会复制整个进程地址空间,而是让父子进程共享同一个拷贝
只有真正需要写入的时候才会复制,从而使各个进程拥有各自的拷贝,也就是说资源的复制只有真正需要写入的时候才进行,在此之前都只是以只读方式共享。也就是说,如果子进程立马执行exec族函数,那就没必要复制资源了,所以fork()的实际开销只有复制父进程的页表以及子进程创建唯一的进程描述符
fork
Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父子进程需要共享的资源。fork()、vfork()和__clone()根据各自的需要的参数标志去调用clone(),然后由clone()调用do_fork()
do_fork()在kernel/fork.c文件里,该函数调用copy_process()函数,copy_process()函数的工作内容:
- 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这个时候的子进程还是和父进程一模一样的
- 检查并确保子进程被创建后,当前用户所拥有的进程数目没有超过资源的限制
- 子进程着手把自己和父进程区分开来,进程描述符内的许多成员都要被清0或设置为初始值
- 子进程的状态被设置为TASK_UNINTERRUPTIBLE,确保子进程不会被投入运行
- copy_process()函数调用copy_flags()以更新task_struct的flags成员。这时候把进程是否拥有超级用户权限的F_SUPERPRIV标志被清0,表明进程还没有执行exev()函数的PF_FORKNOEXEC标志被设置
- 调用alloc_pid()为新进程分配一个有效的PID
- 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间
- 最后,返回一个指向子进程的指针
再回到do_fork()函数,如果copy_process函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程先运行,因为子进程一般都是用于执行exec()函数,这样可以避免写时复制的额外开销
vfork()
除了不拷贝父进程的页表项之外,vfork()和fork()功能相同。子进程作为父进程的一个单独线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或者执行exec()。子进程不能向地址空间写入。
vfork()通过向clone()系统调用传递一个特殊的标志来进行的
- 调用copy_process(),task_struct的vfor_done成员被设置为NULL
- 在执行do_fork(),如果给定特别标志,则vfork_done会指向一个特定的地址
- 子进程先开始指向后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号
- 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,不为空则会向父进程发送信号
- 回到do_fork(),父进程醒来并返回
线程在Linux中的实现
Linux实现线程的机制非常独特,懂内核的角度来说,它并没有线程的概念。Linux把所有线程都当作是进程来实现。内核并没有准备特别的调度算法或者是定义特别的数据结构来表征线程。线程仅仅被视为一个与其他进程共享某些资源的进程,每个线程都有唯一隶属于自己的task_struct,因此在内核看来,线程就是一个普通的进程。其他操作系统一般把线程称之为轻量级进程
。而对于Linux来说,线程只是进程之间共享资源
的手段(Linux的进程本身就已经够轻量级了)
假设有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个指向四个线程的指针的进程描述符,该描述符负责描述像地址空间、打开的文件这样的共享资源,线程再去独占它的资源
在Linux中则是创建四个进程并分配四个task_struct结构,建立这四个进程时指定它们共享某些资源
创建线程
线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源
clone(CLONE_VM | CLONE_FS | CLONE_FIILES | CLONE_SIGHAND, 0);
上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。换个说法就是新建的进程和它的父进程就是所谓的线程
对比一下,普通的fork()则是
clone(SIGCHLD, 0);
而vfork则是:
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
传递给clone()的参数标志决定了新创建的进程的行为方式和父子进程之间共享的资源种类。这些标志在<linux/sched.h>中定义
/*
* cloning flags:
*/
#define CSIGNAL 0x000000ff /* signal mask to be sent at exit */
#define CLONE_VM 0x00000100 /* 父子进程共享地址空间 */
#define CLONE_FS 0x00000200 /* 父子进程共享文件系统信息 */
#define CLONE_FILES 0x00000400 /* 父子进程共享打开的文件 */
#define CLONE_SIGHAND 0x00000800 /* 父子进程共享信号处理函数以及被阻断的信号 */
#define CLONE_PIDFD 0x00001000 /* set if a pidfd should be placed in parent */
#define CLONE_PTRACE 0x00002000 /* 继续调试子进程 */
#define CLONE_VFORK 0x00004000 /* 准备调用vfork,所以父进程准备睡眠等待子进程将其唤醒 */
#define CLONE_PARENT 0x00008000 /* 指定父子进程拥有同一个父进程 */
#define CLONE_THREAD 0x00010000 /* Same thread group? */
#define CLONE_NEWNS 0x00020000 /* 为子进程创建新的命名空间 */
#define CLONE_SYSVSEM 0x00040000 /* share system V SEM_UNDO semantics */
#define CLONE_SETTLS 0x00080000 /* 为子进程创建新的TLS */
#define CLONE_PARENT_SETTID 0x00100000 /* set the TID in the parent */
#define CLONE_CHILD_CLEARTID 0x00200000 /* clear the TID in the child */
#define CLONE_DETACHED 0x00400000 /* Unused, ignored */
#define CLONE_UNTRACED 0x00800000 /* set if the tracing process can't force CLONE_PTRACE on this clone */
#define CLONE_CHILD_SETTID 0x01000000 /* set the TID in the child */
#define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */
#define CLONE_NEWUTS 0x04000000 /* New utsname namespace */
#define CLONE_NEWIPC 0x08000000 /* New ipc namespace */
#define CLONE_NEWUSER 0x10000000 /* New user namespace */
#define CLONE_NEWPID 0x20000000 /* New pid namespace */
#define CLONE_NEWNET 0x40000000 /* New network namespace */
#define CLONE_IO 0x80000000 /* Clone io context */
/* Flags for the clone3() syscall. */
#define CLONE_CLEAR_SIGHAND 0x100000000ULL /* Clear any signal handler and reset to SIG_DFL. */
#define CLONE_INTO_CGROUP 0x200000000ULL /* Clone into a specific cgroup given the right permissions. */
/*
* cloning flags intersect with CSIGNAL so can be used with unshare and clone3
* syscalls only:
*/
#define CLONE_NEWTIME 0x00000080 /* New time namespace */
内核线程
内核线程:独立运行在内核空间的标准进程
内核线程和普通的进程的区别在于内核线程没有独立的地址空间(mm指针 = NULL),它们只在内核空间运行,从不切换到用户态运行。内核线程和普通进程一样可以被调度,也可以被抢占
内核线程只能被其他内核线程创建,从现有内核线程中创建一个新的内核线程的方法如下:
struct task_struct *kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...)
新的任务是由kthread内核进程通过clone()系统调用创建的,新的进程将运行threadfn()函数,给其传递的参数为data。进程会被命名为namefmt,namefmt接受可变参列表格式化参数。新创建的进程处于不可运行状态,如果不通过调用wake_up_process()明确地唤醒它,它不会主动运行。
创建一个进程并让它运行起来,可以调用kthread_run()
#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; \
})
其实只是简单的调用kthread_create()和wake_up_process()
内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出传递给kthread_create()函数返回的task_struct地址
int kthread_stop(struct task_struct *k)
进程终结
一般来说,进程的析构是自身引起的。它发生在进程调用exit()系统调用时,也可能从某个程序的主函数返回。当进程接受到它不能处理也不能忽略的信号或异常时,它还可能被动地结束,不管进程是如何结束的,该任务大部分都要靠do_exit()来完成,do_exit()完成以下繁琐的工作
- 将task_struct中的标志成员设置为PF_EXITING
- 调用del_timer_sync()函数任一内核定时器,确保没有定时器在排队,也没有定时器处理程序在运行
- 如果BSD的进程记账功能是开启的,do_exit()调用acct_up_date_intefrals()来输出记账信息
- 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们,就彻底释放它们
- 接下来调用sem_exit()函数,如果进程在排队等候IPC信号,则它离开队列
- 调用exit_files()和exit_fs(),分别递减文件描述符、文件系统的引用计数。如果没有引用计数的数值变为0,则表示此时没有其他进程在使用响应的资源,此时可以释放
- 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他内核机制规定的退出动作。退出代码存放在这里供父进程随时检索
- 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者init进程,并把进程状态(存放在task_struct结构的exit_state)设置为EXIT_ZOMBIE
- do_exit()调用schedule()切换到新的进程。因此处于EXIT_ZOMBIE状态的进程不会再被调度,所以这是进程最后一段代码。
do_exit()
永不返回
至此,进程相关联的所有资源都被释放掉了,进程处于不可运行的EXIT_ZOMBIL状态,也没有地址空间给它运行。此时还占用的只有内核栈、thread_info结构和task_struct结构。
它现在还存在的唯一目的就是向它的父进程提供信息,随后它剩余的资源全部被释放
删除进程描述符
调用了do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。父进程可以通过wait()来获取子进程的PID,此外,调用该函数时提供的指针还会包含子函数退出时的退出代码
当最终需要释放进程描述符的时候,release_task()函数被调用,用以完成以下工作
- 它调用__exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid(),从pidhash上删除该进程,同时也要从任务列表中删除该进程
- __exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录
- 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程
- release_task()调用put_task_struct()释放进程内核栈和thread_info所占的页,并释放task_struct占用的slab高速缓存
至此,进程描述符和所有进程独享的资源就全部被释放掉了
孤儿进程
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会退出的时候永远处于僵尸态,白白的占用资源。
这个问题解决之法便是:在子进程当前的线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程,do_exit()中会调用exit_notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来进行寻父之旅
以下代码位于kernel/exit.c中
/*
* When we die, we re-parent all our children.
* Try to give them to another thread in our thread
* group, and if no such member exists, give it to
* the child reaper process (ie "init") in our pid
* space.
*/
static struct task_struct *find_new_reaper(struct task_struct *father)
__releases(&tasklist_lock)
__acquires(&tasklist_lock)
{
struct pid_namespace *pid_ns = task_active_pid_ns(father);
struct task_struct *thread;
thread = father;
while_each_thread(father, thread) {
if (thread->flags & PF_EXITING)
continue;
if (unlikely(pid_ns->child_reaper == father))
pid_ns->child_reaper = thread;
return thread;
}
if (unlikely(pid_ns->child_reaper == father)) {
write_unlock_irq(&tasklist_lock);
if (unlikely(pid_ns == &init_pid_ns))
panic("Attempted to kill init!");
zap_pid_ns_processes(pid_ns);
write_lock_irq(&tasklist_lock);
/*
* We can not clear ->child_reaper or leave it alone.
* There may by stealth EXIT_DEAD tasks on ->children,
* forget_original_parent() must move them somewhere.
*/
pid_ns->child_reaper = init_pid_ns.child_reaper;
}
return pid_ns->child_reaper;
}
以上代码试图找到子进程所在线程组内的其他进程,如果线程组内没有其他进程,它找到并返回的是init进程,那么这个时候只需要遍历所有的子进程给它们设置新的父进程,于是乎
reaper = find_new_reaper(father);
list_for_each_entry(p, &father->children, sibling) {
for_each_thread(p, t) {
t->real_parent = reaper;
BUG_ON((!t->ptrace) != (t->parent == father));
if (likely(!t->ptrace))
t->parent = t->real_parent;
以上代码是说:A进程(father)死了,那么A进程的所有子进程(father->children)都变成了孤儿进程,因此调用find_new_reaper找到养父(reaper,find_new_reaper()返回的时候,reaper已经被赋值为init进程),然后遍历A进程的子进程,把它们都过继给init进程
碍于笔者学识有限,学习笔记写的实在拙劣,这一部分强烈建议去参考一下大佬的文章,进程托孤
笔者注:
reaper:收割者,这里的意思是给子进程找一个收尸的进程
第三章小结
本章讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系,然后讨论了Linux如何存放和表示进程(task_struct和thread_info),如何创建进程,如何把新的执行映像装入地址空间,父进程如何收集后代的信息,进程最终如何消亡
笔者后续会不断更新学习笔记,喜欢可以关注一下~