您的位置:首页 > 运维架构 > Linux

学习Linux(35)进程

2020-07-25 18:34 211 查看

进程的由来
程序:静态文件
进程:运行着的实体(task_struct)

查看进程之间的关系:pstree命令查看
进程的身份证:PID 用ps -ef 查看 ps -ef | more

查看进程
即使刚打开Linux电脑,没有运行任何程序,电脑中也会有进程存在,因为系统中必须要有进程在处理一些必要的程序,以保证系统能正常运行。其实在Linux中是通过检查表记录与进程相关的信息的,进程表就像一个数据结构,它把当前加载在内存中的所有进程的有关信息保存在一个表中,其中包括进程的PID、进程的状态、命令字符串和其他一些ps命令输出的各类信息。操作系统通过进程的ID对它们进行管理,这些PID是进程表的索引,就目前的Linux系统而言,系统支持可以同时运行的进程数可能只与用于建立进程表项的内存容量有关,而没有具体的数量的限制,也就是说系统有足够的内存的话,那么理论上就可以运行无数个进程。
进程ID
Linux系统中的每个进程都都会被分配一个唯一的数字编号,我们称之为进程ID(ProcessID,通常也被简称为 PID)。进程ID 是一个 16位的正整数,默认取值范围是从2到32768(可以修改),由 Linux在启动新进程的时候自动依次分配,当进程被启动时,系统将按顺序选择下一个未被使用的数字作为它的PID,当PID的数值达到最大时,系统将重新选择下一个未使用的数值,新的PID重新从2开始,这是因为PID数字为1的值一般是为特殊进程init保留,即系统在运行时就存在的第一个进程, init进程负责管理其他进程。
父进程ID
任何进程(除init进程)都是由另一个进程启动,该进程称为被启动进程的父进程,被启动的进程称为子进程,父进程号无法在用户层修改。父进程的进程号(PID)即为子进程的父进程号(PPID)。用户可以通过调用getppid()函数来获得当前进程的父进程号。

父进程与子进程
进程启动时,启动进程为新进程的父进程,新进程是启动进程的子进程。
每个进程都有一个父进程(除了系统中如”僵尸进程”这种特殊进程外),因此,读者可以把 Linux 中的进程结构想象成一个树状结构,其中 init进程就是树的“根”;或者可以把init进程看作为操作系统的进程管理器,它是其他所有进程的祖先进程。我们将要看到的其他系统进程要么是由init进程启动的,要么是由被init进程启动的其他进程启动的。
总的来说init进程下有很多子进程,这些子进程又可能存在子进程,就像家族一样。系统中所有的父进 程ID被称为PPID,不同进程的父进程是不同的,这个值只是当前进程的父进程的ID,系统中的父进程与子进 程是相对而言的,就好比 爷爷<->爸爸<->儿子 之间的关系,爸爸相对于爷爷而言是儿子,相对于儿子而言则是爸爸。

1.程序只是一系列指令序列与数据的集合,它本身没有任何运行的含义,它只是一个静态的实体。而进程则不同,它是程序在某个数据集上的执行过程,它是一个动态运行的实体,有自己的生命周期,它因启动而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被销毁。
2.进程和程序并不是一一对应的,一个程序执行在不同的数据集上运行就会成为不同的进程,可以用进程控制块来唯一地标识系统中的每个进程。而这一点正是程序无法做到的,由于程序没有和数据产生直接的联系,既使是执行不同的数据的程序,他们的指令的集合依然是一样的,所以无法唯一地标识出这些运行于不同数据集上的程序。一般来说,一个进程肯定有一个与之对应的程序,而且有且只有一个。而一个程序有可能没有与之对应的进程(因为这个程序没有被运行),也有可能有多个进程与之对应(这个程序可能运行在多个不同的数据集上)。
3.进程具有并发性而程序没有。
4.进程是竞争计算机资源的基本单位,而程序不是。

进程状态
通过ps命令将系统中运行的进程信息打印出来

进程状态转换

进程在完成任务后会退出,那么此时进程状态就变为退出状态,这是正常的退出,比如在main函数内 return 或者调用 exit()函数或者线程调用pthread_exit()都是属于正常退出。为什么作者要强调正常退出呢?因为进程也会有异常退出,比如进程收到kill信号就会被杀死,其实不管怎么死,最后内核都会调用do_exit()函数来使得进程的状态变成”僵尸态(僵尸进程)”,这里的”僵尸”指的是进程的PCB(Process Control Block,进程控制块)。为什么一个进程的死掉之后还要把尸体(PCB)留下呢?因为进程在退出的时候,系统会将其退出信息都保存在进程控制块中,比如如果他正常退出,那进程的退出值是多少呢?如果被信号杀死?那么是哪个信号将其杀死呢?这些”死亡信息”都被一一封存在该进程的PCB当中,好让别人可以清楚地知道:我是怎么死的。那谁会关心他是怎么死的呢?那就是它的父进程,它的父进程之所以要启动它,很大的原因是要让这个进程去干某一件事情,现在这个孩子已死,那事情办得如何,因此需要把这些信息保存在进程控制块中,等着父进程去查看这些信息。
当父进程去处理僵尸进程的时候,会将这个僵尸进程的状态设置为EXIT_DEAD,即死亡态(退出态),这样子系统才能去回收僵尸进程的内存空间,否则系统将存在越来越多的僵尸进程,最后导致系统内存不足而崩溃。那么还有两个问题,假如父进程由于太忙而没能及时去处理僵尸进程的时候,要怎么处理呢?又假如在子进程变成”僵尸态”之前,它的父进程已经先它而去了(退出),那么这个子进程变成僵死态由谁处理呢?第一种情况可能不同的读者有不同的处理,父进程有别的事情要干,不能随时去处理僵尸进程。在这样的情形下,读者可以考虑使用信号异步通知机制,让一个孩子在变成僵尸的时候,给其父进程发一个信号,父进程接收到这个信号之后,再对其进行处理,在此之前父进程该干嘛就干嘛。而如果如果一个进程的父进程先退出,那么这个子进程将变成”孤儿进程”(没有父进程),那么这个进程将会被他的祖先进程收养(adopt),它的祖先进程是init(该进程是系统第一个运行的进程,他的 PCB是从内核的启动镜像文件中直接加载的,系统中的所有其他进程都是init进程的后代)。那么当子进程退出的时候,init进程将回收这些资源。

system()
这个system ()函数是C标准库中提供的,它主要是提供了一种调用其它程序的简单方法。可以利用system()函数调用一些应用程序,它产生的结果与从 shell中执行这个程序基本相似。事实上,system()启动了一个运行着/bin/sh的子进程,然后将命令交由它执行

fork()
函数原型如下:
pid_t fork(void);
头文件:
#include <unistd.h>
在fork()启动新的进程后,子进程与父进程开始并发执行,谁先执行由内核调度算法来决定。fork()函数如果成功启动了进程,会对父子进程各返回一次,其中对父进程返回子进程的 PID,对子进程返回0;如果fork()函数启动子进程失败,它将返回-1。失败通常是因为父进程所拥有的子进程数目超过了规定的限制(CHILD_MAX),此时errno将被设为EAGAIN。如果是因为进程表里没有足够的空间用于创建新的表单或虚拟内存不足,errno变量将被设为ENOMEM。

在前面的文章我们也了解到,init进程可以启动一个子进程,它通过fork()函数从原程序中创建一个完全分离的子进程,当然,这只是init进程启动子进程的第一步,后续还有其他操作的。不管怎么说,fork()函数就是可以启动一个子进程,在父进程中的fork()调用后返回的是新的子进程的PID。新进程将继续执行,就像原进程一样,不同之处在于,子进程中的fork()函数调用后返回的是0,父子进程可以通过返回的值来判断究竟谁是父进程,谁是子进程。

fork()函数用于从一个已存在的进程中启动一个新进程,新进程称为子进程,而原进程称为父进程。使用fork()函数的本质是将父进程的内容复制一份,正如细胞分裂一样,得到的是几乎两个完全一样的细胞,因此这个启动的子进程基本上是父进程的一个复制品,但子进程与父进程有不一样的地方,作者就简单列举一下它们的联系与区别。
子进程与父进程一致的内容:
进程的地址空间。
进程上下文、代码段。
进程堆空间、栈空间,内存信息。
进程的环境变量。
标准 IO 的缓冲区。
打开的文件描述符。
信号响应函数。
当前工作路径。
子进程独有的内容:
进程号 PID。 PID 是身份证号码,是进程的唯一标识符。
记录锁。父进程对某文件加了把锁,子进程不会继承这把锁。
挂起的信号。这些信号是已经响应但尚未处理的信号,也就是”悬挂”的信号,子进程也不会继承这些信号。
因为子进程几乎是父进程的完全复制,所以父子两个进程会运行同一个程序,但是这种复制有一个很大的问题,那就是资源与时间都会消耗很大,当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要做一些事情:
为子进程的页表分配页面。
为子进程的页分配页面。
初始化子进程的页表。
把父进程的页复制到子进程相应的页中
创建一个地址空间的这种方法涉及许多内存访问,消耗许多CPU周期,并且完全破坏了高速缓存中的内容,因此直接复制物理内存对系统的开销会产生很大的影响,更重要的是在大多数情况下,这样直接拷贝通常是毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。因此在Linux中引入一种写时复制技术(Copy On Write,简称COW),我们知道,Linux系统中的进程都是使用虚拟内存地址,虚拟地址与真实物理地址之间是有一个对应关系的,每个进程都有自己的虚拟地址空间,而操作虚拟地址明显比直接操作物理内存更加简便快捷,那么显而易见的,写时复制是一种可以推迟甚至避免复制数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间(页面)。
那么写时复制的思想就是在于:父进程和子进程共享页面而不是复制页面。而共享页面就不能被修改,无论父进程和子进程何时试图向一个共享的页面写入内容时,都会产生一个错误,这时内核就把这个页复制到一个新的页面中并标记为可写。原来的页面仍然是写保护的,当还有进程试图写入时,内核检查写进程是否是这个页面的唯一属主,如果是则把这个页面标记为对这个进程是可写的。
总的来说,写时复制只会用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间,资源的复制是在需要写入的时候才会进行,在此之前,父进程与子进程都是以只读方式共享页面,这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。而在绝大多数的时候共享的页面根本不会被写入,例如,在调用fork()函数后立即执行exec(),地址空间就无需被复制了,这样一来fork()的实际开销就是复制父进程的页表以及给子进程创建一个进程描述符。

fork函数特性
执行fork函数之后,fork函数会返回两次
在旧进程中返回时,返回值为0
在新进程中返回时,返回值为进程的pid

fork函数总结
在执行fork函数之前,操作系统只有一个进程,fork函数之前的代码只会被执行一次。
在执行fork函数之后,操作系统有两个几乎一样的进程,fork函数之后的代码会被执行两次

exce系列函数
事实上,使用fork()函数启动一个子进程是并没有太大作用的,因为子进程跟父进程都是一样的,子进程能干的活父进程也一样能干,因此世界各地的开发者就想方设法让子进程做不一样的事情,因此就诞生了exce系列函数,这个系列函数主要是用于替换进程的执行程序,它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新程序的内容替换。另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。简单来说就是覆盖进程,举个例子,A进程调用exce系列函数启动一个进程B,此时进程B会替换进程A,进程A的内存空间、数据段、代码段等内容都将被进程B占用,进程A将不复存在。
exec 族函数有 6 个不同的 exec 函数,函数原型分别如下:
int execl(const char *path, const char *arg, …)
int execlp(const char *file, const char *arg, …)
int execle(const char *path, const char *arg, …, char *const envp[])
int execv(const char *path, char *const argv[])
int execvp(const char *file, char *const argv[])
int execve(const char *path, char *const argv[], char *const envp[])
这些函数可以分为两大类, execl、 execlp和execle的参数个数是可变的。execv、execvp和execve的第2个参数是一个字符串数组,参数以一个空指针NULL结束,无论何种函数,在调用的时候都会通过参数将这些内容传递进去,传入的参数一般都是要运行的程序(可执行文件)、脚本等。
总结来说,可以通过它们的后缀来区分他们的作用:
名称包含 l 字母的函数(execl、 execlp 和execle)接收参数列表”list”作为调用程序的参数。
名称包含 p 字母的函数(execvp 和execlp)接受一个程序名作为参数,然后在当前的执行路径中搜索并执行这个程序;名字不包含p字母的函数在调用时必须指定程序的完整路径,其实就是在系统环境变量”PATH”搜索可执行文件。
名称包含 v 字母的函数(execv、execvp 和 execve)的命令参数通过一个数组”vector”传入。
名称包含 e 字母的函数(execve 和 execle)比其它函数多接收一个指明环境变量列表的参数,并且可以通过参数envp传递字符串数组作为新程序的环境变量,这个envp参数的格式应为一个以 NULL 指针作为结束标记的字符串数组,每个字符串应该表示为”environment =virables”的形式。
execl函数演示

execlp函数演示

execv函数演示

execve函数演示

exec函数总结
l后缀和v后缀必须二选一来使用
p后缀和e后缀是可选想
exce函数有可能执行失败
新程序的文件路径出错
传参或者自定义环境变量时,没有加NULL结尾
新程序没有执行权限

终止进程
正常终止:
从main函数返回。
调用exit()函数终止。
调用_exit()函数终止。
异常终止:
调用abort()函数异常终止。
由系统信号终止。
在Linux系统中,exit()函数定义在stdlib.h中,而_exit()定义在unistd.h中,exit()和_exit()函数都是用来终止进程的,当程序执行到exit()或_exit()函数时,进程会无条件地停止剩下的所有操作,清除包括 PCB在内的各种数据结构,并终止当前进程的运行。

头文件:
#include <unistd.h>
#include <stdlib.h>
函数原型:
void _exit(int status);
void exit(int status);

等待进程
在Linux中,当我们使用fork()函数启动一个子进程时,子进程就有了它自己的生命周期并将独立运行,在某些时候,可能父进程希望知道一个子进程何时结束,或者想要知道子进程结束的状态,甚至是等待着子进程结束,那么我们可以通过在父进程中调用wait()或者waitpid()函数让父进程等待子进程的结束。

从前面的文章我们也了解到,当一个进程调用了exit()之后,该进程并不会立刻完全消失,而是变成了一个僵尸进程。僵尸进程是一种非常特殊的进程,它已经放弃了几乎所有的内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。那么无论如何,父进程都要回收这个僵尸进程,因此调用wait()或者waitpid()函数其实就是将这些僵尸进程回收,释放僵尸进程占有的内存空间,并且了解一下进程终止的状态信息。

头文件:
#include <sys/wait.h>
函数原型:
pid_t wait(int *wstatus);

wait()函数在被调用的时候,系统将暂停父进程的执行,直到有信号来到或子进程结束,如果在调用wait()函数时子进程已经结束,则会立即返回子进程结束状态值。子进程的结束状态信息会由参数wstatus返回,与此同时该函数会返子进程的PID,它通常是已经结束运行的子进程的PID。状态信息允许父进程了解子进程的退出状态,如果不在意子进程的结束状态信息,则参数wstatus可以设成NULL。
wait()函数有几点需要注意的地方:
1.wait()要与fork()配套出现,如果在使用fork()之前调用wait(),wait()的返回值则为-1,正常情况下wait()的返回值为子进程的PID。
2.参数wstatus用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针,但如果我们对这个子进程是如何死掉毫不在意,只想把这个僵尸进程消灭掉,(事实上绝大多数情况下,我们都会这样做),我们就可以设定这个参数为NULL。
当然,除此之外,Linux系统中还提供关于等待子进程退出的一些宏定义,我们可以使用这些宏定义来直接判断子进程退出的状态:
WIFEXITED(status) :如果子进程正常结束,返回一个非零值
WEXITSTATUS(status): 如果WIFEXITED非零,返回子进程退出码
WIFSIGNALED(status) :子进程因为捕获信号而终止,返回非零值
WTERMSIG(status) :如果WIFSIGNALED非零,返回信号代码
WIFSTOPPED(status): 如果子进程被暂停,返回一个非零值
WSTOPSIG(status): 如果WIFSTOPPED非零,返回一个信号代码

子进程休眠3秒,父进程等待子进程结束

进程的生老病死

进程状态:
TASK_RUNNING: 就绪/运行状态
TASK_INTERRUPTIBLE: 可中断休眠状态
TASK_UNINTERRUPTIBLE: 不可中断休眠状态
TASK_TRACED: 调试态
TASK_STOPPED: 暂停态
EXIT_ZOMBIE: 僵死态
EXIT_DEAD: 死亡态

进程组
作用:对相同类型的进程进行管理

进程组的诞生:
在shell里面直接执行一个应用程序,对于大部分进程来说,自己就是进程组的首进程。进程组只有一个进程
如果进程调用了fork函数,那么父子进程同属于一个进程组,父进程为首进程。
在shell中通过管道执行连接起来的应用程序,两个程序同属于一个进程组,第一个程序为进程组的首进程。

进程组id:pgid,由首进程pid决定。

会话
作用:管理进程组

会话的诞生:
调用setsid函数,新建一个会话,应用程序作为会话的第一个进程,称为会话首进程。
用户在终端正确正登录后,启动shell时Linux系统会创建一个新的会话,shell进程作为会话首进程。

会话id:会话首进程id,SID

前台进程组
shell进程启动时,默认是前台进程组的首进程。
前台进程组的首进程会占用会话所关联的终端来运行,shell启动其他应用程序时,其他程序成为首进程

后台进程组
后台进程中的程序是不会占用终端
在shell进程里启动程序时,加上&符号可以指定程序运行在后台进程组里面

ctrl + z :由前台进程组 切换成 后台进程组 同时停止执行

jobs:查看有哪些后台进程组
fg+job id 可以把后台进程组切换成前台进程组

终端
物理终端
串口终端
lcd终端
伪终端
ssh远程连接产生的终端
桌面系统启动的终端
虚拟终端
Linux内核自带,ctrl+alt+f0~f6可以打开7个虚拟终端

守护进程
会话用来管理前后台进程组
会话一般管理着一个终端

当终端被关闭之后,会话中的所有进程都会被关闭。

守护进程:不受终端影响,就算终端退出,也可以继续在后台运行。

编写一个守护进程
1、创建一个子进程、父进程直接退出(fork函数)
2、创建一个新的会话,拜托终端的影响(setsid函数)
3、改变守护进程的当前工作目录,改为“/”(chrdir函数)
4、重设文件权限掩码,新建文件受文件权限掩码影响
uamsk:022,000010010,只写

新建默认文件执行权限:666,110110110
真正是文件执行权限:666&~umask

通过umask()函数修改文件权限掩码为 0

5、关闭不需要的文件描述符(close函数)
0、1、 2 :标准输入、输出、出错

查看后台运行状态

查看输出数据

重新打开终端,依然在运行

普通进程伪装成守护进程
nohup

重新打开之后,依然在运行

PS命令详解

aux
axjf
a:显示一个终端所有的进程
u:显示进程的归属用户及内存使用情况
x:显示没有关联控制终端的进程
j:显示进程归属的进程组id、会话id、父进程id
f:以ascii形式显示出进程的层次关系
ps aux

user:进程是哪个用户产生的
pid: 进程的身份证号码
%cpu:占用cpu计算能力的百分比
%mem: 占用系统内存的百分比
VSZ:进程使用虚拟内存大小
RSS:进程使用物理内存大小
tty: 进程关联的终端号
stat:进程的当前状态
start:进程的启动时间
time:进程的运行时间
command:进程执行的具体程序

ps axjf

ppid:进程的父进程id
pid:进程的身份证号码
pgid:进程所在进程组的id
sid:进程所在会话的id
tty:进程关联的终端
tpgid:值为-1,进程为守护进程
stat:进程当前的状态
uid:启动进程的用户id
time:进程运行的时间
command:进程的层次关系

使用场景
关注进程本身:ps aux
关注进程层次关系;ps axjf

僵尸进程和托孤进程
进程的正常退出步骤
子进程调到exit()函数退出
父进程调用wait()函数为子进程处理其他事情

僵尸进程
子进程退出后,父进程没有调用wait()函数处理身后事,子进程变成僵尸进程

子进程变成僵尸进程

托孤进程
父进程比子进程先退出,子进程变成孤儿进程,Linux系统会把子进程托孤给1号进程(init进程)

进程间的通信(ipc)
进程间通信
数据传输
资源共享
事件通知
进程控制
Linux系统下的ipc
早期unix系统ipc
管道
信号
FIFO
system-v ipc(贝尔实验室)
sysctem-v消息队列
sysctem-v信号量
sysctem-v共享内存
socket ipc(BSD)
posix ipc(IEEE)
posix消息队列
posix信号量
posix共享内存

Hankin
2020.07.21

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: