深入理解Linux内核-进程-进程

文章详细阐述了Linux系统中进程和线程的概念、状态、创建、切换、资源限制以及终止过程。讨论了进程描述符、线程描述符、硬件上下文、等待队列等核心概念,并介绍了内核如何通过调度程序、硬件上下文切换和进程间通信来管理进程和线程。同时,提到了内核线程和用户进程的区别以及进程之间的关系和生命周期管理。
摘要由CSDN通过智能技术生成

进程,轻量级进程和线程

进程,是程序执行时的一个实例。从内核观点看,进程的目的就是担当分配系统资源(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
进程的执行被终止。但是,父进程还没执行wait4waitpid来返回有关死亡进程的信息。执行wait类系统调用前,内核不能丢弃包含在死进程描述符中的数据。
(2). 僵死撤销状态(EXIT_DEAD
最终状态:由于父进程刚发出wait4waitpid系统调用,因而进程由系统删除。
为防止其他执行线程在同一个进程上也执行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。线程组的领头线程其tgidpid值相同。

进程描述符处理

对每个进程来说,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(取值范围是0139),run_list字段把该进程链入优先权为k的可运行进程的链表中。

多处理器系统中,每个CPU都有它自己的运行队列。内核必须为系统中每个运行队列保存大量的数据,不过运行队列的主要数据结构还是组成运行队列的进程描述符链表,所有这些链表都由一个单独的prio_array_t数据结构来实现。

类型字段描述
intnr_active链表中进程描述符的数量
unsigned long[5]bitmap优先权位图:当且仅当某个优先权的进程链表不为空时设置相应的位标志
struct list_head[140]queue140个优先权队列的头结点

进程描述符的prio字段存放进程的动态优先权,进程描述符的array字段是一个指针,指向当前运行队列的prio_array_t数据结构。

进程间的关系

程序创建的进程具有父子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。
进程0,进程1是由内核创建的;进程1init)是所有进程的祖先。下表是进程描述符中表示进程亲属关系的字段的描述:

字段名说明
real_parent指向创建了P的进程的描述符;如该进程不再存在,指向进程1(init)的描述符
parent指向P的当前父进程。
children链表的头部,链表中的所有元素都是P创建的子进程
sibling指向兄弟进程链表中的下一个元素或前一个元素的指针,这些兄弟进程的父进程一致

进程之间还存在其他关系:一个进程可能是一个进程组或登录会话的领头进程,也可能是一个线程组的领头进程,还可能追踪其他进程的执行。下表是建立非亲属关系的进程描述符字段

字段名说明
group_leaderP所在进程组的领头进程的描述符指针
signal->pgrpP所在进程组的领头进程的PID
tgidP所在线程组的领头进程的PID
signal->sessionP的登录会话领头进程的PID
ptrace_children链表的头,该链表包含所有被debugger程序跟踪的P的子进程
ptrace_list指向所跟踪进程其实际父进程链表的前一个和下一个元素(用于P被跟踪时)

pidhash表及链表

几种情况下,内核必须能从进程的PID导出对应的进程描述符指针。为了加速从pid到进程描述符的查找,引入了四个散列表。需要四个散列表是因为进程描述符包含了表示不同类型PID的字段,且每种类型的PID需要它自己的散列表。

Hash表的类型字段名说明
PIDTYPE_PIDpid进程的PID
PIDTYPE_TGIDtgid线程组领头进程的PID
PIDTYPE_PGIDpgrp进程组领头进程的PID
PIDTYPE_SIDsession会话领头进程的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字段中。

类型名称描述
intnrpid的数值
struct hlist_nodepid_chain链接散列表的下一个和前一个元素
struct list_headpid_list每个pid的进程链表头

在这里插入图片描述
即先通过哈希表,定位到线程组领头进程,再依赖每个进程task_structpid_list字段,找到此线程组下每个成员.
每个进程task_structpid_chain字段用于将位于同一个哈希桶索引下各个进程组织在一起.

如何组织进程

运行队列链表把处于TASK_RUNNING状态的所有进程组织在一起。当要把其他状态的进程分组时,不同的状态要求不同的处理,Linux选择了下列方式之一:
1.没有为处于TASK_STOPPED,EXIT_ZOMBIEEXIT_DEAB状态的进程建立专门的链表。对处于暂停,僵死,死亡状态进程的访问比较简单,或者通过PID,或者通过特定父进程的子进程链表,所以,不必对这三种状态进程分组。
2.根据不同的特殊事件把处于TASK_INTERRUPTIBLETASK_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_MSGQUEUEPOSIX消息队列中最大字节数
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). 当用户态进程试图通过inout访问一个I/O端口时,CPU需访问存放在TSS中的I/O许可位图以检查该进程是否有访问端口的权力。确切地说,进程在用户态下执行Inout指令时,控制单元执行下列操作:
a.检查eflags中的2IOPL字段,如值为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存放在全局描述符表中。每个CPUtr寄存器包含相应的TSSTSSD选择子。也包含两个隐藏的非编程字段:TSSDBase字段和Limit字段。

thread字段

每个进程描述符包含一个类型为thread_structthread字段。只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。这个数据结构包含的字段涉及大部分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扩展,该扩展为处理包含在8128位寄存器(叫做XMM寄存器)的浮点值增加了功能。这样的寄存器不与FPUMMX寄存器重叠,因此,SSEFPU/MMX指令可以随意地混合。

Pentium 4模型指令还引入另一种特点:SSE2扩展。该扩展基本上是SSE的一个扩展,支持高精度浮点值。SSE2SSE使用同一XMM寄存器集。80x86微处理器并不在TSS中自动保存FPU,MMXXMM寄存器。不过,它们包含某种硬件支持,能在需要时保存这些寄存器的值。硬件支持由cr0寄存器中的一个TS标志组成,遵循以下规则:
(1). 每当执行硬件上下文切换时,设置TS标志。
(2). 每当TS标志被设置时执行ESCAPE,MMX,SSESSE2指令,控制单元就产生一个"Device not available"异常。

TS标志使得内核只有在真正需要时才保存和恢复FPUMMXXMM寄存器。为说明它如何工作,我们假设进程A使用数学协处理器。当发生上下文切换时,内核置TS标志并把浮点寄存器保存在进程ATSS中。如果新进程B不利用协处理器,内核就不必恢复浮点寄存器的内容。

但,只要B打算执行ESCAPEMMX指令,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由具有SSESSE2扩展功能的CPU模型使用。进程描述符包含两个附加的标志:
(1). 包含在thread_infostatus字段的TS_USEDFPU标志。它表示进程在当前执行过程中是否使用过FPU,MMX,XMM寄存器。
(2). 包含在task_structflagsPF_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进程第一次试图执行ESCAPEMMXSSE/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单元

内核也可使用FPUMMXSSE/SSE2单元。这样做的时候,应该避免干扰用户态进程所进行的任何计算。故:
1.在使用协处理器之前,如用户态进程使用了FPU,内核需调用kernel_fpu_begin,本质就是调用save_init_fpu来保存寄存器的内容,重新设置cr0.TS
2.使用完协处理器之后,内核必须调用kernel_fpu_end设置cr0.TS。稍后,用户态进程执行协处理器指令时,math_state_restore将恢复寄存器的内容(就像进程切换后的恢复)。

但注意,当前用户态进程正使用协处理器时,kernel_fpu_begin的执行时间相当长,以至于无法通过使用FPUMMXSSE/SSE2达到加速目的。内核只在有限场合使用FPUMMXSSE/SSE2,典型情况有:当移动或清除大内存区字段时,或当计算校验和函数时。

创建进程

现代Unix内核引入三种不同机制解决基于父进程产生子进程速度慢问题:
(1). 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。
(2). 轻量级进程允许父子进程共享每进程在内核的很多数据结构,如页表,打开文件表,信号处理。
(3). vfork系统调用创建的进程能共享父进程的内存地址空间。阻塞父进程执行。直到子进程退出或执行一个新的程序为止。

clone,fork,vfork系统调用

clone时标志

名称描述
CLONE_VM共享内存描述符和所有的页表
CLONE_FS共享根目录,当前工作目录所在的表,用于屏蔽新文件初始许可权的位掩码值
CLONE_FILES共享打开文件表
CLONE_SIGHAND共享信号处理程序的表,阻塞信号表,挂起信号表。必须搭配CLONE_VM
CLONE_PTRACE如父进程被跟踪,则子进程也被跟踪。
CLONE_VFORKvfork使用
CLONE_PARENT设置子进程的父进程为调用进程的父进程
CLONE_THREAD把子进程插入到父进程的同一线程组,迫使子进程共享父进程的信号描述符。必须搭配CLONE_SIGHAND
CLONE_NEWSclone需要自己的命名空间时
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存入currentptrace_message并调用ptrace_notifyptrace_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_flagsCLONE_PARENT_SETTID被设置,就把子进程的PID复制到参数parent_tidptr指向的用户态变量中。
(11). 初始化子进程描述符中的list_head数据结构和自旋锁,为挂起信号,定时器,时间统计表相关的字段赋值。
(12). 调copy_semundocopy_filescopy_fscopy_sighandcopy_signalcopy_mmcopy_namespace创建新的数据结构,把父进程相应数据结构值复制到新数据结构。
(13). 调copy_thread,用发出cloneCPU寄存器的值初始化子进程的内核栈。对eax强行置0。子进程的thread.eip指向ret_from_fork。如父进程使用I/O权限位图,子进程获得该位图的一个拷贝。如,CLONE_SETTLS被设置,子进程获取由clone的参数tls指向的用户态数据结构所表示的TLS段。
(14). 如clone_flags被置为CLONE_CHILD_SETTIDCLONE_CHILD_CLEARTID,就把child_tidptr复制到tsk->set_child_tidtsk->clear_child_tid
(15). 清除子进程thread_infoTIF_SYSCALL_TRACE。使得ret_from_fork不会把系统调用结束的消息通知给调式进程。
(16). 用clone_flags低位编码tsk->exit_signalCLONE_THREAD设置下,tsk->exit_signal初始化为-1。只有线程组最后一个成员(通常是线程组的领头进程)死亡时,才会产生一个信号,以通知线程组领头进程的父进程。
(17). 调sched_fork完成新进程调度程序数据结构的初始化。函数把新进程状态设置为TASK_RUNNING,把thread_infopreempt_count置为1(这样禁止了内核抢占)
(18). 设置新进程的thread_info->cpu为当前本地cpu
(19). 建立亲子关系。如CLONE_PARENTCLONE_THREAD被设置,用current->real_parent初始化tsk->real_parenttsk->parent。否则,把tsk->real_parenttsk->parent置为当前进程。通过fork得到的新进程,可以是执行fork进程的子进程(不含CLONE_PARENTCLONE_THREAD)也可是执行fork进程的兄弟进程(含CLONE_PARENTCLONE_THREAD)。线程组内各个轻量级进程间是兄弟关系。父进程都是线程组内首个轻量级进程的父进程。
(20). 如不需跟踪子进程,把tsk->ptrace置为0tsk->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_TGIDPIDTYPE_PGIDPIDTYPE_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

所有进程的祖先叫进程0idle进程,或因为历史原因叫swapper进程。是Linux初始化阶段从无到有创建的一个内核线程。这个祖先进程使用了下列静态分配的数据结构(所有其他进程的数据结构都是动态分配的):
(1). 存放在init_task变量中的进程描述符,由INIT_TASK完成对它的初始化
(2). 存放在init_thread_union中的thread_info描述符和内核堆栈,由INIT_THREAD_INFO完成对它们的初始化
(3). 由进程描述符指向的下列表:init_mminit_fsinit_filesinit_signalsinit_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);新创建内核线程的PID1,并与进程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创建的内核线程执行initinit依次完成内核初始化。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字段设置成进程的终止代号,这个值要么是_exitexit_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;如已经给父进程发送了一个信号,就调用wait4waitpid系统调用。在前一种情况下,内存的回收将由调度程序来完成。在后一种情况下,函数还将回收进程描述符所占用的内存空间,

函数执行下述步骤:
(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_PIDPIDTYPE_TGID类型的PID散列表中删除进程描述符
(5.3). 如进程是线程组的领头进程,则再调用两次detach_pid,从PIDTYPE_PGIDPIDTYPE_SID类型的散列表中删除进程描述符
(5.4). 用宏REMOVE_LINKS从进程链表中解除进程描述符的链接
(6). 如进程不是线程组的领头进程,领头进程处于僵死状态,且进程是线程组的最后一个成员,则该函数向领头进程的父进程发送一个信号,通知它进程已死亡。
(7). 调sched_exit来调整父进程的时间片
(8). 调put_task_struct递减进程描述符的使用计数器。如计数器变为0,则函数终止所有残留的对进程的引用。
(8.1). 递减进程所有者的user_struct数据结构的使用计数器。如使用计数器变为0,就释放该数据结构。
(8.2). 释放进程描述符及thread_info描述符和内核态堆栈所占用的内存区域。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

raindayinrain

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值