3.1 进程
-
进程:进程是处于执行期的程序以及相关的资源总称。
- 相关资源:打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址空间、一个或多个执行线程、用来存放全局变量的数据段。
- 进程提供两种虚拟机制:虚拟处理器和虚拟内存。
- 虚拟处理器:给进程一些假象,让它们觉得自己在独享处理器。
- 虚拟内存:虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源。
💧同一进程中的线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。
-
fork()系统:
- 该系统调用通过复制一个现有进程来创建一个全新的进程;
- 调用fork()的进程成为父进程,新产生的进程成为子进程;
- 调用结束时,会返回到这个位置上,父进程恢复执行,子进程开始执行;
- fork()系统调用从内核返回两次:一次回到父进程,一次回到新产生的子进程。
-
🌂
通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。fork()实际上是由clone()系统调用实现的,后者将在后面讨论。
最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait4()系统调用检查子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或者waitpid()为止。 -
线程:
- 线程是在进程中活动的对象;
- 每个线程都有一个独立的程序计数器、进程栈和一组进程寄存器;
- 内核调度的对象是线程,而不是进程;
- 一个进程可以包含多个线程;
- Linux系统的线程实现非常特别:它对线程和进程并不特别区分,线程只不过是一种特别的进程罢了。
3.2 进程描述符及任务结构
内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符的结构。进程描述符中包含了一个进程的所有信息。
3.2.1 分配进程描述符(没太懂)
Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的。只需要再栈底(对于向下增长的栈来说)或者栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info。
每个任务的thread_info结构在它的内核栈尾端分配。结构中task域中存放的是指向该任务实际task_struct的指针。
3.2.3 进程描述符的存放
PID最大值为32768(short int),存放在进程描述符中。不考虑兼容的话,可以通过系统中以下节点来修改。
/proc/sys/kernel/pid_max
内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏🎈查找到正在运行进程的进程描述符的速度就显得尤为重要。
在x86体系中,current先找到thread_info,再从thread_info的task域中提取task_struct的地址;还有一种方法是将task_struct保存在一个寄存器中,这样current就可以直接访问了。
🎈 current宏,是一个全局指针,指向当前进程的struct task_struct结构体,即表示当前进程。
🎈 例如current->pid就能得到当前进程的pid,current-comm就能得到当前进程的名称。
3.2.4 进程状态
进程的状态存在进程描述符的state域里,总共有下面五种状态:
- TASK_RUNNING(运行)—— 进程是可执行的;它或者正在执行,或者在运行队列中等待执行(运行队列第四章讨论)。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。
- TASK_INTERRUPTIBLE(可中断)—— 进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置成运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。
- TASK_UNINTERRUPTIBLE(不可中断)—— 🎈接收到信号不会被唤醒,其他和可中断一样。这个状态用在进程在等待时不能受干扰时、等待的事件很快就会发生时。
- TASK_TRACED —— 被其他进程跟踪的进程。例如,通过ptrace对调试程序进行跟踪。
- _TASK_STOPPED(停止) —— 进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。
🎈这就是在执行ps(1)命令时,看到那些被标为D而又不能被杀死的进程的原因。因为此时它不接收SIGKELL信号。
3.2.4 设置当前进程状态
task->state
3.2.5 进程上下文
可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。
一般程序在用户空间执行,当一个程序调用了系统调用或者触发了某个异常,它就陷入了内核空间。此时我们称内核“代替进程执行”,此时内核便处于进程上下文中,在此上下文中current宏是有效的。
除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。
系统调用和异常处理是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。
3.2.6 进程家族树
Linux中所有进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本(initscript)并执行其他的相关程序,最终完成系统启动的整个过程。
对于当前进程,可以通过下面的代码获得其父进程的进程描述符:
struct task_struct *my_parent = current->parent;
同样,也可以通过以下方式依次访问子进程:
struct task_struct *task;
struct list_head *list;
list_for_each(list, ¤t->chuildren) {
task = list_entry(list, struct task_struct, sibling);
/*task现在指向当前的某个子进程*/
}
init进程的进程描述符是作为init_task静态分配的,下面代码可以很好的演示所有进程之间的关系:
struct task_struct *task;
for(task = current; task != &init_task; task = task->parent)
;
/*task现在指向init进程*/
我们也可以通过一个进程来访问任务队列中的任意进程,但没必要。
3.3 进程创建
Linux提供以下两个创建进程:
- fork():通过拷贝当前进程创建一个子进程。
- exec(): 负责读取可执行文件并将其载入地址空间开始运行。
这两个函数组合起来使用的效果和其他系统使用的单一函数效果相似。
子进程和父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的PID)和某些资源和统计量(例如,挂起的信号,它没有必要被继承)。
3.3.1 写时拷贝
- 传统的fork()机制:直接把所有的资源复制给新创建的进程;
这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟糕的是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。 - Linux的fork()机制:使用写时拷贝页实现。
- 写时拷贝:只有在需要写入的时候,数据才会被复制。
也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写入的情况下(例如,fork()后立即调用exec())他们就无需复制了。
- 写时拷贝:只有在需要写入的时候,数据才会被复制。
3.3.2 fork()
fork()–>clone()–>do_fork()–>copy_process()
copy_process():
- 调用dup_task_struct()为新进程创建一个内核栈、thread_info和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的进程描述符是完全相同的;
- 检查当前用户所拥有的进程数目有没有超出给它的分配的资源限制;
- 子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清零或设置为初始值。task_struct中的大多数数据都依然未被修改;
- 子进程的状态被设置为TASK_UNINTERRUPTIBLE,保证其不会投入运行;
- copy_process()调用copy_flags()以更新task_struct的flags成员;PF_SUPERPRIV标志被清零,表明没有超级用户权限;PE_FORKNOEXEC标志被设置,表示还没有调用exec()函数;
- 调用alloc_pid()为新进程分配一个有效的PID;
- 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;
- 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
3.3.3 vfork()
…
3.4 线程在LINUX中的实现
线程是抽象概念。从linux内核的角度来说,没有线程这个概念。每个线程都拥有一个隶属于自己的task_struct,所以在内核中,它看起来就像是一个进程(只是线程和其他一些进程共享某些资源,如地址空间等)。
3.4.1 创建线程
clone(CLONE_VM, CLONE_FS, CLONE_FILE | CLONE_SIGHAND, 0);
上面代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符、信号处理程序。
普通的fork():
clone(SIGCHLD, 0);
vfork():
clone(CLONE_VFORK, CLONE_VM, SIGCHLD, 0);
3.4.2 内核线程
内核线程和普通进程的区别在于内核线程没有独立的地址空间(指向地址空间的指针mm为NULL),它只在内核空间中运行,不切换到用户空间。
- 创建一个新的线程:
- threadfn:新线程要运行的函数
- data:传给threadfn的参数
- namefmt:线程名
struct task_struct *thread_create(int (*threadfn)(void *data), void *data, const char namefmt[], ...)
- 让线程运行起来:
wake_up_process();
- 创建一个线程并让它运行起来:
staruct task_struct *kthread_run(int (*threadfn)(void *data), void *data, const char namefmt[], ...)
它是通过宏来实现的,调用了thread_create()和wake_up_process():
#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; \
})`
- 退出线程
- 自己调用do_exit();
- 其他地方调用kthread_stop
3.5 进程终结
- 进程调用exit()系统调用
- exit()调用do_exit(), do_exit()函数的工作如下:
- 将task_struct中的标志成员设置为PF_EXCITING;
- 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序正在运行;
- 如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()来输出记账信息;
- 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(即:这个地址空间没有被共享),就彻底释放它们;
- 接下来调用sem_exit()函数。如果进程排队等候IPC信号,它则离开队列;
- 调用exit_file()和exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放;
- 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索;
- 调用exit_notify向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct结构中的exit_state中)设置为EXIT_ZOMBLE;
- do_exit调用schedule()切换到新的进程(参看第四章:进程调度)。因为处于EXIT_ZOMBLE状态的进程不会再被调度,所以这是进程执行的最后一段代码。do_exit()永不返回。
至此,进程所占用的资源被释放完了(假设该进程是这些资源的唯一使用者)。但是进程本身还没有被释放,接下来介绍进程的彻底释放过程。
3.5.1 删除进程描述符
进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或者通知内核它并不关系那些信息后,子进程的task_struct才被释放。
wait()这一族函数都会通过唯一的一个系统调用wait4()来实现的。它的标准动作时挂起调用它的进程,直到其中一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供指针会包含函数退出时的退出代码。
最终需要释放进程描述符时,会调用release_task()函数,它完成以下工作:
- 它调用_exit_signal(),该函数调用_unhash_peocess(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。
- _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
- 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。
- release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。
至此,进程描述符和所有进程独享的资源就全部释放掉了。
3.5.2 孤儿进程造成的进退维谷
如果父进程在子进程退出之前退出,就给这个子进程在当前线程组中找到一个线程作为新的父进程,如果这样不行的话,就让init成为其新的父进程。否则,该子进程没有新的父进程就会变成孤儿进程,它在退出的时候会永远处于僵死状态,白白耗费内存。
寻找过程:do_exit()–>exit_notify()–>forget_original_parent()–>find_new_reaper()
3.6 小结
- Linux存放和表示进程:task_struct 和 thread_info
- 创建进程:fork(),实际上最终是clone()
- 把新的执行映像装入到地址空间:exec()系统调用族
- 表示进程的层次关系
- 父进程收集其后代的信息:wait()系统调用族
- 进程消亡:强制或自愿地调用exit()