前言
本篇文章主要了解进程的概念。
#j 冯·诺依曼体系结构
什么是冯·诺依曼体系结构?
冯·诺伊曼体系结构是计算机体系结构的一种经典范式,由计算机科学家约翰·冯·诺伊曼(John von Neumann)提出。该体系结构在计算机设计中起到了重要的指导作用,它包括以下关键概念:
-
存储程序: 冯·诺伊曼体系结构采用存储程序的概念,即程序和数据存储在同一存储器中(磁盘中)。当运行这个程序时,程序的代码和数据会被加载到内存中,此时的程序的数据和代码会变成指令存储在内存中,等待CPU的调度访问。
-
二进制系统: 冯·诺伊曼体系结构使用二进制表示数据和指令。计算机内部的所有信息,包括指令和数据,都以二进制形式存储和处理。
-
中央处理单元(CPU): CPU是计算机的核心,负责执行存储在内存中的指令。它包括算术逻辑单元(ALU)用于执行计算操作,以及控制单元(CU)用于管理指令的执行顺序。
-
顺序执行: 冯·诺伊曼体系结构的计算机按照存储在内存中的程序顺序执行指令,一条接一条地完成操作。这种顺序执行的特性使得程序的控制流程更加可控。
-
存储器层次结构: 冯·诺伊曼体系结构引入了存储器层次结构的概念,包括寄存器、高速缓存、主内存等。这有助于提高数据访问速度和系统整体性能。
-
输入输出设备: 冯·诺伊曼体系结构将输入输出设备看作特殊的外部设备,通过指令进行数据传输。这种设计允许计算机与外部环境进行交互。常见计算机中的输入设备有:键盘、鼠标等,输出设备有:显示器、音箱等。当然了有些外部设备即可以是输入设备也可以是输出设备:如网卡、磁盘等等。
冯·诺伊曼体系结构的优势在于其简洁而通用的设计,为后来计算机体系结构的发展奠定了基础。绝大多数现代计算机系统都基于冯·诺伊曼体系结构,这使得软件开发更为灵活,硬件设计更为标准化。
补充1:如何让各个硬件连接起来?
各个硬件间都是独立的实体,如何将它们进行组织协同起来形成计算机硬件系统呢?答案是需要借助总线,总线扮演连接各个计算机硬件的数据通道。使得各个计算机能够有效的交换数据以完成操作系统分发的各种任务。
补充2:为什么冯诺依曼体系结构规定以存储器为核心?
-
这里的存储器指的是内存。冯诺依曼体系结构是以存储器为核心的。
-
存储器是有分级别的。根据存储器是以的离CPU的距离和造价以及性能比是类似成金字塔结构的。分别是 寄存器 > cache > 内存 > 磁盘。
-
以内存为核心是因为计算机的存储介质中,寄存器和cache由于造价比较高昂,无法使计算机普及给普罗大众使用,而磁盘由于离CPU较远,并且处理数据的性能较差。所以,根据木桶效益,冯诺依曼体系结构规定了以内存为核心。
-
由于CPU的速度太快,而外部设备的速度相较于CPU的处理速度,又显得太慢。所以,外部设备会现将指令预加载到内存中,CPU就可以直接从内存获取对应的数据。CPU将对应的数据处理好后,又会将数据写回到内存中,外部设备再从内存中获取对应的数据。这样大在一定的“性价比”范围内使得计算机的性能得到了保证。
为什么需要了解冯·诺依曼体系结构
了解进程的概念需要理解冯诺依曼体系结构,因为进程的概念与计算机系统的基本架构密切相关。冯诺依曼体系结构是一种计算机硬件组织结构,其中存储器和处理器是分开的,程序以指令的形式存储在内存中。
进程是计算机中执行的程序实例,它包含了程序的代码、数据和执行状态。在冯诺依曼体系结构下,程序的执行是以进程为基础的。进程之间通过共享内存或消息传递等机制进行通信和同步,这是计算机系统中重要的概念。
理解冯诺依曼体系结构有助于理解进程是如何在计算机系统中被创建、调度和执行的。进程的概念涉及到指令的执行、内存的管理以及与其他进程的协同操作,这些都直接关联到冯诺依曼体系结构中的存储器和处理器的交互方式。因此,对计算机硬件体系结构的理解为深入了解进程的概念提供了基础。
如何结合对于冯·诺依曼体系结构理解进程与它的关系?
这里我就以一个小故事来讲解这个话题。假设一个场景,你正在用你的QQ和你心仪的对象在聊天。你给她发送了一条消息,“月上柳梢头,今晚一起吃饭吗?”。此时,你的这段话是通过你的键盘(输入设备),敲到了你的QQ对话框中(软件-进程,内存-存储你输入的数据)。然后,这段话通过在CPU处理过后在网络上传输(网卡-输出设备)发送到了QQ的服务器,这里就暂时忽略网络的情况。你的心仪对象就收到了你的消息(网卡-输入设备,显示器-输出设备)。
什么是操作系统?
操作系统是一款进行管理的软件,它们管理计算机硬件和计算机软件(提供给进程一个执行环境)。
-
软硬件资源管理: 操作系统负责管理计算机的硬件资源,如内存、处理器、硬盘和输入/输出设备。用户通过操作系统调用接口访问这些资源。为什么需要通过系统调用接口来访问这些系统资源呢?因为,操作系统不相信任何用户,但是。又要为用户提供服务。所以,操作系统会提供对应的系统调用接口给用户来访问系统数据,这样即提供了服务,又保护了数据不会被用户恶意篡改。
-
进程和线程管理: 操作系统允许程序以进程和线程的形式运行。程序员编写代码时,可以创建多个并发执行的进程或线程,操作系统负责调度和协调它们的执行。
-
文件系统: 操作系统提供文件系统,允许程序员以文件的形式组织和访问数据。通过文件系统,程序员可以读取、写入和管理文件,而不必直接处理磁盘或存储细节。
-
设备驱动程序: 操作系统提供设备驱动程序,使得程序员可以通过高级接口访问各种硬件设备,如打印机、键盘、鼠标等,而无需了解底层硬件细节。
-
系统调用和API: 操作系统通过系统调用和应用程序接口(API)提供了一组功能,程序员可以使用这些接口与操作系统进行交互。这包括文件操作、内存分配、进程控制等。
-
安全性和权限: 操作系统通过权限和安全性控制确保系统的稳定性和用户数据的安全。程序员需要遵循操作系统提供的权限模型,以确保他们的应用程序按照预期运行。
总体而言,操作系统为程序员提供了一个抽象层(系统调用接口),使得他们可以专注于应用程序的逻辑和功能,而无需过多关注底层硬件和系统管理的细节。通过操作系统,程序员能够更高效地开发应用程序,并利用计算机硬件资源。
为什么需要操作系统进行管理?
计算机系统需要操作系统进行管理的原因有多个:
-
资源管理: 操作系统负责有效地管理计算机系统的硬件资源,包括内存、处理器、硬盘、网络接口等。它协调这些资源的分配,确保多个应用程序能够同时运行而不会相互干扰。
-
进程调度: 操作系统能够调度和协调多个进程的执行。通过分时操作,它确保每个进程都有机会在处理器上执行,从而实现多任务处理和提高系统的吞吐量。
-
内存管理: 操作系统负责管理计算机的内存,包括分配和释放内存空间,以及虚拟内存的管理。这有助于确保应用程序可以访问所需的内存,同时避免内存冲突和浪费。
-
文件系统: 操作系统提供文件系统,使得程序员和应用程序可以以文件的形式组织和访问数据。文件系统简化了对数据的管理,使其更有组织性,也提供了数据持久性的解决方案。
-
设备管理: 操作系统通过设备驱动程序来管理硬件设备,使得程序员可以通过高级接口而非硬件级别来访问设备。这简化了与各种硬件设备的交互。
-
用户接口: 操作系统提供用户接口,使得用户能够与计算机进行交互。这可以是命令行界面(CLI)或图形用户界面(GUI),为用户提供了友好的操作方式。
-
安全性和权限控制: 操作系统实施安全性控制和权限管理,确保只有经过授权的用户或程序可以执行敏感操作。这有助于保护系统免受未经授权的访问和损害。
总体而言,操作系统提供了计算机系统的管理和协调机制,使得硬件资源得以合理利用。对下为用户管理各种软硬件资源的手段,目的是对上为用户提供了一个安全、稳定、高效的运行环境。
操作系统是如何进行管理的
管理的本质
在生活中,我们学生属于管理这一行为受众的被管理者,而校长就是一个典型的管理者。你的校长对你进行管理需要经常跟你见面交流吗?答案是不需要。他只需要通过你的数据,如专业课的成绩、体侧数据、学校活动记录等就可以对你进行相应的管理。所以,管理的本质其实不是对你这个人做管理,而是对你这个人的数据进行管理。校长管理你,其实更多是根据你的数据进行决策,而决策的执行往往需要辅导员来进行。其实,这就很想计算机系统中,操作系统和驱动程序以及硬件的关系。
校长对于你的管理也可以抽象操作系统管理的六字真言 “先描述,再组织”。
为什么管理要“先描述,再组织”:
操作系统对于数据的管理首先描述再组织的原因在于建立一个有效的数据抽象和结构,使得数据能够被高效地访问、处理和维护。这包括以下几个方面:
-
数据抽象: 描述数据首先涉及对数据的抽象,即将数据的特征和属性进行定义和描述。这为程序员提供了一个高层次的概念,使得他们能够更容易理解和处理数据,而不必关心底层的存储和组织细节。
-
易理解性: 描述数据的结构和属性使得数据更易理解。通过定义数据的属性、类型和关系,程序员能够清晰地了解数据的含义和用途,从而更加方便地进行编程。
-
数据模型: 描述数据的管理往往包括建立数据模型,即对数据的逻辑结构进行定义。这为程序员提供了一种抽象的方式来理解数据的组织方式,而不必考虑底层的存储实现。
-
操作和访问: 描述数据的结构和属性后,操作系统可以更有效地组织数据以支持高效的访问和操作。通过了解数据的特性,可以选择合适的数据结构和算法,提高数据的检索和处理效率。
-
数据关系和依赖: 描述数据的组织结构有助于理解数据之间的关系和依赖。这对于确保数据的一致性、完整性和有效性至关重要。
-
抽象和封装: 描述数据的管理使得数据能够被封装为一个独立的模块,这有助于实现数据的抽象和封装。程序员可以通过定义和组织数据来创建独立的数据结构,提高代码的模块化和可维护性。
通过先描述再组织,操作系统为程序员提供了一个清晰的数据管理框架,使得数据能够以更高层次的抽象进行处理,同时又能够在底层得到有效的组织和管理。这种方法提高了程序的可读性、可维护性,并有助于提高程序的性能。
库函数与系统调用的关系
从上图可以清晰的看到,库函数与系统调用时上下层的调用与被调用关系。
-
系统调用: 系统调用是操作系统提供给应用程序的接口,用于访问操作系统的底层服务和资源。系统调用允许应用程序请求操作系统执行特定的任务,如文件操作、进程管理、网络通信等。系统调用是用户空间与内核空间之间的接口,是应用程序与操作系统之间进行交互的桥梁。
-
库函数: 库函数是由程序员编写或由第三方提供的可重用代码块,它们通常封装了一组相关的功能,以供应用程序使用。库函数可以包含多个系统调用,但在用户空间运行。这些函数通常被组织成库,例如C标准库(libc)或其他第三方库。库函数提供了更高级别的接口,简化了程序员与底层系统调用的交互。
它们之间的关系:
-
库函数封装系统调用: 库函数通常封装了底层的系统调用,提供更友好和高级别的接口,使得程序员能够更轻松地使用这些功能而无需关心底层的实现细节。如常用的printf函数、scanf函数。
-
调用系统调用: 在库函数的内部,可能会调用一个或多个底层系统调用来完成特定的任务。这是库函数与操作系统之间的桥梁,通过系统调用来实现底层操作。
-
提高可移植性: 库函数的使用可以提高程序的可移植性,因为库函数提供了统一的接口,而不同的操作系统可以在底层采用不同的系统调用实现相同的功能。
总体而言,系统调用和库函数协同工作,使得应用程序能够与底层的操作系统进行交互,同时提供了高级别的抽象,简化了编程过程。
谈谈是什么是进程?以及进程相关的概念
一个正在运行的程序叫做进程。也可以认为一个加载到内存中的程序叫做进程。下面我以Linux系统为例,简单写一段小代码来演示。
下面我以另一种视角来谈谈是什么是进程。先说结论,进程=内核pcb数据结构对象+你的代码和数据。这是站在操作系统对于进程的管理的视角的得到结论。因为你在操作系统中不止一个进程再被运行,往往操作系统要同时运行多个进程。比如你正在刷视频,但是并不影响你收发微信消息。是因为你的操作系统同时在运行多个进程。那么操作系统就一定要对运行的进程进行管理。如何进行管理呢?当然是先描述,再组织。
如何描述进程?
描述进程本质是对进程的属性进行描述,因为我们人是通过属性来认识事物的。当属性足够多且具体时,我们就能够认识到所描述的对象。内核的 PCB(Process Control Block)数据结构是一个关键的数据结构,它用于描述一个进程的属性。每个运行在操作系统中的进程都有一个相应的 PCB。
PCB 中通常包含了以下信息:
-
进程状态(Process State): 表示进程当前的状态,例如运行、就绪、阻塞等。
-
程序计数器(Program Counter): 记录下一条即将执行的指令的地址。
-
寄存器集合(Register Set): 保存进程的寄存器内容,包括通用寄存器、栈指针等。
-
进程标识符(Process ID): 用于唯一标识一个进程。
-
父进程标识符(Parent Process ID): 用于唯一标识一个父进程。
-
进程优先级(Priority): 用于进程调度,决定哪个进程优先执行。
-
内存管理信息: 包括进程使用的内存区域、页表等。
-
文件描述符表(File Descriptor Table): 记录进程打开的文件以及相关的信息。
-
等待队列和信号信息: 用于实现进程同步与通信。
PCB 的存在使得操作系统能够有效地管理多个并发运行的进程,并在需要时保存和恢复它们的状态。这样,操作系统可以实现进程的调度、同步和通信,确保系统资源的合理分配和协调执行。
如何进行组织?
组织其实就是将多个操作系统中正在运行的进程的pcb数据结构对象进行增删查改操作。在pcb数据结构中加入相应的指针字段信息,就可以让多个pcb数据结构对象像链表一样链接起来,然后对这个pcb数据结构对象链表来进行组织。而你的代码和数据本质是存储在磁盘的可执行程序。
操作系统需要组织进程控制块(PCB)数据结构对象,因为PCB在操作系统中扮演着关键的角色,用于管理和跟踪每个进程的状态、资源使用情况以及执行信息。以下是一些原因:
-
进程管理: PCB包含了与进程相关的所有信息,如进程状态、程序计数器、寄存器值等。通过PCB,操作系统可以有效地管理和追踪多个并发运行的进程。
-
上下文切换: 当操作系统决定切换到另一个进程时(上下文切换),PCB中的信息被用来保存当前进程的状态,以便稍后能够恢复到该状态。这是多任务操作系统中实现多进程共享CPU时间的关键机制。
-
资源管理: PCB包含了进程使用的各种资源的信息,如内存分配情况、打开的文件、I/O设备状态等。这有助于操作系统有效地分配和释放资源,确保系统资源的合理利用。
-
进程调度: 操作系统使用PCB中的信息来确定哪个进程将获得CPU执行时间。通过比较进程的优先级、状态和其他调度相关的信息,操作系统可以智能地选择下一个执行的进程。
-
进程通信: PCB还包含了进程间通信所需的信息,如信号量、消息队列等。这有助于实现进程间的合作和数据交换。
总体而言,PCB是操作系统中用于维护和管理进程信息的核心数据结构,它使得操作系统能够高效、有序地协调和控制多个并发执行的进程。
简单介绍task_struct
task_struct是Linux操作系统描述PCB的一种内核数据结构。task_struct 中包含了很多的成员,这些成员共同构成了对进程的完整描述。Linux内核使用 task_struct 来跟踪和管理系统中运行的所有进程。在多任务操作系统中,每个运行的任务都有一个对应的 task_struct 结构。
Linux系统中当前工作目录
在上面的代码演示中,我是用了ps axj命令来查看当前Linux系统中正在运行的程序。下面,我再演示一个比较冷门的方式,即通过ls 查看当前系统的proc目录,来查看当前Linux系统正在运行的程序。
下面我通过刚刚的示例代码来进行验证,这些蓝色字体的数字其实是当前操作系统正在运行进程的pid。
下面我们查看一下这个目录文件的内容。
从上图可以看到,当前进程被运行,exe这个文件会链接可执行文件在当前操作系统中的的绝对路径,它用于找到这个可执行程序在系统的位置。而cwd文件即当前工作目录(current work dirctory),它用于指向当前进程所在的工作目录。这里我们可以和之前学习的一些关于文件操作的接口进行一些知识的链接。在学习c语言时,我们在使用文件操作接口总会提到一个概念,就是当不指定目录时,该文件会被创建在当前工作目录下。以及在指令学习中,我们常常说不指定路径的情况下,touch指令会在当前路径下创建文件。为什么实在当前工作目录下呢?是因为你的程序或者指令在执行后,变成了一个进程,而进程里面的cwd信息字段中存储了这个进程的cwd。所以,你在不指定路径的情况下,操作系统是会为你在当前工作路径下,进行对应的文件操作。
初始系统调用——getpid()和getppid()
用户想要获取对应的进程的pid和ppid,其本质是访问操作系统对应的内核数据结构对象task_struct的信息。需要借助系统调用接口来进行获取。
这里我就简单带大家看一下对应的man手册。
getpid()无参,返回值为pid_t,pit_t其实就是有符号整型值。它会返回该进程的pid。getppid()无参,返回值为pid_t,它会返回该进程的父进程的pid。下面简单用代码演示一下。
通过ps axj指令查看可以发现,我们可执行程序的父进程是bash命令行。这里就可以得到一个简单的结论。在bash命令行中输入指令执行我们写的程序,它会创建一个子进程来执行我们的代码。具体如何创建我们后续再谈。
通过系统调用创建一个进程——fork()函数
从上面的实验中我们可以看到,当我们在bash命令行中输入指令运行我们写的程序,操作系统会为这个程序创建一个进程。如果我想手动创建一个进程应该怎么做呢?需要用到一个系统调用fork()。下面简单看一下man手册。
fork()函数用于在代码层面上创建一个子进程。无参数,返回值为pid_t(其实就是有符号整数),它是一个系统调接口。如果调用成功它会创建一个子进程,并把子进程的pid返回给父进程,并返回0给进程。调用失败返回-1给父进程。相信看到这个接口,你一定会会有诸多的疑惑。为什么一个函数会返回两次?为什么给父进程返回子进程pid?下面一一为你解答。
先通过代码来观察fork函数如何创建子进程
下面我通过代码带大家先看看fork函数。
从上面的简单实验中可以看到,fork()后代码都会执行2次。为什么呢?
fork之后,子进程被创建出来,因为进程 = PCB内核数据结构+数据和代码。由于我们没有给子进程写入它对应的代码。操作系统会默认将父进程的代码通过写时拷贝,让子进程能够读取父进程的代码。
通过上面的实验可以看到,在fork调用函数后,子进程被创建。 id此时对于父进程而言是子进程pid,而对于子进程而言是0。所以,两份死循环代码被同时执行。那么为什么fork要被子进程返回0,给父进程返回子进程pid呢?可以这种视角来理解这样设计的目的,在生活中,一个人有多个子女。但是,就我们而言,只会有一个父亲。当父亲有多个子女时,就需要通过喊子女名字,让他的子女知道父亲喊得是他。所以,fork()给父进程返回子进程pid是为了让父进程能够区分它所创建的子进程,以便在未来执行不同地任务以及管理子进程。给子进程返回0更多是为了在使用接口时,让我们更好的区分子进程和父进程。
fork函数被调用时,操作系统做了些什么工作
当 fork()
函数在一个 Linux 中被调用时,操作系统执行以下步骤:
-
创建进程结构:操作系统为新的子进程分配一个进程控制块(PCB),这是一个数据结构,用于存储进程的重要信息,如进程状态、进程ID(PID)、程序计数器、寄存器集合、优先级、内存指针等。
-
拷贝父进程的状态:子进程是父进程的副本。操作系统拷贝父进程的地址空间,包括代码、数据、堆和栈到子进程。但实际上,现代系统使用写时拷贝技术,这意味着初始时只拷贝页表项,而物理内存页在写入数据时才会被拷贝。
-
分配唯一的PID:子进程获得一个唯一的进程ID。
-
设置返回值:在父进程中,
fork()
返回子进程的PID;在子进程中,fork()
返回0。这是区分父子进程的关键。 -
调整进程计数器:确保子进程从
fork()
调用之后的下一条指令开始执行。 -
复制文件描述符:子进程复制父进程的所有文件描述符。如果父进程打开了文件,则子进程也将拥有这些文件的副本,且文件的当前读写位置也被共享。
-
处理资源限制和统计:复制与进程相关的资源限制和使用统计。
-
调度子进程运行:子进程被标记为可运行状态,等待操作系统调度器进行调度。
这个过程是创建新进程的基础,使得在 Linux系统中,进程创建和管理变得既高效又灵活。由于写时拷贝技术的使用,fork()
变得更加高效,因为它避免了不必要的数据拷贝,只有在父进程或子进程试图修改数据时才进行拷贝。
为什么fork()之后要让父子进程代码共享?以及是如何做到的?
站在 Linux 内核的视角,fork() 函数调用后父子进程共享代码的原因主要是基于效率和性能的考虑:
1. 写时拷贝:Linux 使用写时拷贝技术来实现 fork()。在这种机制下,当 fork() 被调用时,子进程最初并不拷贝父进程的整个内存空间。相反,它仅拷贝内存页表项,并且父子进程共享相同的物理内存页。
2. 内存效率:这种方法极大地提高了内存的使用效率。如果父进程和子进程只是读取相同的数据(例如代码段),它们可以安全地共享同一物理内存页,而不需要额外的内存空间。
3. 性能优化:共享代码和其他只读资源减少了 fork() 操作的开销。这对于性能至关重要,因为在创建新进程时复制整个进程的内存空间会非常耗时和资源密集。
4. 修改行为:当父进程或子进程尝试写入共享的内存页时,内核会进行所谓的“页复制”,此时为写入操作的进程创建一个新的内存页副本。这保证了修改不会影响到另一个进程,维持了进程间的隔离。
5. 保护进程空间:此方法还确保了进程空间的完整性和安全性。由于每个进程都有其独立的地址空间,因此即使共享了代码和数据,一个进程的崩溃或不当行为也不会直接影响到其他进程。
总之,fork() 后父子进程代码共享的设计是 Linux 内核追求高效内存管理和系统性能的一个体现,同时确保了进程间的独立性和安全性。
那么操作系统是如何做到的父子代码共享呢?下面以上面所介绍的进程的概念,来简单理解一下操作系统是如何做到父子进程代码共享的。从进程的视角来看,fork()
实现父子进程代码共享的过程可以这样理解:
-
初始状态:
- 当父进程调用
fork()
时,它已经有一段代码在内存中,即其代码段。这些代码是存储在进程的虚拟内存空间中的。
- 当父进程调用
-
调用
fork()
:fork()
被调用时,操作系统开始创建一个子进程。但这并不意味着立即在物理内存中复制父进程的所有数据。
-
共享虚拟内存:
- 在创建子进程的过程中,Linux 内核利用虚拟内存系统,让子进程的虚拟内存页表指向父进程的相同物理内存页,尤其是对于代码段和其他只读数据。
- 这意味着,尽管父进程和子进程有各自独立的虚拟内存空间,但它们的代码段实际上指向相同的物理内存地址。
-
写时拷贝 (Copy-On-Write, COW):
- 当父进程或子进程尝试修改这些共享的内存页(例如修改一个全局变量)时,操作系统的内存管理机制会介入。
- 此时,操作系统会为进行写入操作的进程创建这个内存页的一个副本。这个新的副本会被修改,而原始的物理页面保持不变,并继续被另一个进程共享。
-
结果:
- 通过这种方式,
fork()
在创建子进程时,并不需要复制代码段到新的物理位置。父子进程继续共享相同的代码段,直到它们中的一个尝试修改共享的内存。 - 这种机制大大提高了内存利用率,并减少了
fork()
操作的开销。
- 通过这种方式,
总的来说,从进程的视角看,fork()
通过写时复制(COW)机制和虚拟内存管理,使得父子进程能够高效地共享代码,同时保持了各自的独立性和安全性。
如何理解一个函数会返回两次呢?
我们需要理解,fork()也是一个函数。所以它会有它对应的实现方法。
修正:上图的3中的带啊为代码。
如何理解fork()之后一个变量会有两个值呢?
因为进程具有独立性,若如果子进程不修改数据,那么它默认的数据的就是父进程的数据。如果修改数据,就会产生写实拷贝,操作系统会另外开辟物理空间存储子进程的数据.所以,当fork()调用后,给子进程返回的是-1,给父进程返回的是子进程的pid。对应的两个进程的数据被修改,进而产生了子进程的写实拷贝。当访问这个变量是,不同进程看到的内容就不一样了。
进程状态
对于进程状态,主要以两个维度来进行介绍。操作系统学科对于进程状态的概念以及Linux操作系统下进程的概念。
操作系统学科对于进程状态的概念
主要介绍一下运行、阻塞以及挂起的概念
运行状态的介绍
什么是运行状态呢?我是这样理解的,由于CPU是属于比较稀缺的资源。相信在座的各位的电脑只有一个CPU吧。就单从一个CPU的视角看,操作系统上的进程都要在CPU跑。这也意味着操作系统需要管理这些被调度的进程,所以注定了对应的进程要被操作系统上的某种特定的数据结构组织起来。即运行队列(runQueue)。
排队其实就是你的PCB数据结构在运行队列里排队。你的代码和数据不需要参与排队,因为没有意义。如果一个进程正在CPU中被运行,它是不是要到运行完后才会出队列呢?肯定不是这样的。常识告诉我们,当我们在VS上写了一个死循环时,我们对应的音乐软件会因为死循环就崩了吗?答案是不会的。因为每个进程访问CPU的时间都是操作系统设置好的,在一定的时间片内进程才能使用CPU资源。这也说明在CPU上会有大量的将进程放上去,拿下来的动作,这些动作我们称之为进程切换。最后,在简单提及一个概念,即在特定的时间段内,当前跑在操作系统上的进程以及它们的代码都会被操作系统并发执行。
阻塞状态的介绍
进程由于等待某些事件(如 I/O 完成)而无法继续执行。在这个状态下,即使有可用的 CPU 时间,进程也不会执行。假设我们的进程正在执行读取键盘文件的命令,在我们输入回车前,它都会处于等待我们输入数据的状态,这种状态就是阻塞状态。因为,此时进程需要读取我们在键盘上输入的数据。所以不会被CPU给调度。因为,它处于对应的等待队列中(waitqueue)。
挂起状态的介绍
挂起状态通常是在计算机内存资源不足时才会有的状态。当内存资源不足时,操作系统为了保证自身正常的运行,会将进程的数据和代码交换到磁盘(外设)的交换分区以节省出内存资源。在Linux中这个交换分区即swap分区,而windows为temp分区。
Linux系统的进程状态的介绍
进程状态在kernel源代码里定义
/*
* 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 */
};
R状态即运行状态。它表示一个进程正在运行。下面用一份简单的代码带大家看一看
为什么明明./运行的进程正在屏幕上输出内容,而使用ps axj命令查看时,却是S状态呢?首先,S状态表示浅度睡眠状态。它是Linux系统中进程阻塞状态的一种。这里运行着的程序进程状态却是S状态是因为由于CPU和外设的运行时间单位有10的6次方级别的代差。所以,大部分的时间进程都在等待显示器响应,每一次输出hello这一时刻,进程是R状态。
D状态是一种深度睡眠状态。它用于表示该进程无法被杀死。它也是操作系统学科中阻塞状态的一种实现方式。为什么会有D状态?当一个进程正在向磁盘写入数据时,由于CPU和磁盘间的性能代差。当进程下达写入的指令后,不可避免的需要等待磁盘写入,待磁盘写入完成后,进程在交付给上层。若是在极端情况下,当前进程被杀死,那等磁盘写入完毕之后,数据大概率是要被丢弃。如果数据非常重要势必造成极大损失。为了避免这类情况出现,所以在设计Linux系统时,引入了D状态。
T状态代表进程已被停止。这通常是因为进程接收到了停止信号(如SIGSTOP)。处于停止状态的进程不会占用CPU时间,直到它接收到一个继续信号(如SIGCONT)。
X状态表示进程已死亡。通常操作系统会将X状态的进程添加到垃圾回收队列中定期清理。
Z状态即僵尸状态。用于表示描述一个进程已经终止,但是它的父进程或关心这个进程的进程还没获取它的描述信息,所以此时它正处于Z状态。下面我用一份代码简单演示一下。
从事例中可以看到,当进程退出的时候,如果父进程没有主动回收子进程的话。子进程会一直保持Z状态以保证自己的task_struct关键信息字段不会被系统释放。同理,当操作系统中有许多的进程都是Z状态就会导致内存泄漏问题。所以,不仅仅在C/C++语言中malloc会导致内存泄漏,进程在Z状态没有及时回收也会导致内存泄漏问题。
下面介绍一个根僵尸进程相关的概念。如果父进程已经退出,但是子进程还在执行。操作系统为了能够有人能对子进程进行回收释放,会将子进程进行领养。通常是1号进程进行领养。 为什么需要由OS进程进行领养呢?本质是更好地对孤儿进程更好地管理。OS需要保证孤儿进程在结束时,它的资源正常的释放。不仅如此,OS还需要对它的一些字段信息进行监视和维护。若孤儿进程异常,如死循环、占用过多系统资源时,OS为了自身稳定的运行,需要对他进行有效的管理措施如杀死它。
进程的优先级
简单提一下优先级的概念。首先,优先级和权限两者的区别是,权限决定能不能做,而优先级是在能做的基础上排先后顺序。就好比学生下课去食堂吃饭,权限决定了你在饭点去吃饭。而吃饭顺序的谁先谁后是由你打饭排队时处于的位置决定的。
为什么要有优先级?
相信大家的电脑都是一个CPU,那么CPU资源是相对有限的。而操作系统上的进程是多个的。所以,进程之间是竞争关系的。但是,操作系统为了稳定的运行,它要求进程之间的竞争关系是良性竞争关系。因此,进程之间需要确认各自的优先级。
关于良性竞争引申一个话题。什么是进程不良竞争?那就是在多进程运行环境下,由于某个进程占据CPU时间片过长,导致当前运行的进程的代码无法得到推行。就像大家在使用电脑时,偶尔会出现软件未响应的情况。这就是进程的饥饿问题。
优先级具体是怎么做?
我们可以用ps -al命令查看当前用户所启动的进程的关键信息。
PRI表示当前进程的优先级。该值越小表示权限越高,越早被调度执行。默认的nice值是80,它的 取值范围是[60,99]。
NI表示nice值,它用于调整进程的PRI值,已达到控制进程的优先级。取值范围是[-20,19]。
下面简单演示一下使用top命令修改nice值以达到修改进程优先级的目的。
首先运行一个进程
查看一下当前进程的信息
更改进程优先级需要root身份权限,切换root后再top命令中,按r调整nice值。
查看一下结果
通过上面实验可以发现,nice值的最大作用范围是19。虽然设置了30但是不会生效。同理最小nice值也只会是-20。
优先级的调整公式为 PRI(new) = PRI(old) + nice。而这里的PRI(old)永远都是80。也就是说每一次调整nice值都是从PRI = 80开始的。
Linux内核2.6的O(1)调度算法的介绍
上面介绍了进程优先级的概念,那具体Linux操作系统是如何对于进程进行调度的呢?既然要调度就要有描述进程优先级的数据结构,在Linux内核里会维护一个运行队列。它由如下几个成员
struct bitmap
{
char[5] _b; // 映射40个比特位
};
struct RunningQueue
{
bitmap _bm; // 用于快速判断当前优先级所处的桶是否为空
stask_struct **run; // 被调度的进程的PCB的地址
stask_struct **wait;// 指向等待调度的哈希桶的第一个进程
stask_struct[140] *running; // 维护运行的哈希桶
stask_struct[140] *waiting; // 维护等待的哈希桶
//其中[0,99]下标映射的桶是给别的类型的进程使用的
//[100, 139]分别映射pri值60-99,上面挂载的是对应pri值进程的PCB
};
当CPU开始调度进程时,由于哈希映射是根据pri大小来进行映射的,较小的pri值会在哈希桶中下标较小处,因此优先级较高的进程会被OS优先调度。每次调度时直接取run指针指向的PCB块即可,然后让run指针指向下一个被调度的进程PCB,直至遍历完整个哈希桶。
当OS正在调度运行队列时,此时新启动的进程都会被插入到一个等待的哈希桶去。当运行队列的哈希桶遍历结束后,交换swap(&run, &wait),让等待队列中的哈希桶成为运行队列的哈希桶被OS调度。这就是Linux内核2.6版本的大O(1)调度算法。