3.1 进程,轻量级进程和线程
一个进程通常定义为正在执行的程序实体。你可以把它当做是用来充分描述程序已经执行到何种地步的数据结构的集合。
进程就好像人类一样:它们被生成,它们有个或多或少有点意义的生命周期,它们可以随意生成一个或多个子进程,最终它们都会死去。一个小小的区别是每个进程都只有一个父亲。尽管父进程和子进程可能共享含有程序代码的物理页,但是它们各自有份数据的拷贝(栈和堆),所以子进程对内存的修改对父进程来说是不可见的(反之亦然)。
从早期的内核观点来看,一个多线程的应用程序只是一个普通的进程,内核为其创建多个执行流,对这些执行流的处理和调度完全处于用户态。
现在linux使用轻量级进程(light weight processes)来更好地支持多线程应用程序。实现多线程应用程序比较直接的方式就是把每一个线程跟轻量级进程关联起来。通过这种方式,线程通过分享同一块内存地址空间、同一个打开文件描述符集合等方式就可以访问相同的数据结构;同时,每一个线程都可以独立地被内核调度,因此其中一个睡眠的时候,其他的线程可以接续执行。在linux中,线程组(thread group)是多个轻量级进程的组合,用来实现多线程应用程序,涉及到一些如getpid(),kill(),_exit()之类的系统调用时,线程组内的所有轻量级进程作为一个整体来运行。
3.2 进程描述符
进程描述符task_struct结构体的成员包含了与一个进程有关的所有信息。
3.2.1 进程状态
在目前的linux版本中,这些状态直接是互相排斥的,因此每时每刻只能设置一个状态;
TASK_RUNNING
进程要么正在一个CPU上执行,要么正在等待被执行
TASK_INTERRUPTIBLE
TASK_UNINTERRUPTIBLE
在这种状态下,进程必须等待至某个给点的事件发生,无法被中断
TASK_STOPPED
进程的执行已被停止;进程在收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU信号后会进入该状态。
TASK_TRACED
还有两个额外的进程状态可以存放在进程描述符的state状态和exit_state中,正如成员名字所暗示的,当进程的执行被停止后进程就会进入这两个状态:
EXIT_ZOMBIE
在wait()被调用之前,内核不能丢弃已停止的进程描述符包含的数据,因为它的父进程可能需要用到它们。
EXIT_DEAD
内核使用set_task_state和set_current_state宏:它们分别设置一个指定进程的状态和当前正被执行进程的状态。更多的是,这两个宏保证编译器或CPU控制单元不会把赋值操作指令跟其他的指令混合在一起。
3.2.2 识别一个进程
即使是轻量级进程也有它自己的进程描述符。
PIDs在数字上是连续的:新创建进程的PID通常是前一个被创建进程的PID增1。当循环利用PID号时,内核需要管理一个pidmap_array位图,用来指示当前已被分配的PID和可供使用的PID。因为一个页包含32768位,在32位体系结构上,pidmap_array位图存储在一个物理页中。这些页永远不会被释放。
另一方面,Unix程序员期望同一个线程组内的线程拥有同一个PID。例如,如果向某个PID指定的进程发送一个信号,那么该信号将能影响线程组内的所有线程。
为了适应这个标准,Linux使用了线程组(thread group)。组内的线程所具有的标示符是线程组领头线程的PID,即组内第一个轻量级进程的PID,存储在进程描述符的tgid成员中。getpid()系统调用返回当前进程的tgid的值,而不是pid的值,因此多线程应用程序中的所有线程共享同一个标示符。对线程组领头线程来说,tgid成员值和pid成员值相同。
3.2.2.1 处理进程描述符
进程描述符被存储在动态内存中,而不是内核中的永久内存中。对每个进程而言,Linux把两个不同的数据结构整合成每个进程都含有一份的内存区域:这是一小块链接到进程描述符的数据结构,叫做thread_info和内核栈(kernel Mode Process stack)。这块内存区域的长度通常是8192字节(两页)。考虑到效率,内核把这8KB内存区域存储在两个连续的页当中,第一个页的地址必须跟2^13对齐(8KB对齐)。
thread_info结构体放在该内存区域的首地址处,内核栈从下往上生长。
当进程从用户态模式(User Mode)切换到内核态模式(Kernel Mode)后,该进程的内核栈总是空的。
内核使用alloc_thread_info和free_thread_info宏来分配和释放存储thread_info结构体和内核栈的内存区域。
3.2.2.2 识别当前进程
内核可以轻易从sp寄存器中得到当前运行在CPU上的进程的thread_info结构体的地址。事实上,如果thread_union结构体的长度是8KB,内核是把从sp中取出地址的最后13位给屏蔽掉以获得thread_info结构体的基地址。这就是current_thread_info()函数所做的。
3.2.2.4 进程链表
进程链表的头部节点是init_task进程描述符,它就是所谓的交换进程或PID为0的进程的进程描述符。
3.2.3 进程之间的关系
进程0和1由内核创建,进程1(init进程)是所有其他进程的祖先。
表3-3 进程描述符中用来表述父子关系的成员
成员名字 描述
real_parent 指向创建了P的进程的进程描述符或者当父进程不存在时指向进程1的描述符。
(因此,当用户启动了一个后台程序并且退出了shell,该后台程序就变成了init
进程的后代。)
parent 指向P当前的父亲(子进程终止时需要发生信号给该进程以告知它);parent成员
的值通常和real_parent成员的值一致。在某些场合会有些不同,比如当某个其
他进程被允许调用ptrace()系统调用来监视P的时候。
children
sibling
3.2.3.1 哈希表和链表
在一些环境下,内核必须能够从一个PID得到其对应的进程描述符。
顺序遍历进程链表并检查每个进程描述符的pid成员的方法可行但相当低效。为了加速查找,引出了4个哈希表。为何有多个哈希表?仅仅是因为进程描述符的PID有多个类型,每个PID的类型都需要有一张哈希表。
3.2.4 进程如何被组织
- 处于TASK_STOPPED,EXIT_ZOMBIE,EXIT_DEAD状态的进程并没有被链到某些链表。没有必要将处于这三种状态的进程组合在一起,因为停止的、僵死的和死 亡的进程只能通过PID来访问或者通过某个父进程的子进程链表来访问。
- 处于TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE状态的进程被分成很多类,其中每一类都对应指定的事件。在这种情况下,进程状态并不能提供足够的信 息来快速检索该进程,因而有必要引出额外的进程链表。称作等待队列(wait queues)。
3.2.4.1 等待队列
一个等待队列代表了一个正在睡眠的进程集合,当某些条件为真时由内核唤醒。
然而,将一个等待队列里的所有睡眠进程都唤醒并不总是方便的。例如,如果两个或更多的进程正在互斥地等待一些将要被释放的资源,那么只唤醒等待队列中的一个进程就是有意义的。该进程占有那些资源,其他的进程继续睡眠。
3.2.4.2 处理等待队列
wake_up,wake_up_nr,wake_up_all,wake_up_interruptible_nr...
这些名字中带"nr"的宏用来唤醒给定数目的处于相应状态的排他进程(exclusive processed);该数字是宏的一个参数。名字中带有"all"的宏唤醒所有处于相应状态的排他进程。最后,名字中既不带有"nr"也不带有"all"的宏只唤醒一个处于相应状态的排他进程。
3.2.5 进程资源限制
当前进程的资源限制值存储在current->signal->rlim成员中。
表3-7 资源限制
成员名字 描述
RLIMIT_AS 进程地址空间的最大大小,单位是字节。内核在进程使用malloc()或相关的函数
来增大其地址空间时检查这个值。
RLIMIT_CORE
RLIMIT_CPU 进程能拥有的最大CPU时间,单位为秒。如果进程超过了限制,内核就发送一个
SIGXCPU信号给进程,如果进程没有停止,就再发送SIGKILL信号。
RLIMIT_DATA 最大堆大小,单位为字节。
RLIMIT_FSIZE
RLIMIT_LOCKS
RLIMIT_MEMLOCK
RLIMIT_MSGQUEUE
RLIMIT_NOFILE 打开文件最大个数。
RLIMIT_NPROC 用户可以拥有的最多的进程数目。
RLIMIT_RSS
RLIMIT_SIGPENDING
RLIMIT_STACK 最大栈尺寸,单位是字节。
rlim_cur成员是进程当前的资源限制。例如,current->signal->rlim[RLIMIT_CPU].rlim_cur代表了运行进程当前在CPU时间上的限制。
3.3 进程切换
3.3.1 硬件上下文
尽管每个进程都拥有各自的地址空间,但所有的进程共享CPU寄存器。因此在恢复某个进程的执行之前,内核必须确保每个寄存器载入了该进程被挂起之前的值。
在恢复进程在CPU上的执行之前需要载入到寄存器中的数据集叫做硬件上下文。硬件上下文是进程执行上下文的一个子集。在Linux中,进程的部分硬件上下文存储在进程描述符中,剩余的存储在内核栈中。
Linux 2.6 基于以下两点理由使用软件来进行进程切换:
- 通过mov指令逐步进行切换使得可以对载入的数据进行有效性控制。特别是,可以检查ds和es段寄存器的值,因为它们可能会被用户伪造。
- 硬件和软件来执行硬件上下文切换的时间是一样的,但是硬件的切换时间是无法优化的,软件的切换可能还存在优化空间。
进程切换只发生在内核模式。进程在用户态所使用的全部寄存器的内容在进行切换之前已经保存在内核栈中。这包括指定了用户栈地址的ss和esp寄存器的内容。
3.3.2.1 thread成员
每个进程描述符包含一个thread_struct类型的thread成员,当进程被切换出去时内核用来保存硬件上下文。
3.3.3 执行进程切换
本质上,每个进程切换包含两步:
1. 切换页全局目录来载入新的地址空间;
2. 切换内核栈和硬件上下文,它们提供了内核执行一个新进程所需要的全部信息,包括CPU寄存器。
3.4 创建进程
传统的Unix使用同种方法对待所有的进程:父进程拥有的所有资源被复制给子进程。这种方式使得创建进程很慢且低效,因为它需要拷贝父进程的全部地址空间。子进程很少需要读或者修改它从父进程继承的所有资源;很多情况下,它发出一个execve()调用然后擦除之前精心拷贝的地址空间。
现代Unix内核引出三种不同的机制来解决该问题:
- 写时拷贝机制允许父进程和子进程读取相同的物理页。只要其中一个试图写某一页,内核就拷贝该页内容到分配给该写进程的新的物理页。
- 轻量级进程允许父进程和子进程共享很多内核数据结构,比如页表(包括全部的用户空间),打开的文件描述符和信号。
- vfork()系统调用创建一个进程共享父进程的地址空间。为了防止父进程修改子进程需要用到的数据结构,父进程的执行被阻塞直到子进程退出或者执行新的程序。
3.4.1 clone(),fork()和vfork()系统调用
Linux使用clone()函数来创建轻量级进程,使用以下几个参数:
fn
指定新进程执行的函数;当该函数返回时,子进程也就终止。函数返回一个整数作为子进程的退出码。
arg
指向传递给fn()函数的参数
flags
混合的信息。低字节指定了当子进程终止时发送给父进程的信号;通常是SIGCHILD信号。其余的三个字节包含了一组clone标志,见表3-8。
child_stack
指定用户栈用来分配给子进程的ARM_sp寄存器。调用进程(父进程)应当为子进程分配一个新栈。
tls
ptid
指定了父进程的一个用户变量的地址用于储存新创建的轻量级进程的PID。只有当CLONE_PARENT_SETTID标志设置了才有意义。
ctid
指定了新创建轻量级进程的用户变量的地址,用来存放该进程的PID。只有当CLONE_CHILD_SETTID标志设置了才有意义。
表3-8 Clone标志
标志名字 描述
CLONE_VM 共享内存描述符和所有的页表。
CLONE_FS 共享根目录和当前工作目录,和用来初始化新文件的权限位值。
CLONE_FILES 共享打开文件。
CLONE_SIGHAND 共享信号句柄,阻塞的和挂起的信号。如果该位设置了,那么CLONE_VM也必须设置。
CLONE_PTRACE 如果父进程被调试器追踪,那么子进程也被追踪。
CLONE_VFORK 调用系统调用vfork()的时候设置该位。
CLONE_PARENT 将子进程的父亲设置为调用clone()的进程的父进程。
CLONE_THREAD 将子进程插入父进程所在的线程组中,强制子进程共享父进程的信号。子进程的tgid和group_leader另外设置。如果该位设置了, CLONE_SIGHAND也必须设置。
CLONE_NEWNS
CLONE_SYSVSEM
CLONE_SETTLS
CLONE_PARENT_SETTID
CLONE_CHILD_CLEARTID 如果设置了,内核设置一种机制将会在子进程退出或者开始执行其他新程序的时候触发。在这些情况下,内核就会清除指向ctid参数的用户 变量并且唤醒等待该事件的任何进程。
CLONE_DETACHED
CLONE_UNTRACED
CLONE_CHILD_SETTID
CLONE_STOPPED 强制子进程始于TASK_STOPPED状态
clone()实际上是一个封装好的C库函数,用来配置新的轻量级进程栈并调用对程序员隐藏的clone()系统调用。实现了clone()系统调用的sys_clone()服务程序没有fn和arg参数。实际上,该封装函数把指针fn保存在子进程的栈中,对应于该函数返回的地址处;arg保存在子进程栈中fn的后面。当该封装函数终止时,CPU从栈中取得返回地址,执行fn(arg)函数。
传统的fork()系统调用是linux用clone()系统调用实现的,flags指定了SIGCHLD信号,所有的clone标志都被清除,child_stack参数是当前父进程的栈指针,因此,父进程和子进程共享同样的用户栈。由于“写时拷贝”机制,当它们之中某个试图写栈的时候就会各自得到用户栈的拷贝。
vfork()系统调用时由设置了SIGCHLD信号和CLONE_VM和CLONE_VFORK标志的clone()系统调用实现的,child_stack参数等于当前父进程的栈指针。
3.4.1.1 do_fork()函数
用来实现clone(),fork(),vfork()系统调用的do_fork()函数,有以下几个参数:
clone_flags
stack_start
与clone()的child_stack参数一致
regs
当从用户态切换到内核态时保存在内核栈中指向通用寄存器的值
stack_size
未使用(设置为0)
parent_tidptr,child_tidptr
与clone()的ptid、ctid参数一致
4. 如果设置了CLONE_STOPPED标志,就设置进程状态为TASK_STOPPED,并且添加挂起的SIGSTOP信号。进程的状态就会保持TASK_STOPPED直到其他进程将其状态反转为TASK_RUNNING,通常通过发送SIGCONT信号。
5. 如果没有设置CLONE_STOPPED标志,它调用wake_up_new_task()函数,做了以下几个操作:
a. 调整父进程和子进程的调度参数
b. 如果子进程和父进程运行在同一个CPU,并且父子进程不共享页表(没有设置CLONE_VM),它通过将子进程插入到父进程运行队列上并且在父进程的前面来强制子进程运行在父进程之前。这步在子进程清空它的地址空间并且在fork()之后执行一个新的程序的时候能有更好的性能。如果我们让父进程先运行,“写时拷贝”技术会引起一系列不必要的页拷贝。
c. 如果子进程和父进程不是运行在一个CPU上,或者父进程和子进程共享页表(设置CLONE_VM),就把子进程插入到父进程运行队列的最后一个位置。
8.如果设置了CLONE_VFORK标志,将父进程插入到等待队列上并挂起它直到子进程释放了它的内存地址空间。
3.4.1.2 copy_process()函数
copy_process()函数配置了进程描述符和其他子进程执行所需的内核数据结构。这里描述一下它最重要的几步:
1.检查clone_flags设置的标志是否是兼容的。特别是,在以下情况下返回错误值:
a. CLONE_NEWNS和CLONE_FS同时设置。
b.设置了CLONE_THREAD,却没有设置CLONE_SIGHAND标志(处于同一个线程组的轻量级进程之间共享信号)。
c.设置了CLONE_SIGHAND,却没有设置CLONE_VM标志(共享信号的轻量级进程之间必须共享内存描述符)。
3.调用dup_task_struct()来获取子进程的进程描述符。
12.调用copy_semundo(),copy_files(),copy_fs(),copy_sighand()等来创建新的数据结构并且从父进程的对应的数据结构中拷贝内容给它们,除非由clone_flags参数特别指定。
13.调用copy_thread()来初始化子进程包含有CPU寄存器值的内核栈。
16.只有线程组中最后一个成员(通常是领头线程)的终止才会通知领头进程的父进程。
17.为了保持调度公平,sched_fork()函数在子进程和父进程之间共享剩余的时间片。
23.调用attach_pid()将新进程描述符的pid添加到pidhash[PIDTYPE_PID]哈希表。
28.通过返回子进程的进程描述符终止函数。
让我们看看do_fork()结束后会发生什么。现在运行队列上存在了一个子进程,但它实际上并没有处于运行中。由调度器来决定什么时候把CPU给该子进程。在未来切换该进程执行的时候,会把其thread_info中的CPU寄存器值载入CPU(硬件上下文),此时PC指针为ret_from_fork,该汇编语言函数调用schedule_tail()函数载入栈中其他的寄存器值,并且强制CPU回到用户模式。新的进程就从fork()、vfork()和clone()等系统调用后开始执行。系统调用返回值存储在eax寄存器中:该值对子进程来说是0,对父进程来说是子进程的pid。为了理解这是怎么做的,可以看copy_thread()函数是怎么处理子进程的eax寄存器的。
(在copy_thread()中将ARM_pc寄存器赋值为ret_from_fork函数地址,那么调度子进程执行后,就由ret_from_fork开始执行)
3.4.2 内核线程Kernel Threads
传统的Unix系统委托一些任务给断断续续地执行的进程,在后台调度这些进程能使任务和用户进程能得到更好的响应。因为一些系统进程运行在内核模式,现在操作系统就把任务委托给了内核线程(Kernel threads),就不会被用户上下文所累。在Linux中,内核线程和普通进程有以下区别:
- 内核线程只运行在内核模式,而普通进程既可以运行在内核态也可以运行在用户态。
- 因为内核线程只允许在内核态,它们只使用大于PAGE_OFFSET的线性地址。普通进程,另一方面,使用全部的4G地址,不管是用户态还是内核态。
3.4.2.1 创建内核线程
kernel_thread()函数创建一个内核线程。
CLONE_VM避免了对调用进程页表的拷贝,该拷贝浪费了时间和内存,因为新的内核线程不会访问用户地址空间。
3.4.2.2 进程0
所有进程的祖先,称作进程0,空闲进程(idle process),由于历史的原因,也被称作是交换进程,是一个内核线程。
start_kernel()函数初始化所有的内核需要的数据结构,使能中断,创建了另一个内核线程,称作process 1,
kernel_thread(init,NULL,CLONE_FS|CLONE_SIGHAND);
该新创建的内核线程的PID是1并且共享进程0的所有内核数据结构。当它被调度器调度后,开始执行init()函数。
进程0只有在没有其他的进程处于TASK_RUNNING状态的时候才会被调度器选中执行。
在多处理器系统中,每个CPU都有一个进程0。上电以后,电脑的BIOS启动单个CPU的同时禁止掉其他的。交换进程运行在CPU0上,初始化内核数据结构,使能其他的CPUs并通过copy_process()创建其余的交换进程,为每个创建进程的thread_info设置合适的CPU索引。
3.4.2.3 进程1
init进程保持活跃直到系统关闭,它创建并且监测实现操作系统布局的所有进程的活动。
3.5 销毁进程
内核必须能知晓进程的终止以释放其所占用的资源。
exit()函数可以由程序员显式调用。除此以外,C编译器总是在main()函数的最后一句语句后插入exit()函数。
3.5.1 进程终止
- _exit()系统调用,用来终止单个进程,而不管线程组中其他的进程。