掌握不深,能力有限,仅作学习探讨,需在以后更深层次的去理解。
本篇主要是对于进程知识的一个梳理,顺便简介了一下线程:
进程是Unix操作系统最基本的抽象之一,一个进程就是处于执行期的程序(目标码存放在某种存储介质上)。
执行线程,简称线程,实在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。
内核调度的对象是线程,而不是进程。Linux系统的线程实现非常特别——它对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程罢了。
进程的另一个名字是任务(task),Linux内核通常把进程也叫做任务。
1 进程描述符及任务结构
内核把进程存放叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux.sched.h>中。进程描述符中包含一个具体进程的所有信息。
进程描述符中包含的数据能完整的描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态,还有其它更多的信息(如下图)。
1.1 分配进程描述符
在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端。这样做是为了让那些想x86这样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的位置,从而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info(如下图)。这个新的结构能使在汇编代码中计算其偏移变得相当容易。
struct thread_info {
struct task_struct *任务;
struct exec_domain *exec_domain;
unsigned long flags;
unsigned long status;
__u32 cpu;
__s32 preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
unsigned long previous_esp;
__u8 supervisor_stack[0];
};
每个任务的thread_info结构在它的内核栈的尾端分配。结构中task域中存放的是指向该任务实际task_struct的指针。
1.2 进程描述符的存放
movl $-8192, %eax
andl %esp, %eax
这里假定栈的大小为8KN。当4KB的栈启用时,就要用4096,而不是8192。
1.3 进程状态
- TASK_RUNNING(运行)——进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行惟一可能的状态;也可以应用到内核空间中正在执行的进程。
- TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
- TASK_UNINTERRUPTIBLE(不可中断)——除了不会因为接收到信号而被唤醒从而投入运行外,这个状态与可打断状态相同。这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不作响应,所以较之可中断状态,使用的较少。
- TASK_ZOMBIE(僵死)——该进程已经结束了,但是其父进程还没有调用wait4()系统调用。为了父进程能够获知它的消息,子进程的描述符仍然被保留着。一旦父进程调用了wait4(),进程描述符就会被释放。
- TASK_STOPPED(停止)——进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。
1.4 设置当前进程状态
1.5 进程上下文
1.6 进程家族树
所有进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。
系统中的每个进程必有一个父进程。相应地,每个进程也可以拥有零个或多个子进程。拥有同一个指向其父进程tast_struct、叫做parent的指针,还包含一个成为children的子进程链表。所以对于当前进程,可以通过下面的代码获得其父进程的进程描述符:
struct task_struct *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);
/*task 现在指向当前的某个子进程*/
}
init进程的进程描述符是作为init_task静态分配的。下面的代码可以很好地演示所有进程之间的关系:
struct task_struct *task;
for (task = current; task != &init_task; task = task->parent)
;
/*task 现在指向init*/
任务队列是一个双向循环链表。对于给定的进程,获取链表的下一个进程:
list_entry(task->tasks.next, struct task_struct, tasks)
获取前一个进程的方法相同:
list_entry(task->tasks.prev, struct task_struct, tasks)
这两个例程分别通过next_task(task)宏和prev_task(task)宏实现。而实际上,for_each_process(task)宏提供了依次访问整个任务队列的能力。每次访问,任务指针都指向链表中的下一个元素:
struct task_struct *task;
for_each_process(task) {
/*它打印出每一个任务的名称和PID*/
printk("%s[%d]\n", task->comm, task->pid);
}
需要注意的是,在一个拥有大量进程的系统中通过重复来遍历所有的进程是非常耗时的。因此,如果没有充足的理由的话别这样做。
2 进程创建
Unix的进程创建很特别。许多其他的操作系统都提供了产生(spawn)的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。在Unix中,将上述步骤分解到两个单独的函数中去执行:fork()和exec()。fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程惟一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如挂起信号,它没有必要被继承)。exec()函数负责读取可执行文件并将其载入地址空间开始运行。把这两个函数组合起来使用的效果跟其他系统使用的单一函数的效果相似。
2.1 写时拷贝
传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下——举例来说,fork()后立即调用exec()——它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。
2.2 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成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还有没有调用exec()函数的PF_FORKNOEXEC标志被设置。
- 调用get_pid()为新进程过去一个有效的PID。
- 根据传递给clone的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
- 让父进程和子进程平分剩余的时间片。
- 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
2.3 vfork()
- 在调用copy_process()时,task_struct的vfork_done成员被设置为NULL。
- 在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特殊地址。
- 子进程开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。
- 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。
- 回到do_fork(),父进程醒来并返回。
3 线程在Linux中的实现
线程机制是现代编程技术中常用的一种抽象。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术(concurrent programming),在多处理器系统上,它能保证真正的并行处理(parallelism)。
Linux实现线程的机制非常独特。从内核的角度来说,他并没有线程这个概念。Linux把所有线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有惟一隶属于自己的task_struct,所以在内核中,他看起来就像是一个普通的进程(只是该进程和其它一些进程共享某些资源,如地址空间)。
上述线程机制的实现与Microsoft Windows或是Sun Solaris等操作系统的实现差异非常大。这些系统都在内核中提供了专门支持线程的机制(这些系统尝尝把线程称作轻量级进程,lightweight process)。“轻量级进程”这种叫法本身就概括了Linux在此处与其它系统的差异。在其它的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于Linux来说,它只是一种进程间共享资源的手段(Linux的进程本身就够轻了)。举个例子来说,假如我们有一个包含4个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建4个进程并分配4个普通的task_struct结构。建立这4个进程时指定它们共享某些资源就行了。
线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符合信号处理程序。换个说话就是,新建的进程和它的父进程就是流行的所谓线程。
对比一下,一个普通的fork()的实现就是:
clone(SIGCHLD, 0);
而vfork()的实现是:
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。下表列举了这些clone用到的的参数标志以及它们的作用,这些是在<linux/sched.h>中定义的。
参数标志 | 含义 |
CLONE_FILES | 父子进程共享打开文件 |
CLONE_FS | 父子进程共享文件系统信息 |
CLONE_IDLETASK | 将PID设置为0(只供idle进程使用) |
CLONE_NEWNS | 为子进程创建新的命名空间 |
CLONE_PARENT | 指定子进程与父进程拥有同一个父进程 |
CLONE_SETTID | 将TID回写至用户空间 |
CLONE_SETTLS | 为子进程差创建新的TLS |
CLONE_SIGHAND | 父子进程共享信号处理函数 |
CLONE_SYSVSEM | 父子进程共享Sytem V SEM_UNDO语义 |
CLONE_THREAD | 父子进程放入相同的线程组 |
CLONE_VFORK | 调用vfork(),所以父进程准备睡眠等待子进程将其唤醒 |
CLONE_UNTRACED | 防止跟踪进程在子进程上强制执行CLONE_PTRACE |
CLONE_STOP | 以TASK_STOPPED状态开始进程 |
CLONE_SETTLS | 为子进程创建新的TLS(thread-local storage) |
CLONE_CHILD_CLEARTID | 清楚子进程的TID |
CLONE_CHILD_SEETID | 设置子进程的TID |
CLONE_PARENT_SETTID | 设置父进程的TID |
CLONE_VM | 父子进程共享地址空间 |
内核线程
内核经常需要在后台一些操作。这种任务可以通过内核线程(kernel thread)完成——独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。
Linux确实会把一些任务交给内核线程去做,想pdflush和ksoftirqd这些任务就是明显的例子。这些线程在系统启动时由另外一些内核线程启动。实际上,内核线程也只能由其它内核线程创建。在现有内核线程中创建一个新的内核线程的方法如下:
int kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
新的任务也是通过向普通的cline()系统调用传递特定的flags参数而创建的。在上面的函数返回时,父线程退出,并返回一个指向子线程task_struct的指针。子线程开始运行fn指向的函数,arg是运行时需要用到的参数。一个特殊的clone标志CLONE_KERNEL定义了内核线程常用到的参数标志:CLONE_FS、CLONE_FILES、CLONE_SIGHAND。大部分的内核线程把这个标志传递给它们的flags参数。
4 进程的终结
- 首先,将task_struct中的标志成员设置为PF_EXITING
- 其次,调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
- 如果BSD的进程计账功是开启的,do_exit()调用acct_process()来输出计帐信息。
- 然后调用_exit_mm()函数放弃进程占用的mm_struct,如果没有别的进程使用它们(也就是说,它们没有被共享),就彻底释放它们。
- 接下来调用exit_sem()函数。如果进程排队等候IPC信号,它则离开队列。
- 调用_exit_files()、_exit_fs()、exit_namespace()和exit_sighand(),已分别递减文件描述符、文件系统数据、进程名字空间和信号处理函数的引用计数。如果其中某些引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
- 接着把存放在task_struct的exit_code成员中的任务退出代码置位exit()提供的代码中去,或者去完成任何其它由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
- 调用exit_notify()向父进程发送信号,将子进程的父进程重新设置为线程组中的其它线程或init进程,并把进程状态设成TASK_ZOMBIE。
- 最后,do_exit()调用schedule()切换到其它进程。因为处于TASK_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。
4.1 删除进程描述符
在调用了do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。
wait()这一族函数都是通过惟一(但是很复杂)的一个系统调用wait4()实现的。它的标准动作是挂起调用它的进程,直到其中一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。
当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:
- 首先,它调用free_uid()来减少该进程拥有者的进程使用计数。Linux用一个单用户高速缓存统计和记录每个用户占用的进程数目、文件数目。如果这些数目都将为0,表明这个用户没有任何进程和文件,那么这块缓存就可以销毁了。
- 然后,release_task()调用unhash_process()从pidhash上删除该进程,同时也要从task_list中删除该进程。
- 接下来,如果这个进程正在被ptrace跟踪、release_task()将跟踪的父进程重设为其最初的父进程并将它从ptrace list上删除。
- 最后,release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占用的slab高速缓存。
4.2 孤儿进程长成的进退维谷
struct task_struct *p, *reaper = father;
struct list_head *list;
if (father->exit_signal != -1)
reaper = prev_thread(reaper);
else
reaper = child_reaper;
if (reaper == father)
reaper = child_reaper;
这段代码将reaper设置为该进程所在的线程组内的其他进程。如果线程组内没有其他进程,它就将reaper设置为child_reaper,也就是init进程。现在,合适的父进程也已经找到了,只需要遍历所有子进程并为他们设置新的父进程:
list_for_each(list, &father->children) {
p = list_entry(list, struct task_struct, sibling);
reparent_thread(p, reaper, child_reaper);
}
list_for_each(listm &father->ptrace_children) {
p = list_entry(list, struct task_struct, ptrace_list);
reparent_thread(p, reaper, child_reaper);
}
这段代码遍历了两个链表:子进程链表和ptrace子进程链表,给每个子进程设置新的父进程。这两个链表同时存在的原因很有意思,它也是2.6内核的一个新特性。当一个进程被跟踪是,它被暂时设定为调试进程的子进程。此时如果它的父进程退出了,系统会为它和它的所有兄弟重新找一个父进程。在以前的内核中,这就需要遍历系统所有的进程来找这些子进程。现在的解决办法是在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程——用两个相关链表减轻了遍历带来的消耗。