Linux系统编程 -- 进程概念(一)
        我们上期输出的结论中,最重要的是我们在对生活中数据的提取中得到了一个结论:"先描述、在组织"。校长对学生的管理,老板对员工的管理,国家对老百姓的管理都是基于"先描述、在组织"的,每一位人民都有自己的身份证,这就是描述;将所有人民信息进行管理的过程就是在组织。所以管理过程本质上是对数据做管理,数据量大了之后我们就需要把数据转化成对应的数据结构,进而我们就将对特定事物的管理,转化成了对特定对象以某种数据结构的形式进行管理。
所以操作系统内一定会存在大量的数据结构和与该数据结构匹配的算法,无论是在系统层面还是网络层面,你会发现数据结构这门课程是贯穿学习始终的。你必须真的搞懂了数据结构,才能把操作系统搞懂,才能把网络搞懂,才能把基于数据结构的所有学科搞懂。在计算机的世界里,根本不会区分这么多学科,但是我们在学习的时候都是分成多门课程学的,如果你不讲这些课程串联起来,你会发现学哪一门都是一知半解。所以我们在学一门学科,比如操作系统的时候,一定要有打通学科信息差的意识,这样我们在学操作系统是才能时用俯视的角度看待操作系统。
进程
基本概念
        课本概念:程序的一个执行实例,正在执行的程序等。
内核观点:担当分配系统资源(CPU时间、内存)的实体。
但是这样说你可能并不是很明白。一个程序运行起来后叫进程,如果一个程序没运行起来,这个程序在哪呢?根据我们目前的认识,我们知道当我们自己编译好了一个可执行程序cmd,它本质上就是一个二进制文件,根据我们之前对冯诺依曼的学习可知,在没有运行之前,它都是我们磁盘上的一个文件罢了,后来我们想运行这个程序,就得先加载到内存,为什么要加载?因为体结构这么决定的。下图内存中就是我们cmd这个程序的代码和数据,其中我们写的for循环等就是代码,我们定义的变量等就是数据,这个是我们所说的进程吗?其实并不是。

上面只是一个程序被加载到内存,如果同一时刻,有上百个程序全部被加载到内存中呢?其实在我们还没有加载这么多程序的时候,OS已经被启动了。当多个程序被同时加载到内存中时,他们都需要申请内存、释放内存甚至进程本事需要被管理调度,而操作系统也无法识别不同的程序并同时管理多个程序,但是操作系统必然要对所有加载的程序进行管理。所以操作系统为了管理加载进来的代码和数据,基于"先描述、在组织"的方式构建了struct结构体,结构体中包含了OS管理程序代码和数据所需要的全部信息,所有加载到内存中程序的struct结构体会在内存中通过链表的形式被排列起来,称之为进程列表。

综上,本质上,进程 = 内核数据结构(task_struct) + 自己的程序代码和数据。

在OS中,内核数据结构(进程控制块)又被称为PCB(process control block),Linux操作系统下的PCB是task_struct,task_struct是PCB的一种,是在Linux中描述进程的结构体。task_struct是Linux内核的一种数据结构类型,它会被装载到RAM(内存)里并且包含着进程的信息。
因此,在Linux中,进程 = PCB(task_struct) + 自己的代码和数据。
举例说明:
几年后,各位就会毕业去找工作,面试前需要把个人简历做好,然后想对应公司去投递简历,HR会将所有的简历进行筛选后一起放在桌子上。其中你找工作,本质上是你的简历在找工作,你在查看投递进度时显示正在排队,本质上是你的简历正在排队,你的简历本质上就是对你进行了"先描述",简历投递给HR,本质上是你将自己的本身属性信息投递给了HR。在面试时,所有的面试者会根据简历的排列顺序进行排队。此时的所有简历相当于排成一个队列,面试官筛选某人本质上把所有简历进行淘汰,而不是对人进行筛选淘汰。所以程序的代码和数据被加载到内存后,就称为了最不重要的,重要的是OS要创建对应的PCB来描述对应程序。此时的CPU就是面试官,PCB是你的简历,自己的代码数据是你本人。
PCB(task_struct)内容分类:
标识符:描述本进程的唯⼀标识符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下一条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据:进程执行时处理器的寄存器中的数据。
I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备集合被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
具体的详细信息会在后续介绍。
进程的组织方式:
在内核源代码里,所有运行在系统里的进程都以 task_struct 双链表的形式存在内核中。

现在我们有了一个比较宏观的视角,了解了进程是由PCB以及自身的代码和数据组成,所以摒弃了"一个程序只要运行起来就叫进程"的认知,真正的进程重点应该放在内核数据结构(PCB),我们虽然没见过操作系统的工作方式,但我们知道在OS中会形成一个双链表来把所有的操作系统内task_struct管理起来,所以对进程的管理就转化成了对链表的增删查改了。所以我们今天要调度一个进程,本质上是对当前链表中的进程选择一个优先级最高的进程,然后放给CPU让它执行。
        我们历史上执行的所有指令、工具、自己的程序,运行起来全部都是进程!为了直观的看到这些进程,我们需要了解下面两个函数getpid和getppid,用于获得进程标识符,即id。只要你是个进程,我就可以获取到你的进程id;而只要你获得了进程id,我们就能确定你是一个进程。

第一步是创建一个myprocess.c文件和一个Makefile文件:


执行make指令后,运行myprocess,我们可以看到当前程序的pid:

我们新开一个当前用户窗口,使用ps axj | grep myprocess,可以查询到当前进程相关的id:可是查询到了这么多信息,它们的含义是什么我们并不知道,这时就需要 head 选项

我们也可以使用 ps ajx | head -1 ; ps axj | grep myprocess 指令,先打印列名,方便我们查看。其中分号用于分割两条指令,用于同时执行,也可以使用 && 来代替 ;

在打印信息的最后一行,我们可以看到有一个grep命令,原因是在使用ps axj 对进程进行过滤时,grep也执行了筛选文件功能,此时grep也是一个进程,同时它本身就存在myprocess这个关键词,所以也会被打印出来。如果你不想显示grep行,可以在上面命令基础上补充指令:| grep -v grep即可。

        我们前面了解过一个正在运行的程序可以使用ctrl+c强制结束,原因是ctrl+c是用来杀掉进程的!但我们今天是在进程视角了解,我们在结束进程之后重新运行程序,我们发现重启后的pid发生了变化,也就是说我们每次启动程序,它的pid都是不一样的。其实在linux中,pid是以不断递增的整形值进行分配的,因为你在退出程序并重启过程中,还启动了其他任务,所以看起来这个pid值并不连续。这些并不重要,只要pid在运行中是唯一值就行了。

kill -9 pid 同样具有杀死进程的作用,也就是说,如果我们想要杀掉进程,除了ctrl+c,我们也可以使用kill。

我们linux中使用的所有命令都是进程,只不过有的进程运行速度快,有的运行比较慢,有的进程会运行后自动退出,而有的需要手动退出。也就是说在linux系统中,用户是以进程的方式来访问操作系统的,我们可以把用户当成老师,操作系统是学生,老师给学生布置的任务就是进程,所以在linux中的进程也叫task,其实在国外一直叫task,只不过国内翻译过来一般叫进程。
除了 ps 命令,我们也可以在proc目录中查看到所有进程,proc是process的简写,说白了就是你现在是在通过文件的方式在查看进程,操作系统不仅可以把磁盘上的文件让你用指令查到,它把内存上的数据也通过文件的形式给你呈现出来,让你能够动态看到内存相关的数据,proc就是内存级的文件系统,它和磁盘没有任何关系,所有数据都是内存上的数据,这也符合linux上一切皆文件的观点,linux甚至把每个进程都转换成了对应的文件。我们知道这些蓝色文件都是目录,而这些目录的命名就是特定进程的pid。

如果我们想要在proc目录查找在运行的进程文件,使用ls /proc/pid即可,如果我们将该进程杀掉后,再查看就会查找不到了。

总结:
我们的proc目录里记录的是当前系统里所有进程信息,其中的数字目录是不同进程的pid,每个数字目录中的内容包含的是将来该进程的动态属性,进程一旦退出,该目录会被自动移除。同理,当我们创建进程时,相应的数字目录也会被同时创建出来。
补充:
我们每一个进程中都有一个exe,他会记录下来当前进程对应的可执行文件,也就是说一个进程被执行时它知道自己是从哪里来的,我在执行哪个指令时才有了这个进程,PCB会记录对应可执行文件的绝对路径。

如果我们在进程运行时把可执行程序删掉,该进程依然在运行,原因是你删除的是磁盘上的文件,而该文件的拷贝已经在内存中了,所以你删掉他并不会直接影响进程。

当我们再次运行ls /proc/20255 -l,你会发现这个exe就开始报警告了。它再告诉我们,进程虽然还在,但是文件已经被delete了。

我们重新生成一个可执行程序,再次启动进程后,看到cwd(current work dir),表示当前工作目录。也就是说,进程的PCB会记录下来当前的工作目录,因此如果你只使用文件名创建文件时,它会默认在当前目录下生成文件。

进程启动时有自己对应的cwd,PCB会记录当前工作目录,在新建非绝对路径文件时就会把cwd带上,文件就会被创建在当前工作目录中,这是程序自己做的,而非空口胡诌。
我们来演示一下:我们将.c文件中补充写入文件,重新编译为可执行程序并运行,我们会发现当前目录中多出了hello.txt的文件。


如果你想要更改当前文件的所处路径,可以使用chdir(Change Directory):使用man chdir可以查看使用文档。

在打开文件前更改当前工作目录,这样我们就会发现文件的路径发生了变更。


了解了getpid,我们再来看getppid(get parent pid):用于获取父进程的pid。学习getppid之间,我们要了解,linux系统是个单亲繁殖的系统,只有父进程,没有母进程。所有的子进程均由父进程创建,我们写出来的代码程序被对应的父进程创建为子进程,而父进程也会有它的父进程。一个父进程可以创建多个子进程,所以linux中,所有的进程也叫进程树。
getppid函数是用来获取父进程id的,用法与getpid一致,我们将代码修改后,编译运行,就可以看到进程对应的父进程id。而多次启动进程我们会发现他们的父进程是一样的。


那这个父进程是谁呢?我们使用父进程的pid搜索发现是一个bash,也就是命令行解释器。

所以命令行解释器(王婆)也是一个进程,其实我们在每一次登录云服务器时,操作系统会给每一个用户分配一个bash。bash这个进程启动后,会现在命令行上打印,然后进行scanf,这样就会停下来接收下一条用户输入的指令,bash拿到字符串后就可以开始进行分析了。
当我们有了这个认识后,我们还知道王婆一般不会直接工作,她会把任务都分配给手下的实习生(子进程),因此所有指令的父进程都是bash。那么这些子进程是怎么创建的呢?下面我们来了解一下。
创建子进程的函数是fork:

我们将原代码注释掉,输入图中三行代码来测试。

在代码刚开始运行时,我们的代码应该是一个执行流的,等我们执行完fork后会变成两个执行流,然后两个执行流继续向下运行打印第三行的代码,也就是说第三行代码会被执行两次。因为第二个执行流是刚开始执行,所以会先输出用户信息,再打印结果。

我们知道进程 = PCB(task_struct) + 自己的代码和数据,如果我们创建了子进程,系统中就会多一个进程,而每个子进程都要有自己的PCB和代码数据,子进程会默认拷贝父进程的PCB并指向父进程指向的代码数据,也就是说有人抄了你的简历,只是改了个名字,连电话都没改,所以子进程会继续向下执行,而不是回到最开始去执行。换句话说,子进程没有自己的代码和数据,因为目前没有程序新加载!

fork的返回值解释为:如果成功,返回父进程返回子进程的pid,子进程返回0;如果失败,父进程会返回-1,子进程没有返回值并报错。也就是说,fork会有两个返回值。

我们将代码做如下修改,使用id变量接收fork的返回值,并进行分支语句判断是父进程还是子进程。


在id接收到返回值时,子进程也已经被创建了,下面父进程和子进程就会继续往下运行,父进程的id>0,所以会进入else里面循环,而子进程则会进入到id==0的函数体中循环,最终就呈现出了上图中的结果。
我们以前学C/C++时,从来没有见过一个程序中会有同时进入两个函数体的情况,也没有见过一个变量会同时>和==0的情况,更没见过一个函数会返回两个返回值的情况。
下面我们来简单解答一下:
1、为什么fork给父子返回各自不同的返回值?
答:在linux系统里,子进程 : 父进程 = n : 1。任何一个父进程可以有一个或多个孩子,所以一定要把子进程的pid返回给父进程,方便未来对子进程做管理。而子进程只需要确认是否成功建立即可,不需要获取父进程的pid,因为子进程可以通过getppid()获取父进程id。
2、为什么一个函数会返回两次?
答:一个函数已经到return xx了,说明核心功能已经做完了。基于这个理解,fork函数本质上是系统调用,fork函数一旦执行完,就会return id,然后就会返回到函数下一行继续向下执行,fork在创建子进程时会申请新的PCB、拷贝父进程PCB到自己的PCB、将子进程PCB放入list中甚至放入调度队列中。也就是说在走到return语句时,父子都已经被创建甚至调度了,在此时会有父子进程两个执行流去执行下面的return语句,所以会返回两个返回值。
3、为什么一个变量能同时 == 0 又 > 0 ?导致if else同时成立?
答:进程具有独立性!一个进程的运行与终止不会影响另一个进程,所以父子进程两个执行流一个挂了,并不会影响另一个,两个程序的后续执行也是独立的。
补充(目前先了解):
把父子任何一方进行数据修改,OS把被修改的数据在底层拷贝一份,让目标进程修改这个拷贝,称为写时拷贝。也就是说子进程修改的数据,不会改变父进程中的数据。
总结:
父子进程独立性的保证是如何做到的?
1、数据结构独立(PCB)
2、代码共享
3、数据以写时拷贝的形式各自持有一份
