进程概念理解
理解:进程完成任务需要属于自己的内存空间,内存空间的管理是操作系统,Linux一切皆文件,所以操作系统想要管理系统中的进程则要结构化进程的各种信息,以方便对进程的管理。所以的 程序运行后,程序会加载到内存中代码和数据,操作系统为管理程序,建立进程的PCB结构进行管理。综上进程等于进程PCB结构+程序加载到内存的代码和数据。
内核看进程: 担当分配系统资源(CPU时间、内存等)的实体。
描描述进程PCB
- 是什么:操作系统管理进程的数据结构,存放在操作系统管理的内存(RAM)中。
- Linux下的PCB:task_struct,里面包含进程中的所有信息,是进程的属性的集合。
- task_struct内容
- 进程的基本信息
- 指向内存区的文件描述符指针
- 内存指针
- 上下文的数据:进程执行时处理器中寄存器的数据
- 标识符
- .......
Linux下查看进程
Fork()系统调用(子进程创建)
fork函数
代码实践
int main()
{
pid_t id = fork();
if(id<0)
{
cout<<"debug : fork error!"<<endl;
}
else if(id ==0)
{
while(1)
{
cout<<"I am child..."<<"(getpid="<<getpid()<<endl;
sleep(1);
}
}
else
{
while(1)
{
cout<<"I am father..."<<"(getpid="<<getpid()<<endl;
sleep(1);
}
}
return 0;
}
fork之前父进程独立执行,fork之后,父子两个执行流分别执行,fork之后父子进程谁先执行完全看CPU调度,下面使用after和before证明该观点。
int main()
{
cout<<"befor"<<endl;
pid_t id = fork();
cout<<"after"<<endl;
if(id<0)
{
cout<<"debug : fork error!"<<endl;
}
else if(id ==0)
{
while(1)
{
cout<<"I am child..."<<"(getpid="<<getpid()<<endl;
sleep(1);
}
}
else
{
while(1)
{
cout<<"I am father..."<<"(getpid="<<getpid()<<endl;
sleep(1);
}
}
return 0;
}
子进程和父进程执行相同代码:fork调用之后,子进程复制父进程的代码,但是父进程的私有代码无法被子进程复制;两个if判断同时进行,父进程中id是子进程的id,子进程的id则是0,父子进程同时运行,但是进入不同if判断,所以执行不同的代码。子进程可以执行程序替换来执行自己想要执行的程序内容。
父子进程返回数值不一致的原因分析:父进程和子进程的比例关系是1:N的关系,子进程可以有多个,但是父进程只有一个,所以父进程如果想要知道是哪一个子进程,就需要根据返回的pie数值进行处理。
fork调用后两个返回值内核理解: fork创建子进程之后,操作系统需要对该子进程进行管理,所以需要创建一个子进程结构体对其进行管理,CPU在调度的时候,子进程的调度紧随父进程之后,所以fork之后ID返回值也就是两个。
Linux内核级别理解父子进程
父子进程本质都是进程,所有都属于操作系统进行管理,操作系统管理进程的方法则是上述内核数据结构+进程代码和数据。
Linu要保证每个进程都是独立,依据该原则分析的话,子进程应该的自己虚拟地址空间同时在物理内存中也拥有自己的内存空间。但是上述fork系统调用后结论是子进程复制了父进程的代码。深层原因是代码是共享(因为代码只可读),所以可以被的父子进程看到。
操作系统利用写时拷贝技术实现父子进程的数据分离。因为父进程不确定子进程是否需要自身的全部数据,为了实现的高效的内存分配,只有当子进程需要父进程提供的代码和数据时,才在内核领域将父进程中共享的代码拷贝到子进程的页表中。本质上也就是内存中父进程为子进程拷贝一份相同的数据,然后通过文件描述符修改其文件指向。
写时拷贝原理图
进程调用fork后内核执行的步骤
- 分配新的内存块的和内核数据结构给子进程
- 将父进程的部分数据写时拷贝到子进程中
- 添加子进程到系统进程列表中
- fork返回,CPU开始调度进程
fork应用场景分析
父进程希望复制自己,让子进程执行不同的代码段,http服务器中,父进程等待客户端请求,然后生成子进程处理客户端的请求;让子进程执行不同的程序,子进程返回后使用程序替换,从而让子进程执行对应程序。
fork失败的可能的原因
系统中进程过多,无法再创建新的进程;实际存在的用户进程超过了操作系统最大进程的限制。
进程状态
僵尸进程
含义: 父进程创建子进程,子进程退出了,但是父进程不知道,父进程没有收到子进程已经"去世"的信号,此时子进程就成为僵尸进程。子退父不知。
僵尸进程终止后,终止z状态仍然保存在进程表中,并且一直等待父进程读取自己退出状态码。所以只要子进程突然挂掉,父进程在运行,子进程便成为了僵尸进程。
僵尸进程会导致内存泄漏,父进程创建子进程后,交给子进程任务,子进程处理任务数据父进程一直不读取,子进程因为某种原因退出了,父进程始终等不到自己想要的数据。但是操作系统仍需要保存该进程的基本信息,将其放到task_pcb中,此时便会浪费内存空间资源。如果这种情况大量的出现,占据大量内存空间,此时便会造成内存泄漏问题。
孤儿进程
父进程创建子进程后,子进程还没有执行完时,父进程退出了,此时没有进程等待子进程返回,此时的子进程就成为了孤儿进程。父退子不知就是孤儿进程。孤儿进程会被1号进程领养,不会像僵尸进程一样导致进程崩溃。
运行进程
Task_struct中排队运行的进程都属于运行状态。
阻塞状态
等待CPU资源准备完成后再执行的进程。某些进程中的函数,执行相应功能的时候,需要从内存中调用相应的资源,此时便需要区内存中调用对应的数据。此时进程无法继续运行,也就是阻塞状态。
挂起状态
进程中需要使用的代码和数据已经被放到磁盘,但是进程仍然在内存的时候,此时该进程就是挂起进程。内存是有限的,为了保证内存空间的充足,所以需要将内存中某些进程中的代码和数据放入到物理内存中。
进程关系总结
- 竞争性:系统多个进程抢一个资源,导致进程之间产生了竞争属性。为了高效完成任务,同时的合理分配临界资源,从而需要的设计线程的优先级。
- 独立性:多进程同时运行,每个进程都应该有自己专属内存空间,且进程运行期间互不干扰。
- 并行:多个进程在多个的CPU下同时运行,该种状态称为并行。
- 并发:多个进程在一个CPU下采用进程切换的方式,让多进程在一个CPU调度下完成对应任务。
环境变量
环境变量含义的理解: 环境变量告知用户命令对应的可执行文件存在在系统的什么区域中,然后方便对其调用。类似于给可执行文件其了一个别名,所以在Linux系统下可以直接使用ls这样的命令对其进行操作。
每个进程都会自己环境变量,父进程创建子进程后,子进程会继承父进程的环境变量。所以可以使用环境变量实现父子进程之间的传输数据。
export:查看当当前系统中的所有环境变量
[root@hcss-ecs-b4a9 test_db]# export
declare -x HISTCONTROL="ignoredups"
declare -x HISTSIZE="10000"
declare -x HISTTIMEFORMAT="%F %T root "
declare -x HOME="/root"
declare -x HOSTNAME="hcss-ecs-b4a9"
declare -x LANG="en_US.UTF-8"
declare -x LESSOPEN="||/usr/bin/lesspipe.sh %s"
declare -x LOGNAME="root"
declare -x LS_COLORS="rs=0:di...
declare -x MAIL="/var/spool/mail/root"
declare -x OLDPWD="/home"
declare -x PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin"
declare -x PWD="/home/test_db"
declare -x SHELL="/bin/bash"
declare -x SHLVL="1"
declare -x SSH_CLIENT="112.3.....
declare -x SSH_CONNECTION="11...
declare -x SSH_TTY="/dev/pts/0"
declare -x TERM="xterm"
declare -x USER="root"
declare -x XDG_RUNTIME_DIR="/run/user/0"
declare -x XDG_SESSION_ID="985"
echo $PATH:查看 当前PATH环境变量的数值
[root@hcss-ecs-b4a9 test_db]# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin
环境变量存储在main栈中或者堆中,每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以'\0'结尾的环境字符串。
系统调用获取和设置环境变量 putenv getenv
[root@hcss-ecs-b4a9 test_db]# ./mytest
MAG: post
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
std::string pathvar = "MAG=post";
putenv((char*)pathvar.c_str());
printf("MAG: %s\n", getenv("MAG"));
return 0;
}
进程地址空间
进程地址空间理解
操作系统为了保证内存的安全性,不会直接分配给进行对应的物理内存。因为操作系统不信任这些进程可以合理使用对应的物理内存。所以操作系统使用虚拟地址空间给每个进程都有完整内存空间的假象。
进程地址空间存在的意义,因为操作系统内核上,内存管理和金层呢管理是分离开的,进程访问内存时,Linux内核对地址空间和页表等操作进行了封装,所以无法感知自己访问的是虚拟地址空间。
虚拟地址空间是什么?操作系统会给每个进程一个虚拟地址空间,具体表现是虚拟表,进程之间虚拟地址空间的地址会发生冲突,但是虚拟地址并不是物理地址的准确位置,虚拟地址空间在转换成物理地址的时候,需要借助页表。
综上,操作系统在有限空间的条件下,给进程拥有全部内存空间的假象,让进程合理使用内存空间的同时,也保证多了操作系统对内存管理和进程管理的分离,最终要的是保证了物理内存被进程滥用。
进程与文件的交互(p458,后续补充)
进程控制
进程创建
fork系统调用;父子进程的创建;参考上文。
进程终止
进程终止本质就是在释放进程中的系统资源,将该进程从操作系统的进程管理中取出,同时删除进程使用资源和数据。存在多种方式让进程终止,例如程序崩溃、结果不正确、main函数返回等。
进程终止方法exit()调用,内核层面在进程退出时会做出相应的处理,首先执行用户定义的清理函数,然后冲刷缓冲,关闭流等操作,这些都是交给Linux内核完成。
进程等待
进程等待原因分析,上文已经叙述,如果子进程突然退出,父进程无法回收子进程的时候,子进程此时会变成僵尸进程。进程等待存在的意义,就是父进程不断的去确认子进程是否退出,防止子进程的成为僵尸进程。另一方面,通过进程等待,父进程也可以得知子进程是否完成的父进程交付的任务,或者是否正确的退出。
pid_t waitid(pid_t pid , int *status , int options);
返回值:
正常返回:等待到的子进程Pid
错误返回:返回-1,errno被设置成相应的值指出错误所在
设置WNOHANG:发现没有已退出的子进程时,返回0
参数:
pid=-1:等待任意一个子进程,和wait作用相同
pid>0:等待进程ID和pid相等的子进程
status: WIFEXITED,如果为正常终止子进程返回的状态,则为真。
status: WEITSTATUS,如果为非零则提取子进程的退出码
options:0表示阻塞状态
父进程会一直阻塞在wait等待子进程退出后再向下运行。
进程程序替换
调用fork后,子进程中运行着和父进程中相同代码,通过进程程序替换,能够实现让子进程执行其他代码。进程程序替换存在的意义在于项目中让父进程专注于读取解析数据,指派子进程处理解析后的数据,解析后的数据通过管道或者环境变量交给子进程进行处理。从而实现父子进程一个解析一个处理,两个进程互不影响。
进程程序替换之前,父子进程之间代码时共享的,因为代码只可读所以可以共享,数据是写时拷贝,节约物理内存空间。进程程序替换之后,父子进程代码是分离的,两者执行程序内容不一致。
进程替换不会替换掉环境变量,所以父子进程之间还是可以通过环境变量实现通信。因为子进程拿到的是父进程相同的环境变量表。
//code.cc
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == 0) // 子进程进行进程替换
{
cout << "I am a child process, pid = " << getpid() << endl;
cout << "exec is begining" << endl;
execl("./code2", "code2", "-a", "-b", NULL); // 运行自己的程序code2
cout << "exec end" << endl; // execl之后的代码不会运行,因为此时进程被exec替换
}
//父进程执行自己的程序
int w = wait(NULL);
if (w > 0)
cout << "wait success" << endl;
else
cout << "wait failure" << endl;
return 0;
}
//code2cc
#include <iostream>
#include <cstdio>
using namespace std;
int main(int argc, char* argv[], char* env[])
{
for (int i = 0; argv[i]; i++)
{
fprintf(stdout, "argv[%d]: %s\n", i, argv[i]);
fprintf(stdout, "env[%d]: %s\n", i, env[i]); //运行此时的环境变量
}
cout << "code2.exe option" << endl;
return 0;
}
头文件
#include <unistd.h>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[]);exec* 函数的共性
1,程序一旦被 exec* 替换成功,exec* 后续的代码不在执行,因为此时的进程被替换掉了。若替换失败,此进程后面的代码才继续执行。
2,exec* 只有失败返回值-1,没有成功返回值,因为一旦成功此进程将被替换。
3,替换完成,不会创建新的进程,即PCB结构。
程序替换内存级别原理理解
进程间通信
进程通信的目的
- 数据传输:不同进程之间传输数据
- 资源共享:不同进程之间共享相同的资源
- 通知事件:通知某种信号的到来或者某件事情需要进行处理
- 进程控制:一个进程控制另一个进程的执行,并获取其错误信息,例如debug程序
管道通信
管道通信原理理解,两个进程如果想要通信,并且通信在操作系统控制之下,那么就要让两个进程看到的同一块内存区域,这样才能够实现进程间的通信。管道通信借助修改进程文件描述符,改变进程的输入输出流向(统一在两个进程都能够看到的区域进行),从而实现两个进程通信的目的。
管道读写遵循一般的规则,无数据可读的时候,等待进程传入数据后再进行读取;管道中的数据被读满的时候,阻塞进程。读/写端口关闭会导致错误。
管道特点
- 管道实现进程通信只存在的有血缘关系的进程之间,例如父子进程之间使用管道进行通信。
- 管道提供的是流式传输服务,即面向字节流。
- 管道通信的生命周期跟随进程的生命周期而定,随着进程的终止而终止。
- 管道是半双通的通信方式,如果想要实现全双工的通信,则需要建立I两个管道才可以实现。
匿名管道
pipe匿名管道实现通信的本质还是修改两个进程的文件描述符,一个进程负责向进程中写入数据,另一个负责向进程中读取数据。匿名管道只是将这些过程进行了底层封装。
文件描述符的理解,操作系统为管理进程会给每个进程创建一个task_struct结构体,每个进程在运行中又需要对物理内存中的文件做相应的操作,所以在就结构体内部又一个file_struct结构体进行管理。文件描述符是该结构体中的数组下标,每个下标对应的是一个文件指针,每一个文件指针都指向打开文件的指针。
Linux内核下文件描述符
文件描述符是files_struct结构体中数组的索引。上述的文件描述符只局限于Linux进程下,实际上Linux内核维护的三个数据结构,分别进程级的文件描述符表、系统级的文件描述符表、文件系统的i-node表。
Linux进程启动后,Linux内核空间会创建PCB控制块(task_struct),控制块里面便有文件描述符表,记录当前进程中所有可用的文件描述符,因为进程之间是相互独立的,所以文件描述符在不同进程中是可以重复的。系统维护的另外两张表分别是打开文件表(存储了每个打开的文件的句柄)和I-Node表(里面存储了打开文件的相关信息)。
操作系统管理文件描述符表才是真正映射到物理内存的,进程所拥有的文件描述符表,其中的文件指针不是直接指向物理内存,而是指向操作系统级别的文件描述符表中。所以上述无论是管道还是fork让两个进程看到同一个文件中内容实现的本质便是让两个独立进程的文件描述符表指向系统级别的文件描述符表。
命名管道
用于没有血缘关系的进程之间进行通信。进程使用命名管道进行通信,只可以使用命名管道实现访问数据的功能,不可以实现将传输的数据放入到内存中。
匿名管道由pipe创建并打开,命名管道使用mkfifo函数创建,使用open打开。两者的区别在于一个是血缘直接的进程通信,另一个支持不同进程之间的通信。另外一个区别便是创建和打开方式不同。
system V共享内存
原理实现
操作系统在内存中创建一块内存区域,供两个进程进行通信。两个进程通过页表拿到该共享区域的虚拟内存地址。因为进程独立性原则,即使这两个虚拟地址不同,但是指向的物理地址是相同的。最终实现了两个进程都可以看到并使用该块内存空间,从而实现进程之间的通信。
共享内存释放过程分析,首先删除两个进程和内存地址空间的映射关系,然后由操作系统释放磁盘上是物理空间即可。
两个进程通信的实现,共享内存中存放在用户层的标识符类似于fd,每个共享内存中都会有一个key值,只有两个key值相等的时候才可以进行通信。类似于给两个进行每人一把打开操作系统开辟共享内存的钥匙,从而让两个进程可以看到同一片物理空间,从而最终实现通信的目的。
操作系统开辟的共享内存存放在共享区中,所以双方通信不需要调用系统接口,直接进行内存级别的读写接口实现的进程之间的通信。但是共享内存没有访问控制,所以可能会出现并发问题,数据还没有写入完成就被读取走。
深入理解共享内存使用:宋宝华:世上最好的共享内存(Linux共享内存最透彻的一篇)-腾讯云开发者社区-腾讯云
system V信号量
互斥、临界资源、临界区
- 临界资源:多个执行流都能够看到并且调用的资源就是临界资源
- 互斥:任何时刻,只允许一个执行流访问临界资源,这种行为称为进程之间的互斥。
- 原子性:任务只有做完和没做完两种情况,没有正在做等其他情况。
- 临界区:进程访问临界资源的代码就是临界区
信号量
例如一个教室中存放着健身器材,该教室每一次只允许一个人进入。所以每个人想要进入该教室需要申请,申请成功后会发送一个凭证,凭借该凭证可以去进入教室。从而避免所有人同时进入进教室。上述例子中教室中的资源就是临界区资源,凭证就是信号量。
每个进程想要访问临界资源必须先申请信号量,信号量的本质就是计数器,进程只有信号量申请成功的时候,才能够访问临界资源。