进程的概念(上篇)

1 冯诺依曼体系结构

我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。

在这里插入图片描述截至目前,我们所认识的计算机,都是由一个个的硬件组件组成:

输入单元: 包括键盘, 鼠标,扫描仪, 写板等

中央处理器(CPU): 含有运算器和控制器等

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

关于冯诺依曼,必须强调几点:

  1. 这里的存储器指的是内存
  2. 不考虑缓存情况,这里的 CPU 能且只能对内存进行读写,不能访问外设(输入或输出设备)
  3. 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
  4. 所有设备都只能直接和内存打交道。

2 计算机的体系结构

计算机整个体系结构包含硬件部分、系统软件部分和用户部分:
1、系统软件部分:系统软件部分包括操作系统,涉及四大管理模块(内存管理、进程管理、文件管理和驱动管理),整个操作系统对上要提供 system call (系统调用)。
2、用户部分: 系统调用之上会有用户操作接口,如 shell 外壳、图形化操作界面、各种等,再往上就是用户的指令操作开发操作。这里的用户指的是程序员。
3、在程序员用户之上还有基于程序员做好的桌面级软件进行使用的普通用户。
注: 整个计算机的体系结构是层状的,不能绕过特定层必须从上到下贯穿,自底向上获取,其中操作系统起到了很重要的地位。

在这里插入图片描述

3 系统调用和库函数概念

3.1 系统调用概念

在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口叫做系统调用

3.2 库的概念

系统调用在使用上,功能比较基础,对用户的要求相对也比较高,用起来不方便,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成,有了库,就很有利于更上层用户或者开发者进行二次开发。

3.3 语言本质

语言的本质是一套在应用层实现的标准,所有被发明出来的语言一定有自己配套的编译器或者解释器,是因为编译器可以把高级语言转换成计算机能够识别的二进制语言,所以高级语言的本质就是在别人开发的底层语言基础上提供更加丰富的功能。比如有些语言能够对文件进行操作,而文件操作就是在磁盘中调用系统调用文件相关的接口,所以语言对文件操作的模块就是对系统调用接口的封装。
总之,只要语言支持某些系统特性,就一定封装了系统调用系统调用操作系统提供的接口库函数就是对系统调用接口的封装。库和系统调用是上下层关系,库可能调用系统调用,但不是所有的库都会调用系统调用。

为什么要有库?
因为系统调用使用起来成本太高,用库函数可以通过调用系统调用,提高开发效率。

4 进程

4.1 基本概念

课本概念: 程序的一个执行实例,正在执行的程序等。
内核观点: 担当分配系统资源(CPU时间,内存)的实体。

举例:

比如在 windows 下打开一些软件,这些软件是以进程的方式给用户提供服务,图示如下:

在这里插入图片描述

4.2 如何理解这些进程呢

在命令行执行一些指令,如 lspwd 或者自己写一个程序编译运行,都是要转换成进程的形式让操作系统调度完成特定的任务。所以,任何启动并运行程序的行为都是由操作系统帮我们将程序转换成进程,完成特定的任务。在 windows 双击一个软件就是启动了一个进程,在 linux 上 ./ 运行一个程序也是启动了一个进程,在手机上打开一个 app,也就转换成了一个进程。

举例:

计算机中的磁盘可以保存很多文件,包含普通文件、目录和写好的 cc++ 文件,这些文件编译好了就成为一个程序保存为磁盘中的一个普通二进制文件
此时将操作系统关闭或者重启,磁盘中的这些文件依然存在,文件 = 内容(代码和数据) + 属性(拥有者、权限、创建时间等),当我们 ./ 这个二进制文件将其代码和数据加载到内存,此时 cpu 才能访问代码和数据并执行。
在这里插入图片描述
但是此时这些代码和数据能被称为进程吗?

答案是不对,因为需要操作系统实现对加载到内存中的数据进行先描述再组织,所以在操作系统内核中,要为每一个进程在加载到内存之时创建一个数据结构对象(pcb / task_struct),这个数据结构对象(结构体)提取了所有进程的属性,通过进程的属性访问到内存中对应的代码和数据。

磁盘中加载了很多个进程的时候,都会在内核中创建对应的结构体对象,但是在内核中,被创建的数据结构对象都叫 task_struct,所以为了区分这些进程,在结构体对象中再新增结构体指针,让所有的数据结构对象关联起来。

如下图所示,在内核中创建了以下数据结构对象,分别对应内存中多个进程,将他们用指针的方式关联起来形成了一个单链表

在这里插入图片描述

当结束一个进程的时候,就相当于在整个进程链表中遍历,找到要结束的进程,进而找到此进程对应的代码和数据进行释放。

在这里插入图片描述
当用户从磁盘又加载了一个程序到内存,此时操作系统又在内核中创建了对应的 tast_struct 把进程的所有属性填入到结构体并将其放入链表中。在这里插入图片描述
对新增的进程管理就变成了对链表的增删查改。此时就完成了进程的建模过程,此时对进程的管理就转化成对进程的 tast_struct 结构体链表进行增删查改。
在这里插入图片描述

总结:
1、真正的管理不是对加载到内存中的代码和数据进行管理,而是对代码和数据提取出的进程的相关属性用结构体进行描述,再形成一个单链表进行管理。
2、进程是加载到内存的代码和数据和在内核中创建的 task_struct 数据结构叫进程。
3、进程 = 内核关于进程的相关数据结构 + 当前进程的代码和数据

4.3 描述进程-PCB

为什么进程管理中需要 pcb

1、因为进程需要被操作系统管理,而管理的思路是先描述再组织,而 pcb 可以更好地描述进程。

2、进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,课本上称之为 PCB(process control block)。

3、Linux 操作系统下的 PCB 是: task_struct task_struct-PCB 的一种。在 Linux 中描述进程的结构体叫做 task_struct
task_structLinux 内核的一种数据结构,它会被装载到 RAM (内存) 里并且包含着进程的信息。

4、task_ struct 内容分类:

标示符: 描述本进程的唯一标示符,用来区别其他进程。

状态: 任务状态,退出代码,退出信号等。

优先级: 相对于其他进程的优先级。

程序计数器: 程序中即将被执行的下一条指令的地址。

内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针

上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。

I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。

记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。

其他信息

在 linux 中编写如下代码并进行编译:

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

int main()
{
	while(1)
	{
		printf("hello process\n");
		sleep(1);
	}
}

将代码保存退出并编译执行,此时操作系统就自动将可执行程序就从磁盘加载到内存,并创建内核相关的 pcb 数据结构,将数据结构插入到可管理的链表中,程序运行后,被 cpu 周期调度,此时程序就变成了一个进程。

在这里插入图片描述

4.4 查看进程的两种方式

可以使用 ps axj 查看系统中的所有进程,通过管道过滤掉其他进程,只看自己的进程,使用命令 ps axj | grep myprocess,此时可以看到有一个进程在运行。

在这里插入图片描述
输入 ps axj | head -1 命令拿到进程属性中的第一行数据

在这里插入图片描述

再执行 ps axj | head - 1 && ps ajx | grep myprocess 命令即执行进程属性的头,又得到了自己进程的属性

在这里插入图片描述

因为在系统之中查找进程时,ps 将所有进程的文本都打印出来,经过管道传递给 grepgrep 也是一个进行文本过滤的进程,所以 grep 也将自己过滤出来了。如果不想看到 grep 只想看到自己的进程,就执行 ps axj | head - 1 && ps ajx | grep myprocess | grep -v grep 命令。

在这里插入图片描述

此时如果再次执行这个可执行程序,可以看到两个进程在同时运行。

在这里插入图片描述

这两个进程里面的 pid 不同,所以时两个不同的进程。

在这里插入图片描述总之,任何一个程序运行起来,都会有一个 pid 信息,当操作系统创建 pcb 的时候,会自动申请一个 pid

除此之外,还有另一种方式查看已经启动的进程,输入指令 ls /proc 查看根目录下的 proc 文件夹,此文件夹保存的是进程属性目录。这个 proc 目录是一个内存级的文件系统,只有当操作系统启动的时候,才会存在,在磁盘上并不存在。

在这里插入图片描述

此目录中会有很多蓝色字体的目录,这些数字就是特定进程的 pid,进程一旦被创建好,操作系统会自动的在 proc 目录下创建一个以新增进程的 pid 命名的文件夹。

在这里插入图片描述

此时执行 cd /proc/13045 就可以查看所启动进程的相关属性。

在这里插入图片描述

当进程终止的时候,系统中不会存在 pid 值为 13045 的进程,此时再进行 ls 命令就不能打开对应的目录。

在这里插入图片描述

以上就是查看进程的两种方式。

5 获取进程的 pid

5.1 获取进程的 pid

参考以下代码获取进程的 pid

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
	while(1)
	{
		printf("hello process,我已经是一个进程了,我的pid是:%d\n\n",getpid());
		sleep(1);
	}
}

将代码编译运行,此时输出结果如下:

在这里插入图片描述

再输入 ps axj | head -1 && ps ajx | grep myprocess | grep -v grep 查看当前进程的 pid,发现与打印的 pid20446 一致。

在这里插入图片描述

结束程序再启动程序,也就是将可执行程序加载到内存变成内存的时候,发现每次对应 pid 值都不一样。

在这里插入图片描述

每次进程加载启动的时候,操作系统都会创建一个 pid ,这个 pid 是操作系统帮忙维护的,而且 pid 的值是递增的。

5.2 获取父进程的 pid

参考以下代码获取进程的 pid

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
	while(1)
	{
		printf("hello process,我已经是一个进程了,我的pid是:%d,我的父进程是:%d\n",getpid(),getppid());
		sleep(1);
	}
}

将上述代码编译运行,输出结果如下:

在这里插入图片描述

将进程终止再运行,结果输出如下:

在这里插入图片描述发现进程的 pid 一直在递增变化,而它的父进程的 pid 却一直没有变化,输入 ps axj | head -1 && ps ajx | grep 13969 查看 pid13969 的进程,会发现它是 bash,所以 bash 也是一个进程。

在这里插入图片描述结论:
1、bash 命令行解释器,本质上也是一个进程。
2、命令行启动的所有程序,最终都会变成进程,而该进程对应的父进程都是 bash

给进程发送终止信号:kill -9 + 进程pid

在这里插入图片描述

发现当结束掉 bash 后,再去执行任何命令发现无法执行任何命令。

在这里插入图片描述bash 如何创建的子进程?
有没有一种方式从代码层面直接创建子进程而不是 ./ 运行呢?

6 fork 函数及使用方法

6.1 示例程序及解释

bash 如何创建的子进程?
有没有一种方式从代码层面直接创建子进程而不是 ./ 运行呢?
答案是使用 fork 函数,看如下示例代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
		printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAA\n");
		fork();
		printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n");
		sleep(1);
		return 0;
}

编译上述代码运行,结果如下:

在这里插入图片描述

会发现 B 被打印两次,再执行以下代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
		printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAA\n");
		fork();
		printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBB:pid:%d,ppid:%d\n",getpid(),getppid());
		sleep(1);
		return 0;
}

编译运行结果如下:

在这里插入图片描述

说明执行 printf 打印 B 是两个进程,此时再打印出 A 的进程,代码如下:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
		printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAA:pid:%d,ppid:%d\n",getpid(),getppid()");
		fork();
		printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBB:pid:%d,ppid:%d\n",getpid(),getppid());
		sleep(1);
		return 0;
}

编译运行结果如下:

在这里插入图片描述

结果表明: 2824828241 的子进程,2824124259 的子进程。

使用 ps ajx | grep 24259,查看 24259 对应的进程是 bash。说明当运行 myprocess 程序时,myprocess 就成了 bash 的子进程,而 myprocess 程序内部又创建了一个子进程 28242

在这里插入图片描述

第一次执行 printf 时是单执行流,fork 之后变成了父与子两个执行流,都会执行 printf 命令。

在这里插入图片描述\

6.2 fork 函数的返回值

使用 man 手册查看 fork 的返回值,如下图所示:

在这里插入图片描述

如果父进程创建成功,子进程的 pid 会返回给父进程,0 会返回给子进程,失败则返回 -1。看如下代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
		printf("AAAAAAAAAAAAAAAAAAAAAAAAAAAA:pid:%d,ppid:%d\n",getpid(),getppid()");
		pid_t ret = fork();
		printf("BBBBBBBBBBBBBBBBBBBBBBBBBBBBB:pid:%d,ppid:%d,ret:%d,&ret:%p\n",getpid(),getppid(),ret,&ret);
		sleep(1);
		return 0;
}

运行代码,结果如下图所示:

在这里插入图片描述

可以看出给 pid29674 的父进程返回子进程的 pid29675,给 pid29675 的子进程返回 0

问题:
1、为什么一个函数有两个返回值?一个变量相同地址读取的数据内容不一样呢?

这里先不进行解释,等以后学到进程地址空间的时候再谈论这个问题。

2、如果现在想让父子进程执行不同的任务去运行,应该怎么操作呢?

fork 创建子进程之后,一般通过 if 判断,如果 ret == 0,就执行子进程,如果 ret > 0,就执行父进程。

#include <stdio.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
	pid_t ret = fork();
	if(ret == 0)
	{
		//子进程
		while(1)
		{
			printf("我是子进程,我的pid是:%d,我的父进程是:%d\n",getpid(),getppid());
		}
	}
	else if(ret > 0)
	{
		//父进程
		while(1)
		{
			printf("我是父进程,我的pid是:%d,我的父进程是:%d\n",getpid(),getppid());
			sleep(2);
		}
	}
	else
	{}
}

让不同的执行流,执行不同的代码块,编译运行程序,结果如下:

在这里插入图片描述

注意以上运行结果,发现:

1、 ifelse if 同时成立,而且两个 while 循环同时进行。

2、 fork 可以运行子进程,fork 之后会变成两个执行流,fork之后谁先运行由调度器决定。

3、fork 之后,fork 之后的代码共享,如上述 printf 打印两行 B。通常通过 ifelse if 进行执行流分流,让父子进程执行不同的代码块。

6.3 fork 的原理

fork 做了什么?

fork 创建了子进程,因为进程 = 内核数据结构 + 进程的代码和数据,父进程有自己的 pcb 和自己的代码数据,在创建子进程的时候,子进程 pcb 的大部分属性会在内核中以父进程 pcb 的属性创建一个模板,将父进程里面的属性和数据拷贝给子进程,而 pidppid 被修改是子进程所独有的。所以创建好之后,父进程指向自己的代码和数据,子进程和父进程一样,指向同样的代码和数据。

在这里插入图片描述

所以:

1、调用 fork 函数创建一个子进程,就相当于在内核中创建一个独立的 pcb 结构,让子进程父进程看到同样的代码和数据。

2、printf 打印 B 的时候,父子进程都执行 printf 说明父子进程执行同样的代码。

3、而到最后父子进程都打印出了一行 B 说明父子进程看到的是同一份数据。

6.4 fork 如何看待代码和数据

进程在运行的时候是有独立性的,父子进程运行的时候也是具有独立性的。如使用 kill -9 4894 关闭掉子进程。

在这里插入图片描述

发现只有父进程在运行。说明父子进程虽然都指向同一份代码和数据,但是互不影响,都具备进程的独立性。

在这里插入图片描述

独立性是如何保证的?

当父进程和子进程在被执行的时候,在代码层面:因为代码是只读的,所以父进程和子进程各自读取代码执行,并不会相互影响;在数据层面,将代码做出如下修改:

#include <stdio.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
	int x = 100;
	pid_t ret = fork();
	if(ret == 0)
	{
		//子进程
		while(1)
		{
			printf("我是子进程,我的pid是:%d,我的父进程是:%d,%d,%p\n",getpid(),getppid(),x,&x);
		}
	}
	else if(ret > 0)
	{
		//父进程
		while(1)
		{
			printf("我是父进程,我的pid是:%d,我的父进程是:%d,%d,%p\n",getpid(),getppid(),x,&x);
			sleep(2);
		}
	}
	else
	{}
}

编译运行,看到读取 x变量地址都是一样的,说明父子进程虽然执行各自的代码块,但是还是打印出了相同的 x 和它的地址

在这里插入图片描述

此时将父进程的 x 值进行修改,代码如下:

#include <stdio.h>
#include <assert.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
	int x = 100;
	pid_t ret = fork();
	if(ret == 0)
	{
		//子进程
		while(1)
		{
			printf("我是子进程,我的pid是:%d,我的父进程是:%d,%d,%p\n",getpid(),getppid(),x,&x);
		}
	}
	else if(ret > 0)
	{
		//父进程
		while(1)
		{
			printf("我是父进程,我的pid是:%d,我的父进程是:%d,%d,%p\n",getpid(),getppid(),x,&x);
			x = 4321;
			sleep(1);
		}
	}
	else
	{}
}

运行结果如下:

在这里插入图片描述

可以看到:

1、刚开始运行时第一次执行的时候父进程和子进程的 x 值都是 100
2、在更改父进程的 x 值后,父进程的值变成 4321 ,但是子进程的 x 值仍为100,所以在更改一个进程的时候,并不会影响另一个进程。

父子进程中任何一个进程对共享的数据做出修改,并不会影响另一个进程,因为当有一个执行流尝试修改数据的时候,操作系统(OS)会自动给当前进程触发一种机制:写时拷贝。

写时拷贝就是当一个进程想写的时候,操作系统会将需要写的数据重新拷贝一份,然后对拷贝的数据进行修改,而不是修改原始数据。所以写入的位置发生变化,并不会影响读的进程,在数据层面也保证了独立性。

总结:

1、fork 调用的时候,操作系统内部为了维护进程,会创建一个子进程 pcb,父子进程代码共享。

2、因为代码都是只读的,不会互相影响,数据也不会互相影响,如果想修改数据,操作系统会重新申请一块空间让进程修改。

3、父进程代码共享,数据以写实拷贝的方式各自私有一份,就保证了两个进程不会相互干扰。

6.5 fork 如何理解两个返回值的问题

一个有返回值的函数,执行到 return 语句的时候,函数的主体功能是否已经跑完了?

当函数内部执行 return 的时候,函数的主体功能已经完成,如 fork 是一个操作系统提供的有返回值的函数,当程序中父进程调用 fork 函数的时候,会有一个执行流调用 fork 里面的代码,当 fork 时创建新的 pcb 并初始化字段,然后放入系统的链式结构中进行管理,而创建新的 pid 等动作就是在创建一个子进程。

创建完子进程后,fork 是一个有返回值的函数,所以需要进行 return 返回,而 return 也是一个语句,可以被父进程和子进程调用,所以才有两个返回值。

而用一个变量接收返回值,就是对一个变量的写入过程,当 fork 返回的时候,父进程定义的局部变量发生了操作系统的写时拷贝,看起来地址一样,实则被存放到了不同的空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值