进程
1.进程的状态
-
一般来说进程有一下几种状态
- 新生状态 :刚刚被创建出来,还没有完成初始化,不能被调度执行。在被初始化之后,会进入预备状态。
- 预备状态 :该状态表示进程可以被调度执行,但是还没有被调度器选择,由于 CPU 数量可能少于进程数量,在某一个时刻只有部分进程能被调度到 CPU 上运行,此时,系统中其他的可被调度的进程都处于预备状态。在被调度器选择执行厚,进程迁移到运行状态。
- 运行状态 :该进程正在 CPU 上运行,当一个进程执行一段时间后,调度器可以选择中断它的执行并且重新将它放回调度队列,他就迁移到 预备状态,当该进程运行结束后,他就会迁移至终止状态。如果一个进程需要等待某些外部事件,他就可以放弃 CPU 并迁移到阻塞状态。
- 阻塞状态 :该状态表示进程需要等待外部事件 ( 比如某个 IO 的请求完成),暂时无法被调度。当进程等待的外部事件完成之后,他就会迁移到预备状态。
- 终止状态 :该状态表示该进程已经完成了执行,而且不会再被调度。
进程的内存空间布局
- 进程具有独立的虚拟内存空间。
- 用户栈 : 栈保存了进程需要使用的各种临时数据,如临时变量的值。栈是一种可以伸缩的数据结构。其扩展方向是自顶向下,栈底在高地址,栈顶在低地址上。当临时数据被压入栈内时,栈顶会向低地址扩展。
- 代码库 : 进程的执行有时候需要依赖共享的代码库 ( 比如 Libc ) ,这些代码库会映射到用户栈下方的虚拟地址处,并且被标记为只读。
- 用户堆 : 堆管理的是进程动态分配的内存。与栈相反,堆的扩展方向是自顶向上,堆顶在高地址上,当进程需要更多内存时,堆顶会向高地址扩展。
- 数据与代码段 : 处于较低地址的是数据段与代码段,他们原本都保存在进程需要执行的二进制文件中。在进程执行前,操作系统会将它们载入虚拟地址空间中。其中,数据段主要保存的是全局变量的值,而代码段保存的是进程执行所需要的代码。
- 内核部分 : 处于进程地址空间最顶端的是内核内存。每个进程的虚拟地址空间里映射了相同的内核内存。当进程在用户态运行时,内核内存对其不可见;只有当进程进入内核态时,才能访问内核内存。与用户态相似,内核部分也有内核遇到的代码和数据段,当进程由于中断或者系统调用进入内核后,会使用内核的栈。
进程控制块和上下文切换
在内核中,每个进程都通过一个结构体来保存他的相关状态,
例如他的 进程标识符,进程状态,虚拟内存状态,打开的文件描述符
我们一般称之为 进程控制块 ,( Process Control Block, PCB ),
不同操作系统对应的进程控制块不相同哦
struct task_struct {
//进程状态
volatile long state;
//虚拟内存状态
struct mm_struct* mm;
//进程标识符
pid_t pid;
//进程组标识符
pid_t tgid;
//进程之间关系
struct task_struct___rcu* parent;
struct list_head children;
//打开的文件
struct files_struct* files;
// 等等
};
- 进程的上下文 ( context )包括进程运行时的寄存器状态。它能够用于保存和恢复一个进程在处理器上运行的状态。当操作系统需要切换当前的进程时,就会使用上下文进程切换 ( context switch ) 机制,该机制会将当前一个进程的寄存器状态保存到 PCB 中,然后把下一个进程先前保存的状态写入寄存器,从而切换该进程执行。
- 在早期的操作系统中,进程是操作系统调度的基本单位。但随着更加轻量的运行时抽象-----线程的提出,调度和上下文切换的基本单位也从进程变为了线程。
进程的创建 fork linux/c
- 在linux操作系统中,fork是最基本的创建一个线程的函数调用.( fork 的中文翻译是 叉子,是不是很形象呢?)
#include <sys/types.h>
#include <unistd.h>
pid_t fork( void );
- fork 不接受任何参数,我们一般认为调用他的进程为父进程,而fork出的进程称为子进程,父进程与子进程在创建时,拥有相同的内存,寄存器,程序计数器等等。
- 但拥有不同的 PID 和虚拟地址空间,然后各自执行且互不干扰。
- 同时 fork 在父进程的返回值 是 子进程的 process id,在父进程的返回值是 0,所以我们在编程的时候,通常用fork的返回值用于父进程或者子进程需要执行的部分。
- 但其实我们有时候不需要调用 fork 这么底层的接口,可能大家用的更多的还是 exec 函数族,这里对于exec函数族不做赘述。有兴趣可以自行了解。
- 在 exec 函数执行后,会有以下几个步骤:
- 1.函数会将参数中指明的路径的可执行文件的数据段和代码段加载到当前进程的地址空间。
- 2.重新初始化堆和栈,操作系统会进行地址空间随机化( Address Space Layout Randomization, ASIR )操作,改变堆和栈的起始地址,增强进程的安全性。
- 3.将 PC 寄存器设置到可执行文件代码段定义的入口点,该入口点会最终调用 main 函数。
- 在 exec 函数执行后,会有以下几个步骤:
写时拷贝技术在进程中的应用
早期的 fork 实现很简单粗暴,直接将父进程中的 物理内存完整拷贝一份,并
映射到子进程的内存空间中。这种方式在很多情况下是不必要的。其实一部分虚拟
内存其实是只读的(动态库,代码段),这些虚拟页,父进程和子进程共享即可,
对于一些容易发生改变的虚拟页( 如堆和栈这些虚拟页),容易发生写操作,就会
触发写时拷贝,由操作系统负责处理。写时拷贝技术既能提升 fork 的性能,
又能降低进程占用的资源。
进程管理
进程间的关系和进程树
- 在 Linux 中,进程都是通过fork创建的 ( 其他函数在上面封装了而已 ),由于我们在上文中提到过,进程中的 Task_struct 都会记录自己的父进程和子进程,进程间便构造了进程树这种结构。
- 我们可以看到,根节点是 init进程,他是操作系统创建的第一个进程,之后所有的进程都由它来创建( fork )。
进程间监控 : wait
- Linux 给我们提供了很多监视子进程的函数,这里主要介绍 waitpid
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* wstatus, int options);
/*
pid_t pid 需要等待的子进程 ID
int* wstatus 保存子进程的状态
int options ....一些参数和选项
*/
- wait 函数族不仅可以监控,还可以回收子进程和释放资源。
- 1.如果父进程没有调用 wait 操作,或者还没来得及调用 wait 函数,就算子进程已经终止了,他所占用的资源 也不会完全释放。 僵尸进程,我们一般都叫这种进程为僵尸进程,内核会为僵尸进程保留其进程描述符( PID )和终止时的信息( waitpid status ),以便父进程在调用 wait 时可以监控子进程的状态。
进程组和会话
- 为了方便应用程序进行进程管理,内核还定义了可以由多个进程组合而成的
“小集体”,就是进程组和会话.- 进程组 ( process group )是进程的集合,可以由一个或者多个进程组成。在默认情况下,父进程和子进程属于同一个进程组。在Linux中,如果子进程想要摆脱当前的检查组,可以通过调用setpgid创建一个新的进程组或者移入已有的进程组。
- 进程组的一大作用体现在对信号的处理上。信号和中断比较类似。
- 应用程序可以调用killpg来向一个进程组发送信号,这个信号会被发送这个进程组的每个进程。
- 会话是进程组的集合,可以由一个或者多个进程组构成。会话将进程组根据执行状态分为前台进程组 ( foreground thread group ) 和 后台进程组 ( backgroud thread group ),控制终端( controlling terminal )进程是会话与外界进行交互的 “窗口”。fork调用后,子进程和父进程同属于一个会话.
- 会话和进程组主要用于 Shell环境中的进程管理。
fork过时了吗?
fork的优点
- fork的设计很简单,Windows中常用的创建线程的函数 CreateProcess 需要十个参数!!!,fork完全不需要任何参数。
- fork同时还强调了 进程与进程之间的关系,由于 fork具有创建原有进程的拷贝的语义。所以父进程和子进程的关系比较强烈。这为管理进程带来了很大的便利。
fork的局限性
- fork距今大约已经有了70年的历史,很多属性在计算机的发展中,fork已经变化了很多
- fork已经变得过于复杂了,这种复杂并不是fork的接口变得复杂。我们已经知道,fork会将子进程和父进程一起拷贝。这会让子进程和父进程共享大量的状态。可能会让进程表现出看似违反直觉的行为。每当操作系统需要为进程的结构添加功能时,就必须要考虑到对fork的实现和修改。
- fork的性能很差。fork会拥有写时拷贝,读时共享的特点,当父进程的资源特别大时,这种复制是十分消耗资源的。尤其是现在的大型服务器,几百GB运行内存,上面跑的程序几十万的并发,一次复制十分消耗资源。
- fork存在潜在的安全漏洞。fork会让父进程和子进程拥有一定的联系,但这其实是一把双刃剑。我们可以通过这个联系来攻击。
- 除此以外,fork还有其他的一些缺点,例如 扩展性差,与异质硬件兼容差,线程不安全等等的缺点。
- 所以 Linux操作系统也提供了一些其他接口给我们,例如 spawn,vfork ,clone 等函数调用。
天人合一 : posix_spawn
- posix_spawn 是 POSIX 提供的另外一种创建进程的方式。
#include <spawn.h>
int posix_spawn(pid_t* pid, const char* path,
const posix_spawn_file_actions_t* file_actions,
const posix_spawnattr_t* attrp,
char* const argv[],char* const envp[]);
/*
path , argv, envp 你懂的
pid_t* pid 该参数会在posix_spawn 返回时被写入新进程的 PID
const posix_spawn_file_actions_t* file_actions,
const posix_spawnattr_t* attrp,这两个参数会在 pre_exec 状态之前应用程序
会对这两个参数的配置完成一系列操作。
*/
- posix_spawn 一般我们认为他是 fork 和 exec 两者功能的结合,他会使用类似于fork的方法( 或者直接调用 fork )获得一份进程的拷贝,然后调用 exec 执行。
限定场景 : vfork
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
- 从接口上看,vfork与fork的接口一致。vfork 的作用 会在 fork之上做一些剪裁。
- vfork 会从父进程中创建子进程,但是不会为子进程单独创建地址空间,而是 让子进程和父进程共享同一地址空间。所以父进程和子进程对内存的修改都会对另一个进程产生影响。
- 为了确保正确性,vfork 会在结束前阻塞父进程( 就是优先保证vfork出的进程先运行 ) ,vfork可以大幅度减少fork所带来到的性能损耗。但是他不是安全的。
精密控制 :rfork / clone
- 由于 fork 接口简单,所以他的可控制能力有限。当应用程序希望能选择性的共享父进程和子进程的部分资源时,fork就 ====" 爱莫能助"====了,在上世纪 80 年代,贝尔实验室推出了 rfork 函数,后来 Linux也借鉴了 rfork函数,提出了类似的接口 clone。
#define _GNU_SOURCE
#include <shed.h>
int clone(int (*fn)(void* ), void* stack, int flags, void* arg, ...
/ pid_t* parent_tid, void* tls, pid_t* child_tid );
/*
flags 允许应用程序指定不需要复制的部分。
stack 用于指定子进程栈的位置,解决了父进程与子进程共享地址空间的问题
fn 和 arg 则是进程创建完成后将执行的函数和输入参数。
*/