Linux 进程———详解

1、各种进程相关的基本概念

1.1 区分程序和进程

程序: 是存储在存储设备(磁盘)上的数据,包含了可执行机器指令(二进制代码)和数据的静态实体。我们说程序不占用系统资源,这里的系统资源指CPU、内存等,但是不包括磁盘
进程: 运行的程序会变成进程,是已经被 OS 从磁盘加载到内存上的、动态的、可运行的指令与数据的集合

1.2 MMU 内存管理单元

存在于CPU中,他有两个功能:
完成虚拟内存地址到物理内存地址的映射
设置 / 修改内存的访问级别

1.3 虚拟内存

每个进程都有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址。虚拟内存可以看作是由软件产生的,并不真实存在,他和物理内存相反,物理内存就是内存条等真实存在的存储空间。虚拟地址可通过每个进程的页表与物理地址进行映射,获得真正的物理地址。在32位系统上,虚拟内存的大小是4G,它被分为用户存储空间和内核存储空间两大块。

虚拟内存中保存了进程运行的所有数据,如:代码,变量,栈(里面有函数及函数里的变量等),堆(malloc分配的存储空间等),PCB等。程序中的变量等使用的都是虚拟内存,但是,虚拟内存实际上并不存在,他们的虚拟内存地址实际上是被MMU映射到了真实的物理内存中。

进程在实际的物理内存中也远远没有4G那么大,进程运行时需要多大内存,才把相应的数据映射到物理内存中。

虚拟地址空间如何分布?

由低地址到高低值分别为:
1、只读段: 该部分空间只能读,不可写;(包括:代码段、rodata 段(C常量字符串和#define定义的常量) )
2、数据段: 保存全局变量、静态变量的空间;
3、堆 : 就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数 brk 和 sbrk 进行动态调整。
4、文件映射区域: 如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。
5、栈: 用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
6、内核虚拟空间: 用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。
Linux 进程———详解_第1张图片

1.4 PCB

PCB本质是一个结构体(task_struct),每个进程的PCB都存储在内核虚拟空间中中,PCB结构体中包含如下信息:

  • 进程ID,类型是pid_t,本质是个unsigned int
  • 进程的状态:初始态、就绪、运行、阻塞、停止
  • 进程切换时需要保存和恢复的CPU寄存器
  • 页表指针,指向页表,也表上存储了虚拟地址与物理地址的映射关系
  • 当前进程的控制终端的信息
  • 当前进程的工作目录
  • umask掩码,保护文件创建或修改的默认权限
  • 文件描述符表,这其实是一个指针,指向了内核中的文件描述符表
  • 和信号相关的信息
  • 用户ID 和 组ID
  • 会话 和 进程组
  • 进程可以使用的资源上限

1.5 环境变量

用于指定操作系统运行环境的一些参数。环境变量有很多。

PATH 就是一个典型的环境变量,他用于存储可执行文件的路径。当我们在shell上输入一个命令时,shell就会在自己的PATH环境变量中寻找该命令对应的文件在哪条路径中。进行如下实验:
Linux 进程———详解_第2张图片
输入命令①,shell在自己的PATH环境变量中寻找哪条路径含有date文件
输入命令②,shell直接去到/bin路径运行date文件
命令③则是查看shell的PATH环境变量。(echo用于输出shell的变量的值)

2、进程相关函数

2.1 fork

#include
#include
pid_t fork(void);

创建一个子进程。父进程中返回子进程的进程ID,子进程中返回0,失败返回-1。

创建子进程后,父进程中打开的文件描述符在子进程中也是打开的,且文件描述符的引用计数+1,次啊外,父进程的用户根目录、当前工作目录等变量的引用计数都会+1。

2.2 进程共享

① 子进程对父进程信号的继承情况

Linux 进程———详解_第3张图片

② 调用fork后父子进程的异同

父子相同处: 全局变量、数据段、代码段、栈、堆、环境变量、用户ID、宿主目录、进程工作目录

父子不同处: 进程ID、fork的返回值、父进程ID、进程运行时间、定时器、未决信号集、

可以认为:子进程0-3G用户区的内容和父进程相同,内核区的内容(主要是PCB)有所不同。

虽然父子进程0-3G的内容一样,但子进程并不是将0-3G地址空间完全拷贝一份,而是遵循 读时共享写时复制 的原则。共享和复制都是指共享或复制物理内存,与虚拟内存无关。若fork调用的后续只有对数据的读操作,那么子进程与父进程共享同一块物理内存,若存在写操作,那么子进程则会复制一份自己的数据。这样的设计,能够节省系统内存开销。

Linux操作系统底层借助MMU实现读时共享写时复制。

③ 父子进程共享的内容

  • 文件描述符表
  • mmap 建立的映射区

注意:对于全局变量这种父子相同的内容,如果后面只有读操作,则父子共享该全局变量,若后面有写操作,则父子将不再共享。

2.3 getpidgetppid

#include
#include
pid_t getpid(void);		获取 当前 进程的ID。
pid_t getppid(void);	获取 父 进程的ID。

2.4 getuidgeteuid

#include
#include
pid_t getuid(void);		获取当前进程 实际 用户ID
pid_t geteuid(void);	获取当前进程 有效 用户ID

2.5 getgidgetegid

#include
#include
pid_t getgid(void);		获取当前进程 实际 用户组ID
pid_t getegid(void);	获取当前进程 有效 用户组ID

3、exec族函数

fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间(0-3G的部分)代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后进程的ID并未改变。

一共有很多种exec函数,但是它们的功能都是执行另一个程序,具体的方式有略微变化。

注意: exec族函数只有失败才返回值-1并设置errno 。因为一旦这类函数调用成功,则后续的代码永远不会被执行,而是执行exec加载的新程序并结束。原来程序的代码段整个被替换成了新程序的代码段。所以exec族函数的调用成功返回值没有意义。原来进程中打开的文件会通过隐式回收关闭,不用担心这个问题。

exec族函数不会关闭源程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性。

3.1 execlp函数

函数名中的l是 ‘list’ ,代表命令行参数列表 pPATH,代表PATH环境变量 。所以该函数的功能是加载一个进程,可以借助PATH环境变量。

#include
int execlp(const char* file, const char* arg, ...);

file参数:

要执行的可执行程序的文件名

arg参数:

可执行程序的argv[0]参数,其实还是可执行程序的文件名。

...可变参数:

命令行参数列表,以NULL结尾。

execlp("ls", "ls", "-l", "-a", NULL);		  等价于在 shell 中执行 "ls -l -a" 命令
execlp("ls", "djsdkjash", "-l", "-a", NULL);  等价于上一行调用

为什么第二个参数可以乱传呢?
第二个参数相当于argv[0]参数,在ls的内部实现中,该参数并没有被使用,所以可以乱传,所以第三个、第四个以及往后的参数都不允许乱传。

3.2 execl函数

没有了PATH环境变量,需要使用路径 + 程序名的方式加载程序。

#include
int execl(const char* pathname, const char* arg, ...);

除了第一个参数的含义有改变外,其他参数含义与execlp相同。
pathname必须是含根目录的完整路径名当前目录的相对路径

execl("/bin/ls", "ls", "-l", "-a");		与上面的 execlp 调用示例等价

这个函数使我们能够调用自己编写的可执行程序,只要把相对路径说清楚即可。

3.3 execle函数

末尾的e是 environment。该函数令加载的新程序使用调用者提供的环境变量表,不使用进程原有的环境变量表。即,设置新加载程序运行的环境变量表。

#include
int execle(const char* pathname, const char* arg, ...);

3.4 execv函数

末尾的v是 vector 。使用命令行参数数组。

#include
int execv(const char* pathname, char* const argv[]);

使用该函数前,首先要构建命令行参数数组,即,第二个参数argv。如下面的例子所示:

char* argv[] = {"ls", "-l", "-a", NULL};
execv("/bin/ls", argv);			其实和我们在 shell 中敲命令差不多

3.5 execvp函数

execv函数类似,只不过在使用时可以借助PATH环境变量。

3.6 execve函数

execv函数类似,只不过在使用时可以借助我们提供的环境变量表。

4、回收子进程

一个进程在终止时会关闭所有文件描述符,并释放在用户空间分配的内存但他的PCB还保留着,内核在其中保存了一些信息:如果正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用waitwaitpid获取这些信息,并彻底清除掉这个进程(及其PCB)。

在shell中,可以使用echo $?命令查看上一个进程的退出状态,因为shell是他的父进程,当他终止时shell调用waitwaitpid得到他的退出状态同时彻底清除掉这个进程。

4.1 孤儿进程

父进程先于子进程结束,则子进程称为孤儿进程。此时,子进程的父进程变成了 init 进程,由init进程回收该子进程。

4.2 僵尸进程

子进程先于父进程终止,但是父进程没有回收子进程,此时,子进程的PCB残留于内核中,占有部分资源,变成僵尸进程。这样的僵尸进程越来越多会对系统运行造成危害。

kill命令不能回收僵尸进程。 因为kill命令是用来终止进程的,而僵尸进程已经终止了。

4.3 回收子进程

4.3.1 wait函数

父进程调用 wait函数能够阻塞的回收一个子进程,并获得其结束状态:

#include
#include
pid_t wait(int* status);

status是一个传出参数,用于获取子进程退出状态,我们需要定义一个整型参数,并传其地址进去。
函数成功返回回收的进程ID,调用失败或没有可回收的子进程返回-1并设置errno

wait阻塞的等待子进程退出,只要子进程不退出,父进程也干不了别的。

如何回收僵尸进程?只能杀死他的父进程,从而迫使子进程的父进程变更为init进程,init进程发现他是个僵尸进程,会调用wait函数回收它。

4.3.2 使用status获取子进程退出状态

首先明确子进程终止的两种情况:
———正常终止 → 退出值
———异常终止 → 终止信号(linux所有程序的异常终止都是因为收到了某个信号)

我们的目的是获取子进程的退出值 或 终止信号。需要借助status和宏函数进一步判断进程终止的具体原因。分为三种情况。

① 正常终止

WIFEXITED(status);		返回非0 ---→ 进程正常结束,要获取退出值继续调用下面的宏
WEXITSTATUS(status);	若上宏为真,调用此宏 ---→ 获取退出值(exit函数的参数,或最后return的值)

第一个宏只能判断是否正常结束,要获取具体的退出值需第一个宏为真后调用第二个宏。

① 异常终止

WIFSIGNALED(status);	为非0 ---→ 进程异常结束,要获取退出值继续调用下面的宏
WTERMSIG(status);		若上宏为真,调用此宏 ---→ 获取使进程终止的信号的编号

③ 子进程没有结束,而是暂停

WIFSTOPPED(status);		为非0 ---→进程暂停
WSTOPSIG(status);		若上宏为真,调用此宏 ---→ 获取使进程暂停的信号的编号
WIFCONTINUED(status);	为真 ---→ 进程暂停后以继续运行

4.3.1 waitpid函数

waitpid相较于wait函数更加灵活,他能回收指定ID的子进程,而且可以工作在非阻塞模式:

#include
#include
pid_t waitpid(pid_t pid, int* status, int options);

pid指定要回收的进程ID,其值有四种情况:
① >0: 要回收的子进程ID
② - 1: 回收任意一个子进程
③ =0: 回收和当前调用waitpid一个组的任意一个子进程
④ < -1: 回收指定进程组内的任意一个子进程,如-10023表示回收10023进程组内的任意一个子进程。

status参数的含义与wait函数相同。

options参数用于指定回收是阻塞还是非阻塞。若其值为0,则为阻塞,若其值为 WNOHANG ,则为非阻塞。

若第三个参数的值为WNOHANG,且调用waitpid时子进程还没结束,那么此次调用返回0。若子进程被回收,则返回子进程ID,调用失败或没有可回收的子进程返回-1。

5、进程间通信 IPC(Inter Process Communication)

5.1 进程间通信的4种方式

  • 管道(使用最简单)
  • 信号(开销最小,几乎没有开销)
  • 共享映射区(可以使无血缘关系的进程间通信)
  • 本地套接字(最稳定,但实现复杂)

5.2 进程间管道通信

在调用fork创建子进程之前,创建一个管道,然后后面调用fork后,父子进程就都有了管道的读端和写端,这样父子进程就能通过管道通信了。

管道通信的局限性

  • 数据只能一端写入另一端读出
  • 数据一旦被读走,管道中就没了,不能反复读取
  • 数据只能在一个方向上流动
  • 只能在有血缘关系的进程间使用管道

5.3 共享内存映射

5.3.1 mmap函数

mmap函数就是把磁盘中的文件映射到内存中的一片地址空间(这个工作是由MMU完成的),并返回指向该地址空间的指针。

#inlcude<sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);

addr参数: 我们可以用该参数指定建立映射区的首地址,但是一般都传入NULL,表示由Linux内核指定首地址。该参数类型void*是个泛型指针,它可以隐式转换为任何类型的指针,因为可能还不知道映射区中存放什么类型的数据。

length参数: 指定映射区的大小,一般由被映射的文件大小决定。毕竟你是吧文件映射到这里。

prot参数: 设置用户对该映射区的访问权限(注意:这只是对内存的限制

flags参数: 设置 映射区内数据被修改后产生的结果。(如:映射区数据修改后,映射的文件中的数据也被修改还是不被修改)

fd参数: 映射的文件

offset参数: 映射文件的偏移(必须是4Kb的整数倍),从开头开始映射,还是从4Kb的位置开始映射,还是从8Kb的位置开始映射…

函数调用成功返回映射区的首地址,失败返回MAP_FAILED并设置errno

munmap函数:

#include
int munmap(void* addr, size_t length);

该函数用来关闭mmap打开的映射区。addr为映射区首地址的指针,length为映射区的大小。成功返回0,失败返回-1。

mmap函数的注意事项:

  • 可以把当前程序新创建的文件映射到内存中,但是必须用类似 ftruncate 的函数指定文件大小,因为默认创建的文件大小为0,而映射区的大小不能为0,所以必须指定文件大小。
  • 映射区的权限必须 <= 文件打开的权限,创建映射区的过程中隐含着一次对文件的读操作。
  • 文件描述符先关闭,对于映射区的读写没有任何影响。文件描述符是操作文件的一个句柄,而现在我们可以通过映射区间接读写文件,操作文件的方式发生了改变,所以关闭文件描述符没有任何影响。映射区一旦创建成功,文件描述符可以立即关闭。

5.3.2 mmap父子进程通信

有血缘关系的父子进程

有血缘关系的父子进程通过mmap建立的映射区通信时,要注意flags参数应设置为MAP_SHARED

MAP_PRIVATE		父子进程各自独占映射区
MAP_SHARED		父子进程共享映射区

匿名映射

目前为止,每次创建映射区一定要依赖一个临时文件才能实现,我们需要对该临时文件进行openunlinkclose等操作。如果我们创建映射区只是为了父子进程通信,其实根本不需要文件以及里面的内容,这些文件操作就显得很多余。Linux提供了创建匿名映射区的方法,无需依赖文件即可创建映射区。该操作主要通过flags参数指定。

MAP_ANONYMOUS	

flags 的值表示:这段映射区不是从文件映射而来的,其内容全部被初始化为0,这时, mmap 的最后两个参数将被忽略。而第二个参数length(文件的大小),可以随意指定。
在这里插入图片描述

无血缘关系进程间通信

无血缘关系的进程间通信的关键是:每个进程中都使用同一个文件去创建映射区。并设置好读方和写方,这样就能实现进程通信了,当然喽,MAP_SHARED是必须的。

文件名可以使用命令行参数argv[]传入。

你可能感兴趣的:(Linux,Linux,内核,Linux,服务器)