Linux学习笔记之进程

进程是多道程序设计的操作系统中的基本概念,通常把进程定义为程序执行的一个实例, 例如16个user在使用vi,那么操作系统就有16个独立的进程,尽管他们共享同一个可执行代码。

进程,轻量级进程和线程
当一进程创建的时候,它几乎与父进程相同,它接受父进程地址空间的一个(逻辑)拷贝(假设当前进程A中定义了一个变量X,其地址为 0x01,那么拷贝之后,子进程中对应的X,地址空间任然为0x01,虽然两者的地址一样,但是这只限于在虚拟内存的地址空间内,两者在物理地址上肯定是不一样的)。并且从进程创建系统调用的下一条指令开始执行与父进程相同的代码,尽管父子进程可以共享有程序代码的页,但是他们各自有独立的数据拷贝(堆和栈),因此子进程对内存单元的修改对父进程来说是可见的.

进程描述符
进程描述符中包含了与一个进程相关的所有信息,它不仅包括了很多进程属性的字段,而且一些字段还包括了指向其他数据结构的指针。进程描述符都是task_struct结构。

进程的状态
进程描述符中的state字段描述当前进程的状态,进程每种状态是互斥的。

  • 运行状态(TASK_RUNNING)
  • 可中断的等待状态(TASK_INTERRUPTIBLE):进程被挂起,知道某个条件为真。
  • 不可中断的等待状态(TASK_UNINTERRUPTERABLE) 这个时候即使把信号传递到挂起进程也不能改变它的状态,在一些特定的情况下这种状态很有用,例如,当进程打开一个设备文件,其相应的设备驱动程序开始探测相应的硬件设备时就会用到这种状态,探测完成之前,设备驱动程序不能被中断,否则,硬件设备处于不可知的状态。
  • 暂停状态(TASK_STOPPED) 进程的执行被暂停,当进程接收到SIGSTOP 等信号的时候进入暂停状态。
  • 跟踪状态(TASK_TRACED)进程的执行由debugger程序暂停,当一个进程被另一个进程监控的时候(例如debugger执行ptrace()系统调用监控一个测试程序),任何程序都可以把这个程序置于TASK_TRACED.
  • 僵死状态(EXIT_ZOMBIE) 进程的执行被终止,但是父进程还没有发布wait4() 或者 waitpid() 系统调用来返回死亡进程的信息,发布wait()类系统调用前,内核不能丢弃包含在死进程描述符中的数据,因为父进程可能还需要它。
  • 僵死撤销状态(EXIT_DEAD) 最终状态,由于父进程刚发出wait4()或者waitpid()系统调用,因而进程由系统删除。

标识一个进程
一般来说能被独立调度的每个执行上下文都必须拥有它自己的进程描述符,因此即使共享内核大部分数据结构的轻量级线程,也有他们自己的task_struct结构。
进程和进程描述符之间有非常严格的一一对应的关系,这使得用32位进程描述符地址标识进程成为一种方便的方式,进程描述符指针指向这些地址,内核对进程的大部分引用是通过进程描述符指针进行的。

类Unix操作系统允许用户使用一个叫做进程标识符的processID(PID)的数字来标识进程,PID存放在进程描述符的pid字段中,PID被顺序编号,新建的PID通常是前一个进程的PID加1,pid的值有一个上限,在默认情况下是 32767, 但是系统管理通过修改/proc/sys/kernel/pid_max来减小这个值。同时在64位的体系中可以把PID的上限扩大到 4194303。当内核的使用的PID达到这个上限的时候必须开始循环使用已闲置的小PID号。

由于循环使用PID编号,内核必须通过一个pidmap-array 位图来表示当前已经分配的PID号和闲置的PID号,因为一个页框包含了32768 (4 1024 8)个位,所以在32位体系结构中位图单独放在一个页中,在64位体系结构中,当内核分配了超过当前位图大小的pid号时,需要为PID位图增加更多的页。系统会一直保存这些页不会释放掉。

进程的切换
尽管每个进程都可以拥有属于自己的进程空间,但是所有的进程都必须共享CPU寄存器,因此在CPU恢复一个进程的执行之前,内核必须必须确保每个寄存器装入挂起进程时的值。

进程恢复之前必须装入寄存器的一组数据称为硬件的上下文,硬件上下文是可执行上下文的一个子集,因为执行上下文包含了进程执行时需要的所有信息。在Linux中,进程硬件上下文的一部分存在TSS段(任务状态段(Task State Segment, TSS)是x86架构电脑上是一个保存任务信息的数据结构,被操作系统内核用于任务管理),而剩余部分存放在内核态堆栈中。

thread字段
在每次切换进程的时候,被替换进程的硬件上下文必须要保存在别处,不能像Intel原始设计那样把它保存在TSS中,因为linux为每个处理器而不是每个进程使用TSS.

因此每个进程描述符包含一个thread_struct的tread 字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。这个数据结构包含的字段设计大部分CPU寄存器,但是不包含eax, ebx这类通用寄存器,他们的值保存在内核堆栈中。

执行进程切换
进程切换可能只能发生在精心定义的点:schedule()函数。
本质上,每个进程切换由两步组成

  1. 切换页全局目录以安装一个新的地址空间。
  2. 切换内核堆栈和硬件上下文

创建进程
为了避免子进程拷贝时耗费资源的问题,现在Linux中引入了三种机制

  1. 写时复制技术允许父子进程读相同的物理页,只要两者中有一个尝试写一个物理页,内核就把这个页的内容拷贝到新的物理页,并把这个页分配给正在写的进程。
  2. 轻量级进程允许父子进程共享进程在内核的很多数据结构,如页表(也就是整个用户态地址空间),打开的文件表和信号处理
  3. vfork(),系统调用创建的进程能共享其父进程的内存地址空间,为了防止父进程重写子进程需要的数据,子进程创建后将阻塞父进程的执行,一直到子进程退出或执行一个新的程序为止。

clone(), fork()和 vfork();

在linux中,轻量级进程是由clone() 创建的。在传统的Linux中fork使用clone实现,vfork()系统调用在Linux中也是用clone实现的。

do_fork()函数负责处理clone, fork, vfor,系统调用, do_fork() 利用辅助函数copy_process 来创建进程描述符已经进程执行所需的其他所有内核数据。

线程在linux中的实现
liux中实现线程的机制非常的独特,从内核角度来说,它并没有线程这个概念,linux把所有的线程都当做进程来实现,内核并没有定义特别的调度算法和数据结构来表示线程,相反,线程仅仅被视作一个与其他进程共享某些资源的进程,每个线程都有唯一里属于自己的task_struct,所以在内核中他看起来就是一个普通的进程。

内核线程
内核经常需要在后台执行一些操作,这种任务可以通过内核线程(独立运行在内核空间的标准线程)来完成,内核线程和普通的进程间的区别在于内核线程没有的地址空间,它们只在内核空间运行,从来不切换到用户空间去,内核进程和普通进程一样,可以被调度也可以被抢占

内核线程只能由其他内核线程创建,内核线程创建之后需要调用wake_up_process()才能运行,否则它不会主动运行,创建一个进程并让它运行起来,可以调用kthread_run()来达到,这个例程是以宏实现的,只是简单的调用了kthread_create和wake_up_process();

内核线程启动之后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop的参数为kthread_create函数返回的task_struct结构的地址。

int kthread_stop(struct task_struct *k);

进程的终结
一般来说进程的析构是由自身引起的,它发生在exit系统调用时,既可能是显式的调用,也可能是隐式的从程序的主函数返回,C语言的编译器会在函数的返回点后面放置调用exit()的代码,当进程遇到既不能自己处理也不能忽略的信号时,还可能被动的终结。

删除进程描述符
在调用了do_exit()方法之后,尽管线程已经僵死不能运行了,但是系统还是保留了他的进程描述符,这样可以让系统在子进程终结之后仍然能获取它的信息,因此进程终结时所需的额清理工作和进程描述符的删除被分开执行,在进程获取已总结的子进程的信息后,或者内核通知他不再关注该信息后,紫禁城的task_struct才被释放。

wait这一族函数都是通过唯一的系统调用wait4来实现的,它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的pid,此外调用该函数提供的指针会包含子函数退出时的代码,

处理孤儿进程
如果父进程在子进程之前退出,那么系统会给子进程找一个新的父进程,否则这些孤儿进程在退出自后就会永远处于僵死状态而无法释放其所占用的内存,对于这个问题,解决方法是给子进程在当前线程组内找一个进程作为父亲,如果不行就让init做他们的父亲。在do_exit()方法当中会调用exit_notify() 该函数会调用forget_orginal_parent() 而后者会调用find_new_reaper()来执行寻父的过程。