一、深入理解进程管理:

1、进程控制:

提到进程控制,那么就不得不提到linux操作系统中一个重要的函数,fork()函数,前面的代码已经出现过了,但是没有做详细的解析与系统性的介绍。

fork()函数:

使用man命令可以查看fork函数的使用方法。

fork()函数的返回值:

pid_t fork(),失败则返回-1,成功返回下面的内容。

父进程返回子进程的id,而子进程返回0。

pid_t类型表示进程id。

注意这里,fork函数是有两个返回值吗?fork函数怎么能有两个返回值呢?我们在写c/c++代码的时候,都是return一个值啊,这看似有点不符合常理啊。

fork函数执行之前,是父进程独立执行,fork之后,父子两个执行流分别执行,就变成了如下图所示的情景,需要注意的是fork之后,谁先执行或者说执行顺序由调度器决定,如果还觉得抽象的话不妨这么理解,fork函数之前就像是一个本体在进行战斗,而fork就相当于一个分身技能,开启分身技能后(也就是执行fork函数之后)那么就变成两个了,一个是本体(父进程),一个是分身出来的影子(子进程)。这样的话就应该能理解了!

上面提到fork函数失败的话返回-1,那么fork为什么会调用失败呢?

原因有两个,一个是系统中有太多的进程,另一个是实际用户的进程数超过了限制。

1、先创建子进程的PCB,同时复制父进程的执行状态(比如下一条要执行的指令位置、寄存器里的值),这样子进程才能知道从哪开始跑。

2、 给子进程搭一套虚拟地址空间,跟父进程的虚拟地址布局一模一样,但这时候还没真的复制物理内存。

3、创建并设置页表,让父子进程的页表一开始都指向同一块物理内存,不过会标成“只读”——这是为了后面的写时复制做准备。

4、写时复制这步是核心:只要父子进程都只读取内存,就一直共享同一块物理内存;但只要有一方想修改数据,CPU会立刻“报警”(触发页错误),这时候内核才会真的复制一块新的物理内存给修改的一方,让它的修改只影响自己,保证两边互不干扰。

5、 然后内核给返回值“填数”:父进程的返回值是子进程的PID,子进程的返回值是0——这俩值是内核直接写到各自栈里的,不是函数真的返回了两次。

6、最后执行流分叉:父进程看返回值大于0,就走自己的逻辑;子进程看返回值是0,就走自己的逻辑,从此各跑各的,由系统调度决定谁先谁后。

fork 函数本身只被父进程主动调用了一次。但内核在创建子进程时,会复制父进程的执行上下文(比如程序计数器、栈、寄存器),让子进程‘继承’父进程的执行流。
子进程的程序计数器会指向 fork 返回后的代码,所以看起来像子进程也调用了一次 fork。但本质是,内核为了区分父子身份,直接修改了父子进程的栈内存:给父进程的栈写子进程 PID,给子进程的栈写 0。
所以并不是 fork 函数真的返回了两次,而是 两个独立进程从同一段代码继续执行,且内核通过内存赋值给了它们不同的返回值,让我们感觉返回了两次。这是 fork 最巧妙的设计——用进程继承 + 内存修改’实现了‘一次调用,两次逻辑分叉的效果。

​
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() 
{
    printf("调用fork()前,当前进程ID:%d\n", getpid());
    // 调用fork(),获取返回值
    pid_t ret = fork();
    if (ret == 0) {
        printf("子进程中:fork()返回值 = %d(子进程ID:%d,父进程ID:%d)\n", 
               ret, getpid(), getppid());
    } else {
        printf("父进程中:fork()返回值 = %d(父进程ID:%d,子进程ID:%d)\n", 
               ret, getpid(), ret);  // 父进程中ret就是子进程ID
        wait(NULL);
    }
    return 0;
}

​

下面将再举一个例子来解释这个fork函数:

 1: myfork.c                                               
  1 #include<stdio.h>
  2 #include<sys/types.h>
  3 #include<unistd.h>
  4 #include<stdlib.h>
  5 
  6 int main()
  7 {
  8   pid_t pid;
  9   int x=1;
 10   pid=fork();
 11    if(pid==0)//child
 12    {
 13     printf("child:x=%d",++x);
 14     exit(0);
 15    }
 16    printf("parents:x=%d\n",--x);                                            
 17    exit(0);
 18 }

上面是代码及其输出结果,那么我将画图为此进行解释,为什么child的x是等于2的,parent的x是等于0的

这个例子说明了:

1、调用一次但是返回两次,fork函数被父进程调用一次但是却返回两次,一次是返回到父进程,一次是返回到新创建的子进程。

2、并发执行,子进程与父进程是并发运行的两个独立的进程,内核能够以任意方式交替执行他们的逻辑控制流中的命令。

3、相同但是独立的地址空间:进程所看到的是虚拟地址,而非真正的物理内存(这里后续会详细讲解),当fork创建子进程时,系统不会立刻为子进程赋值完整物理内存,而是让父子进程的虚拟地址空间初始映射到相同的物理,总结就是一句话,虚拟地址相同,但是物理页暂时共享。

虚拟地址相同了,独立又从何而来呢?答案是虽然他们的虚拟地址相同但是父子进程尝试修改变量时,系统触发写时拷贝,为子进程分配新的物理页,这个时候父进程和子进程在物理内存上是不同的,所以修改child的x++就变成了1+1=2,而parnent的x从1变成了0,也就是变成了上面的输出结果。

对上述例子做个总结:

如何理解相同但独立,所谓的相同,是虚拟地址表面相同但实际上,一旦要修改变量值的时候,物理内存就会分离,虚拟内存通过页表映射到不同的物理内存,这样做就既利用共享节省内存,又通过写时拷贝保障进程间地址空间独立、互不干扰,是多进程高效运行的重要基础!

对fork函数做一个总结:

当一个进程发出fork请求的时候,操作系统会执行以下的功能:

1、在进程表中范围新进程分配一个空项。

2、为子进程分配一个唯一的进程标识符。

3、复制父进程的进程映像,但共享内存除外。这里:属性集合称之为进程控制块,程序、数据、栈和属性存的集合合称为进程映像。

4、增加父进程所拥有的文件计数器(文件后续的博客会详细介绍,这里就不做详细的叙述了),反映另一个进程现在也拥有这些文件的事实。

5、将子进程置为就绪态。

6、将子进程的ID号返回给父进程,将0值返回给子进程。

这里再补充一条命令:ps aud |  grep 关键字    搜索系统中包含关键字的进程

getpid()函数:这个就不陌生了,就是获取当前进程的ID。

getppid()函数:获取当前进程父进程的ID。

区分一个函数是系统函数还是库函数的依据:

1、是否访问内核数据结构。

2、是否访问外部硬件资源。

如果说这两个条件有其中之一,那么它就是一个系统函数,如果这两个条件中一个都不满足,那么它就是一个库函数。

2、进程终止:

进程退出的常见方法:

正常终止

1、从main返回

2、_exit

3、调用exit

我们指定一个进程的退出状态可以在shell中用特殊的变量$查看,因为shell是它的父进程,当它终止时shel调用它的wait或者waitpid得到它的状态同时清楚掉这个进程。

进程终止的三种情景:

代码运行完,结果不正确,运行完,结果正确,以及根本没运行完,代码异常终止。

exit函数与_exit函数:

_exit函数:

函数参数status的定义了进程的终止状态,父进程通过wait来获取该值

其次需要注意的是这里的status虽然写的是int类型,但是只有低8位可以使用,也就是说这个status的范围就是到2的8次方也就是256。

exit函数:

void exit (int status)

_exit与exit的区别:

1、头文件不同:

exit函数的头文件为<stdlib.h>而_exit函数的头文件为<unistd.h>

2、

_exit属于系统函数,而exit属于库函数。

3、使用场景:

exit函数不但会结束进程还会进行刷新缓冲区,而_exit函数只会终止进程,并不会进程缓冲区的刷新。

下面将使用一张图来感受一下他们的不同:

不仅如此,下面我将用代码来进行验证:

#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("exit 函数测试:这句话在缓冲区中");
    // 调用 exit,会刷新缓冲区并终止程序
    exit(0);
}

由上面的输出结果可知,exit函数会刷新缓冲区,打印出结果。而_exit只起到终止进程的作用,所以并不会打印出结果

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("_exit 函数测试:这句话在缓冲区中");
    // 调用 _exit,直接终止程序,不刷新缓冲区
    _exit(0);
}

此代码不会打印出对应的结果,因为_exit函数不会刷新缓冲区。

3、进程等待:

wait函数:

#include<sys/wait.h>

pid_t wait(int *status)

返回值:

如果成功则返回回收进程的PID,失败返回-1.

函数的主要作用:

1、回收子进程残留在内核的PCB。

2、获取子进程的退出状态(传出参数wstatus)

3、阻塞等待子进程退出(终止)。

下面将通过下面的代码给大家来看一下wait函数的使用以及作用:

#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork error");
        return 1;
    } else if (pid == 0) {
        // 子进程逻辑
        printf("子进程 pid: %d 运行中...\n", getpid());
        sleep(3);  // 模拟子进程执行一些操作,延迟退出
        printf("子进程 pid: %d 即将退出\n", getpid());
        // 子进程正常退出,返回状态 10(自定义,用于演示)
        exit(10); 
    } else {
        // 父进程逻辑
        int status;
        printf("父进程等待子进程退出...\n");
        // 父进程调用 wait 阻塞等待子进程退出,回收子进程
        pid_t wait_pid = wait(&status); 
        if (wait_pid == -1) {
            perror("wait error");
            return 1;
        }
        printf("回收的子进程 pid: %d\n", wait_pid);
        // 判断子进程退出状态
        if (WIFEXITED(status)) { 
            printf("子进程正常退出,退出状态: %d\n", WEXITSTATUS(status));
        } else {
            printf("子进程异常退出\n");
        }
    }
    return 0;
}

介绍完wait函数接下来看waitpid函数

waitpid:

在介绍waitpid函数之前先来介绍两个宏,提到宏我们并不陌生,因为在学习c语言的时候有过接触。

正常退出场景:

WIFEXITED:

可以理解为判断函数,返回非0则证明进程正常结束,返回0,说明子进程异常退出。

WEXITSTATUS:

如果上述宏为真,则使用此宏,并获取子进程的退出状态(exit函数的参数)。

异常终止情景:

WIFSIGNALED:

返回非0则说明--->子进程是被信号终止(比如被kill -9干掉),返回0则说明不是信号杀死的退出。

WTERMSIG(status):

如果上述宏为真,那么使用此宏来获取使进程终止那个信号的编号。

如果不存在子进程,则立即出错返回!

如果在任意时刻调用wait或者waitpid,子进程存在且正常运行,则进程可能阻塞。

在看下面的代码之前,我们需要了解status的结构。

status的结构较为特殊:

进程的退出状态status是一个32为的整数,但是实际有效信息主要是在低16位,其中高8位,也就是低16位里面的高8位,储存的退出状态,低8位储存的是终止信号。

所以status>>8也就是移到了低8位,那么位与上0xFF,,0xFF的二进制是11111111(8个1),这样截断了高24位,只保留了低8位的退出状态值。

status&0x7F,直接保留了低7位,也就是终止信号,同时把高25位的清零,从而得到准确的终止信号编号。

下面将画图来展示一下status的结构:

这里的等待方式分为两种阻塞等待和非阻塞等待:

进程的阻塞等待方式:

代码如下:

非阻塞等待:

1 #include <stdio.h>
  2 #include <unistd.h>
  3 #include <stdlib.h>
  4 #include <sys/wait.h>
  5 
  6 int main()
  7 {
  8  pid_t pid;
  9 
 10  pid = fork();
 11  if(pid < 0)
 12  {
 13    perror("false");
 14  return 1;
 15  }else if( pid == 0 ){ //child
 16  printf("child is run, child pid is : %d\n",getpid());
 17  sleep(3);
 18  exit(14);
 19  } else{
 20  int status = 0;
 21  pid_t ret = 0;
 22  do
 23  {
 24  ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
 25  if( ret == 0 ){
 26  printf("child is running\n");
 27  }
 28  sleep(1);
 29  }while(ret == 0);//说明此时子进程正在运行
 30 
 31  if( WIFEXITED(status) && ret == pid ){ //如果正常退出,并且返回值等于子进程的pid。
 32  printf("等待三秒钟成功, child return code is :%d.\n",(status>>8)&0xFF);
 33  }else{
 34  printf("等待子进程失败了!, return.\n");
 35  return 1;
 36   }
 37  }
 38  return 0;
 39 }

waitpid函数总结:

pid_t waitpid(pid_t pid,int *status,int options)
功能/作用:

等待子进程终止,如果子进程终止了,此函数回收子进程的资源

参数pid:

pid>0 等待进程ID等于pid的子进程

pid=0 等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid将不再等待它。

pid=-1 等待任何一个子进程,此时waipid的作用约等于这个wait的作用。

pid<-1 等待指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值

 status:进程退出时的状态信息,其结构我们在上面已经详细介绍了。

opinion:

  0:同wait(),阻塞父进程,等待子进程退出

WNOHONG:没有任何意见结束的子进程,则立即返回,上面的非阻塞等待正是利用了这一点,也就是说,它不会在那里一直等着你,如果这个子进程正在运行,那么它会直接返回。

该函数的返回值:

如果正常返回,那么此函数返回已经回收的子进程的进程号。

如果设置了选项WNOHANG,waitpid()发现没有已退出的子进程可等待,则返回0,因为此时子进程正在运行,而它不会去等待子进程运行完,会直接返回!这一点需要注意一下!

如果出错了,返回-1。

以上就是进程等待的全部内容。

4、进程虚拟地址空间(重中之重):

对于5环境变量中的问题,我在这里进行详细地解析。

对于上面这个问题,就不得不提到内存管理了,那么内存是如何进行管理的?为什么同样的地址会打印出不同的值呢?

首先存储器由内存和辅存组成,内存可以直接访问,辅存要间接访问。

Linux虚拟内存系统:

linux为每个进程维护了一个单独的虚拟地址空间,一个虚拟地址空间的大小通常是2的32次方或者2的64次方。

在上面的图,在c/c++地址空间中,我们所说的地址是内存吗?答案不是,例如我们用的取地址符号得到的地址并不是真正意义上的内存,只是虚拟地址。

接着往下看,那么这个虚拟地址和物理内存之间有什么关系呢?

CPU上的管理单元MMU可以将这个虚拟地址转换为物理内存,为什么相同的地址即虚拟地址相同而值却不同?因为在进行转换的过程中,它们被映射到不同的物理内存,即虚拟地址是系统的但是物理内存是不同的,所以对应的值也是不同的,这就是为什么虚拟地址相同但是值不同的原因。

请看下面这张图你或许就懂了。

这里的虚拟内存并不是真正的物理内存,它只是一个传话员,而你去修改变量的值,也是去物理内存中修改,但是并不能直接访问物理内存,而是通过一个虚拟地址去映射它的物理内存,那为什么这么做呢,后续会给出答案。

二、冯诺依曼体系结构:

如今,我们常见的电脑,如笔记本,都遵循冯诺依曼体系。

输入单元:像我们的键盘,鼠标,扫描仪等。

输出单元:显示器,打印机等。

中央处理器:我们常谈的CPU:包括运算器和控制器两大部分。

需要注意的是:

这里的存储器指的是内存!

外设要输入或输出数据只能写入内存或从内存上读取。

所有设备都只能直接和内存打交道。

三、操作系统:

操作系统是管理计算机硬件与软件资源的系统软件,是计算机系统的核心,能为用户和应用程序提供交互接口以及运行环境。

主要功能如下:

1、进程管理:负责进程的创建,调度,终止等,合理分配cpu资源,让多个程序高效运行起来。

2、内存管理:对内存进行分配、回收以及保护,确保各程序都能安全使用内存空间。

3、文件管理:包括文件创建、删除、读写等一些列操作。

4、设备管理:协调计算机与外部设备的通信。

5、用户接口:提供用户与计算机交互的方式,分为命令行接口和图形用户接口。

我们可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作都必须依靠操作系统来完成。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐