《Linux内核设计与实现》读书笔记——进程
发现读书不记笔记还是不行,过两天就忘得差不多了…最近在看《Linux内核设计与实现》,决定记些笔记。我可能有讲错的地方,一切以书上的讲解以及Linux 2.6.X源码为准 (逃
进程
进程包括执行的代码,数据,打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间以及一个或多个执行线程。实际上,进程就是正在执行的程序代码的实时结果。
执行线程,简称线程,是进程中活动的独享。每个线程有一个独立的程序计数器、进程栈和一组进程寄存器。Linux对进程和线程并不特别区分,线程只不过是一种特殊的进程。调度的对象是线程。
熟悉Unix/Linux系统编程的童鞋都知道,fork()
函数通过复制一个已有的进程来创建一个全新的进程。然后可能会用exec()
函数创建新的地址空间,然后把新的程序载入其中。最终通过exit()
系统调用退出执行,并把占有的资源释放。父进程可以通过wait4()
系统调用查询子进程是否终结。进程退出后被设置为僵死状态,直到父进程调用wait()
或者waitid()
为止。
需要注意的是,Linux上实现fork()
是用的clone()
系统调用。
进程描述符及任务结构
内核把进程的列表放在叫做 task list
的双向循环链表中。链表中的每个项目类型为 task_struct
,称为进程描述符(process descriptor)的结构。进程描述符中包含一个具体进程的所有信息,包括:打开的文件、进程的地址空间、挂起的信号、进程的状态,还有其他的信息。
分配进程描述符
Linux 通过 slab
分配器分配 task_struct
结构,能达到对象复用和缓存着色(cache coloring)的目的。在内核栈的栈底(当然,这是对于向下增长的栈来说)创建爱你一个新的结构 struct thread_info
,
在X86上,thread_info
定义如下:
1 | struct thread_info |
struct task_struct *task
是指向该任务实际 task_struct
的指针。
进程描述符的存放
内核通过一个唯一的 PID
来标识每个进程。PID是一个数字,为 pid_t
类型。
在内核中,访问任务通常需要通过 current
宏获得指向其 task_struct
的指针。current
针对专门的硬件体系结构有不同的定义。在X86上,current
把栈指针的后13个有效位屏蔽掉,用来计算出 thread_info
的偏移。该操作是通过 current_thread_info()
函数来完成的。然后从得到的 thread_info
的 task
域中提取并返回 task_struct
的地址: current_thread_info()->task
。
进程状态
进程描述符中的 state
域描述了进程的当前状态。
1 | /* |
可以看到,state
的状态有五个:
- TASK_RUNNING(运行): 进程是可执行的。它正在执行,或者在运行队列中等待执行。
- TASK_INTERRUPTIBLE(可中断): 进程正在睡眠,也就是被阻塞,等待某些条件达成。一旦这些条件达成,内核就会把进程的状态设为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并素食准备投入运行。
- TASK_UNINTERRUPTIBLE(不可中断):除了就算是接收信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。
- __TASK_STOPPED(停止):进程停止执行。通常发生在接收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU等信号的时候。
- __TASK_TRACED:被其他进程跟踪的进程。
设置当前进程状态
一般通过 set_task_state(task, state)
这个函数。set_current_state(state)
和 set_task_state(current, state)
含义是等同的。
进程上下文
可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当执行了系统调用或者出发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。此时 current
宏是有效的。
进程家族树
Linux中进程有明显的继承关系,所有的进程都是 PID 为 1 的 init
进程的后代。内核在启动的最后阶段启动 init
进程。该进程读取系统的初始化脚本(initscript)并执行其他的相关程序。
每个进程都有一个父进程,零个或多个子进程。每个 task_struct
都包含一个指向其父进程叫做 parent
的指针,还包含一个称为 children
的子进程链表。
对于当前进程,可以通过下面的代码获得其父进程的进程描述符:
1 | struct task_struct *my_parent = current->parent; |
同样,也可以按下面的方式依次访问子进程:
1 | struct task_struct *task; |
init
进程的描述符是作为 init_task
静态分配的。向上遍历的方法如下:
1 | struct task_struct *task; |
可以通过这个树型结构,从任何一个进程出发,查找任意指定的其他进程。但很多时候,只需要通过简单的遍历,因为任务队列本来就是一个双向的循环链表。
对于给定的进程,获取链表的下一个进程:
1 | list_entry(task->tasks.next, struct task_struct, tasks) |
获取前一个进程:
1 | list_entry(task->tasks.prev, struct task_struct, tasks) |
for_each_process(task)
宏提供了依次访问整个任务队列的能力。
1 | struct task_struct *task; |
进程创建
Unix 通过fork()
拷贝当前进程创建一个子进程,然后通过exec()
函数负责读取可执行文件并将其载入地址空间开始运行。
写时拷贝
Linux的fork()
采用写时拷贝(copy-on-write)页实现。写时拷贝是一种推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父进程和子进程共享一个拷贝。只有在需要写入的时候,数据才会被复制。这种技术使得地址空间上页的拷贝被推迟到实际发生写入的时候才进行。fork()
的实际开销就是赋值父进程的页表以及给子进程创建唯一的进程描述符。
fork()
Linux通过clone()
系统调用实现fork()
。这个调用通过一系列的参数标志来指明父子进程需要共享的资源。fork()
、 vfork()
、 __clone()
库函数都根据各自需要的参数去调用clone()
,然后由clone()
去调用do_fork()
。
do_fork()
完成了创建进程中的大部分工作。该函数调用copy_process()
函数,然后让进程开始运行。
copy_process()
完成的工作:
调用
dup_task_struct()
为新进程创建一个内核栈、thread_info
结构和task_struct
。这些值与当前进程值相同。检查并确保新创建子进程后,当前用户所拥有的进程数没有超出给它分配的资源的限制。
子进程着手使自己与父进程区别开来。
task_struct
内的许多成员都清0或者设置为初始值。大多数据仍然未修改。子进程的状态被设置为
TASK_UNINTERRUPTIBLE
,以保证不会投入运行。调用
copy_flags()
以更新task_struct
的flags
成员。调用
alloc_pid
为新进程分配一个有效的PID。根据传给
clone
的参数标志,copy_process()
拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。最后返回一个指向子进程的指针。
回到do_fork()
函数,如果copy_process()
函数返回成功,新创建的子进程被唤醒并让其投入运行。
vfork()
除了不拷贝父进程的页表项外,vfork()
和fork()
的功能相同。子进程作为父进程的一个单独的线程再它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()
。子进程不能向地址空间写入。由于fork()
是写时复制并明确子进程先执行,所以vfork()
的好处就只限于不拷贝父进程的页表项了。
线程在Linux中的实现
Linux从内核的角度讲,没有线程的概念。线程被视作一个与其他进程共享某些资源的的进程。每个线程都有一个属于自己的task_struct
,所以在内核中,它看起来就像一个普通的进程,只是和其他task会共享某些资源,比如地址空间。
创建线程
线程的创建和普通进程的创建类似,只不过在调用clone()
的时候参数需要传递一些参数标志来指明需要共享的资源:
1 | clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0); |
新创建的task和他的父亲共享地址空间、文件系统资源、文件描述符和信号处理程序。换言之,新创建的task是父进程的一个线程。
对应的,fork()
的实现是:
1 | clone(SIGCHLD, 0); |
vfork()
的实现是:
1 | clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0); |
内核线程
内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成————独立运行在内核空间的标准进程。和普通进程的区别在于没有独立的地址空间。
进程终结
当一个进程结束时,内核会释放它占有的资源并给父进程发送信号。
进程的终止有可能是自身引起的,比如调用exit()
系统调用,既可能显示调用,也可能隐式地从某个程序的主函数返回;也可能是被动地终结,比如进程接收到既不能处理又不能忽略的信号或异常。
大部分任务需要靠do_exit()
来完成。至此进程不可运行,并处于EIXT_ZOMBIE
退出状态,它所占有的所有内存就是内核栈、thread_info
结构和task_struct
结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息或者通知内核那是无关的信息后,进程所持有的剩余内核被释放,归还给系统使用。
删除进程描述符
wait()
这一族的函数都是通过唯一的一个系统调用wait4()
来实现的。它的标准动作是挂起调用它的进程,直到其中一个子进程退出,此函数会返回该子进程的PID。
最终释放进程描述符时,release_task()
会被调用:
- 它调用
__exit_signal()
,该函数调用__unhash_process()
,后者又调用detach_pid()
从pidhash
上删除该进程,同时从任务列表中删除该进程。 __exit_signal()
释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。- 如果这个进程是进程组最后一个进程,并且领头进程已经死亡,那么通知僵死的领头进程的父进程。
- 调用
put_task_struct()
释放掉进程内核栈和thread_info
结构所占的页,并释放task_struct
所占的slab高速缓存
至此,进程描述符和所有的进程独享的资源就被全部释放掉了。
孤儿进程
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些子进程就变成了孤儿进程,在退出时永远处于僵死状态。解决方法就是给子进程在当前进程组内找一个线程作为父亲;如果不行,就让init
做它们的父进程。在do_exit()
会调用exit_notify()
,该函数会调用forget_original_parent()
,而后者会调用find_new_reaper()
来执行寻找父进程。