一些概念
进程:处于执行中的目标代码以及相关资源的合集
线程:进程中的活动对象,也是内核的直接调度对象,管理独立的
- 指令计数器
- 栈
- 寄存器
Linux系统并不区分进程和线程;线程被视作一种特殊的进程,都是lightweight process。
操作系统对进程提供两种虚拟机制
- 虚拟处理器:用于进程调度{todo}
- 虚拟内存:用于进程分配及管理内存{todo}
进程的生命周期:
- 在Linux系统中,进程通常由fork()系统调用创建,这个系统调用通过复制一个已存在的进程(父进程)创建新进程(子进程)。
- fork()调用从内核返回两次,在父进程返回时,其恢复执行;在子进程返回时,其开始执行。
- 往往在fork()调用后,通过exec()函数族创建新的地址空间并加载运行不同的程序。
- 最后exit()系统调用终止进程及释放资源,进程退出后就进入了僵尸态,等待父进程通过系统调用wait()或waitpid()等待子进程终止并获取其终止状态。
进程状态
TASK_RUNNING:正在执行的或在运行队列中等待执行的。普通进程运行于用户空间,当系统调用或发生异常时进入内核空间。
TASK_INTERRUPTIBLE:进程处于挂起状态,当满足某些条件时,例如硬件中断,获取资源或收到信号,进程被唤醒并设为TASK_RUNNING状态
TASK_UNINTERRUPTIBLE:和上一状态唯一区别在于进程无法通过信号唤醒,较少使用
TASK_TRACED:进程被另一进程追踪(traced),例如通过ptrace调试。
TASK_STOPPED:进程停止执行,一般收到SIGSTOP, SIGTSTP, SIGTTIN, 或SIGTTOU信号时进程进入此状态。当进程被调试时,收到任何信号都会进入此状态
下面两个状态是执行终止后进入的状态,被存储在state或exit_state字段:
EXIT_ZOMBLE:进程已终止执行,但父进程没有wait()调用收集返回信息
EXIT_DEAD:最后状态,父进程调用wait()之后,进程从系统中移除。
进程描述符及Task结构
进程描述符分配
内核使用双向环链表(任务列表)存储进程描述符
<linux/sched.h>
struct task_struct
在内核2.6版本之前,task_struct 存储在进程内核栈顶,这样的设计使得内核可以直接通过栈指针计算出进程描述符的位置,节省了使用额外寄存器存储位置的开销。之后的版本中,task_struct由slab分配器动态创建{todo}。由此产生新的结构struct thread_info,它也被存储在栈顶。
内核栈的大小一般为8KB或4KB。
26 struct thread_info {
27 struct task_struct *task; /* main task structure */
28 struct exec_domain *exec_domain; /* execution domain */
29 __u32 flags; /* low level flags */
30 __u32 status; /* thread synchronous flags */
31 __u32 cpu; /* current CPU */
32 int saved_preempt_count;
33 mm_segment_t addr_limit;
34 struct restart_block restart_block;
35 void __user *sysenter_return;
36 unsigned int sig_on_uaccess_error:1;
37 unsigned int uaccess_err:1; /* uaccess failed */
38 };
进程识别值:PID(一般为int类型),存储在进程描述符中。之前提到,Linux内核并不区分进程与线程,但是在用户看来,属于同一进程的一组线程应当拥有相同的PID。因此,内核使用了线程组结构,组内的第一个进程的PID被全组的进程共享,存储在描述符tpid字段。第一个进程的tpid与其pid字段相等。
操作系统最大支持400多万个PID以有效保证较大的PID进程比较小的PID进程后执行这一特征。而一般桌面系统最大默认PID为32768,
当超过32768,PID将循环分配,内核使用bitmap来记录哪些PID被占用而哪些是空闲的。在32位系统上,bitmap刚好占用1个page。
大部分对进程的处理都是通过struct task_struct进行。
movl $0xffffe000,%ecx /*or 0xfffff000 for 4KB stacks*/
andl %esp,%ecx /*esp CPU stack pointer*/
movl %ecx,p /*p contains thread_info pointer*/
上述指令将内核栈的首地址存入p中,即thread_info结构的指针,事实上,下面的指令更为常用
movl $0xffffe000,%ecx
andl %esp,%ecx
movl %(ecx),p /*p contains task_struct pointer*/
这三条指令直接将位于thread_info结构0偏移字段的值存入p中,即task_struct 结构的指针。
双向环链表
Linux Kernel广泛使用list_head 结构,使用宏定义提供了一系列操作。它不包含任何数据,而是嵌入到其他的数据结构中,这类链表称为侵入式链表。
struct list_head {
struct list_head *next, *prev;
};
- 不涉及任何数据与类型,API很容易通用;
- 可以直接嵌入到其他数据结构中避免了为每种数据结构都实现链表操作,节省了精力及存储空间;
- 便于管理异构数据结构,即链表中的节点不必是相同的结构体。
需要注意:
- next,prev中存储的是目标结构体中的list_head字段的地址,并非目标结构体的地址
- 整个链表的第一个元素仅充当类似占位符的作用。
因此提供一个比较重要的宏定义,根据list_head字段地址计算结构体地址:
#define list_entry(ptr, type, member)
(type *)((char *)(ptr) - (char *) &((type *)0)->member)
进程列表
每个task_struct结构都包含名为tasks的list_head类型字段,它的prev,next指针分别指向前一个以及后一个task_struct结构。进程列表的头部是init_task进程描述符,也被称为0号进程,它的tasks->prev指向列表中的最后一个进程描述符。
另一个重要的宏定义,遍历整个进程列表,从init_task->next开始,直到再次回到init_task:
#define for_each_process(p)
for (p=&init_task; (p=list_entry((p)->tasks.next,struct task_struct, tasks)) != &init_task;)
TASK_RUNNING 进程列表
先前的linux版本中,所有处于runnable的进程都被排在一个列表中,由于进程优先级概念,早期调度器需要遍历整个列表来获取“最好”的进程;2.6版本调度器可以在常数时间{todo}选择“最好”的进程。基础的做法是为每个优先级都维护一个进程列表,一共有0~139这140种优先级,通过bitmap指示这一优先级进程列表是否为空。
进程之间的关系
进程间有四种关系,在描述符结构中由以下字段指,使用P代表当前进程。
real_parent:指向创建P的进程描述符;若父进程已经终止,则指向init进程(1号进程)
parent:指向当前的父进程(当P终止时,必须向父进程发送信号),一般与上一字段相同,当其他进程调用ptrace()L5时,此进程作为P的parent
children:指向P创建的进程列表头部
sibling:指向前一个及后一个兄弟进程,拥有相同的parent
PID哈希表
进程描述符有4种类型的PID,因此有4张哈希表
PIDTYPE_PID pid PID
PIDTYPE_TGID tgid 线程组leader的PID
PIDTYPE_PGID pgrp 进程组leader的PID
PIDTYPE_SID session 会话leader的PID
哈希表冲突时有发生,Linux使用链式管理发生冲突的PID,
进程组织
TASK_STOPPED, EXIT_ZOMBIE, EXIT_DEAD这三种状态的进程只能通过PID或父进程的child字段访问,不使用列表维护。
TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE 根据不同情况分为很多种类,所以仅靠进程状态不能快速检索进程,Linux使用了等待队列(wait queues)
等待队列
等待队列头部:
struct _ _wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
typedef struct _ _wait_queue_head wait_queue_head_t;
等待队列元素:
struct _ _wait_queue {
unsigned int flags; /*1: exclusive process*/
struct task_struct * task;
wait_queue_func_t func;/*指定唤醒方式,成功唤醒时返回1,唤醒所有非排他进程和一个排他进程*/
struct list_head task_list; /*等待相同事件的进程列表,当满足事件时,可能都会唤醒。*/
};
typedef struct _ _wait_queue wait_queue_t;
使用两种类型睡眠进程来避免惊群效应:排他进程,非排他进程;
处理等待队列
DECLARE_WAIT_QUEUE_HEAD(name):初始化lock以及task_lists
DEFINE_WAIT :定义 wait_queue_t变量
add_wait_queue():非排他进程插入等待队列头部
add_wait_queue_exclusive():排他进程插入等待队列末尾
remove_wait_queue():删除一个进程
waitqueue_active():检查等待队列是否为空
关于sleep,进程调用以下函数进入sleep状态:
- sleep_on:调用schedule(),UNINTERRUPTABLE
- interruptable_sleep_on():INTERRUPTABLE
- sleep_on_timeout(),interruptable_sleep_on_timeout()
- Linux 2.6 prepare_to_wait(),prepare_to_wait_exclusive(),finish_wait()
- wait_event,wait_event_interruptible
关于wake_up,将满足条件的进程设为TASK_RUNNING:
wake_up, wake_up_nr
wake_up_all
wake_up_interruptible
wake_up_interruptible_nr
wake_up_interruptible_all
wake_up_interruptible_sync
wake_up_locked
- 不含有interruptible的函数可以处理TASK_UNINTERRUPTIBLE状态。
- 可以唤醒所有非排他进程
- nr-唤醒指定数量的排他进程
- all-唤醒所有排他进程
- 其他函数唤醒1个排他进程
- 不包含sync的函数检查唤醒进程的优先级是否高于正在运行的进程,必要时调用schedule()
- wake_up_locked:当持有自旋锁lock时调用
进程资源限制
通过current->signal->rlim 字段指定。
struct rlimit {
unsigned long rlim_cur;
unsigned long rlim_max;
};
getrlimit()和 setrlimit()系统调用。
当用户登录时,系统管理员调用 setrlimit()限定资源,用户创建的所有进程都继承这一限制。
进程切换
进程切换仅在内核态进行。
所有进程共享寄存器,所以内核保证进程挂起和重新执行时寄存器内容一致,这些数据称为硬件上下文,一部分存储在进程描述符中,其余存储在内核栈中。
使用 prev表示被换出(挂起)的进程,mext表示替代它的(执行)进程,目标是最优化存储和加载硬件上下文的时间。
先前版本Linux利用X86架构的far jmp指令自动S/L硬件上下文的特性实现进程切换,但2.6版本用软件执行进程切换,因为:
- 通过mov指令逐步切换更好地控制加载数据的有效性,特别地,检查DS,ES寄存器以防止恶意用户伪造数据
- 新老方式所用时间基本相同,但是软件方式可能有提升空间
任务状态段(TSS)
x86使用TSS存储硬件上下文,Linux也(强制地)为每个CPU设置TSS,因为:
- x86CPU从用户态转到内核态时,从TSS中取得内核栈地址
- 当用户态进程试图通过in out指令访问I/O端口时,CPU需要检查存储在TSS中的I/O权限位图以确认进程是否拥有权限:
- 检查efalgs寄存器的IOPL段,如果置为3,执行指令,否则:
- 访问tr寄存器,得到TSS,进而得到位图所在地址。
- 如果对应端口置为0,执行指令;否则产生异常
发生进程切换时,内核更新进程TSS的一些字段,反映了进程的CPU权限。每个TSS拥有8字节的TSS描述符。
存储在全局描述符表中(GDT),它的基址存在gdtr寄存器。每个CPU的tr寄存器包含TSS的TSSD选择符,同时包含两个隐藏的非编程字段:TSSD的Base字段和Limit字段。这样,处理器就能直接对TSS寻址而不用从GDT中检索TSS的地址。
thread字段
进程描述符包含一个thread_struct类型的 thread字段,存储了进程切换时的进程上下文,这个字段存储了大多数寄存器。
执行进程切换
进程切换通过一个函数实现:schedule(){todo},主体包含两个步骤:
1.切换全局页目录装载新的地址空间{todo}
2.切换内核栈以及硬件上下文
switch_to宏
第二步切换由这个宏完成。这个宏有三个参数,除了之前的prev,next还包括last。实际上,任何进程切换都有三个进程参与。当内核决定挂起进程A,激活进程B,prev=A next =B,switch_to使进程A失效。当内核重新激活A时,必须挂起另外一个进程C,prev=C next =A。A恢复执行后,它加载原来的内核栈,此时prev=A next =B,因此调度器丢失了关于C的信息,但这部分信息是有用处的。
因此last参数用作输出,指定了写C描述符的内存地址。进程切换之前,将prev存在eax寄存器中。进程切换之后,将eax内容写在last指定的内存地址中。2.6的实现中,last 指向prev地址,所以prev会被eax覆盖。
实现进程切换的宏以及函数
switch_to macro
_ _switch_to() function
创建进程
早期Unix系统复制父进程所有的资源到子进程,这样的操作缓慢且低效,现代版本使用三种不同机制:
- 写拷贝技术,父进程与子进程读取相同的物理页,当其中一个试图写入时,内核将内容拷贝到新的物理页,并复制给写线程。
- 轻量线程,允许父子进程共享很多进程独有内核数据结构,包括分页表,文件描述符以及信号处理方式
- vfork()系统调用,子进程共享父进程的内核地址空间。为防止父进程更改子进程所需的数据,直到子进程终止或执行新的进程, 父进程都被阻塞。
轻量级进程通过clone()系统调用创建。fork()调用将flags参数设为SIGCHLD,vfork()设为SIGCHLD|CLONE_VM|CLONE_VFORK,不用拷贝内存描述符和页表。由于写拷贝技术,child_stack 参数均指向父进程的栈指针。
int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
clone调用包装了sys_clone调用,fn函数指针及其参数arg被存储在子进程栈中,当函数执行完成,CPU从栈内获取返回地址,并执行fn(arg);
do_fork函数
do_fork函数处理clone系统调用,调用copy_process()创建进程描述符以及其他内存数据结构。
do_fork调用产生了完整的,处于runnable状态的子进程,但是并没有运行。由调度器决定子进程什么时候获取CPU,在未来很有可能会发生进程切换,所以在执行函数期间,一些子进程thread字段中的值被加载到寄存器中,fork,vfork系统调用的返回值存储在eax中,子进程为0,父进程为PID。
一般情况下,内核希望先执行子进程,因为子进程更有可能调用exec()类似的函数。如果先执行父进程,那么父进程很有可能写内存,会触发写拷贝,产生开销 。
内核线程
先前Unix系统将关键任务委派给间歇性执行的进程,然而,使用严格的线性方式执行任务并不高效。由于一些系统进程只在内核态运行,现代操作系统将任务委派给内核线程,与普通进程有如下不同:
- 只在内核态运行,不会在用户态之间进行切换。
- 只用高位线性空间,一般对于4GB内存,最高位1G为内核空间,低位3G为用户空间;一般的进程使用所有4G线性空间。
kernel_thread()函数创建内核线程,实际上调用了do_fork(),设置一些flags。
0号进程
idle进程,是Linux初始化阶段创建的内核线程,是所有进程的祖先,使用静态分配的一系列数据结构(其他进程的数据结构都是动态分配),其中进程描述符存储在init_task中。
start_kernel()函数初始化这些数据结构,启用中断,创建另一个内核线程init(PID 1)。init进程与0号进程共享所有的数据结构,当被调度器选中后,执行init()函数。此后0进程执行cpu_idle()函数,仅当没有处于TASK_RUNING状态的进程时,0进程才会被选择。
多处理器系统中,每个处理器都拥有0进程。通电的时候,BIOS启动单CPU,idle进程初始化内存数据结构,启动其他CPU,通过copy_process创建其他的idle进程,PID均设为0,并且为每个进程中thread_info结构中cpu字段设定适当的CPU索引。
1号进程
通过执行init()调用execve(),加载init程序,init进程在系统运行期间一直存在。
销毁进程
一般调用exit()库函数终止进程,释放资源,执行特定函数并从系统中退出。
内核也可以强制整个线程组退出。当进程受到不能处理或忽略的信号以及进程在内核态执行时CPU出现不可恢复的异常。
在2.6版本,有两个结束用户态进程的系统调用:
- exit_group(),终止整个线程组,触发do_group_exit()->do_exit()。
- _exit(),终止线程组中的单一进程,例如pthread_exit()函数,触发do_exit()。
/*do_exit()函数*/
设置进程描述符为PF_EXITING
释放进程描述符以及其他资源,关闭引用数为0的资源
设置exit_code
其子进程被设为同线程组的子进程或init进程的子进程
调用 exit_notify( )函数:
如果进程是线程组最后一个(final),并且exit_signal不为-1,向父进程发送信号(一般是SIGCHLD)。
如果(!final || e_s == -1)当该进程被跟踪时(btrace),向父进程(debuger)发送信号
如果(e_s == -1 && !btrace) 设为EXIT_DEAD,回收内存,减PID计数。
如果(e_s != -1 || btrace) exit_state = EXIT_ZOMBIE
设置进程描述符为PF_DEAD
调用schedule()选择新进程
进程移除
处于EXIT_ZOMBLE状态的进程描述符仍然被保存,直到通知其父进程(父进程调用wait)。
如果父进程更早结束,所有孤儿进程都要变为init进程的子进程。
release_task()释放僵尸进程的数据结构,通过do_exit或wait,区别在于父进程是否对终止信号感兴趣。
/*release_task()函数*/
减少用户拥有的进程数
如果(btrace)从debugger ptrace_chlidren列表移除
取消所有判定信号
取消信号处理函数
unhash_process:
nr_thread减1
PIDTYPE_PID 和 PIDTYPE_TGID哈希表删除进程描述符
如果是线程组leader,从PIDTYPE_PGID 和 PIDTYPE_SID删除
从进程列表移除描述符
如果(! t_grp leader && leader == zombie && blast_of_tgrp),向leader的父进程发信号
sched_exit(),调整父进程时间片
减少描述符使用计数,如果为0:
减少用户拥有进程数的使用计数
释放描述符及内核栈。