Linux操作系统-进程(一)
1.进程的基本概念与基本操作
在一些课本上是这样描述进程的,说进程就是运行起来的程序,或者是内存中的程序。
而我们的电脑中打开任务管理器,也是能看到进程的:
我们可以看到,在任务管理器的左上角现实的就是进程,那么上面课本上的说法是否就是我们上面看到的任务管理器中的进程呢?
要说明这个问题,我们先来思考一个问题:我们的电脑中,也就是操作系统中,能同时运行很多程序吗?
这个问题的答案想必大家都知道,当然是可以的,我们在日常使用当中也经常会同时打开多个程序。
而每个启动的程序都要加载到内存中,也就是说操作系统中一定会存在很多的进程,这点我们上面也通过任务管理器看到了,进程不止一个,而是有很多。
我们再来思考一个问题:这些进程,操作系统要管理起来吗?
毫无疑问,当然要被操作系统管理起来,毕竟操作系统就是用来管理电脑的软硬件资源的,那要如何管理呢?
这里我们可以想象一下学校和学生之间的关系,思考一下:你怎么证明你是你们学校的学生?
答案就是你们学校的学生管理系统中有你的各种信息,加上你人在学校中才能证明你是你们学校的学生对吧。
并且学校要管理学生,是校长或者老师直接来你们寝室直接通知你要什么吗?
答案肯定不是,学校肯定是通过学生的信息来进行管理的,也就是先描述,在组织,而操作系统管理进程同样也是如此。
操作系统要想管理进程,首先得有进程的相关信息,就和上面的学校管理学生要有学生的信息一样,那用什么来保存进程的各种信息呢,或者说各种属性呢?
Linux用C语言来实现的,而在C语言中我们是用什么来保存一个对象的各种属性呢?
想必大家心中都有答案,就是结构体无疑,在C语言中我们都是用结构体来保存各种属性,在C++中我们用类来保存。
回到最初的问题,很明显,课本上的说法并不准确,进程并不单单只是程序而已,同时包含描述程序的结构体。
而我们到现在也只说明了进程的构成,那操作系统是如何管理进程的呢?
操作系统要管理进程,无非就是要对进程进行增删查改,大家可以想一想是不是这个道理。
启动一个程序,把它加载到内存中,对进程而言不就是增嘛,关掉一个程序,对进程而言不就是删嘛,那我们C语言中是如何高效的对数据进行增删查改呢?
答案很明显,数据结构嘛,我们通过数据结构能对数据进行高效的增删查改。栈,队列,链表等等各种数据结构都能高效的处理数据,所以在操作系统中就运用了大量的数据结构来管理数据。
而操作系统管理进程同样也是利用数据结构来进行管理,对其进行增删查改等操作:
从上图我们就可以得出进程真正的概念,进程=内核数据结构+自己的代码和数据!!!
2.描述进程-PCB
我们上面只说了是通过一个结构体来记录程序的各种属性,那么这个结构体叫什么呢?
因为不同的操作系统在实现时对这个结构体的称呼都不一样,所以它有一个统称:PCB(process control block),而在linux中描述进程的结构体称为task_struct。进程的所有属性都能直接或间接地通过这个结构体找到。
我们在Linux中执行的各种指令,Windows下双击图标,手机上点击APP等等,这些行为的本质都是启动进程。
3.进程的属性
说了这么多,那么进程的属性都有哪些呢?下面我只列举一些常见的进程属性,并不代表全部:
我们都知道,cpu中存在寄存器,并且寄存器不止一个,寄存器中保存的就是我们所写的程序在运行过程中产生的各种临时数据。
而上下文数据就是寄存器中存储的各种临时数据,当然上下文数据不会就讲这点东西。
我们来思考一个问题:一个进程,执行代码,占用了cpu,是把自己的代码执行完才放弃cpu的吗?
答案并不是,在当代计算机中,会给每一个进程分配一个时间片,时间片执行完毕,就会自动让出cpu,让另一个进程执行,这种模式叫做基于时间片的轮转调度。
也就是说,一个进程没有执行完,就可能会把cpu让出去,因为时间片到达,就会存在进程切换和调度的动作。
进程一旦切换,那么系统是怎么知道你上个程序执行到哪里了呢?
我们举个例子,在大学中,有时部队会来学校征兵,这种事想必大家都知道,而你此时响应了号召,准备去当兵了,你会直接收拾东西什么也不说,直接就走了吗?
答案肯定不是,你会先找到导员,跟他说你要去当兵了,此时导员就会把你从开学到现在的个人信息保存在档案袋中交给你,并且在学校的学生管理系统中把你的学习状态改为休学状态。
完成上面的操作后你才会正式的离开学校去当兵。而在你当完兵回来后,你拿着你的档案找到你的导员,说你之前去当兵了,现在当完兵回来要接着完成你的学业。导员检查了你的档案袋中的个人信息,确认没有问题,就会在校的学生管理系统中把你的学习状态改为在学状态,此时你就可以接着上课完成你的学业了。
上面的例子和进程切换是一样的道理,当一个进程的时间片执行完毕,要切换进程,系统就会将当前的进程从cpu上剥离下来,让下个程序去执行。
而上下文数据就和上面的你当兵去拿的档案袋一样,里面记录了截止到你当兵时的信息,所以你当完兵回来才能接着完成学业,而进行进程切换时,进程就携带着它的上下文数据,所以当再次切换到这个进程时,系统读取到它保存的上下文数据,就知道它执行到哪儿了,就可以接着执行。
这个现象在我们日常生活中也很常见,就比如我们的手机有后台,当我们直接退出一个app时,我们隔段时间再次打开这个app,发现还在我们上次浏览的地方,可以接着浏览,道理和上面是一样的。
那么可能有人会有疑问:那当进程切换时,自己的上下文数据保存在哪里呢?
答案是保存在PCB中,也就是保存进程属性的结构体中,我们如果去看Linux的源码,在task_struct结构体中就能找到一个结构体是用来保存上下文数据的。
而上面的程序计数器本质就是寄存器,存储的程序中即将被执⾏的下⼀条指令的地址,也就是记录此时程序执行到哪儿了。而寄存器中存储的临时数据属于上下文数据,所以程序计数器也可以说是上下文数据的一部分。
3.2标识符
上面的介绍中说标识符是描述本进程的唯一标识符,用来区别其他进程。这个作用就和我们日常生活中的身份证一样,每个人只有一张身份证,而每个进程也只有一个标识符。
我们来思考一个问题:进程是如何获得自己的标识符呢?
答案是通过系统调用接口,但是我们在日常操作时是见不到系统调用接口的,因为系统调用接口功能比较单一,所以会对其进行封装,此时就形成了我们常见的库函数,比如:print函数,scanf函数等,这些函数的底层实现中就有系统调用接口。
相应的,进程也是通过函数来获取自己的标识符,这个函数就叫做:getpid:
我们通过查询getpid函数可以看到它的作用就是获得进程的标识符,并且要包含的头文件也相应地显示出来,下面我们写个简单例子来看一看生成的标识符是什么。
可以看到生成的标识符就是常见的int类型的数据,虽然getpid函数的返回值是pid_t的类型,但是我们如果看linux的源码就会发现,pid_t其实就是int类型typedef了一下而已。
我们再多次执行一下看看:
可以看到每次执行值后进程的标识符都是不一样的,可能有人会不理解为什么会不一样,我们举个例子。
你第一年高考考的不理想,进了一所大学,此时你有一个学号。但是你不甘心,所以你复读了一年,但是依旧没考好,还是来到了这所大学,那么此时你的学号会和上一年的一样吗?
答案肯定不一样,标识符就和这个学号是一样的道理,每次启动进程时会生成新的标识符。
有时候你重复上面的操作可能会发现生成的标识符并不是连续的,那是因为还有其他的进程也要生成相应的标识符,所以不用担心。
而在上面我们也注意到不止有getpid,还有getppid,那这个getppid是什么呢?
这个getppid就是来得到父进程的标识符,没错,进程也是分父子进程的,下面我们呢来看看这个getppid得到的父进程标识符长什么样:
我们通过getppid这个函数得到了父进程的标识符,和我们通过getpid生成的标识符长得差不多。
但是我们同样也发现了一个问题,就是我们每次启动进程,通过getpid得到的标识符都不一样,但是父进程的标识符一直都没有改变,那么这个父进程到底是谁呢?下面我们来看看:
我们通过ps指令来观察系统中的进程,可以看到ppid为17667的父进程名为bash,下面则是我们刚才启动的进程myprocess。
这个bash进程我们称为命令行解释器,它的作用简而言之就是我们在命令行启动的进程,父进程都是这个bash进程。所以我们在上面每次启动进程时,父进程的ppid都不变。
3.3查看进程
上面我们是通过ps指令来查看系统中正在执行的进程,而在linux中同样有其他的方式来查看:
在根目录下有一个叫proc的目录,我们查看这个目录中的内容发现这里面的文件名字长得都和上面我们生成的标识符差不多。
和大家想的一样,前面的那些文件就是系统中正在执行的进程,以它们各自的pid来命名,目录里面就包含着进程的各种属性。
注意:进程会实时在proc目录下显示!!!
注意是实时,那怎么体现实时呢?我们来看看:
此时我们的进程正在执行,在proc目录下就可以查看进程的相关属性。
现在我们把进程结束,再次在proc目录下查看我们的进程时,此时就显示没有这个文件或目录了,因为我们的进程已经结束了,系统就会把相应的目录给删除。
通过上面的例子我们就能清晰地体会到进程会实时的在proc目录下显示这句话。
下面我们来说一说在上面在进程中显示的cwd属性:
要理解这个cwd,我们先看一个例子:
我们在学C语言时打开一个文件要用fopen这个函数,里面要写明要打开的文件,一般我们要写绝对路径或者相对路径,并且如果没有这个文件就会创建一个,但是此时我只写一个文件名,不写具体的路径,那么这个文件会在哪儿创建呢?大家可以思考一下这个问题。
没错,它会在目前你当前路径下创建这个文件,这个路径就是cwd所显示的这个路径,这个路径就是进程的工作路径,每个进程启动的时候都会有一个默认的cwd。
那么此时就有一个问题:这个cwd所对应的路径能否被修改呢?
答案当然是可以的,我们如果要修改cwd所对应的路径,就要用到chdir这个函数:
我们可以看到,它的作用就是改变工作路径,参数就传你想改变的路径即可,下面我们来试验一下:
可以看到,当我们改变路径后,log.txt文件就在修改后的路径中创建了。
3.4fork函数
我们在上面的标识符中了解了父子进程的概念,那么我们来思考一个问题:父进程是如何创建子进程的呢?
此时就要引入fork这个函数,我们来看看这个函数:
可以看到这个函数的作用就是创建一个子进程,下面我们来通过一个例子来看一下它的作用:
通过上面的例子我们可以看到,经过fork函数之后,while循环打出来的内容有不一样的,其中一个是我们最初启动的进程18176,但是我们发现还有一个进程18177启动了,并且这个子进程的父进程就是18176,同样也执行了while循环中的代码。
也就是说,在经过fork函数执行后,当前的进程会创建一个子进程,产生分流,并且默认情况下父子进程的代码和数据是共享的,这也就能说明为什么上面的子进程同样也会执行while循环。
这里解释一下为什么父子进程的代码和数据在默认情况下是共享的:
我们要创建一个子进程,本质就是要创建一个新的task_struct,子进程没有自己的代码和数据,所以只能以父进程的task_struct为模板来创建。
但子进程与父进程不同的是,对于父进程,我们是先有的代码和数据,在启动进程时,操作系统会给父进程创建一个task_struct,保存其属性。但是子进程与之正好相反,子进程是先有的task_struct,那子进程的代码和数据去哪里找呢?
没有代码和数据啊,但是要组成进程就要有代码和数据,所以子进程就与父进程共享代码和数据。
上面只是介绍了fork函数的功能,其实我们再看fork函数就会发现,它的返回值居然是pid_t,也就是返回一个整型数据,那么fork函数返回的是什么整型数据呢?我们来看:
这里就是对fork的返回值进行了介绍,这句话的意思就是如果子进程能够创建成功,就返回子进程的pid给父进程,0返回给子进程;如果子进程创建失败,就返回-1给父进程,子进程不会创建。
那我们根据返回值再来写一个例子来观察一下:
通过运行的结果我们可以发现,因为返回值的不同,父子进程所执行的代码是不一样的,因为我们通过返回值将它们要执行的代码给区分开来。
所以上面的bash创建子进程时大体上逻辑和这里是差不多的,只不过实现起来肯定比我们这里写的要复杂得多。
讲到这里肯定会有很多问题,这些问题可以用下面三个问题来概括:
1.为什么给子进程返回的是0,给父进程返回的是子进程的pid?
2.要同时执行下面的父子进程的代码,说明需要两个id值,fork函数一次返回两个值不可能,那就是fork函数返回了两次,怎么做到返回两次的?
3.明明只有一个id来接收fork函数的返回值,怎么能接收两个不同的值?换句话说,id怎么既==0,又>0?
第一个问题:我们首先要知道父子进程的关系和我们在数据结构中的所学的多叉树一样,这点通过bash和要启动的进程之间就能体现,也就是说父进程:子进程是1:n的关系,如果给父子进程的返回值反过来,那么子进程怎么知道自己的父进程到底是哪个进程?
按上面的情况,bash进程的返回值是0,给18215进程返回的也是0,给18216进程返回数据>0,那么18216怎么区分到底bash是自己的父进程还是18215是自己的父进程?
所以说不能反过来,并且我们只关心子进程是否创建成功即可,不需要知道子进程创建后的pid是什么,所以给子进程返回0,而将子进程的pid返回给父进程是为了让父进程通过子进程的pid来区分不同的子进程。
第二个问题:在解决第二个问题之前我们先思考一个问题:如果一个函数有返回值,那么当代码执行到return时,是否说明这个函数的核心功能做完了?
答案当然是,既然都准备返回值了,那么当然说明函数已经执行到最后一步了,既然如此我们来看:
我们现在知道fork函数的功能就是创建子进程,那也就是说fork函数的函数主体就实现了创建子进程的功能,这么说没问题吧。
既然上面的函数主体已经创建出了子进程,那也就说明在函数主体和return之间已经存在父子进程:
那既然已经存在父子进程,那么在函数主体后就会产生分流,父子进程同时会执行后面的代码,那不就相当于return了两次吗?
第三个问题:要解决这个问题我们先引入一个虚拟地址空间的概念,这个东西后面会详细介绍,这里先知道有这个东西即可,下面我们来看:
在id这个数据存入物理内存之前,会先存到虚拟地址空间中,父子进程都是如此,之后才会将id存入到物理内存中,这个过程叫做写时拷贝。
正常情况下如果父子进程的id相同的话,存入物理内存中时只会开辟一块空间来保存。但是如果不同,换句话说就是一方要修改这个值,那么就会有两个id值,如果还是只有一块空间来存储的话,就会对另一方产生影响,所以在这种情况下,就会在物理内存中再开辟一块空间:
也就是会变成这样,虽然两块空间都叫id,但是存储的值是不一样的。也就是说在执行完fork函数之后,判断id之前,父子进程所对应的id已经不同了。
而在fork函数执行后,虽然父子进程要执行的代码是一样的,但是它们所对应的id不一样,也就导致它们执行了不同id条件下的代码。
这一点因为我们没有细讲这个虚拟地址空间,所以这里了解一下大概过程即可。
以上就是Linux操作系统-进程(一)的全部内容。