从抽象的意义来讲,进程是指一个正在运行的程序的实例,而线程是一个CPU指令执行流的最小单位。进程是操做系统资源分配的最小单位,线程是操做系统中调度的最小单位。从实现的角度上讲,XV6系统中只实现了进程, 并无提供对线程的额外支持,一个用户进程永远只会有一个用户可见的执行流。html
根据[1],进程管理的数据结构被叫作进程控制块(Process Control Block, PCB)。一个进程的PCB必须存储如下两类信息:node
在XV6中,与进程有关的数据结构以下linux
// Per-process state struct proc { uint sz; // Size of process memory (bytes) pde_t* pgdir; // Page table char *kstack; // Bottom of kernel stack for this process enum procstate state; // Process state int pid; // Process ID struct proc *parent; // Parent process struct trapframe *tf; // Trap frame for current syscall struct context *context; // swtch() here to run process void *chan; // If non-zero, sleeping on chan int killed; // If non-zero, have been killed struct file *ofile[NOFILE]; // Open files struct inode *cwd; // Current directory char name[16]; // Process name (debugging) };
与前述的两类信息的对应关系以下算法
操做系统管理进程有关的信息:内核栈kstack
,进程的状态state
,进程的pid
,进程的父进程parent
,进程的中断帧tf
,进程的上下文context
,与sleep
和kill
有关的chan
和killed
变量。shell
进程自己运行所须要的所有环境:虚拟内存信息sz
和pgdir
,打开的文件ofile
和当前目录cwd
。windows
额外地,proc
中还有一条用于调试的进程名字name
。数组
在操做系统中,全部的进程信息struct proc
都存储在ptable
中,ptable
的定义以下缓存
struct { struct spinlock lock; struct proc proc[NPROC]; } ptable;
除了互斥锁lock
以外,一个值得注意的一点是XV6系统中容许同时存在的进程数量是有上限的。在这里NPROC
为64,因此XV6最多只容许同时存在64个进程。安全
在proc.c
中,userinit()
用于建立第一个用户进程,allocproc()
则被用于在ptable
中寻找空位并在空位上建立一个新的进程。当操做系统初始化时,经过userinit()
调用allocproc
建立第一个进程init
。绝大多数进程相关信息都会在这里初始化。因为XV6系统只容许中断返回一种从内核态进入用户态的方式,所以allocproc()
会建立中断调用的栈结构,而userinit
会设置其中的值,仿佛是从一次真正的中断里返回进程同样。最后,在mpmain()
中,系统调用schedule()
函数,开始用户进程的调度。在init
进程被调度启动后,会建立shell
进程,用于和用户交互。数据结构
Linux系统的实现中并不刻意区分进程和线程,而是将其一律存储在被称做task_struct
的数据结构中。当两个task_struct
共享同一个虚拟地址空间时,它们就是同一个进程的两个线程。与Linux进程有关的数据结构定义大多数都在/include/linux/sched.h
中。task_struct
数据结构至关复杂,在32位机器上一条能占据1.7KiB的空间。task_struct
中主要包含的数据结构有管理处理器底层信息的thread_struct
、管理虚拟内存的mm_struct
、管理文件描述符的file_struct
、管理信号的signal_struct
等等。Linux中的进程与XV6同样都有独立的内核栈,内核模式下的代码是在内核栈中运行的。
操做系统维护多个task_struct
队列来实现不一样的功能。全部的队列都是用双向链表实现的。有一个队列存放了全部的进程;另外一个队列存放了全部正在运行的进程(kernel/sched.c
中的struct runqueue
);此外,对于每个会致使进程挂起的等待事件,都有一个队列存放由于等待此事件而挂起的进程(include/linux/wait.h
中的wait_queue_t
)。
Linux会将task_struct
数据结构分配到这个进程的内核栈的顶部,将thread_info
数据结构分配到这个进程的内核栈的底部。thread_info
的名称有些误导,它存储的实际上是一个task
中更加底层和更加体系结构相关的属性。进程数据结构的分配方法被称为Slab Allocator,经过精心优化的虚拟内存机制来提高进程管理的效率、实现对象重用。
struct thread_info { struct task_struct *task; struct exec_domain *exec_domain; __u32 flags; __u32 status; __u32 cpu; int preempt_count; mm_segment_t addr_limit; struct restart_block restart_block; void *sysenter_return; int uaccess_err; };
在Windows NT之后的Windows系统中,进程用EPROCESS
对象表示,线程用ETHREAD
对象表示。在一个EPROCESS
对象中,包含了进程的资源相关信息,好比句柄表、虚拟内存、安全、调试、异常、建立信息、I/O转移统计以及进程计时等。每一个EPROCESS
对象都包含一个指向ETHREAD
结构体的链表。值得一提的是Windows系统中EPROCESS
和ETHREAD
的设计都是分层的,KPROCESS
和KTHREAD
成员对象专门用来处理体系结构有关的细节,而Process Environment Block和Thead Environment Block对象则暴露给应用程序来访问。
在大多数教科书使用的标准五状态进程模型中,进程分为New、Ready、Running、Waiting和Terminated五个状态,状态转换图如图所示(图出自Operating System Concepts, 7th Edition)
除去标记进程块未被使用的UNUSED
状态,XV6操做系统中的状态与上述的五状态模型彻底对应。在XV6中这五个状态的定义为
enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };
XV6实现中源代码中具体的转换关系以下
这个转换关系图中,标识出了在XV6系统中发生状态转换所须要的函数或者事件。
Linux中的进程转换图与Xv6大体相同,可是有以下区别:
TASK_TRACED
和TASK_STOPPED
。关于Windows的进程状态,网上并无关于实现细节的特别详细的解释。一份关于Windows进程的文档[6]使用了以下的进程转换图,但并无显式地说明其与Windows进程实现之间的关系。
从中能够看出,Windows进程可能额外多出了Suspend状态,用于如下状况的一种:
我认为操做系统设计这些状态,是出于在有限的计算机系统资源上,对管理和调度多个进程的需求。若是一个CPU核同一时间只会有一个进程运行,那就彻底不须要设置进程的状态。可是一个实用的现代操做系统必须支持大量进程共享一个CPU,也必须支持进程的不断建立与终止,从而实现资源利用效率的最大化和系统功能的多样化。所以,操做系统选取了如今的五状态进程设计,而且出于不一样系统的需求不一样,也会有更加细化的设计。
Xv6系统中的进程可使用fork()
系统调用建立新进程。为了建立一个进程,操做系统必须为这个进程分配相应的资源,包括内存、CPU时间、文件等,与此同时,操做系统必须对此进程作出相应的管理,包括设置它的进程ID、调度优先级、虚拟内存结构、运行资源限制等等。为了可以维持多个进程在一个CPU上运行,必须对此作出相应的调度。调度算法有不少种,由简到难以下
一个现代操做系统所使用的调度算法一般是Priority Based Multilevel Queue的一种变体。具体地说,根据操做系统的具体需求,将不一样类别的进程赋予不一样的优先级。好比,Windows系统中,用户当前使用的窗体进程具备很是高的优先级。对于每个优先级内的进程都会维护一个独自的队列,每一个优先级可使用不一样的调度算法。高优先级的前台进程可使用Round-Robin,后台进程可使用First Come First Served。若是一个进程好久没有获得执行,那么能够提高它的优先级,从低优先级队列进入高优先级队列,从而避免饥饿的问题。
通常而言,出于CPU资源的限制和操做系统内核空间的内存限制,操做系统会指定容许同时存在的最大进程数。在Xv6系统中,最多同时存在64个进程。操做系统会维护一个大小为64的struct proc
数组,并在其中分配新的进程。
进程的上下文包含了这个进程执行时所须要的所有信息,主要是寄存器的值和运行时栈。在Xv6系统中,执行进程的上下文切换就意味着要保存原进程的调用保存寄存器 %ebp %ebx %esi %ebp
,栈指针%esp
和程序指针eip
,并载入新的进程的上述寄存器。特别地,Xv6中的进程切换只会切换到内核调度器进程,并经过内核调度器切换到新的进程。
关于进程调度的具体细节,官方文档具备精彩的描述,在此再也不赘述。
多进程和多CPU之间的关系在于,在操做系统面前,每一个进程都好似占用了一个独立的虚拟CPU,但事实上操做系统会将多个进程分配在一个或多个CPU上运行,进程的数量与CPU的数量之间并无直接的关系。
内核态进程,顾名思义,是在操做系统内核态下执行的进程。在内核态下运行的进程通常用于完成操做系统最底层,最为核心,没法在用户态下完成的功能。好比,调度器进程是Xv6中的一个内核态进程,由于在用户态下是没法进行进程调度的。相比较而言,用户态进程用于完成的功能能够多种多样,而且其功能只依赖于操做系统提供的系统调用,不须要深刻操做内核的数据结构。好比init进程和shell进程就是xv6中的用户态进程。
Xv6进程在虚拟内存中的布局如上图。固然,其中的每一页在物理内存中大几率并非这样排列的,可是虚拟内存系统为每一个进程提供了统一的内存抽象。进程的栈用于存放运行时数据和运行时轨迹,包含了函数调用的嵌套,函数调用的参数和临时变量的存储等。栈一般较小,不会在运行时增加,不适合存储大量数据。相比较而言,堆提供了一个存放全局变量和动态增加的数据的机制。堆的大小一般能够动态增加,而且通常用于存储较大的数据和程序执行过程当中始终会被访问的全局变量。
fork()
函数// Create a new process copying p as the parent. // Sets up stack to return as if from system call. // Caller must set state of returned proc to RUNNABLE. int fork(void) { int i, pid; struct proc *np; struct proc *curproc = myproc(); // Allocate process. if((np = allocproc()) == 0){ return -1; } // Copy process state from proc. if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){ kfree(np->kstack); np->kstack = 0; np->state = UNUSED; return -1; } np->sz = curproc->sz; np->parent = curproc; *np->tf = *curproc->tf; // Clear %eax so that fork returns 0 in the child. np->tf->eax = 0; for(i = 0; i < NOFILE; i++) if(curproc->ofile[i]) np->ofile[i] = filedup(curproc->ofile[i]); np->cwd = idup(curproc->cwd); safestrcpy(np->name, curproc->name, sizeof(curproc->name)); pid = np->pid; acquire(&ptable.lock); np->state = RUNNABLE; release(&ptable.lock); return pid; }
fork()
函数的代码如上。fork()
函数首先调用allocproc()
函数得到并初始化一个进程控制块struct proc
(12-14行)。此外,在allocproc()
函数中还会对进程的内核栈进行初始化,在内核栈里设置一个Trap Frame,把Trap Frame的上下文部分都置为0。而后,fork()
函数使用copyuvm()
函数复制原进程的虚拟内存结构(17-24行)。为了能让子进程返回时处在和父进程如出一辙的状态,Trap Frame也会被拷贝(25行,须要注意这里的运算符优先级)。为了让子进程系统调用的返回值为0,子进程的eax
寄存器会被置为0(28行)。而后,父进程打开的文件描述符会被所有拷贝给子进程(30-32行),还有父进程所处于的目录(33行)。这些操做都会增长文件描述符和目录的被引用数。最后,fork()
函数拷贝了父进程的名字,设置了子进程的状态为RUNNABLE
,而后返回子进程pid
给父进程。子进程被建立后,在某个时刻调度子进程运行时,fork()
函数会第二次返回给子进程,此时返回值为0。
wait()
函数// Wait for a child process to exit and return its pid. // Return -1 if this process has no children. int wait(void) { struct proc *p; int havekids, pid; struct proc *curproc = myproc(); acquire(&ptable.lock); for(;;){ // Scan through table looking for exited children. havekids = 0; for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ if(p->parent != curproc) continue; havekids = 1; if(p->state == ZOMBIE){ // Found one. pid = p->pid; kfree(p->kstack); p->kstack = 0; freevm(p->pgdir); p->pid = 0; p->parent = 0; p->name[0] = 0; p->killed = 0; p->state = UNUSED; release(&ptable.lock); return pid; } } // No point waiting if we don't have any children. if(!havekids || curproc->killed){ release(&ptable.lock); return -1; } // Wait for children to exit. (See wakeup1 call in proc_exit.) sleep(curproc, &ptable.lock); //DOC: wait-sleep } }
wait()
函数的代码如上。wait()
函数首先必需要得到ptable
的锁(10行),由于它有可能会对ptable
作出修改。而后它会遍历ptable
,从中寻找本身的子进程(14-32行)。若是发现僵尸子进程,就把僵尸子进程回收,具体地说要回收它的虚拟内存,内核栈,并设置状态为UNUSED
(18-30行),有趣的是,在这里wait()
函数根本没有回收这个子进程打开的文件描述符,由于在exit()
函数内这个进程打开的文件描述符已经所有被关闭了,并且只有exit()
以后的进程才多是ZOMBIE
状态。对于没有子进程的状况,wait()
会直接返回,不然他会调用sleep()
,并传入ptable
的锁做为参数。之因此要在sleep
函数中传入ptable
锁,是为了不在wait()
把进程设置为SLEEP
状态以前,子进程就已经成为僵死进程并在exit()
函数中调用了wakeup()
,这会使得父进程接收不到wakeup
从而进入死锁状态。
exit()
函数// Exit the current process. Does not return. // An exited process remains in the zombie state // until its parent calls wait() to find out it exited. void exit(void) { struct proc *curproc = myproc(); struct proc *p; int fd; if(curproc == initproc) panic("init exiting"); // Close all open files. for(fd = 0; fd < NOFILE; fd++){ if(curproc->ofile[fd]){ fileclose(curproc->ofile[fd]); curproc->ofile[fd] = 0; } } begin_op(); iput(curproc->cwd); end_op(); curproc->cwd = 0; acquire(&ptable.lock); // Parent might be sleeping in wait(). wakeup1(curproc->parent); // Pass abandoned children to init. for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ if(p->parent == curproc){ p->parent = initproc; if(p->state == ZOMBIE) wakeup1(initproc); } } // Jump into the scheduler, never to return. curproc->state = ZOMBIE; sched(); panic("zombie exit"); }
exit()
函数首先关闭这个进程打开的全部文件描述符(15-20行),而后除去本身对所处的文件目录的引用(22-25行),对文件管理相关数据结构的访问必需要得到和释放相关的锁(begin_op()
和end_op()
)。清除这些引用能够容许文件系统管理当前的缓存。若是这个进程的父进程正在等待子进程结束,那么这个进程必须唤醒父进程(30行),只有这样父进程才可以在某个时刻回收僵尸子进程。若是这个进程有子进程的话,就把这个进程的子进程都传给init
进程,并由init
进程来负责回收僵尸子进程(33-39行)。最后,这个进程的状态会被设置为ZOMBIE
,调度器调度其余进程运行(42-44行)。
Operating System Concepts, 7th Edition
Computer Systems: a Programmer's Perspective, 3rd Edition
Process in Linux, https://www.cs.columbia.edu/~junfeng/10sp-w4118/lectures/l07-proc-linux.pdf
10 Things Every Linux Programmer Should Know, http://www.mulix.org/lectures/kernel_workshop_mar_2004/things.pdf
Introduction to Linux Kernel, Chapter 3 Process Management, https://notes.shichao.io/lkd/ch3/#chapter-3-process-management
A Complete Introduction to Windows Processes, Threads and Related Resources, https://www.tenouk.com/ModuleT.html
Windows进程数据结构及建立流程,https://blog.csdn.net/cuit/article/details/9200097