一、进程概念
进程有两种可以理解的方式:
1、已经加载到内存中的程序,叫做进程。
2、正在运行的程序,叫做进程
从概念上挺好理解的,我们运行一个程序必然要通过CPU,所以自然需要加载到内存中……
但我们应该关注的是,OS中不仅仅只有一个进程,可能运行着多个进程(比如我们可能同时运行着qq和qq音乐),所以OS必须要将进程管理起来!!根据我们以往的管理经验,我们需要先描述再组织!!
二、描述进程-PCB
如何描述进程呢??
先思考:人是如何辨别事物或者对象的??
——>比如你在放学路上见到一个女生一见钟情,于是你记住了他的样貌,开始像别人打听这个女生,因为你不认识这个女生,所以你会对他进行描述,比如说长得很漂亮、水灵的眼睛、瓜子脸……当你提供的特征越来越多的时候,认识他的人或许就能通过你的描述找到这个女生。而这个过程中,这个女生的各种特征其实就是他的属性,所以我们可以得出一个结论:人是通过属性去辨别事物和对象的,当属性足够多的时候,这一堆属性的集合,就是目标对象!!
所以我们推断出,任何一个进程加载到内存时,OS需要创建一个描述进程的结构体对象——PCB(process ctrl block进程控制块) ,而他的本质就是对进程属性集合的描述!!
Linux操作系统下的PCB是task_struct,他是是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_struct的内容:
1、标示符: 描述本进程的唯一标示符,用来区别其他进程。
(有点类似学校里每个学生的学号,是一个唯一标识,方便我们通过标示符来管理进程)
2、状态: 任务状态,退出代码,退出信号等。
(OS中同时存在多个进程,所以可能有的进程正在运行、有的正在休眠、有的在正在待定、有的即将销毁……也就是说每个进程当前可能都处于某一种状态)
3、优先级: 相对于其他进程的优先级。
(OS中有多个进程,所以先执行谁肯定是要有一个标准的,所以进程之间可能存在对应的优先级关系)
4、程序计数器: 程序中即将被执行的下一条指令的地址。
(以前我们在学习函数栈帧的时候,我们知道代码是从上往下运行的,但是这个过程中可能会遇到出现某个函数需要我们进行跳转,这个时候当前的栈帧会暂时保存着,然后当跳转过去的相关代码执行结束后再返回之前栈帧的位置继续运行。但是由于OS中不仅仅只有一个进程,所有有可能这个进程在执行的时候可能会被一些切换给中断,转而去执行别的进程,然后该进程可能会进入休眠模式,而后期我们可能还会去唤醒这个进程,这个时候由于之间的栈帧被销毁了,所以已经不记得执行到哪句代码了,因此程序计数器存在的意义就是帮助没我们记住即将被执行的下一条指令的地址!举个更好理解的例子就是,比方说你正在数一堆书,当你数到50的时候,这个时候突然一个电话告诉你外卖到了,为了不让外卖员等太久,你需要暂停当前的工作马上下去,但是你又怕你数过的数字忘记了,所以你就把他记在本子上,当你取完外卖后,你就可以通过从本子上的数字继续往下数!)
5、内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针(我们一个可执行程序要运行还需要有对应的数据和代码,所以PCB对象必然需要有一个指针指向这块空间,当进程响应的时候能够及时找到,另一方面可能会存在多种数据类型的指针,为了满足不同场景下的需求——通过数据结构和算法)
6、上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
7、I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
8、记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
(可能会包含进程的一些运行时间,其实对进程的调度来说是有作用的,因为在多个进程的情况下,只有一个CPU,所以先将哪个进程放到CPU里其实是由调度器决定的,而调度器除了考虑进程状态和一些优先级之外,他会尽可能秉持着公平的原则,比如说有尽可能地优先让执行时间短的进程优先去调度。)
9、其他信息
Linux(内核剖析):04---进程之struct task_struct进程描述符、任务结构介绍_struct task linux .h文件-CSDN博客
而一个可执行程序要运行,就需要由OS创建一个PCB,但具体应该按照什么逻辑去运行是取决于你的数据和代码的,所以我们可以得出 进程=内核PCB数据结构对象+你自己的代码和数据
三、组织进程
我们知道进程=内核PCB数据结构对象+你自己的代码和数据,但是OS本质上是对PCB做管理,他并不关心你的代码和数据,因为他只要能找到PCB,就可以通过他里面的一个相关的指针去找到对应的代码和数据,然后再交给CPU去运行!!举个例子就好比如HR对人才的管理本质上就是对简历进行管理,然后安排面试的时候再通过简历来找到你的相关信息。
但是PCB特别多,所以我们需要想办法管理起来,其实在我们的Linux中task_struct主要是以双链表的形式组织起来,你可能会疑惑,使用一个顺序表来存储不是更好吗??其实在OS内部对于进程的管理方式并没有像我们以前学的数据结构那么纯粹,他的场景会更加复杂,也就是说该进程可能会需要根据不同的需求被存储在队列中、双链表中、二叉树中、栈中……所以将进程按照节点的方式链接起来其实会更方便我们将这个进程放在不同的数据结构中,然后我们可以通过对应的指针信息来讲他们更好地管理起来。
举个例子,比如说我在当前进程中有一个队列指针,因为在OS中可能会有一些存储进程指针的运行队列和等待队列,如果你想让这个进程去哪个队列,你就可以通过修改队列指针的链接队形做到,从而实现更加灵活的管理。
所以对进程管理工作取决于你把他放入哪个正在被组织的数据结构中,因为不同的数据结构有不同的特点,所以背后对应的就是不同的算法,而不同的算法对应的就是不同的应用场景。
四、查看进程
我们电脑开机的时候,其实就是将OS从外设搬到内存中,因为只有在内存中才能对进程进行管理
4.1 ps ajx指令查看所有进程
为了方便观察我们可以写一个死循环的代码,然后去观察该进程
命令:ps axj | head -1 && ps axj | grep mycode
思考:为什么会出现第二行这样的进程??
——>因为所有的指令也是要变成进程才能运行,而这个是grep指令的进程,因为这个进程里面也有对应的关键字所以也会显示出来!!
我们给这个可执行程序写的是死循环的,所以会一直运行下去,但是我们可以用kill指令利用标示符强制杀死这个进程!!
命令:kill -9 进程标示符
4.2 /proc
/proc目录里面存储都是内存级的文件!!在关机时会消失,开机时又会出现,他是对动态运行的所有进程的一个可视化信息!!
蓝色表示的是目录文件,因为一个进程里面可能会存在很多信息!!我们可以试着进去看看
1、其中exe说明当前的进程是可以找到自己要执行的代码的!!(可视化出来了)
2、cwd表示的是当前进程的工作目录(current work dir),所以为什么你fopen出来的新文件会被默认放在当前目录,这其实是由该进程的cwd决定的!!
五、通过系统调用获取进程标识符
5.1 理解PPID
进程id(PID)
父进程id(PPID)
思考:什么是PPID呢??我们来看看刚刚执行程序的PPID是什么进程
我们会发现我们可执行程序的父进程是 -bash命令行。
为什么会这样去设计呢??我们都知道其实bash命令行的作用一方面是解释命令,另一方面是为了阻止用户的非法操作,而我们每一条指令或者是可执行程序其实都是一个进程,因此我们的bash命令行其实是先创建了一个子进程去执行对应的指令,然后自己就可以继续去帮助别的指令创建进程,这样的好处就是一旦子进程崩了,并不会影响bash命令行进程处理其他的指令!!
5.2 系统调用接口getpid
命令:man 3 getpid
重启机器后
我们会发现就是当我们重新运行程序的时候,只会改变子进程的id,父进程id并不会改变,而当我们把机器关了重新开了的似乎,父进程id却改变了,这说明父进程(bash命令行)是在打开机器的时候就创建好的一个进程!!
介绍一条可以时刻监控进程的命令: while :; do ps ajx | head -1 ;ps ajx | grep mycode | grep -v grep;echo "----------------------------";sleep 1;done
六、通过系统调用创建进程-fork(重要)
命令:man 3 fork
以下这句话的意思是:如果成功了,会返回一个子进程的pid给父进程,然后还会返回一个0给子进程,如果失败,返回-1给父进程,没有子进程被创建!! ——>这说明fork有两个返回值!!
为了方便观察后面的结果,我们先写一段代码
运行之后我们会发现一个非常惊奇的现象:竟然if和else if同时在进行!!!
而后我们就要根据以上两个现象,我们大概可以知道fork其实创建了一个子进程,而后我们要针对这个现象进行系统地分析!!
6.1 为什么会需要子进程??
之所以会多此一举搞一个子进程,其实是为了让父和子同时执行不同的事情——>因此我们就要想办法让父和子执行不同的代码块——>解决方法就是fork要有两个返回值!!——>所以返回不同的值的意义是为了区分不同的执行流,让父进程和子进程分别执行不同的代码块!!
6.2 fork为什么要给子进程返回0,给父进程返回子进程pid?
因为一个父亲可以有多个子女,但是一个子女只能有一个父亲。所以对于父进程来说他未来可能需要去控制子进程,所以就需要子进程的PID(用来标定子进程的唯一性)。而子进程只需要用getppid就知道其父进程了!所以我们返回个0就可以了(找到父进程基本不耗费什么成本)。
6.3 fork函数究竟都干了什么?
因为fork函数会创建一个子进程,而进程=内核数据结构(task_struct)+代码+数据,所以首先
1、要先创建一个task_struct的结构体
2、填充该结构体的内容
3、让子进程和父进程指向同样的代码 ——>父子进程的代码是共享的(因为代码是不可修改的)
4、根据需求发生写时拷贝 (重点!!)
任何平台,进程都是具有独立性的,就是我结束了一个进程并不会影响其他的进程,所以父进程和子进程在共享代码的时候,由于代码不能修改,所以是没有问题的,你读你的我读我的,相互之间并不影响,但是如果是数据就不一样了,数据是可以修改的所以我改了可能你也会改,因此从理论上来说,我们的子进程必须想办法拷贝一份相同的数据且独立的数据出来!!
但是其实这样也不太可取,因为父进程的数据可能有非常多,但是我们的子进程可能只是共享了其中一部分的代码,并且也不一定会用到所有的数据,所以如果只是简单粗暴地把这些数据拷贝过来了,势必会造成大量的资源浪费。
因此OS的设计者就不想让子进程直接地去拷贝数据,而是当程序运行的时候,当OS检测到子进程需要去修改父进程的数据的时候,他就会让子进程等一等,然后为他开辟一个新的空间把对应的数据拷贝过来再让他修改
因此我们可以总结出,无论是代码还是数据,父子进程在前期都是共享的,只不过当OS一旦检测到子进程需要去对数据进行修改的时候,需要多少才会开多少空间,这就是数据层面的写时拷贝
5、父子进程都有了独立的task_struct,就可以独立地被CPU调度运行了
6.4 一个函数是如何做到返回两次的?
父子的代码是共享的,所以return ret也是属于代码,因此父子进程各返回了一次!
6.5 一个变量为什么会有不同的内容?
原因是因为子进程要修改父进程的数据的时候,发生了写时拷贝,所以该数据其实有两份内容,然后因为进程的在运行的时候是具有独立性的,所以此时父进程和子进程通过if else 分流去执行共享的代码。 但是要注意的是,子进程被创建好之后,究竟是先运行子进程还是先运行父进程,其实是由调度器(因为CPU只有一个,所以他的作用就是在当前进程中选一个合适的放到CPU中,进程之间会竞争CPU资源,所以调度器会遵循着自己的一套原则来保证进程之间的公平性)去决定的!!
6.6 通过fork来理解bash命令行是如何工作的
bash本身就是一个进程,当你输入相关指令的时候,他会为指令创建子进程,然后由子进程去执行对应的指令,这样即使子进程失败了也不会影响到bash——>目的是为了让bash可以专注于命令行解释的工作!!bash进程是在我们打开机器的时候就创建好的!!