在 Linux 中,进程是指正在运行的程序的实例。每个进程都是一个独立的执行单元,具有自己的内存空间、代码、数据和打开的文件等资源。进程是操作系统中的核心概念之一,它们使得多个程序可以同时运行,并通过相互通信和资源共享来实现协作。操作系统负责管理和调度进程,确保它们能够有效地共享系统资源,并提供一致的运行环境。今天开始,我们就来一起学习Linux进程的那些事儿,来慢慢了解深入Linux进程~
目录
1.冯诺依曼体系结构
关于冯诺依曼,我们需要强调几点:
从软件数据流理解冯诺依曼体系结构
2.操作系统
操作系统是什么?
如何理解 "管理"
系统调用与进程
系统调用
外壳程序和库
不同操作系统之间的差异
进程
进程vs程序
Linux中的进程
task_struct结构体
task_struct 进程的核心字段
进程显示与获取pid和ppid
查看进程的第二种方式
Linux--fork函数-代码创建进程
一个函数两个返回值-fork做到了
fork函数和多进程
fork函数深入理解
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成
输入单元:包括键盘, 鼠标,扫描仪, 写板,网卡,显卡,磁盘/ssd(固态硬盘)等
中央处理器(CPU):含有运算器和控制器,各种寄存器和各种级别的缓存等
输出单元:显示器,打印机,磁盘,网卡,显卡等设备
1.在计算机中,存储器指的是内存,一般不包括缓存,在计算机主板中,一般指的是插入的内存条
2.计算机的几乎所有设备,都有一定的数据存储能力,包括cpu(寄存器)和一些外设等。
3.cpu是处理数据最快的,然后是内存,然后再是外设(磁盘或硬盘),磁盘、内存和ssd之间的区别:
磁盘(硬盘驱动器,HDD)、内存(随机访问存储器,RAM)和固态硬盘(固态驱动器,SSD)是计算机系统中常见的存储介质,它们之间有以下区别:
访问速度:SSD的访问速度比传统磁盘快得多,而内存的访问速度更快。SSD的读写速度远远超过传统磁盘,因为它没有机械运动的部件。内存是最快的存储介质,可以以非常高的速度读取和写入数据。
容量:磁盘的存储容量通常比SSD大得多,而SSD的容量通常比内存大得多。磁盘的容量可以达到数TB(terabytes),甚至更大。SSD的容量通常在几百GB到几TB之间。内存的容量通常在几GB到几十GB之间。
持久性:磁盘和SSD都是持久性存储介质,即使计算机断电,数据仍然保持。内存是一种易失性存储介质,当计算机断电时,内存中的数据将丢失。
价格:相对而言,磁盘的价格较低,SSD的价格较高,而内存的价格更高。磁盘的价格以每TB计算,SSD的价格以每GB计算,而内存的价格以每GB计算。
磁盘主要用于持久性存储数据,SSD用于提供更快的读写速度和改善系统性能,而内存用于临时存储正在运行的程序和数据。
计算机的存储分级结构图如下所示:
4.在数据层面上,cpu和外设一般不会直接交互。外设的响应速度较慢,cpu的各项工作需要快速响应,如果cpu和外设直接交互,那么外设势必会拖慢cpu的响应速度,所以,cpu不会直接和外设进行数据操作,解决办法就是,当cpu要进行一些计算方面的操作需要从外设中获取数据时,会由操作系统将数据送到存储器中,同时cpu先进行着数据送到内存前的其他的计算,这样时间上就会发生重叠,从而提高效率,并且内存的访存速度比外设的速度快,这样就提高了计算的效率,不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备) ,外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。 一句话,所有设备都只能直接和内存打交道。
5.程序运行之前必须先加载到内存,程序=数据+代码,最终都要由cpu进行处理,cpu需要先读取这些代码和数据,而cpu和内存之间具有数据(二进制)层面的交互,但是形成的可执行程序exe,本质上是一个文件,只能在外设(磁盘)中保存。
我们尝试着理解从登录上qq开始和某位朋友聊天开始,在硬件层面上,数据的流动过程。假设发送了一条消息,“你好”。
对于文件的发送和接收,本质上还是和上面的过程类似,只是我们的输入设备变成了磁盘而已,而对方的接收的时候计算机只会显示一个特定的图标并询问是否保存,确认后对方就会将这个文件接收下载并保存到自己的磁盘中。
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS),操作系统是一个进行软硬件资源管理的软件,计算机开机第一个加载的软件,操作系统将软硬件资源管理好,让用户获得稳定、安全、高效、易用的使用环境。
笼统的理解,操作系统包括: 内核(进程管理,内存管理,文件管理,驱动管理) 、其他程序(例如函数库,shell程序等等)
管理的本质是管理数据,基于面向对象的思路,将需要管理的对象抽象成一个个结构体,再将其用链表或其他高效的数据结构等方式联系起来,从而使管理数据变成简单的对数据结构的增删改查操作,像我们一般写一些c的小项目,一般都会率先抽象对象形成对应的结构体,即对对象进行描述,然后用数据结构组织起来,比如数组、链表等。总结起来就是先描述,再组织。同样的,操作系统想要对软硬件资源进行管理,就必须对各个对象进行描述组织,形成对应的数据结构。
操作系统管理的核心是:进程管理,内存管理,文件/IO管理,驱动管理,本系列我们将逐一进行学习。
系统调用(System Call)是操作系统提供的一组接口,用于应用程序与操作系统内核之间进行通信和交互。应用程序通过系统调用请求操作系统执行特权操作或获取操作系统提供的服务。
操作系统的核心功能通常只有在内核态下才能执行,而应用程序运行在用户态下。为了保护操作系统的安全性和稳定性,应用程序不能直接访问内核的功能和资源。因此,当应用程序需要执行特权操作或使用操作系统的服务时,它必须通过系统调用来向操作系统发出请求。用户想访问硬件或者操作系统数据,只能通过系统调用接口来实现。
生活中类似于操作系统的这种系统调用的场景其实有很多,比如我们熟知的一些银行等机关单位,他们对于我们这种普通用户显然是不信任的,因为普通用户中不一定都是“好人”,所以我们一般去银行办理相关业务时,都会与工作人员隔着厚厚的玻璃或者通过极小的窗口进行,这个屏障中唯一的窗口就相当于系统调用的功能,将个人用户与银行内部联系起来,同时也保护了银行内部的安全。
通过系统调用,应用程序可以执行各种操作,例如:
系统调用提供了一种安全且受控的方式,使应用程序能够利用操作系统的功能和服务,从而实现更广泛的计算机操作和资源管理。
外壳程序是一种命令行解释器,本质上也是一个软件,它是用户与操作系统之间的接口。外壳程序在用户状态下执行,接收用户输入的命令并解释执行,可以调用系统调用来执行特定的系统操作。外壳程序提供了一个交互式的命令行界面,用户可以通过输入命令来操作和管理计算机系统。外壳程序是面向用户的接口,提供了一种易于使用的命令行界面。用户可以直接在外壳程序中输入命令,而不需要了解底层的系统调用接口,从而让使用变得简单。
总之,系统调用是应用程序与操作系统内核之间的接口,用于请求操作系统提供的服务;而外壳程序是用户与操作系统之间的接口,提供了交互式的命令行界面。系统调用是底层的接口,外壳程序是在用户层面上提供更高级别的命令解释和操作功能。
站在用户的角度上,我们可以调用外壳程序来间接调用系统调用接口实现对操作系统的操作,当然,站在系统开发人员的角度上,想要调用操作系统,因为此时还没有开发出外壳程序,对操作系统的调用只能停留在系统调用层面,于是,部分开发人员就可以将系统调用的接口封装成各种函数,再将这些函数打包形成对应的库,这样,后面开发者在使用一些功能的时候,就可以直接调用库函数即可,这样就可以提高开发的效率。常见的库,比如C标准库等库就是由此而来。
说到这里,我们会不会好奇,像一些操纵系统的不同版本,或者一些不同的操作系统,他们之间的差异都在哪里?事实上,对于一个操作系统的不同版本,实际上操作系统内核本质上并没有太大的变化,变化的只是外壳程序和库的更迭,或者系统调用的更新,而对于不通的操作系统,比如安卓,Linux和ios操作系统,其区别不仅在系统调用和外壳程序上,其内核也会有对应的修改,所有的操作系统本质上都是共用的同一份内核代码,只是不通的操作系统对内核代码的修改不同,加之不同的系统调用和外壳程序等,才形成了不同的操作系统。
程序是一组指令的集合,以某种编程语言编写,用于完成特定的任务或实现特定的功能。程序是静态的,它只是存储在磁盘或其他存储介质上的一段二进制代码,不具备运行能力。程序需要通过操作系统的支持才能被加载到内存中执行。
进程是程序的执行实例。当程序被加载到内存中,并由操作系统调度和执行时,就成为一个进程。进程是程序在运行过程中的动态状态,包括程序计数器、寄存器、内存分配、打开的文件等。进程是动态的,它是程序在运行时的实体,具有独立的执行流和资源。进程可以分配和释放内存、占用CPU时间、进行输入输出操作等。
操作系统通过pcb来描述一个进程,当一个程序被读入到内存时,操作系统为了管理这个程序,就会为这个程序创建一个独一无二的pcb结构体,其中包含这个程序的相关信息,随着程序被加载到内存成为进程,由操作系统为每个进程所创建的pcb会按照进程进入顺序形成一个pcb链表,对进程的管理,也就变成了对这个pcb链表的管理。也就是说,进程=可执行程序+内核数据结构(pcb)。
Linux操作系统下的PCB是: task_struct,task_struct(PCB的一种), 在Linux中描述进程的结构体叫做task_struct。 task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_struct结构体是一个双向链表结构,但是又与双向链表有些许不同,具体我们可以看下面的图:
为什么要采用这样的在设计思路呢?事实上,我们的进程有可能不会只存在于一个数据结构中,它还可能存在于其他的调度中,比如一个进程除了存在于一个链表中,还存在于另一个调度队列中,那么此时我们为了让这两个进程之间不会相互影响,除了一些区别不同进程的属性以外,如果我们对同一份进程进行操作,可能就会影响另一个进程的使用,这时,我们就可以将进程单独描述成一个结构体并形成排队链表(dlist),在不同的数据结构中使用时进行单独声明,这样就避免了进程之间的相互影响,同时还可以让pcb中包含各种进程,使得pcb可以被连接到各种结构里(队列,链表等)。
在Linux内核中,task_struct
是表示进程的数据结构,包含了进程的各种信息和状态。以下是task_struct
结构体中的一些核心字段:
PPID
: 父进程的进程ID(Parent Process ID),指示生成当前进程的进程的ID。PID
: 当前进程的进程ID(Process ID),是操作系统分配给进程的唯一标识符。PGID
: 进程组ID(Process Group ID),表示进程所属的进程组的ID。SID
: 会话ID(Session ID),指示进程所属的会话(Session)的ID。TTY
: 控制终端(Controlling Terminal),表示与进程关联的终端设备。TPGID
: 控制终端的进程组ID(Terminal Process Group ID),表示与终端设备关联的进程组的ID。STAT
: 进程状态(Process Status),表示进程当前的状态,例如运行(R,Running)、睡眠(S,Sleeping)、僵尸(Z,Zombie)等。UID
: 用户ID(User ID),指示运行该进程的用户的ID。TIME
: 进程占用的CPU时间,表示进程在CPU上运行的总时间。COMMAND
: 进程的命令名称,表示启动该进程的可执行文件的名称。
这只是task_struct
中的一些核心字段,实际上task_struct
结构体还包含了许多其他字段,用于存储进程的各种信息。
现在,我们已经知道我们所写的静态代码不能够叫做进程,只有运行起来的程序才能叫做进程,我们可以通过 ps 命令来实时查看当前的全部进程,具体我们也可以直接使用命令 man ps 来查看 ps 命令的更多用法。
在Linux中,ps -axj
是一个用于显示进程信息的命令。下面是对该命令的解释:
ps
是"process status"的缩写,用于显示当前正在运行的进程信息。-a
选项表示显示所有进程,包括其他用户的进程。-x
选项表示显示没有控制终端的进程。-j
选项表示以"作业控制"格式显示进程信息。 因此,ps -axj
命令将显示所有正在运行的进程的详细信息,包括进程ID(PID)、父进程ID(PPID)、进程状态(STAT)、占用CPU的百分比(%CPU)、占用内存的百分比(%MEM)、进程的启动时间(STARTED)等。
在Xshell中,"复制SSH通道"通常指的是复制当前已配置的SSH连接会话,以便在新的标签页或窗口中创建相同的连接。这样可以方便地在不同的会话中同时访问相同的远程服务器。
我们的目的是显示进程的详细信息,但是在同一个shell窗口下,肯定不允许我们在运行程序的同时查看对应的运行程序的进程,我们可以将我们当前用户的ssh渠道复制一份,然后从另一个窗口中查看运行的进程,这里,我们先给出一个可以持续运行的代码样例:
接着,我们复制我们当前用户的ssh渠道,通过使用两个会话访问同一个用户,
我们在其中一个会话中运行代码,同时在另一个会话中使用ps命令查看我们对应的代码的进程,这里有必要讲解这样一条命令:
ps axj | head -1 && ps axj | grep mycode
//&& 与我们c语言中的含义是一致的,代表左右两条命令都要跑起来
// ps axj | head -1 代表显示当前的进程的列属性,相当于只显示第一行的各个属性
// ps axj | grep mycode 管道过滤,将所有进程中含有mycode信息的进程显示出来,其他的不显示
程序在运行过程中,打印出了其对应的进程,并且我们发现,grep 命令也被当做一个进程正在执行,所以,我们运行的所有的指令、软件包括程序,最终都是进程。
如何获取一个进程的pid,我们有专门的函数,也属于系统调用接口,可以进行获取当前进程的pid和ppid的功能:
我们可以通过重复运行程序发现,程序的pid会随着程序的每次运行而发生改变,但是其父进程不会改变,当我们想要利用ppid来查看其父进程的相关信息是,发现该进程其实就是命令行解释器bash,如果我们杀死这个父进程,就会导致命令行使用出现问题,这里我们了解即可,后面我们还会继续讲解,当然,我们也可以随时将当前进程杀死,程序也会随着进程的杀死而结束,命令为:
kill -9 [进程号]
在Linux系统中,/proc
目录是一个特殊的虚拟文件系统,用于提供关于运行中进程和系统状态的信息。它不是一个真实的文件系统,而是通过内核动态生成的,可以访问和读取其中的文件来获取系统和进程的详细信息。/proc
目录中,查看一个pid对应的进程的常用方式就是 :/proc/
: 该目录下包含了系统中(其实是内存中)每个正在运行的进程的信息,其中
是进程ID,例如/proc/1234
。
我们可以通过对程序的重复编译和执行,来动态观察对应的程序的进程的pid在系统内的存在情况。
有了这种方式,我们可能会好奇进程里到底包含哪些信息,现在,我们可以直接通过目录访问这个进程的所有信息,我们就可以将它打印出来查看,
emmm...,信息确实挺多的,虽然我们看不懂,不过没关系,我们现在只是看其中的两个文件就可以,分别是以下两个文件:
在进程信息中,
cwd
和exe
分别代表以下含义:
cwd
:cwd
是当前工作目录(Current Working Directory)的缩写。它表示进程当前所在的工作目录路径。工作目录是进程在执行文件操作(如相对路径访问文件)时的默认起点。cwd
字段显示了进程当前正在操作的目录路径,这个默认路径可以修改,在Linux中,可以使用chdir
系统调用来修改当前工作目录。chdir
函数是C库中提供的一个函数,它可以改变进程的当前工作目录,需要包含头文件。
exe
:exe
是可执行文件(Executable File)的缩写。它表示进程所对应的可执行文件的路径。该字段指向进程正在运行的可执行文件的完整路径,包括文件名。通过读取exe
字段,可以确定进程正在执行的是哪个可执行文件。
其中这个 exe文件就是该进程的可执行程序的目录所在,当我们开启第三个会话窗口,手动清理掉这个可执行程序,我们会发现,原来正在运行的程序并没有停止而是还在运行,
但是,当我们想要再次查看对应的进程信息时,发现exe文件已经提示我们可执行程序已经被删除。
究其原因,其实也比较简单,可执行程序被加载到内存才变成了进程,当我们执行了可执行程序,其就已经由磁盘被加载到了内存,而我们删除这个文件,只是将磁盘中对应的文件删除了,并没有删除内存中的可执行程序,因此其还能够正常在内存中执行完毕后再删除进程,但是如果再次将其加载到内存,磁盘中就找不到了,这也就是很多情况下,我们不小心将程序的一部分文件删除了,程序并不会马上崩溃,而是在运行完当前进程之后再出错的原因。
我们现在手动启动运行一个程序,都是采用 .\ 的方式将程序加载拷贝带内存运行,启动一个进程,本质上就是系统多了一个进程,操作系统要管理的进程就多了一个,创建一个进程,就是向系统申请一块内存,用来保存一个进程的可执行程序+task_strut对象,并将该进程的task_struct对象加入到进程列表当中。
在Linux中,fork
函数是一个系统调用,用于创建一个新的进程。它会在当前进程的基础上复制一个新的进程,这个新进程被称为子进程,而原始的进程被称为父进程。
我们来看这样一段代码:
对于这样的一段代码,按逻辑上来说,只会有两行输出,可事实上又是如何的呢?我们来运行开结果:
针对这个运行结果,我们来个分析,我们观察语句就会发现,第一行是fork函数之前的代码执行的输出,这是毫无疑问的,那就说明下面两行都是fork函数下面的一个printf打印出来的,这就有点奇怪了,其实,这是fork函数的一个重要的特点:
在
fork
之后,父进程和子进程在执行不同的代码路径。父进程会继续执行后续的代码,而子进程从fork
调用点开始执行新的代码。它们是并行运行的独立进程。
上面的话的大概意思就是:成功时,在父进程中返回子进程的进程ID(PID),在子进程中返回0。失败时,在父进程中返回-1,表示没有创建子进程,并且errno会被适当地设置。其中,errno
是一个全局变量,用于表示发生系统调用或库函数调用失败时的错误代码。它定义在
头文件中,并由操作系统或C库维护。当某个系统调用或库函数返回一个表示错误的值时,可以通过检查errno
变量来获取具体的错误信息,这里只要知道它是一个报错的文件就可以。
由于fork函数后,父子进程会执行不同的代码路径,我们又知道该函数的返回值有两个,那么我们就可以利用一个变量将父子进程的id给打印出来,
从运行结果中不难看出,第二行运行的是父进程的代码,显示的fork函数返回值是子进程的pid,对应的第三行就是fork函数产生的子进程的代码运行结果,fork返回的就是0,正好符合我们上面提出的fork函数返回值的特点。
说到底,fork函数带来两个不同的返回值的同时,也带来了一个从该父进程创建的子进程,但是这样有什么用呢?上述的fork函数产生父子进程后,都在执行一样的工作,那么,既然能从一个进程中创建一个子进程,能不能让父子进程做不一样的事情呢?答案当然是肯定的,我们就从fork函数的两个不同的返回值来区别父子进程,就可以实现多进程工作。下面是一段示例代码:
简单学完了应用,我们回过头来再来讲原理,这里我们给出fork函数的几个经典问题:
1.fork函数干了什么事情?
2.为什么fork函数会有两个不同的返回值?
3.为什么这两个返回值,给父进程返回子进程的pid,给子进程返回了0?
4.fork函数之后,父子进程谁先运行?
5.如何理解同一个变量,会有不同的值?
fork函数为什么给父进程返回了具有唯一标识性的子进程的pid,而给子进程返回了没有唯一性的0呢?
父进程中的
fork()
调用:父进程在调用fork()
时,它需要知道子进程的PID,以便进行管理、跟踪、信号发送等操作。通过将子进程的PID作为返回值,父进程可以唯一地识别和操作子进程。子进程中的
fork()
调用:子进程在调用fork()
后,因为它是新创建的进程,不需要知道自己的PID,而且在子进程中获取自己的PID并不直接提供有意义的信息。因此,将0作为返回值可以简化子进程的处理逻辑,并且可以用于判断当前进程是否为子进程。
也可以说,父子进程具有一对多的关系,即一个父进程可能有多个子进程,但一个子进程只有一个父进程,父进程需要追踪和管理所有的子进程,但是子进程只需要管好自己就可以了。
创建完子进程之后,父进程、子进程和系统其他进程要接着被调度执行,当父子进程的PCB都被创建并调度到执行队列中开始排队,这两个PCB哪一个被先选择执行,哪一个就先执行,这个顺序是不确定的,由操作系统自主决定,对每个PCB按优先级,时间片等,结合调度算法进行排序,然后,按顺序进行,这部分了解即可,后续还会提到。总之,父进程和子进程的执行顺序是不确定的,取决于操作系统的调度机制和运行时环境。
进程间具有独立性,父子进程也是一样,这两个进程互相不影响,杀掉正在运行的父子进程中的任意一个,不会影响另一个进程的继续运行,进程的独立性,表现在进程具有各自的PCB,对于父子进程来说,代码本身是只读的(这里涉及到进程内存空间的代码段保留问题,后续我们还会提及),所以杀死父进程,不会影响子进程继续使用这份代码,因为进程删除删除的是task_struct对象,并不是删除加载到内存的代码,反之亦可,但是数据父子进程之间是会影响的,所以,父子进程之间就必须各自私有一份数据,以保证两个进程之间互不影响,称之为写时拷贝技术。
子进程的数据写时拷贝是一种优化技术,用于在创建子进程时共享父进程的内存页,直到子进程尝试写入这些内存页时才进行实际的拷贝。
当使用
fork()
创建子进程时,子进程会继承父进程的内存空间,包括代码、数据和堆栈等。在初始阶段,父进程和子进程共享相同的物理内存页。这意味着对于只读的内存页,父进程和子进程可以共享相同的物理页面,避免无谓的内存复制。当父进程或子进程尝试对共享的内存页进行写操作时,操作系统会执行写时拷贝操作。它会为子进程分配新的物理内存页,并将需要修改的数据复制到新的内存页中,从而确保父进程和子进程的内存空间相互独立,互不干扰。
因为父子进程代码共享,但是执行路径不同,就会导致出现两个返回值,这并不难理解,但是,我们用一个变量就接受到了两个值,并且这两个值还不一样,这可能吗,会不会这两个返回值储存的变量也是两份,地址空间根本是一个呢?带着这个问题,我们来给出如下的代码示例进行验证:
我们惊奇的发现这两个返回值的地址竟然是相同的,同一块内存上同时存在了两个不一样的值吗?这怎么可能?所以,我们断定,这个地址,绝对不是物理地址!!!
这里先简单提一下,更具体的内容还需要后序掌握进程地址空间的知识才能更深入的理解:
当调用
fork()
函数时,操作系统会创建一个新的进程,该进程与父进程共享相同的代码段、数据段和堆栈。这意味着在创建子进程时,子进程会复制父进程的地址空间中的所有内容,并在一个新的地址空间中运行。虽然子进程共享父进程的地址空间,但操作系统会使用不同的页表来映射这些地址空间。这样,父进程和子进程在逻辑上具有不同的地址空间,虽然它们实际上指向相同的物理内存位置。
由于篇幅较大,此处就不再继续了,欲知后事如何,且等我更新叭......