LInux系统编程(二)操作系统和进程
目录
一、前言:冯诺依曼体系结构
1、图中各个单元的介绍
2、值得注意的几点
二、操作系统
1、操作系统分层图
2、小总结
三、 进程(重点)
1、进程的基本概念
2、存放进程信息的数据结构——PCB(Linux 下称作 task_struct)
2.1 task_struct 的内容分类
3、在终端中通过输入命令查看进程信息
4、父子进程
4.1 1号进程
4.2 shell 进程与用户程序
5、通过系统调用来创建进程与获取进程标识符(pid)
5.1 创建子进程的系统调用接口:fork()的初识(这里浅尝辄止,后面章节会详细介绍)
5.2 获取父进程的ppid和自己进程的pid
四、进程的状态
1、阻塞状态
2、挂起状态
3、阻塞与挂起的区别
4、Linux 内核源代码中的进程状态分类
1.1 R(Running)运行状态
1.2 S(Sleep)睡眠状态
1.3 D(Disk sleep)磁盘休眠状态
1.4 T(Stopped)停止状态
1.5 t(tracing stop)调试用停止状态
1.6 X(dead)死亡状态(终止状态)
1.7 Z(Zombie)僵尸状态
5、查看进程状态的命令
六、僵尸状态(重点)
1、僵尸状态的演示
2、僵尸进程的危害
七、孤儿进程
八、进程优先级
1、引子:查看进程信息
2、PRI 和 NI
3、如何修改 nice 谦让度
九、并行与并发
1、前言:进程与寄存器
2、并行
3、并发
十、进程地址空间(重点,底层之根基)
1、引子
2、基于地址空间,重新理解进程地址
3、为什么要存在虚拟地址空间?
4、缺页中断
本章将介绍操作系统的基本概念,特别是Linux操作系统,并详细解释Linux中的重要概念——进程及其分类。本章同样是个长篇,但是了解透彻后对后面操作系统的学习将很有帮助。
一、前言:冯诺依曼体系结构
我们所认识的计算机,大部分都遵循冯诺依曼体系结构,即由一个个硬件组合而成。
1、图中各个单元的介绍
1、输入设备:键盘、鼠标、网卡、显卡、声卡、磁盘、固态硬盘(SSD)等等
2、存储器:即内存,内存实际上就是一个硬件级别的大的缓存。
3、中央处理器:包含运算器+控制器+寄存器+各种级别的缓存。
4、输出设备:显示器、磁盘、网卡、显卡等等各种外设。
2、值得注意的几点
1、数据处理速度:CPU > 内存 > 各种外设(如磁盘)
2、在数据层面上,CPU优先和内存进行交互,不和外设直接交互(直接交互会因为外设的速度慢而导致 CPU 需要等待,从而影响整体性能);因此外设要输入输出数据,也只能向内存中写入和从内存中读取。
3、一句话总结,所有的设备,都只能直接和内存进行交互。
4、程序在运行前,也必须加载进内存。(因为程序 = 代码 + 数据,最终都要交给 CPU 来处理,但是程序的可执行文件是储存在磁盘(外设)中的,而CPU只和内存交互,因此想让CPU读取到这些代码和数据,必须加载进内存)
二、操作系统
操作系统是一款对软硬件进行资源管理的软件,笼统概括包括内核与其它程序;目的是管理所有的软硬件资源,为用户的程序提供一个良好的执行环境。
1、操作系统分层图
从上往下介绍:
1、用户:广义上指所有计算机使用者,狭义上指开发者。只要有操作系统存在,用户便只能通过操作系统访问底层硬件,无权直接访问。
2、用户操作接口:包括图形化界面,lib 库等等。
3、系统调用接口:操作系统被上层访问的唯一渠道。操作系统会提供一些系统调用接口(大部分是函数),用户只能通过这些系统调用接口访问操作系统,有效防止操作系统收到侵害。如果一个用户想访问很底层的数据 / 访问硬件,就必须通过调用系统调用接口来贯穿整个层状结构。
4、操作系统:真正的软硬件管理者;其内部必然会存在大量的对象和数据结构。内存管理、进程管理、文件管理、驱动管理,是操作系统的四大核心管理模块。
5、驱动程序:用来从硬件中获取数据,交给操作系统。除了CPU和内存外,基本都需要驱动程序来获取数据。
6、底层硬件:被管理者;以冯诺依曼体系结构组织。
2、小总结
硬件方面:操作系统把硬件用 struct 结构体描述起来,再用链表 / 其它高效的数据结构组织起来。
系统调用:在开发的角度来看,操作系统对外会表现为一个整体,并暴露自己的部分接口供上层开发使用。但是系统调用接口往往功能比较基础,对用户的要求也相对较高。
库函数:有心的开发者大佬们,为了便于上层用户开发,对部分系统调用接口进行了适度的封装,从而形成了用户操作接口层面的库。
三、 进程(重点)
核心:进程 = 可执行程序(进程的代码和数据)+ 内核数据结构(PCB)
1、进程的基本概念
进程在课本中的概念一般被定义为:程序的一个执行实例 / 内存中正在执行的程序等
进程在内核中的观点一般是:担当分配系统资源(CPU时间、内存等)的实体。
2、存放进程信息的数据结构——PCB(Linux 下称作 task_struct)
进程本身肯定是有一些属于自己的信息的,这些信息就被放在一个叫做进程控制块(process control block,即PCB)的数据结构中,PCB可以理解为是进程属性的一个集合。
Linux 下描述进程的结构体有一个专有的名称,叫做 task_struct,是PCB的一种;它是 Linux 内核中的一种数据结构,会被装载到内存里,并包含着进程的信息。
// task_struct 的形象化代码 struct task_struct {// 进程ID// 进程程序的代码和数据的地址// 进程状态// 进程优先级// }
2.1 task_struct 的内容分类
1、标识符:即 pid(process id),是描述本进程的唯一标识符,用来区分各个进程;每次启动进程,pid 都会发生变化。
2、进程状态:任务状态,退出代码,退出信号等。
3、优先级:相对于其他进程的优先级。
4、程序计数器:程序中即将被执行的下一条指令的地址。
5、内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
6、上下文数据:进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
7、I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
8、记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
9、其他信息
3、在终端中通过输入命令查看进程信息
我们给出一段最基础的示例代码 test.cpp:
#include <iostream> #include <unistd.h> using namespace std;int main() {while (1){cout << "进程正在运行" << endl;sleep(1);}return 0; }
ps aux | grep test | grep -v grep
命令解释:
ps aux:用来列出系统上所有正在运行的进程的信息(其中 ps 是 "process status" 的缩写,用于显示当前系统的进程快照;a 表示显示所有用户的进程,不仅仅是发出命令的用户;u 表示以用户友好的长格式显示进程信息;x 表示显示没有控制终端的进程)
grep test:用来搜索包含字符串“test”的行(grep 是一个强大的文本搜索工具,一般用来搜索固定的字符串行)
grep -v grep:这里的用于从上一步的结果中排除包含 “grep”的行,参数 -v 表示反向选择,即显示出不包含 “grep”的行。 由于使用了管道,这里的目的就是为了排除由于 grep test 而产生的与 grep命令自身相关的行输出。
4、父子进程
操作系统中除了1号进程(即操作系统本身)外,所有的进程都有一个父进程,由父进程来创建当前进程。
4.1 1号进程
1号进程是系统启动时由内核创建的第一个用户空间进程,负责初始化系统,加载必要的服务和守护进程。
4.2 shell 进程与用户程序
当通过终端登录系统时,系统会启动一个登录 shell 进程(如 bash
或 zsh
)。这个 shell 进程的父进程通常是进程 1。
当在 shell 中运行一个程序时,shell 会自动调用使用 fork
系统调用创建一个子进程,然后在子进程中使用 exec
系统调用替换为我们要运行的程序。这个子进程就是程序的主进程。
5、通过系统调用来创建进程与获取进程标识符(pid)
5.1 创建子进程的系统调用接口:fork()的初识(这里浅尝辄止,后面章节会详细介绍)
1、fork() 有两个返回值,子进程返回值为0,父进程返回值为子进程的 pid(因此通常在调用 fork() 之后会用 if 进行分流,区分父子进程)
2、父子进程之间代码共享,数据各自开辟空间私有一份。(不过子进程是写时拷贝,即暂时与父进程共用一块空间,等到需要修改数据的时候才开辟空间私有一份)
3、在C/C++中,调用 fork() 函数需要包含头文件 <sys/types.h> 和 <unistd.h>
5.2 获取父进程的ppid和自己进程的pid
获取进程id需要使用系统调用 getpid();父进程的 pid 在子进程中被称作 ppid,需要调用系统调用 getppid() 获取。
示例代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{printf("我是一个进程,我的pid是:%d\n",getpid());pid_t ret = fork();if(ret == 0){while(1){printf("我是一个子进程,我的pid是:%d,我的ppid是:%d\n",getpid(),getppid());sleep(1);}}else if (ret < 0){perror("fork 创建子进程失败!\n");return 1;}else{while(1){printf("我是一个父进程,我的pid是:%d,我的ppid是:%d\n",getpid(),getppid());sleep(1);}}return 0;
}
四、进程的状态
教材上把进程状态分为6种:创建、就绪、阻塞、执行、挂起、终止;其本质就是PCB中的一个字段(变量) status
进程状态变化的本质:更改 PCB 中的 status 整数变量 -> 再将PCB从一个队列移动到另一个队列(因为在操作系统中,不同状态的进程交由不同的队列进行管理)
1、阻塞状态
操作系统会维护一个等待队列,我们的代码一定会或多或少访问系统中的某些资源,如果该资源没有就绪,则不具备访问条件,会导致代码无法向后执行,这时就会进程就会陷入阻塞状态。
这时候操作系统就是把该进程的 PCB 放入维护的等待队列(也可以称作阻塞队列)中,表示进程正在等待系统资源就绪,无法被调度。
所以阻塞状态就是进程PCB被链入等待队列中的状态,此时该进程会卡住,无法被CPU调度。
2、挂起状态
处于阻塞状态的进程,仍然在内存中等待,如果此时OS的内存资源已经严重不足了,怎么办呢?
这时OS就会把内存中的阻塞进程的代码和数据移动到磁盘里面腾出空间,这个过程称作挂起(针对所有阻塞进程,将内存数据置换至外设)(因为是由于阻塞进程导致的,所以又称阻塞挂起。当进程被OS调用的时候,进程的代码和数据又会加载进来)(如果磁盘资源也不够,万不得已,操作系统便只能杀掉进程以保持OS的正常运转)
3、阻塞与挂起的区别
阻塞的进程还在内存中,挂起的进程代码和数据已经放入外存里了。
阻塞状态通常是由于某些资源未就绪而使进程被动进入的状态,资源就绪后进程就会被唤醒。
挂起状态通常是由于系统 / 用户要求,主动把进程置于一种停止的状态,需要额外动作才会恢复。
4、Linux 内核源代码中的进程状态分类
/* * The task state array is a strange "bitmap" of * reasons to sleep. Thus "running" is zero, and * you can test for combinations of others with * simple bit tests. */ static const char * const task_state_array[] = {"R (running)", /* 0 */"S (sleeping)", /* 1 */"D (disk sleep)", /* 2 */"T (stopped)", /* 4 */"t (tracing stop)", /* 8 */"X (dead)", /* 16 */"Z (zombie)", /* 32 */ };
可以发现,在 Linux 下的进程,总共有七种状态:运行、睡眠、磁盘休眠、通用停止、调试用停止、死亡、僵尸。
1.1 R(Running)运行状态
表示进程在运行中 / 在运行队列里,并不是代表一定在运行。
运行队列:
运行队列类似于其它OS中的就绪队列,每个CPU都会在其内核维护一个运行队列;只要在运行队列中的进程,状态都是运行态。
运行队列中的进程,除了需要CPU资源以外,其它资源都已就绪,可以随时被调度;只要分配到CPU时间片,就可以立刻运行。(操作系统把某进程 PCB 放进运行队列中,称作唤醒该进程)
1.2 S(Sleep)睡眠状态
这里的睡眠状态是可中断睡眠(又名浅度睡眠),是一种特殊的阻塞状态,可以对外部信号作出相应,也可以被终止。通常是用户主动请求,让进程进入睡眠状态暂停一段时间。
1.3 D(Disk sleep)磁盘休眠状态
磁盘休眠状态又称不可中断睡眠,也是一种特殊的阻塞状态,处于深度睡眠状态下的进程无法被杀掉,不能通过任何信号停止,只能让它自己醒来,一般会等待IO的结束。
1.4 T(Stopped)停止状态
发送 SIGSTOP 信号,就可以让进程进入停止态,被暂停的进程不会被调度,会停留在当前位置;发送 SIGCONT 信号,就可以让进程继续执行。
1.5 t(tracing stop)调试用停止状态
当进程因调试目的被停止时,会被设置为“tracing stop”状态。
这种状态通常发生在使用调试工具(如gdb)对进程进行调试时,当调试器请求停止进程以检查其内部状态时。进程在接收到 SIGTRAP 信号后也会进入这种状态,这通常发生在执行了一个断点指令之后。
在这种状态下,进程同样不会被调度执行,但其目的是为了允许调试器获取进程的信息或修改其状态。要恢复这种状态的进程,通常也是通过调试工具发送继续执行的命令,或者直接发送SIGCONT信号。
1.6 X(dead)死亡状态(终止状态)
死亡状态又名终止状态,是个瞬时状态,只是一个返回态,不会体现在任务列表中。
1.7 Z(Zombie)僵尸状态
僵尸状态是个很重要的状态,也是 Linux 的独有状态,会产生僵尸进程,由于篇幅较长,后文会开辟单独一个小节介绍。
5、查看进程状态的命令
ps aux 或 ps axj
六、僵尸状态(重点)
僵尸状态是 Linux 的独有状态,当一个进程在系统中被终止后,其 PCB 暂时不会被释放,而是会先把 PCB 信息采集给父进程,读取子进程的退出信息。在读取成功后,该进程才会先变成死亡装填,随后释放 PCB。
进程退出后,从 进程退出 -> 父进程 / OS 读取该进程PCB退出信息 这段期间,该退出进程的PCB结构仍然被维护,此时该进程所处的状态,就是僵尸状态。这个进程,就是僵尸进程。
1、僵尸状态的演示
#include <stdio.h> #include <unistd.h>int main() {int res = fork();if (res < 0){perror("子进程创建失败!\n");return 0;}else if (res == 0){// 子进程printf("子进程[%d]变成僵尸模式……\n", getpid());sleep(5);exit(0);}else{// 父进程printf("父进程[%d]正在休眠……\n", getpid());sleep(30);}return 0; }
在 5-30秒期间,子进程已经退出,父进程还在运行,没有读取子进程的退出信息,子进程就会陷入僵尸状态。
2、僵尸进程的危害
僵尸进程会一直以僵尸状态保留在进程表中,一直等待父进程读取退出信息;如果父进程一直不读取,该进程的 PCB 就会一直存在,就会造成内存的浪费,并可能出现内存泄漏问题。
七、孤儿进程
如果父进程先退出,子进程后退出,陷入僵尸状态的子进程就没有父进程来读取退出信息,回收资源了,就会变成孤儿进程。
孤儿进程会被1号进程所领养,由1号进程回收资源,否则会因为无人回收造成资源泄露。
#include <stdio.h>
#include <unistd.h>int main()
{int res = fork();if (res < 0){perror("创建子进程失败!\n");return 1;}else if (res == 0){// 子进程sleep(30);exit(1);}else{// 父进程,父进程先退出,子进程变成孤儿进程sleep(5);printf("父进程已退出\n");exit(1);}return 0;
}
八、进程优先级
进程优先级其实就是指CPU分配资源的先后顺序,本质也是 PCB 中的一个字段,数值越小,优先级越高,优先级高的进程有优先执行权。队列中排队的实质,也就是在确认优先级。
配置进程优先级对多任务环境下的 Linux 比较有用,还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。(不过进程优先级不要轻易更改,降低优先级不要权限,提升优先级需要权限)
1、引子:查看进程信息
ps -l
在 Linux 中,我们可以用 ps -l 来显示当前终端会话中的进程列表:
这里有几个主要的内容:
1、UID:代表执行者身份
2、PID:进程ID
3、PPID: 父进程ID
4、PRI:进程优先级,值越小,越早被执行
5、NI:nice值,进程优先级的修正数据,又名谦让度
2、PRI 和 NI
PRI 比较好理解,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小
进程的优先级别越高。
NI 就是是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。
PRI值越小越快被执行,加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
这当nice值为负值的时候,该程序的优先级值将变小,其优先级将会变高,越快被执行。
所以,调整进程优先级,在Linux下,实际就是调整进程nice值。(值得注意的是,nice值只是进程优先级的修正数据,和进程优先级不是一个概念)
nice其取值范围是-20至19,一共40个级别。(Linux 进程的默认优先级都是80,所以Linux的优先级范围就是 60 - 99)
为什么 Linux 要把优先级设定在一定范围之内?
因为要保证操作系统调用进程的时候较为均衡地确保每一个进程都能得到调度,否则容易导致某些优先级较低的进程始终得不到CPU资源,导致进程饥饿。
3、如何修改 nice 谦让度
top -> 进入top后按“r ”–> 输入进程PID –> 输入nice值
九、并行与并发
1、前言:进程与寄存器
想要了解并行与并发,势必要了解寄存器。
1、我们的进程是怎么知道当前程序运行到了哪里?又是怎么做到函数之间跳转的?
答:CPU内有一个 eip 寄存器(程序计数器),会保存正在执行指令的下一条指令的地址。
2、程序代码运行时产生的各种数据(进程的上下文数据)都会在寄存器中临时保存;如果有多个进程,每一个进程在寄存器中形成的临时数据都应该不同。(有几个进程,就应该有几个上下文数据保存在寄存器中)
3、我们在函数中定义的栈临时变量为什么可以返回到外部?
栈的临时变量会被释放,但是在返回的时候返回语句会把对应的变量内容放在寄存器中(一般是 eax 寄存器),寄存器会充当代码的临时空间。
2、并行
多个进程在多个CPU下,分别同时运行,叫做并行。
3、并发
多个进程在一个CPU下,采用进程切换的方式,让多个进程在一段时间内都可以推进(实际上是一个CPU在高频率地来回切换各个进程,速度很快,也因此,每一个进程不是独占CPU直到运行结束,而是会隔一段时间就从CPU上剥离下来,让进程之间能更均衡地调度),称为并发。
十、进程地址空间(重点,底层之根基)
我们在C/C++专栏中的《 C/C++(三)C/C++内存管理 》中,介绍了程序的地址空间分布,这里的程序地址空间的叫法其实并不规范,规范叫法其实是进程地址空间。
因为每个进程都有其地址空间,都会占用内存,所以OS一定要对地址空间做管理。
1、引子
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;int main()
{pid_t pid = fork();int flag = 0;if (pid == 0){// 子进程,先结束flag = 2014;printf("我是子进程,进程ID:%d, 我的flag:%d,flag地址:%p\n", getpid(), flag, &flag);}else{// 父进程,后结束flag = 2015;sleep(2);printf("我是父进程,进程ID:%d, 我的flag:%d,flag地址:%p\n", getpid(), flag, &flag);}
}
父子进程共享代码,但是当数据要修改的时候,在各自的空间开辟了一份空间,进而达成各自修改变量,但是我们运行可以发现,父子进程的变量的值虽然各自不同了,但是他们的地址却是一样的!
变量的值不一样,说明父子进程输出的一定不是同一个值,但是地址却一样;不是同一个值,地址却一样,这只能说明,这个“地址”,不是真正意义上的物理地址!在Linux下,我们把这个“地址”叫做虚拟地址。所以,进程地址空间又称虚拟地址空间
我们在C/C++日常调试,输出的时候看到的地址,一律都是虚拟地址。用户看不到物理地址,由操作系统统一把虚拟地址转换成物理地址。
2、基于地址空间,重新理解进程地址
每一个进程运行后都会有一个进程地址空间存在,都会在系统层面建立起自己的页表映射结构:
3、为什么要存在虚拟地址空间?
1、让进程可以以统一的视角看待内存,把无序变有序,让任意一个进程都可以通过地址空间+页表的形式把乱序的内存数据分门别类规划好。
2、存在虚拟地址空间,就可以有效地对进程访问内存进行安全检查,对非法访问直接拦截。
3、把内存管理和进程管理实现代码上的解耦,通过页表可以让进程映射到不同物理内存处,进而实现进程独立性。
4、缺页中断
当访问虚拟地址的代码时,如果页表中没有对应的物理地址和代码,操作系统就会触发缺页中断,暂停访问请求,先在内存中重新开辟空间,再把对应可执行程序需要执行的对应虚拟地址的代码加载进内存,然后把将虚拟地址与新分配的物理地址之间的映射关系添加到页表中,最后把标记字段设置为1,表示内存已经分配,对应内容已经填充,把代码解除暂停,继续访问。
可以概括为:
暂停访问 -> 申请内存 -> 填充内容 -> 填入页表 -> 建立映射关系