进程,轻量级进程和线程
进程,是程序执行时的一个实例。从内核观点看,进程的目的就是担当分配系统资源(CPU
时间,内存等)的实体。当一个进程创建时,它几乎与父进程相同。接受父进程地址空间的一个拷贝,并从进程创建系统调用的下一条指令开始执行与父进程相同的代码。
尽管父子进程可共享含有程序代码的页,但它们各自有独立的数据拷贝(栈和堆)。现代Unxi
系统,支持多线程应用程序。这样的系统中,一个进程由几个用户线程组成,每个线程都代表进程的一个执行流。现在,大部分多线程应用程序都是用pthread
库的标准库函数集编写的。
Linux
使用轻量级进程对多线程应用程序提供更好的支持。两个轻量级进程基本上可共享一些资源,诸如地址空间,打开的文件等等。实现多线程应用程序的一个简单方式就是把轻量级进程与每个线程关联起来。这样,线程间可通过简单地共享同一地址空间,同一打开文件集等来访问相同的应用程序数据结构集;每个线程都可由内核独立调度,以便一个睡眠的同时,另一个仍然可运行。POSIX
兼容的多线程应用程序由支持"线程组"的内核来处理最好不过。Linux
中,一个线程组基本上就是实现了多线程应用的一组轻量级进程。
进程描述符
进程描述符都是task_struct
类型结构,它的字段包含了与一个进程相关的所有信息。
进程状态
进程描述符中的state
字段描述了进程当前所处的状态。它们由一组标志组成,其中每个标志描述一种可能的进程状态。
(1). 可运行状态(TASK_RUNNING
)
进程要么在CPU
上执行,要么准备执行.也称为就绪状态.
(2). 可中断的等待状态(TASK_INTERRUPTIBLE
)
进程被挂起(睡眠).
在睡眠期间,可被中断或显式唤醒.
(3). 不可中断的等待状态(TASK_UNINTERRUPTIBLE
)
进程被挂起(睡眠).
必须显式唤醒.
(4). 暂停状态(TASK_STOPPED
)
进程的执行被暂停。
当进程接收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU
信号后,进入暂停状态。
(5). 跟踪状态(TASK_TRACED
)
进程的执行已由debugger
程序暂停。
当一个进程被另一个进程监控时(如debugger
执行ptrace
系统调用监控一个测试程序),任何信号都可把这个进程置于TASK_TRACED
状态。
还有两个进程状态,既可放在进程描述符的state
字段,也可放在exit_state
字段。只有当进程的执行被终止时,进程的状态才会变为这两种状态中的一种:
(1). 僵死状态(EXIT_ZOMBIE
)
进程的执行被终止。但是,父进程还没执行wait4
或waitpid
来返回有关死亡进程的信息。执行wait
类系统调用前,内核不能丢弃包含在死进程描述符中的数据。
(2). 僵死撤销状态(EXIT_DEAD
)
最终状态:由于父进程刚发出wait4
或waitpid
系统调用,因而进程由系统删除。
为防止其他执行线程在同一个进程上也执行wait
类系统调用,而把进程的状态由僵死状态改为僵死撤销状态。
标识一个进程
一般来说,能被独立调度的每个执行上下文都必须拥有它自己的进程描述符。因此,即使共享内核大部分数据结构的轻量级进程,也有它们自己的task_struct
结构。
进程和进程描述符之间有非常严格的一一对应关系,使得用32
位进程描述符地址标识进程成为一种方便的方式。另一方面,类Unix
操作系统允许用户使用一个叫做进程标识符process ID
的数来标识进程,PID
存放在进程描述符的pid
字段中。PID
值有一个上限,当内核使用的PID
达到这个上限值的时候就必须开始循环使用已闲置的小PID
号。缺省下,最大的PID
号是32767
;系统管理员可通过往/proc/sys/kernel/pid_max
这个文件写入一个更小的值来减小PID
的上限值。64
位体系结构中,系统管理员可把PID
上限扩大到4194303
。
由于循环使用PID
编号,内核必须通过管理一个pidmap_array
位图来表示当前已分配的PID
号和闲置的PID
号。因为一个页框包含32768
个位,所以在32
位体系结构中pidmap_array
位图存放在一个单独的页中。然而,64
位体系结构中,当内核分配了超过当前位图大小的PID
号时,需为PID
位图增加更多的页。系统会一直保存这些页不被释放。
Linux
把不同的PID
与系统中每个进程或轻量级进程相关联。另一方面,Unix
程序员希望同一组中的线程有共同的PID
。如,可把信号发给指定PID
的一组线程,这个信号会作用于该组中所有的线程。POSIX 1003.1c
标准规定一个多线程应用程序中的所有线程都必须有相同的PID
。
Linux
引入线程组的表示。一个线程组中的所有线程使用和该线程组的领头线程相同的PID
,也就是该组中第一个轻量级进程的PID
,它被存入进程描述符的tgid
字段中。getpid
系统调用返回当前进程的tgid
值。因此一个多线程应用的所有线程共享相同的PID
。线程组的领头线程其tgid
与pid
值相同。
进程描述符处理
对每个进程来说,Linux
都把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是与进程描述符相关的小数据结构thread_info
,叫做线程描述符。另一个是内核态的进程堆栈。这块存储区域的大小通常是8192
个字节(两个页框)。考虑到效率,内核让这8K
空间占据连续的两个页框并让第一个页框的起始地址是
2
13
2^{13}
213的倍数。
在80x86
体系结构中,在编译时可以进行设置,以使内核栈和线程描述符跨越一个单独的页框。对栈和thread_info
结构来说,8KB
足够了。不过,当使用一个页框存放内核态堆栈和thread_info
结构时,内核要采用一些额外的栈以防止中断和异常的深度嵌套而引起的溢出。
esp
寄存器是CPU
栈指针,用来存放栈顶单元的地址。在80x86
系统中,栈起始于末端,并朝这个内存区低端地址的方向增长。从用户态刚切换到内核态以后,内核的内核栈总是空的。因此,esp
寄存器指向这个栈的顶端。一旦数据写入堆栈,esp
的值就递减。c
语言使用下列的联合结构方便地表示一个进程的线程描述符和内核栈:
union thread_union{
struct thread_info thread_info;
unsigned long stack[2048];
};
标识当前进程
如果thread_union
结构长度是8K
,则内核屏蔽掉esp
的低13
位就可获得thread_info
结构的基地址;如果thread_union
结构长度是4K
,则内核屏蔽掉esp
的低12
位就可获得thread_info
结构的基地址;用栈存放进程描述符的另一个优点体现在多处理器系统上:对于每个硬件处理器,仅通过检查栈就可获得当前正确的进程。
进程链表
进程链表把所有进程的描述符链接起来。每个task_struct
结构都包含一个list_head
类型的tasks
字段,这个类型的prev,next
分别指向前面和后面的task_struct
元素。进程链表的头是init_task
描述符,它是所谓的0
进程或swapper
进程的进程描述符。
TASK_RUNNING状态的进程链表
当内核寻找一个新进程在CPU
上运行时,必须只考虑可运行进程。Linux 2.6
实现的运行队列,其目的是让调度程序能在固定的时间内选出“最佳”可运行进程,与队列中可运行的进程数无关。
提高调度程序运行速度的诀窍是建立多个可运行进程链表,每种进程优先权对应一个不同的链表。每个task_struct
描述符包含一个list_head
类型的字段run_list
。如果进程的优先权等于k
(取值范围是0
到139
),run_list
字段把该进程链入优先权为k
的可运行进程的链表中。
多处理器系统中,每个CPU
都有它自己的运行队列。内核必须为系统中每个运行队列保存大量的数据,不过运行队列的主要数据结构还是组成运行队列的进程描述符链表,所有这些链表都由一个单独的prio_array_t
数据结构来实现。
类型 | 字段 | 描述 |
---|---|---|
int | nr_active | 链表中进程描述符的数量 |
unsigned long[5] | bitmap | 优先权位图:当且仅当某个优先权的进程链表不为空时设置相应的位标志 |
struct list_head[140] | queue | 140个优先权队列的头结点 |
进程描述符的prio
字段存放进程的动态优先权,进程描述符的array
字段是一个指针,指向当前运行队列的prio_array_t
数据结构。
进程间的关系
程序创建的进程具有父子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。
进程0
,进程1
是由内核创建的;进程1
(init
)是所有进程的祖先。下表是进程描述符中表示进程亲属关系的字段的描述:
字段名 | 说明 |
---|---|
real_parent | 指向创建了P的进程的描述符;如该进程不再存在,指向进程1(init)的描述符 |
parent | 指向P的当前父进程。 |
children | 链表的头部,链表中的所有元素都是P创建的子进程 |
sibling | 指向兄弟进程链表中的下一个元素或前一个元素的指针,这些兄弟进程的父进程一致 |
进程之间还存在其他关系:一个进程可能是一个进程组或登录会话的领头进程,也可能是一个线程组的领头进程,还可能追踪其他进程的执行。下表是建立非亲属关系的进程描述符字段
字段名 | 说明 |
---|---|
group_leader | P所在进程组的领头进程的描述符指针 |
signal->pgrp | P所在进程组的领头进程的PID |
tgid | P所在线程组的领头进程的PID |
signal->session | P的登录会话领头进程的PID |
ptrace_children | 链表的头,该链表包含所有被debugger程序跟踪的P的子进程 |
ptrace_list | 指向所跟踪进程其实际父进程链表的前一个和下一个元素(用于P被跟踪时) |
pidhash表及链表
几种情况下,内核必须能从进程的PID
导出对应的进程描述符指针。为了加速从pid
到进程描述符的查找,引入了四个散列表。需要四个散列表是因为进程描述符包含了表示不同类型PID
的字段,且每种类型的PID
需要它自己的散列表。
Hash表的类型 | 字段名 | 说明 |
---|---|---|
PIDTYPE_PID | pid | 进程的PID |
PIDTYPE_TGID | tgid | 线程组领头进程的PID |
PIDTYPE_PGID | pgrp | 进程组领头进程的PID |
PIDTYPE_SID | session | 会话领头进程的PID |
内核初始化期间动态地为四个散列表分配空间,并把它们的地址存入pid_hash
数组。一个散列表的长度依赖于可用RAM
的容量。用pid_hashfn
宏把PID
转化为表索引
#define pid_hashfn(x) hash_long((unsigned long)x, pidhash_shift)
两个不同的PID
散列到相同的表索引称为冲突。Linux
利用链表来处理冲突的PID
:每一个表项是由冲突的进程描述符组成的双向链表。由于需要跟踪进程间的关系,PID
散列表中使用的数据结构非常复杂。
看一个例子:假设内核必须回收一个指定线程组中的所有进程,这意味着这些进程的tgid
是相同的,都等于一个给定值。如果根据线程组号查找散列表,只能返回一个进程描述符,就是线程组领头进程的描述符。为快速返回组中其他所有线程,内核就必须为每个线程组保留一个进程链表。在查找给定登录会话或进程组的进程时,也有同样情形。pid
散列表可解决这些问题。最主要的数据结构是四个pid
结构的数组,它在进程描述符的pids
字段中。
类型 | 名称 | 描述 |
---|---|---|
int | nr | pid的数值 |
struct hlist_node | pid_chain | 链接散列表的下一个和前一个元素 |
struct list_head | pid_list | 每个pid的进程链表头 |
即先通过哈希表,定位到线程组领头进程,再依赖每个进程task_struct
中pid_list
字段,找到此线程组下每个成员.
每个进程task_struct
中pid_chain
字段用于将位于同一个哈希桶索引下各个进程组织在一起.
如何组织进程
运行队列链表把处于TASK_RUNNING
状态的所有进程组织在一起。当要把其他状态的进程分组时,不同的状态要求不同的处理,Linux
选择了下列方式之一:
1.没有为处于TASK_STOPPED,EXIT_ZOMBIE
或EXIT_DEAB
状态的进程建立专门的链表。对处于暂停,僵死,死亡状态进程的访问比较简单,或者通过PID
,或者通过特定父进程的子进程链表,所以,不必对这三种状态进程分组。
2.根据不同的特殊事件把处于TASK_INTERRUPTIBLE
或TASK_UNINTERRUPTIBLE
状态的进程细分为许多类,每一类都对应某个特殊的事件。这种情况下,进程状态提供的信息满足不了快速检索进程的需要,所以必须引入另外的进程链表,这些链表被称作等待队列。
等待队列
等待队列实现了在事件上的条件等待:希望等待特定事件的进程把自己放进合适的等待队列, 并放弃控制权。故,等待队列表示一组睡眠的进程,当某一条件变为真时,由内核唤醒它们。每个等待队列有一个等待队列头
struct __wait_queue_head{
spinlock_t lock;
struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;
等待队列链表中的元素类型为wait_queue_t
:
struct __wait_queue{
unsigned int flags;
struct task_struct* task;
wait_queue_func_t func;
struct list_head task_list;
};
typedef struct __wait_queue wait_queue_t;
有两种睡眠进程:互斥进程由内核有选择地唤醒,非互斥进程总是由内核在事件发生时唤醒。
等待队列的操作
非互斥进程p
将由default_wake_function
唤醒。
1.sleep_on
对当前进程进行操作
void sleep_on(wait_queue_head_t *wq)
{
wait_queue_t wait;
init_waitqueue_entry(&wait, current);
current->state = TASK_UNINTERRUPTIBLE;
add_wait_queue(wq, &wait);
// 初次执行,将自动放弃处理器.
// 再次恢复执行,必然是其他进程对wq上等待者执行了唤醒动作(设置进程状态为TASK_RUNNING).
// 后续调度选择中,进程被选择,进而恢复执行.
schedule();
remove_wait_queue(wq, &wait);
}
2.wait_event
DEFINE_WAIT(__wait);
for(;;)
{
prepare_to_wait(&wq, &__wait, TASK_UNINTERRUPTIBLE);
for(condition)// 防止虚假唤醒
{
break;
}
schedule();// 主动放弃处理器
}
finish_wait(&wq, &__wait);// 将自身从wp的等待者链表移除
3.wake_up
void wake_up(wait_queue_head_t *q)
{
struct list_head *tmp;
wait_queue_t *curr;
list_for_each(tmp, &q->task_list)
{
curr = list_entry(tmp, wait_queue_t, task_list);
// 唤醒进程.通过flags决定唤醒该进程后,是否继续对后一个等待着继续唤醒
if(curr->func(curr, TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE, 0, NULL) && curr->flags)
{
break;
}
}
}
非互斥进程总是在双向链表开始位置。互斥进程在链表尾部。
进程资源限制
对当前进程的资源限制存放在current->signal->rlim
字段。该字段是rlimit
结构的数组。
struct rlimit
{
unsigned long rlim_cur;
unsigned long rlim_max;
};
字段 | 描述 |
---|---|
RLIMIT_AS | 进程地址空间最大数 |
RLIMIT_CORE | 内存信息转储文件的大小 |
RLIMIT_CPU | 进程使用CPU超过此值,内核向它发一个SIGXCPU信号。如果还不终止,再发一个SIGKILL信号 |
RLIMIT_DATA | 堆大小最大值 |
RLIMIT_FSIZE | 如进程试图把一个文件大小扩充到大于这个值,内核就给这进程发SIGXFSZ信号 |
RLIMIT_LOCKS | 文件锁数量最大值 |
RLIMIT_MEMLOCK | 非交换内存的最大值 |
RLIMIT_MSGQUEUE | POSIX消息队列中最大字节数 |
RLIMIT_NOFILE | 打开文件描述符最大数 |
RLIMIT_NPROC | 用户能拥有的进程最大数 |
RLIMIT_RSS | 进程所拥有的页框最大数 |
RLIMIT_SIGPENDING | 进程挂起信号的最大数 |
RLIMIT_STACK | 栈(用户态)大小的最大值。 |
rlim_cur
是资源的当前限制;rlim_max
是资源限制所允许的最大值。
只有超级用户(或具有CAP_SYS_RESOURCE
权能的用户)才能改变rlim_max
,或把rlim_cur
设置成大于rlim_max
。
进程切换
挂起正在CPU
上运行的进程,恢复以前挂起的某个进程的运行。
硬件上下文
所有进程必须共享CPU
寄存器。恢复一个进程的执行前,内核需确保每个寄存器装入了挂起进程时的值。进程恢复执行前需装入寄存器的一组数据称为硬件上下文。硬件上下文是进程可执行上下文的一个子集。Linux
中,进程硬件上下文的一部分存放在TSS
段,剩余部分存放在内核态堆栈。
早期的Linux
版本利用80x86
体系结构所提供的硬件支持,通过far jmp
跳到next
进程TSS
描述符的选择子来执行进程切换。此时,CPU
自动保存原来的硬件上下文,装入新的硬件上下文。Linux 2.6
使用软件执行进程切换。进程切换只发生在内核态。在执行进程切换前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上。
任务状态段
尽管Linux
并不使用硬件上下文切换,但强制为系统中每个不同的CPU
创建一个TSS
。
(1). 当80x86
的一个CPU
从用户态切换到内核态时,就从TSS
中获取内核态堆栈的地址
(2). 当用户态进程试图通过in
或out
访问一个I/O
端口时,CPU
需访问存放在TSS
中的I/O
许可位图以检查该进程是否有访问端口的权力。确切地说,进程在用户态下执行In
或out
指令时,控制单元执行下列操作:
a.检查eflags
中的2
位IOPL
字段,如值为3
,控制单元就执行I/O
指令。否则,进入b
。
b.访问tr
寄存器以确定当前TSS
和相应的I/O
许可权位图。
c.检查I/O
指令中指定的I/O
端口在I/O
许可权位图中对应的位。如对应位清零,这条I/O
指令就执行。否则,控制单元产生一"General protection"
异常。
Linux
设计中,每个CPU
只有一个TSS
。由Linux
创建的TSSD
存放在全局描述符表中。每个CPU
的tr
寄存器包含相应的TSS
的TSSD
选择子。也包含两个隐藏的非编程字段:TSSD
的Base
字段和Limit
字段。
thread字段
每个进程描述符包含一个类型为thread_struct
的thread
字段。只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。这个数据结构包含的字段涉及大部分CPU
寄存器,但不包含诸如eax,ebx
等等这些通用寄存器,它们的值保留在内核堆栈中。
执行进程切换
进程切换可能只发生在精心定义的点:schedule
函数。本质上,每个进程切换由两步组成:
(1).切换页全局目录以安装一个新的地址空间
(2).切换内核态堆栈和硬件上下文
保存和加载FPU,MMX及XMM寄存器
从Intel 80486DX
开始,算术浮点单元已被集成到CPU
中。为维持与旧模式的兼容,浮点算术函数用ESCAPE
指令来执行。这些指令作用于包含在CPU
中的浮点寄存器集。如果一个进程正在使用ESCAPE
指令,则,浮点寄存器的内容就属于它的硬件上下文,且应该被保存。最近的Pentium
模型中,Intel
在它的微处理器中引入一个新的汇编指令集,叫做MMX
指令,用来加速多媒体应用程序的执行。
MMX
指令作用于FPU
的浮点寄存器。选择这种体系结构的缺点是:编程者不能把浮点指令与MMX
指令混在一起使用。优点是:保存浮点单元状态的任务切换代码可不加修改地应用到保存MMX
状态。
MMX
指令加速了多媒体应用程序的执行,它们在处理器内部引入了单指令多数据流水线。Pentium III
模型扩展了这种SIMD
能力:它引入SSE
扩展,该扩展为处理包含在8
个128
位寄存器(叫做XMM
寄存器)的浮点值增加了功能。这样的寄存器不与FPU
和MMX
寄存器重叠,因此,SSE
和FPU/MMX
指令可以随意地混合。
Pentium 4
模型指令还引入另一种特点:SSE2
扩展。该扩展基本上是SSE
的一个扩展,支持高精度浮点值。SSE2
与SSE
使用同一XMM
寄存器集。80x86
微处理器并不在TSS
中自动保存FPU,MMX
和XMM
寄存器。不过,它们包含某种硬件支持,能在需要时保存这些寄存器的值。硬件支持由cr0
寄存器中的一个TS
标志组成,遵循以下规则:
(1). 每当执行硬件上下文切换时,设置TS
标志。
(2). 每当TS
标志被设置时执行ESCAPE,MMX,SSE
或SSE2
指令,控制单元就产生一个"Device not available"
异常。
TS
标志使得内核只有在真正需要时才保存和恢复FPU
,MMX
和XMM
寄存器。为说明它如何工作,我们假设进程A
使用数学协处理器。当发生上下文切换时,内核置TS
标志并把浮点寄存器保存在进程A
的TSS
中。如果新进程B
不利用协处理器,内核就不必恢复浮点寄存器的内容。
但,只要B
打算执行ESCAPE
或MMX
指令,CPU
就产生一个"Device not available"
异常,且相应的异常处理程序用保存在进程B
中的TSS
的值装载浮点寄存器。
union i387_union{
struct i387_fsave_struct fsave;
struct i387_fxsave_struct fxsave;
struct i387_soft_struct soft;
};
i387_soft_struct
由无数学协处理器的CPU
模型使用;Linux
内核通过软件模拟协处理器来支持这些老式芯片。i387_fsave_struct
由具有数学协处理器,也可能有MMX
单元的CPU
模型使用。i387_fxsave_struct
由具有SSE
和SSE2
扩展功能的CPU
模型使用。进程描述符包含两个附加的标志:
(1). 包含在thread_info
的status
字段的TS_USEDFPU
标志。它表示进程在当前执行过程中是否使用过FPU,MMX,XMM
寄存器。
(2). 包含在task_struct
的flags
的PF_USED_MATH
。表示thread.i387
子字段的内容是否有意义。标志在以下两情况下,被清0
:
a. 进程执行execve
系统调用。
b. 用户态下执行的一个程序的进程开始执行一个信号处理程序时。不过内核开始执行信号处理程序前在thread.i387
中保存浮点寄存器,处理程序结束后恢复它们。
保存FPU寄存器
if(prev->thread_info->status & TS_USEDFPU)
{
asm volatile("fxsave %0; fnclex": "=m"(tsk->thread.i387.fxsave));
asm volatile("fnsave %0; fwait": "=m"(tsk->thread.i387.fsave));
prev->thread_info->status &= ~TS_USEDFPU;
movl %cr0, %eax
orl $8, %eax
movl %eax, %cr0
}
装载FPU寄存器
next
进程恢复时,浮点寄存器内容还没恢复。不过,cr0.TS
已经设置。故,next
进程第一次试图执行ESCAPE
,MMX
或SSE/SSE2
指令时,控制单元产生一个"Device not available"
异常。内核运行math_state_restore
。
void math_state_restore()
{
asm volatile ("clts");
if(!(current->flags & PF_USED_MATH))
{
// 重新设置thread.i387
// 设置PF_USED_MATH
init_fpu(current);
}
// 把保存在thread.i387子字段中的适当值载入FPU寄存器
// 根据CPU是否支持SSE/SSE2扩展来使用fxrstor或frstor汇编指令
restore_fpu(current);
// 设置TS_USEDFPU
current->thread.status |= TS_USEDFPU;
}
在内核态使用FPU,MMX和SSE/SSE2单元
内核也可使用FPU
,MMX
和SSE/SSE2
单元。这样做的时候,应该避免干扰用户态进程所进行的任何计算。故:
1.在使用协处理器之前,如用户态进程使用了FPU
,内核需调用kernel_fpu_begin
,本质就是调用save_init_fpu
来保存寄存器的内容,重新设置cr0.TS
。
2.使用完协处理器之后,内核必须调用kernel_fpu_end
设置cr0.TS
。稍后,用户态进程执行协处理器指令时,math_state_restore
将恢复寄存器的内容(就像进程切换后的恢复)。
但注意,当前用户态进程正使用协处理器时,kernel_fpu_begin
的执行时间相当长,以至于无法通过使用FPU
,MMX
或SSE/SSE2
达到加速目的。内核只在有限场合使用FPU
,MMX
或SSE/SSE2
,典型情况有:当移动或清除大内存区字段时,或当计算校验和函数时。
创建进程
现代Unix
内核引入三种不同机制解决基于父进程产生子进程速度慢问题:
(1). 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。
(2). 轻量级进程允许父子进程共享每进程在内核的很多数据结构,如页表,打开文件表,信号处理。
(3). vfork
系统调用创建的进程能共享父进程的内存地址空间。阻塞父进程执行。直到子进程退出或执行一个新的程序为止。
clone,fork,vfork系统调用
clone
时标志
名称 | 描述 |
---|---|
CLONE_VM | 共享内存描述符和所有的页表 |
CLONE_FS | 共享根目录,当前工作目录所在的表,用于屏蔽新文件初始许可权的位掩码值 |
CLONE_FILES | 共享打开文件表 |
CLONE_SIGHAND | 共享信号处理程序的表,阻塞信号表,挂起信号表。必须搭配CLONE_VM |
CLONE_PTRACE | 如父进程被跟踪,则子进程也被跟踪。 |
CLONE_VFORK | vfork使用 |
CLONE_PARENT | 设置子进程的父进程为调用进程的父进程 |
CLONE_THREAD | 把子进程插入到父进程的同一线程组,迫使子进程共享父进程的信号描述符。必须搭配CLONE_SIGHAND |
CLONE_NEWS | clone需要自己的命名空间时 |
CLONE_SYSVSEM | 共享System V IPC取消信号量的操作 |
CLONE_SETTLS | 为轻量级进程创建新的线程局部存储段,由tls指向的结构描述 |
CLONE_PARENT_SETTID | 把子进程的PID写入由ptid指向的变量 |
CLONE_CHILD_CLEARTID | 设置时,内核建立一种触发机制。以便子进程退出或要开始执行新程序时,内核清除由ctid指向的变量。唤醒等待此事件的进程。 |
CLONE_DETACHED | 忽略 |
CLONE_UNTRACED | 禁止跟踪 |
CLONE_CHILD_SETTID | 把子进程的PID写入由ctid指向的变量 |
CLONE_STOPPED | 使得子进程开始于TASK_STOPPED状态 |
do_fork
(1). 通过pidmap_array
位图,为子进程分配新的PID
。
(2). 检查父进程的ptrace
字段:不为0
,说明有另外一个进程正在跟踪父进程。检查debugger
程序是否自己想跟踪子进程。此时,如子进程不是内核线程,设置CLONE_PTRACE
。
(3). 调copy_process
复制进程描述符。
(4).如设置了CLONE_STOPPED
,或必须跟踪子进程,即在p->ptrace
中设置了PT_PTRACED
,则子进程的状态被设置为TASK_STOPPED
,并为子进程增加挂起的SIGSTOP
信号。在另外一个进程把子进程的状态恢复为TASK_RUNNING
之前,子进程将一直保持TASK_STOPPED
状态。
(5). 如没设置CLONE_STOPPED
,调wake_up_new_task
执行下述操作:
(5.1). 调整父子进程调度参数
(5.2). 如子进程将和父进程运行在同一CPU
,且父进程和子进程不能共享同一组页表,则把子进程插入父进程所在的运行队列。
(5.3). 如子进程将和父进程运行在不同CPU
,或父进程和子进程共享同一组页表,把子进程插入自己cpu
的运行队列的队尾。
(6). 如CLONE_STOPPED
被设置,把子进程置为TASK_STOPPED
。
(7). 如父进程被跟踪,把子进程的PID
存入current
的ptrace_message
并调用ptrace_notify
。ptrace_notify
使当前进程停止运行,向当前进程的父进程发送SIGCHLD
。子进程的祖父是跟踪父进程的debugger
进程。SIGCHLD
通知debugger
进程:current
已经创建了一个子进程,可通过查找current->ptrace_message
获得子进程的PID
(8). 如设置了CLONE_VFORK
,则把父进程插入等待队列,挂起父进程直到子进程释放自己的内存地址空间
(9). 结束并返回子进程的PID
。
copy_process
(1). 检查参数clone_flags
(1.1). CLONE_NEWS,CLONE_FS
不可同时设置
(1.2). CLONE_THREAD
被设置时,CLONE_SIGHAND
必须被设置。(进程内线程间需共享信号处理)
(1.3). CLONE_SIGHAND
被设置时,CLONE_VM
必须被设置(共享信号处理,必须共享页表)。
(2). 通过security_task_create
及稍后的security_task_alloc
执行附加的安全检查。
(3). 调dup_task_struct
为子进程获取进程描述符。
(3.1). 如需要,在当前进程中调用__unlazy_fpu
,把FPU,MMX,SSE/SSE2
保存到父进程的thread_info
。稍后,dup_task_struct
把这些值复制到子进程的thread_info
。
(3.2). 执行alloc_task_struct
为新进程获得进程描述符
(3.3). 执行alloc_thread_info
以获取一块空闲内存区,来存放新进程的thread_info
结构和内核栈。
(3.4). 把current
进程描述符复制到子进程描述符。设置子进程描述符的thread_info
。
(3.5). 把current
进程的thread_info
复制到子进程的thread_info
,设置其task
(3.6). 把新进程描述符的使用计数器置为2
(3.7). 返回新进程的进程描述符指针
(4). 检查用户进程数限制。
(5). 递增tsk->user->__count,tsk->user->processes
(6). 检查进程数(nr_threads
)是否超过max_threads
。可通过/proc/sys/kernel/threads-max
来改变
(7). 如实现新进程的执行域和可执行格式的内核函数都含在内核模块中,递增它们的使用计数器
(8). 设置与进程状态相关的几个关键字段:
(8.1). 把tsk->lock_depth
初始化为-1
(8.2). 把tsk->did_exec
初始化为0
:记录了进程发出execve
次数
(8.3). 清除PF_SUPERPRIV
(表示进程是否使用了某种超级用户权限)。设置PF_FORKNOEXEC
,表示子进程还没发出execve
。
(9). 把新进程的PID
存入tsk->pid
(10). 如clone_flags
的CLONE_PARENT_SETTID
被设置,就把子进程的PID
复制到参数parent_tidptr
指向的用户态变量中。
(11). 初始化子进程描述符中的list_head
数据结构和自旋锁,为挂起信号,定时器,时间统计表相关的字段赋值。
(12). 调copy_semundo
,copy_files
,copy_fs
,copy_sighand
,copy_signal
,copy_mm
和copy_namespace
创建新的数据结构,把父进程相应数据结构值复制到新数据结构。
(13). 调copy_thread
,用发出clone
时CPU
寄存器的值初始化子进程的内核栈。对eax
强行置0
。子进程的thread.eip
指向ret_from_fork
。如父进程使用I/O
权限位图,子进程获得该位图的一个拷贝。如,CLONE_SETTLS
被设置,子进程获取由clone
的参数tls
指向的用户态数据结构所表示的TLS
段。
(14). 如clone_flags
被置为CLONE_CHILD_SETTID
或CLONE_CHILD_CLEARTID
,就把child_tidptr
复制到tsk->set_child_tid
或tsk->clear_child_tid
(15). 清除子进程thread_info
的TIF_SYSCALL_TRACE
。使得ret_from_fork
不会把系统调用结束的消息通知给调式进程。
(16). 用clone_flags
低位编码tsk->exit_signal
,CLONE_THREAD
设置下,tsk->exit_signal
初始化为-
1。只有线程组最后一个成员(通常是线程组的领头进程)死亡时,才会产生一个信号,以通知线程组领头进程的父进程。
(17). 调sched_fork
完成新进程调度程序数据结构的初始化。函数把新进程状态设置为TASK_RUNNING
,把thread_info
的preempt_count
置为1
(这样禁止了内核抢占)
(18). 设置新进程的thread_info->cpu
为当前本地cpu
(19). 建立亲子关系。如CLONE_PARENT
或CLONE_THREAD
被设置,用current->real_parent
初始化tsk->real_parent
,tsk->parent
。否则,把tsk->real_parent
,tsk->parent
置为当前进程。通过fork
得到的新进程,可以是执行fork
进程的子进程(不含CLONE_PARENT
或CLONE_THREAD
)也可是执行fork
进程的兄弟进程(含CLONE_PARENT
或CLONE_THREAD
)。线程组内各个轻量级进程间是兄弟关系。父进程都是线程组内首个轻量级进程的父进程。
(20). 如不需跟踪子进程,把tsk->ptrace
置为0
。tsk->ptrace
会存放一些标志,这些标志在一个进程被另外一个进程跟踪时才用到。
(21). 执行SET_LINKS
把新进程描述符插入进程链表
(22). 如子进程必须被跟踪,就把current->parent
赋给tsk->parent
,将子进程插入调式程序的跟踪链表
(23). 调attach_pid
把新进程描述符的PID
插入pidhash[PIDTYPE_PID]
(24). 如子进程是线程组的领头进程(CLONE_THREAD
被清0
)
(24.1). 把tsk->tgid
设置为tsk->pid
(24.2). 把tsk->group_leader置
为tsk
(24.3). 调三次attach_pid
,把子进程分别插入PIDTYPE_TGID
,PIDTYPE_PGID
,PIDTYPE_SID
类型的PID
散列表。
(25). 如子进程属于它的父进程的线程组(CLONE_THREAD
被设置):
(25.1). 把tsk->tgid
置为current->tgid
(25.2). 把tsk->group_leader
置为current->group_leader
(25.3). 调attach_pid
,把子进程插入PIDTYPE_TGID
类型散列表(插入current->group_leader
进程的每个PID
链表)
(26). 递增nr_threads
(27). 递增total_forks
(28). 终止并返回子进程描述符指针
内核线程
Linux
中,内核线程在以下几个方面不同于普通进程:
(1). 内核线程只运行在内核态。普通进程即可运行在内核态,也可运行在用户态。
(2). 内核线程只使用大于PAGE_OFFSET
的线性地址空间。普通进程可用4GB
的线性地址空间。
创建一个内核线程
进程0
所有进程的祖先叫进程0
,idle
进程,或因为历史原因叫swapper
进程。是Linux
初始化阶段从无到有创建的一个内核线程。这个祖先进程使用了下列静态分配的数据结构(所有其他进程的数据结构都是动态分配的):
(1). 存放在init_task
变量中的进程描述符,由INIT_TASK
完成对它的初始化
(2). 存放在init_thread_union
中的thread_info
描述符和内核堆栈,由INIT_THREAD_INFO
完成对它们的初始化
(3). 由进程描述符指向的下列表:init_mm
,init_fs
,init_files
,init_signals
,init_sighand
这些表分别由下列宏初始化:INIT_MM,INIT_FS,INIT_FILES,INIT_SIGNALS,INIT_SIGHAND
(4). 内核页全局目录存放在swapper_pg_dir
中。start_kernel
函数初始化内核需要的所有数据结构,激活中断,创建另一个叫进程1
的内核线程(一般叫init
进程):kernel_thread(init, NULL, CLONE_FS | CLONE_SIGHAND);
新创建内核线程的PID
为1
,并与进程0
共享每进程所有的内核数据结构。当调度程序选择到它时,init
进程开始执行init
函数。
创建init
进程后,进程0
执行cpu_idle
,函数本质上是在开中断的情况下重复执行hlt
汇编语言指令。只有当没有其他进程处于TASK_RUNNING
状态时,调度程序才选择进程0
。
多处理器系统中,每个CPU
都有一个进程0
。只要打开机器电源,计算机的BIOS
就启动某一个CPU
,同时禁用其他CPU
。运行在CPU
0
上的swapper
进程初始化内核数据结构,激活其他CPU
,并通过copy_process
创建另外的swapper
进程,把0
传递给新创建的swapper
进程作为它们的新PID
。内核把适当的CPU
索引赋给内核所创建的每个进程的thread_info
描述符的cpu
字段。
进程1
由进程0
创建的内核线程执行init
,init
依次完成内核初始化。init
调用execve
系统调用装入可执行程序init
。结果,init
内核线程变成一个普通进程,且拥有自己的每进程内核数据结构。系统关闭前,init
进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。
其他内核线程
Linux
使用很多其他内核线程。一些内核线程的例子:
(1). keventd
执行keventd_wq
工作队列中的函数
(2). kapmd
处理与高级电源管理相关的事件
(3). kswapd
执行内存回收
(4). pdflush
刷新"脏"缓冲区内容到磁盘以回收内存
(5). kblockd
执行kblockd_workqueue
工作队列中的函数。本质上,它周期性地激活块设备驱动程序。
(6). ksoftirqd
运行tasklet
;系统中每个CPU
都有这样一个内核线程。
撤销进程
进程结束时,必须通知内核以便内核释放进程所拥有的资源,包括内存,打开文件及其他资源,如信号量等。进程终止的一般方式是调用exit
,该函数释放C
函数库所分配的资源,执行编程者所注册的每个函数,并结束从系统回收进程的那个系统调用。
内核可以有选择地强迫整个线程组死掉。这发生在以下两种典型情况下:当进程接收到一个不能处理或忽视的信号时,当内核正在代表进程运行在内核态产生一个不可恢复的CPU
异常时。
进程终止
在Linux 2.6
中有两个终止用户态应用的系统调用:
(1). exit_group
,它终止整个线程组。借助了do_group_exit
(2). exit
,它终止某一个线程。
do_group_exit
do_group_exit
杀死属于current
线程组的所有进程。它接受进程终止代号作为参数,进程终止代号可能是系统调用exit_group
指定的一个值,也可能是内核提供的一个错误代号。函数执行以下操作:
(1). 检查退出进程的SIGNAL_GROUP_EXIT
是否不为0
。如不为0
,说明内核已经开始为线程组执行退出的过程。此时,把存放在current->signal->group_exit_code
中的值作为退出码。跳到4
。
(2). 设置进程的SIGNAL_GROUP_EXIT
标志,并把终止代号存放到current->signal->group_exit_code
字段。
(3). 调用zap_other_threads
杀死current
线程组中的其他线程。为了完成这个步骤,函数扫描与current->tgid
对应的PIDTYPE_TGID
类型的散列表中的每个PID
链表,向表中所有不同于current
的进程发送SIGKILL
信号。结果,所有这样的进程都将执行do_exit
,从而被杀死。
4.调do_exit
,把进程的终止代号传递给它。do_exit
杀死进程且不再返回。
do_exit
所有进程的终止都由do_exit
处理,这个函数从内核数据结构中删除对终止进程的大部分引用。do_exit
接收进程的终止代号作为参数执行下列操作:
(1). 把进程描述符的flag
字段设置为PF_EXITING
标志,以表示进程正在被删除。
(2). 如需要,通过del_timer_sync
从动态定时器队列中删除进程描述符。
(3). 分别调exit_mm,exit_sem,__exit_files,exit_fs,exit_namespace,exit_thread
。从进程描述符中分离出与分页,信号量,文件系统,打开文件描述符,命名空间,及I/O
权限位图相关的数据结构。如没有其他进程共享这些数据结构,则这些函数还删除所有这些数结构。
(4). 如实现了被杀死进程的执行域和可执行格式的内核函数包含在内核模块中,则函数递减它们的使用计数器。
(5). 把进程描述符的exit_code
字段设置成进程的终止代号,这个值要么是_exit
或exit_group
,要么是由内核提供的一个错误代号。
(6). 调exit_notify
执行下面操作:
(6.1). 更新父进程和子进程的亲属关系。如同一线程组中有正在运行的进程,就将终止进程所创建的所有子进程都变成同一线程组中另外一个进程的子进程。(线程组内孩子转移)否则,让它们成为init
的子进程。
(6.2). 检查被终止进程其进程描述符的exit_signal
字段是否不等于-1
,检查进程是否是其所属线程组的最后一个成员。这种情况下,函数通过给正被终止进程的父进程发送一个信号(SIGCHLD
),以通知父进程子进程死亡。
(6.3). 否则,即exit_signal
字段等于-1
,或线程组中还有其他进程,则只要进程被跟踪,就向父进程发送一个SIGCHLD
信号(此时,父进程是调试程序,故,向它报告轻量级进程死亡的信息)。
(6.4). 如进程描述符的exit_signal
字段等于-1
,且进程没被跟踪,就把进程描述符的exit_state
字段置为EXIT_DEAD
,然后调release_task
回收进程的其他数据结构占用的内存,并递减进程描述符的使用计数器。使用计数器变为1
,以使进程描述符本身正好不会被释放。
(6.5). 如进程描述符的exit_signal
字段不等于-1
,或进程正在被跟踪,就把exit_state
字段置为EXIT_ZOMBIE
。
(6.6). 把进程描述符的flags
设置为PF_DEAD
。
(7). 调schedule
选择一个新进程运行。调度程序忽略处于EXIT_ZOMBIE
状态的进程,所以这种进程正好在schedule
中的宏switch_to
被调用之后停止执行。调度程序将检查被替换的僵死进程描述符的PF_DEAB
标志并递减使用计数器,从而说明进程不再存活的事实。
进程删除
Unix
允许进程查询内核以获得其父进程的PID
,或其任何子进程的执行状态。如,进程可创建一个子进程来执行特定的任务,再调用诸如wait
这样的一些库函数检查子进程是否终止。如子进程已经终止,则,它的终止代号将告诉父进程这个任务是否已成功地完成。
为遵循这些设计选择,不允许Unix
内核在进程一终止后就丢弃包含在进程描述符字段中的数据。只有父进程发出了与被终止进程相关的wait
类系统调用后,才允许这样。这是引入僵死状态的原因。
如父进程在子进程结束之前结束,必须强迫所有的孤儿进程成为init
进程的子进程来完成释放。init
进程在用wait
类系统调用检查其合法的子进程终止时,会撤销僵死的进程。
release_task
从僵死进程的描述符中分离出最后的数据结构;对僵死进程的处理有两种可能的方式:如父进程不需要接收来自子进程的信号,就调用do_exit
;如已经给父进程发送了一个信号,就调用wait4
或waitpid
系统调用。在前一种情况下,内存的回收将由调度程序来完成。在后一种情况下,函数还将回收进程描述符所占用的内存空间,
函数执行下述步骤:
(1). 递减终止进程拥有者的进程个数。
(2). 如进程正被跟踪,函数将它从调试程序的ptrace_children
链表中删除,并让该进程重新属于初始的父进程。
(3). 调用__exit_signal
删除所有的挂起信号并释放进程的signal_struct
描述符。如该描述符不再被其他的轻量级进程使用,函数进一步删除这个数据结构。此外,函数调exit_itimers
从进程中剥离掉所有的POSIX
时间间隔定时器。
(4). 调__exit_sighand
删除信号处理函数。
(5). 调__unhash_process
,该函数依次执行下面的操作:
(5.1). 变量nr_threads
减1
(5.2). 两次调用detach_pid
,分别从PIDTYPE_PID
和PIDTYPE_TGID
类型的PID
散列表中删除进程描述符
(5.3). 如进程是线程组的领头进程,则再调用两次detach_pid
,从PIDTYPE_PGID
和PIDTYPE_SID
类型的散列表中删除进程描述符
(5.4). 用宏REMOVE_LINKS
从进程链表中解除进程描述符的链接
(6). 如进程不是线程组的领头进程,领头进程处于僵死状态,且进程是线程组的最后一个成员,则该函数向领头进程的父进程发送一个信号,通知它进程已死亡。
(7). 调sched_exit
来调整父进程的时间片
(8). 调put_task_struct
递减进程描述符的使用计数器。如计数器变为0
,则函数终止所有残留的对进程的引用。
(8.1). 递减进程所有者的user_struct
数据结构的使用计数器。如使用计数器变为0
,就释放该数据结构。
(8.2). 释放进程描述符及thread_info
描述符和内核态堆栈所占用的内存区域。