进程理解
概念:进程是程序的一个执行实例,其实启动一个程序(静态)本质就是启动了一个进程(动态),进程具有独立性。
用户角度:进程=代码+数据+内核数据结构(PCB结构体+页表+操作系统分配的地址空间)。
内核角度:承担分配系统资源的基本实体。
Linux是可以同时加载多个程序的,即Linux是可能存在大量进程的,因此必须要对大量的进程进行管理,而管理的方式便是“先描述,再组织”。为了描述进程,每个进程都有一个属于自己的PCB结构体,全称process_control_block,也称进程控制块。在不同的操作系统中,PCB的具体名字是不同的,Linux中为struct task_struct{ };它会被加载到内存中,并且携带着进程的信息。
task_struct内容分类
将进程的具体信息抽象成为task_struct之后,对进程的管理就变成了对进程PCB结构体链表的增删查改。操作系统和CPU运行某个进程的本质就是从运行队列中挑选一个task_struct来执行该进程对应的代码。
创建进程
Linux中通过fork()系统调用从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。fork()之后要用if分流,子进程返回0,父进程返回子进程的pid,失败时返回-1。pid就是进程标识符。
fork常规用法
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
系统中有太多的进程
实际用户的进程数超过了限制
进程调用fork,当控制转移到内核中的fork代码后,内核做如下几件事:
1、分配新的内存块和内核数据结构给子进程。
2、将父进程部分数据结构内容拷贝至子进程。
3、添加子进程到系统进程列表当中。
4、fork返回,开始调度器调度。
fork之后,系统内多了一个进程,要给子进程创建对应的内核数据结构,必须子进程自己独有,因为进程具有独立性。理论上,子进程也要有自己的代码和数据,可是一般而言,创建子进程的并没有加载过程,因此,子进程只能“使用”父进程的代码和数据。具体“使用”的策略是代码部分为只读,对应数据部分为写时拷贝。
具体原因
1、当真正使用时再进行对应资源分配,是高效使用内存的一种表现。
2、操作系统无法在代码执行前预知哪些空间会被拷贝。
具体例子:
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<iostream>
int main()
{
pid_t id = fork();
if(id == 0)
{
std:: cout << "我是子进程" << "pid为: " << getpid() << std::endl;
}
else if(id < 0)
{
//创建子进程失败
perror("fork");
exit(-1);//退出码设置为-1;
}
else
{
//父进程
waitpid(id, nullptr, 0);//等待子进程退出,父进程回收子进程资源
std:: cout << "我是父进程" << "pid为: " << getpid() << std::endl;
}
return 0;
}
此处父进程和子进程的调度顺序并不是绝对的,而是要看操作系统的调度策略。
进程状态
概念:进程状态分为新建状态,运行状态,阻塞状态,挂起状态和退出状态。
首先,操作系统管理的资源一定是各种各样的,不仅仅是CPU资源,还有诸如网卡,显卡,磁盘等其他设备,因此除了运行队列之外,系统中还存在其他进行管理资源等待与分配的队列。
运行状态:并不仅仅是该进程正在运行就叫做运行态,而是task_struct在对应的运行队列中排队,这个task_struct对应的进程状态就叫做运行态。
阻塞状态:处于阻塞状态的进程,一定是处于某种资源未就绪的状态,因此等待非CPU资源就绪的进程所处的状态就称为阻塞状态。
挂起状态:内存快不足时,操作系统会执行一种策略,将长时间不执行的进程代码和数据换出到磁盘中,用来缓解内存使用紧张的问题。被换出的进程只有PCB在内存中。此时该进程的状态就称为挂起。
具体Linux下的进程状态
僵尸进程:进程已经退出,但是还不允许被操作系统释放,处于一个被检测的状态的进程称为僵尸进程。
具体例子:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
//创建子进程失败
perror("fork error");
return -1;
}
else if(id > 0)
{
//father process
int cnt = 0;
while(cnt < 10)
{
printf("我是父进程, pid:%d, ppid:%d\n", getpid(), getppid());
++cnt;
}
}
else
{
//child process
int cnt = 0;
while(cnt < 5)
{
printf("我是子进程, pid:%d, ppid:%d\n", getpid(), getppid());
++cnt;
}
}
return 0;
}
上面的例子中,子进程先退出,父进程后退出,在子进程退出而父进程未退出的时间段内,该子进程为僵尸进程。
为什么会有僵尸进程的原因
本质是为了维持该状态,以便于父进程结束后及时回收该系统资源,但维持该状态就意味着要维持该僵尸进程的PCB,虽然代码和数据可以被释放,但PCB不行,所以会存在系统资源层面的内存泄露问题。
要解决僵尸进程的问题,要通过进程等待系列的系统接口wait和waitpid。
孤儿进程
概念:父进程提前退出,子进程并没有退出,此时的子进程称为孤儿进程。该孤儿进程会被1号进程领养,作为该进程的父进程来回收该子进程的资源,避免系统资源层面的内存泄露。1号进程就是bash。
具体例子:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
//子进程创建失败
}
else if(0 == id)
{
//child process
int cnt = 0;
while(cnt < 5)
{
printf("我是子进程 pid: %d, ppid:%d\n", getpid(), getppid());
sleep(1);
++cnt;
}
}
else
{
//father process
int cnt = 0;
while(cnt < 2)
{
printf("我是父进程 pid:%d, ppid:%d\n", getpid(), getppid());
sleep(1);
++cnt;
}
}
return 0;
}
上面例子中,父进程先退出,在父进程退出到子进程退出的时间段内,该子进程称为孤儿进程。
进程地址空间
概念:进程地址空间是操作系统为了管理实际内存而设计的一种数据结构,是一种虚拟地址。每个进程在被创建时,除了进程PCB之外,在合适的时候,操作系统也会为该进程申请对应的进程地址空间和页表,通过填写页表,维护虚拟地址和真实物理地址的关系。
设计进程地址空间的原因及好处
1、进程直接访问物理内存是不安全的,通过进程地址空间,凡是非法的访问或者映射,操作系统都会识别并终止该进程。有效的保护了物理内存和物理内存中的合法数据,因为地址空间和页表是操作系统创建和维护的,凡是向使用地址空间和页表进行映射,也一定会在操作系统的监管之下进行访问。
2、因为有地址空间的存在,因为有页表的映射存在,物理内存中可以对数据进行任意位置的加载,原本物理内存中的几乎所有数据和代码在内存中是乱序的,有了地址空间和页表后,在进程视角,所有的内存分布都可以是有序的,所以地址空间+页表的存在可以将内存分布有序化。
3、进程要访问的物理内存中的数据和代码,可能目前并没有在物理内存中,同样的,也可以让不同的进程映射到不同的物理内存,通过地址空间+页表的方式实现了进程的独立性。页表映射的时候,不仅仅可以映射物理内存,磁盘中的位置也是可以映射的。
4、完成了内存管理和进程管理模块的解耦合,本质上,因为地址空间的存在,上层申请空间其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你,只有当真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系,提高内存整体使用效率。
小问题
在fork后,返回值有两个,是因为两个进程分别return,子进程和父进程返回的值看似存到一个变量中,实际在页表映射时,映射的是不同的物理地址,也就是说,虚拟地址可以显示的一样,但实际上页表映射的物理空间是不一样的。