查看原文
其他

刨根问底 100 个 操作系统 面试题,让 大厂 面试官 震惊到 怀疑人生 !!

在文末获取完整版pdf

操作系统基础篇

在信息化时代,软件被称为计算机系统的灵魂。而作为软件核心的操作系统,已经与现代计算机系统密不可分、融为一体。计算机系统自下而上可粗分为四个部分:硬件、操作系统、应用程序和用户。操作系统管理各种计算机硬件,为应用程序提供基础,并充当计算机硬件和用户的中介。

硬件,如中央处理器、内存、输入输出设备等,提供了基本的计算资源。应用程序,如字处理程序、电子制表软件、编译器、网络浏览器等,规定了按何种方式使用这些资源来解决用户的计算问题。操作系统控制和协调各用户的应用程序对硬件的使用。

综上所述,操作系统是指控制和管理整个计算机系统的硬件和软件资源,并合理的组织调度计算机的工作和资源的分配,以提供给用户和其他软件方便的接口和环境集合。计算机操作系统是随着计算机研究和应用的发展逐步形成并发展起来的,它是计算机系统中最基本的系统软件。

操作系统的特征

操作系统是一种系统软件,但与其他的系统软件和应用软件有很大的不同,他有自己的特殊性即基本特征,操作系统的基本特征包括并发、共享、虚拟和异步。这些概念对理解和掌握操作系统的核心至关重要。

并发

并发是指两个或多个事件在同一时间间隔内发生,在多道程序环境下,一段时间内宏观上有多个程序在同时执行,而在同一时刻,单处理器环境下实际上只有一个程序在执行,故微观上这些程序还是在分时的交替进行。操作系统的并发是通过分时得以实现的。操作系统的并发性是指计算机系统中同时存在多个运行着的程序,因此它具有处理和调度多个程序同时执行的能力。在操作系统中,引入进程的目的实施程序能并发执行。

共享

资源共享即共享,是指系统中的资源可供内存中多个并发执行的进程共同使用。共享可以分为以下两种资源共享方式。

互斥共享方式

系统中的某些资源,,如打印机、磁带机,虽然他们可以提供给多个进程使用,但为使所打印的内容不致造成混淆,应规定在同一段时间内只允许一个进程方位该资源。

为此,当进程a访问某资源时,必须先提出请求,如果此时该资源空闲,系统便可将之分配给进程a使用,伺候若再有其他进程也要访问该资源(只要a未用完)则必须等待。仅当进程a访问完并释放该资源后,才允许另一进城对该资源进行访问。计算机系统中的大所属物理设备,以及某些软件中所用的栈、变量和表格,都属于临界资源,他们都要求被互斥的共享。

同时访问方式

系统中还有一种资源,允许在一段时间内由多个进程“同时”对它进行访问。这里所谓的“同时”往往是宏观上的,而在微观上,这些进程可能是交替的对该资源进行访问即“分时共享”。典型的可供多个进程同时访问的资源是磁盘设备,一些用重入码编写的文件也可以被 “同时” 共享,即若干个用户同时访问该文件。

并发和共享是操作系统两个最基本的特征,这两者之间又是互为存在条件的:1资源共享是以程序的并发为条件的,若系统不允许程序并发执行,则自然不存在资源共享的问题;2若系统不能对资源共享实施有效地管理,也必将影响到程序的并发执行,甚至根本无法并发执行。

虚拟

虚拟是指把一个物理上的实体变为若干个逻辑上的对应物。物理实体是实的,即实际存在的;而后者是虚的,是用户感觉上的事物。相应的,用于实现虚拟的技术,成为虚拟技术。在操作系统中利用了多种虚拟技术,分别用来实现虚拟处理器、虚拟内存和虚拟外部设备。

在虚拟处理器技术中,是通过多道程序设计技术,让多道程序并发执行的方法,来分时使用一台处理器的。此时,虽然只有一台处理器,但他能同时为多个用户服务,是每个终端用户都认为是有一个中央处理器在为他服务。利用多道程序设计技术,把一台物理上的 CPU 虚拟为多台逻辑上的 CPU,称为虚拟处理器。

类似的,可以通过虚拟存储器技术,将一台机器的物理存储器变为虚拟存储器,一边从逻辑上来扩充存储器的容量。当然, 这是用户所感觉到的内存容量是虚的,我们把用户所发哦绝倒的存储器程序虚拟存储器。

还可以通过虚拟设备技术,将一台物理IO设备虚拟为多台逻辑上的IO设备,并允许每个用户占用一台逻辑上的 IO 设备,这样便可使原来仅允许在一段时间内有一个用户访问的设备,变为在一段时间内允许多个用户同时访问的共享设备。

因此操作系统的虚拟技术可归纳为:时分复用技术和空分复用技术。

异步

在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。

异步性使得操作系统运行在一种随机的环境下,可能导致进程产生于时间有关的错误。但是只要运行环境相同,操作系统必须保证多次运行进程,都获得相同的结果。

操作系统五大功能

一般来说,操作系统可以分为五大管理功能部分:

  1. 设备管理:主要是负责内核与外围设备的数据交互,实质是对硬件设备的管理,包括对输入输出设备的分配,初始化,维护与回收等。例如管理音频输入输出。
  2. 作业管理:这部分功能主要是负责人机交互,图形界面或者系统任务的管理。
  3. 文件管理:这部分功能涉及文件的逻辑组织和物理组织,目录结构和管理等。从操作系统的角度来看,文件系统是系统对文件存储器的存储空间进行分配,维护和回收,同时负责文件的索引,共享和权限保护。而从用户的角度来说,文件系统是按照文件目录和文件名来进行存取的。
  4. 进程管理:说明一个进程存在的唯一标志是 pcb(进程控制块),负责维护进程的信息和状态。进程管理实质上是系统采取某些进程调度算法来使处理合理的分配给每个任务使用。
  5. 存储管理:数据的存储方式和组织结构。

操作系统分类

操作系统的类型也可以分为几种:批处理系统,分时操作系统,实时操作系统,网络操作系统等。下面将简单的介绍他们各自的特点:

  1. 批处理系统:首先,用户提交完作业后并在获得结果之前不会再与操作系统进行数据交互,用户提交的作业由系统外存储存为后备作业;数据是成批处理的,有操作系统负责作业的自动完成;支持多道程序运行。
  2. 分时操作系统:首先交互性方面,用户可以对程序动态运行时对其加以控制;支持多个用户登录终端,并且每个用户共享CPU和其他系统资源。
  3. 实时操作系统:会有时钟管理,包括定时处理和延迟处理。实时性要求比较高,某些任务必须优先处理,而有些任务则会被延迟调度完成。
  4. 网络操作系统:网络操作系统主要有几种基本功能
    1. 网络通信:负责在源主机与目标主机之间的数据的可靠通信,这是最基本的功能。
    2. 网络服务:系统支持一些电子邮件服务,文件传输,数据共享,设备共享等。
    3. 资源管理:对网络中共享的资源进行管理,例如设置权限以保证数据源的安全性。
    4. 网络管理:主要任务是实现安全管理,例如通过“存取控制”来确保数据的存取安全性,通过“容错性”来保障服务器故障时数据的安全性。
    5. 支持交互操作:在客户/服务器模型的LAN环境下,多种客户机和主机不仅能与服务器进行数据连接通信,并且可以访问服务器的文件系统。

聊聊:什么是操作系统

操作系统是管理硬件和软件的一种应用程序。操作系统是运行在计算机上最重要的一种软件,它管理计算机的资源和进程以及所有的硬件和软件。它为计算机硬件和软件提供了一种中间层,使应用软件和硬件进行分离,让我们无需关注硬件的实现,把关注点更多放在软件应用上。

通常情况下,计算机上会运行着许多应用程序,它们都需要对内存和 CPU 进行交互,操作系统的目的就是为了保证这些访问和交互能够准确无误的进行。

聊聊:操作系统的主要功能

一般来说,现代操作系统主要提供下面几种功能

  • 进程管理: 进程管理的主要作用就是任务调度,在单核处理器下,操作系统会为每个进程分配一个任务,进程管理的工作十分简单;而在多核处理器下,操作系统除了要为进程分配任务外,还要解决处理器的调度、分配和回收等问题
  • 内存管理:内存管理主要是操作系统负责管理内存的分配、回收,在进程需要时分配内存以及在进程完成时回收内存,协调内存资源,通过合理的页面置换算法进行页面的换入换出
  • 设备管理:根据确定的设备分配原则对设备进行分配,使设备与主机能够并行工作,为用户提供良好的设备使用界面。
  • 文件管理:有效地管理文件的存储空间,合理地组织和管理文件系统,为文件访问和文件保护提供更有效的方法及手段。
  • 提供用户接口:操作系统提供了访问应用程序和硬件的接口,使用户能够通过应用程序发起系统调用从而操纵硬件,实现想要的功能。

聊聊:软件访问硬件的几种方式

软件访问硬件其实就是一种 IO 操作,软件访问硬件的方式,也就是 I/O 操作的方式有哪些。

硬件在 I/O 上大致分为并行和串行,同时也对应串行接口和并行接口。

随着计算机技术的发展,I/O 控制方式也在不断发展。选择和衡量 I/O 控制方式有如下三条原则

(1) 数据传送速度足够快,能满足用户的需求但又不丢失数据;

(2) 系统开销小,所需的处理控制程序少;

(3) 能充分发挥硬件资源的能力,使 I/O 设备尽可能忙,而 CPU 等待时间尽可能少。

根据以上控制原则,I/O 操作可以分为四类

  • 直接访问:直接访问由用户进程直接控制主存或 CPU 和外围设备之间的信息传送。直接程序控制方式又称为忙/等待方式。
  • 中断驱动:为了减少程序直接控制方式下 CPU 的等待时间以及提高系统的并行程度,系统引入了中断机制。中断机制引入后,外围设备仅当操作正常结束或异常结束时才向 CPU 发出中断请求。在 I/O 设备输入每个数据的过程中,由于无需 CPU 的干预,一定程度上实现了 CPU 与 I/O 设备的并行工作。

上述两种方法的特点都是以 CPU 为中心,数据传送通过一段程序来实现,软件的传送手段限制了数据传送的速度。接下来介绍的这两种 I/O 控制方式采用硬件的方法来显示 I/O 的控制

  • DMA 直接内存访问:为了进一步减少 CPU 对 I/O 操作的干预,防止因并行操作设备过多使 CPU 来不及处理或因速度不匹配而造成的数据丢失现象,引入了 DMA 控制方式。
  • 通道控制方式:通道,独立于 CPU 的专门负责输入输出控制的处理机,它控制设备与内存直接进行数据交换。有自己的通道指令,这些指令由 CPU 启动,并在操作结束时向 CPU 发出中断信号。

聊聊:操作系统的主要目的是什么

操作系统是一种软件,它的主要目的有三种

  • 管理计算机资源,这些资源包括 CPU、内存、磁盘驱动器、打印机等。
  • 提供一种图形界面,就像我们前面描述的那样,它提供了用户和计算机之间的桥梁。
  • 为其他软件提供服务,操作系统与软件进行交互,以便为其分配运行所需的任何必要资源。

聊聊:操作系统的种类有哪些

操作系统通常预装在你购买计算机之前。大部分用户都会使用默认的操作系统,但是你也可以升级甚至更改操作系统。但是一般常见的操作系统只有三种:Windows、macOS 和 Linux

聊聊:为什么 Linux 系统下的应用程序不能直接在 Windows 下运行

这是一个老生常谈的问题了,在这里给出具体的回答。

其中一点是因为 Linux 系统和 Windows 系统的格式不同,格式就是协议,就是在固定位置有意义的数据。Linux 下的可执行程序文件格式是 elf,可以使用 readelf 命令查看 elf 文件头。

而 Windows 下的可执行程序是 PE 格式,它是一种可移植的可执行文件。

还有一点是因为 Linux 系统和 Windows 系统的 API 不同,这个 API 指的就是操作系统的 API,Linux 中的 API 被称为系统调用,是通过 int 0x80 这个软中断实现的。而 Windows 中的 API 是放在动态链接库文件中的,也就是 Windows 开发人员所说的 DLL ,这是一个库,里面包含代码和数据。Linux 中的可执行程序获得系统资源的方法和 Windows 不一样,所以显然是不能在 Windows 中运行的。

聊聊:操作系统结构

单体系统

在大多数系统中,整个系统在内核态以单一程序的方式运行。整个操作系统是以程序集合来编写的,链接在一块形成一个大的二进制可执行程序,这种系统称为单体系统。

在单体系统中构造实际目标程序时,会首先编译所有单个过程(或包含这些过程的文件),然后使用系统链接器将它们全部绑定到一个可执行文件中

在单体系统中,对于每个系统调用都会有一个服务程序来保障和运行。需要一组实用程序来弥补服务程序需要的功能,例如从用户程序中获取数据。可将各种过程划分为一个三层模型

除了在计算机初启动时所装载的核心操作系统外,许多操作系统还支持额外的扩展。比如 I/O 设备驱动和文件系统。这些部件可以按需装载。在 UNIX 中把它们叫做 共享库(shared library),在 Windows 中则被称为 动态链接库(Dynamic Link Library,DLL)。他们的扩展名为 .dll,在 C:\Windows\system32 目录下存在 1000 多个 DLL 文件,所以不要轻易删除 C 盘文件,否则可能就炸了哦。

分层系统

分层系统使用层来分隔不同的功能单元。每一层只与该层的上层和下层通信。每一层都使用下面的层来执行其功能。层之间的通信通过预定义的固定接口通信。

微内核

为了实现高可靠性,将操作系统划分成小的、层级之间能够更好定义的模块是很有必要的,只有一个模块 — 微内核 — 运行在内核态,其余模块可以作为普通用户进程运行。由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使整个系统死机。

MINIX 3 是微内核的代表作,它的具体结构如下

在内核的外部,系统的构造有三层,它们都在用户态下运行,最底层是设备驱动器。由于它们都在用户态下运行,所以不能物理的访问 I/O 端口空间,也不能直接发出 I/O 命令。相反,为了能够对 I/O 设备编程,驱动器构建一个结构,指明哪个参数值写到哪个 I/O 端口,并声称一个内核调用,这样就完成了一次调用过程。

客户-服务器模式

微内核思想的策略是把进程划分为两类:服务器,每个服务器用来提供服务;客户端,使用这些服务。这个模式就是所谓的 客户-服务器模式。

客户-服务器模式会有两种载体,一种情况是一台计算机既是客户又是服务器,在这种方式下,操作系统会有某种优化;但是普遍情况下是客户端和服务器在不同的机器上,它们通过局域网或广域网连接。

客户通过发送消息与服务器通信,客户端并不需要知道这些消息是在本地机器上处理,还是通过网络被送到远程机器上处理。对于客户端而言,这两种情形是一样的:都是发送请求并得到回应。

聊聊:为什么称为陷入内核

如果把软件结构进行分层说明的话,应该是这个样子的,最外层是应用程序,里面是操作系统内核。

应用程序处于特权级 3,操作系统内核处于特权级 0 。如果用户程序想要访问操作系统资源时,会发起系统调用,陷入内核,这样 CPU 就进入了内核态,执行内核代码。至于为什么是陷入,我们看图,内核是一个凹陷的构造,有陷下去的感觉,所以称为陷入。

聊聊:什么是用户态和内核态

用户态和内核态是操作系统的两种运行状态。

  • 内核态:处于内核态的 CPU 可以访问任意的数据,包括外围设备,比如网卡、硬盘等,处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况,一般处于特权级 0 的状态我们称之为内核态。
  • 用户态:处于用户态的 CPU 只能受限的访问内存,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。

那么为什么要有用户态和内核态呢?

这个主要是访问能力的限制的考量,计算机中有一些比较危险的操作,比如设置时钟、内存清理,这些都需要在内核态下完成,如果随意进行这些操作,那你的系统得崩溃多少次。

聊聊:用户态和内核态是如何切换的?

所有的用户进程都是运行在用户态的,但是我们上面也说了,用户程序的访问能力有限,一些比较重要的比如从硬盘读取数据,从键盘获取数据的操作则是内核态才能做的事情,而这些数据却又对用户程序来说非常重要。所以就涉及到两种模式下的转换,即用户态 -> 内核态 -> 用户态,而唯一能够做这些操作的只有 系统调用,而能够执行系统调用的就只有 操作系统

一般用户态 -> 内核态的转换我们都称之为 trap 进内核,也被称之为 陷阱指令(trap instruction)

他们的工作流程如下:

  • 首先用户程序会调用 glibc 库,glibc 是一个标准库,同时也是一套核心库,库中定义了很多关键 API。
  • glibc 库知道针对不同体系结构调用系统调用的正确方法,它会根据体系结构应用程序的二进制接口设置用户进程传递的参数,来准备系统调用。
  • 然后,glibc 库调用软件中断指令(SWI) ,这个指令通过更新 CPSR 寄存器将模式改为超级用户模式,然后跳转到地址 0x08 处。
  • 到目前为止,整个过程仍处于用户态下,在执行 SWI 指令后,允许进程执行内核代码,MMU 现在允许内核虚拟内存访问
  • 从地址 0x08 开始,进程执行加载并跳转到中断处理程序,这个程序就是 ARM 中的 vector_swi()
  • 在 vector_swi() 处,从 SWI 指令中提取系统调用号 SCNO,然后使用 SCNO 作为系统调用表 sys_call_table 的索引,调转到系统调用函数。
  • 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行。

聊聊:什么是内核

在计算机中,内核是一个计算机程序,它是操作系统的核心,可以控制操作系统中所有的内容。内核通常是在 boot loader 装载程序之前加载的第一个程序。

这里还需要了解一下什么是 boot loader

boot loader 又被称为引导加载程序,能够将计算机的操作系统放入内存中。在电源通电或者计算机重启时,BIOS 会执行一些初始测试,然后将控制权转移到引导加载程序所在的主引导记录(MBR)

聊聊:什么是实时系统

实时操作系统对时间做出了严格的要求,实时操作系统分为两种:硬实时和软实时

硬实时操作系统规定某个动作必须在规定的时刻内完成或发生,比如汽车生产车间,焊接机器必须在某一时刻内完成焊接,焊接的太早或者太晚都会对汽车造成永久性伤害。

软实时操作系统虽然不希望偶尔违反最终的时限要求,但是仍然可以接受。并且不会引起任何永久性伤害。比如数字音频、多媒体、手机都是属于软实时操作系统。

你可以简单理解硬实时和软实时的两个指标:是否在时刻内必须完成以及是否造成严重损害

聊聊:Linux 操作系统的启动过程

当计算机电源通电后,BIOS会进行开机自检(Power-On-Self-Test, POST),对硬件进行检测和初始化。因为操作系统的启动会使用到磁盘、屏幕、键盘、鼠标等设备。

下一步,磁盘中的第一个分区,也被称为 MBR(Master Boot Record) 主引导记录,被读入到一个固定的内存区域并执行。这个分区中有一个非常小的,只有 512 字节的程序。程序从磁盘中调入 boot 独立程序,boot 程序将自身复制到高位地址的内存从而为操作系统释放低位地址的内存。

复制完成后,boot 程序读取启动设备的根目录。boot 程序要理解文件系统和目录格式。然后 boot 程序被调入内核,把控制权移交给内核。直到这里,boot 完成了它的工作。系统内核开始运行。

内核启动代码是使用汇编语言完成的,主要包括创建内核堆栈、识别 CPU 类型、计算内存、禁用中断、启动内存管理单元等,然后调用 C 语言的 main 函数执行操作系统部分。

这部分也会做很多事情,首先会分配一个消息缓冲区来存放调试出现的问题,调试信息会写入缓冲区。如果调试出现错误,这些信息可以通过诊断程序调出来。

然后操作系统会进行自动配置,检测设备,加载配置文件,被检测设备如果做出响应,就会被添加到已链接的设备表中,如果没有相应,就归为未连接直接忽略。

配置完所有硬件后,接下来要做的就是仔细手工处理进程0,设置其堆栈,然后运行它,执行初始化、配置时钟、挂载文件系统。创建 init 进程(进程 1 )守护进程(进程 2)

init 进程会检测它的标志以确定它是否为单用户还是多用户服务。在前一种情况中,它会调用 fork 函数创建一个 shell 进程,并且等待这个进程结束。后一种情况调用 fork 函数创建一个运行系统初始化的 shell 脚本(即 /etc/rc)的进程,这个进程可以进行文件系统一致性检测、挂载文件系统、开启守护进程等。

然后 /etc/rc 这个进程会从 /etc/ttys 中读取数据,/etc/ttys 列出了所有的终端和属性。对于每一个启用的终端,这个进程调用 fork 函数创建一个自身的副本,进行内部处理并运行一个名为 getty 的程序。

getty 程序会在终端上输入

login:

等待用户输入用户名,在输入用户名后,getty 程序结束,登陆程序 /bin/login 开始运行。login 程序需要输入密码,并与保存在 /etc/passwd 中的密码进行对比,如果输入正确,login 程序以用户 shell 程序替换自身,等待第一个命令。如果不正确,login 程序要求输入另一个用户名。

整个系统启动过程如下

进程线程协程篇

系统调度

在未配置 OS 的系统中,程序的执行方式是顺序执行,即必须在一个程序执行完后,才允许另一个程序执行;在多道程序环境下,则允许多个程序并发执行。程序的这两种执行方式间有着显著的不同。也正是程序并发执行时的这种特征,才导致了在操作系统中引入进程的概念。进程是资源分配的基本单位,线程是资源调度的基本单位。

应用启动体现的就是静态指令加载进内存,进而进入 CPU 运算,操作系统在内存开辟了一段栈内存用来存放指令和变量值,从而形成了进程。早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址。由于进程的上下文关联的变量,引用,计数器等现场数据占用了打段的内存空间,所以频繁切换进程需要整理一大段内存空间来保存未执行完的进程现场,等下次轮到 CPU 时间片再恢复现场进行运算。

这样既耗费时间又浪费空间,所以我们才要研究多线程。一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的 “任务切换” 都是指 “线程切换”。

进程详解

进程是操作系统对一个正在运行的程序的一种抽象,在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。所谓的并发运行,则是说一个进程的指令和另一个进程的指令是交错执行的。无论是在单核还是多核系统中,可以通过处理器在进程间切换,来实现单个 CPU 看上去像是在并发地执行多个进程。操作系统实现这种交错执行的机制称为上下文切换。

操作系统保持跟踪进程运行所需的所有状态信息。这种状态,也就是上下文,它包括许多信息,例如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从上次停止的地方开始。

操作系统为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的是一致的存储器,称为虚拟地址空间。其虚拟地址空间最上面的区域是为操作系统中的代码和数据保留的,这对所有进程来说都是一样的;地址空间的底部区域存放用户进程定义的代码和数据。

程序代码和数据:对于所有的进程来说,代码是从同一固定地址开始,直接按照可执行目标文件的内容初始化。

堆:代码和数据区后紧随着的是运行时堆。代码和数据区是在进程一开始运行时就被规定了大小,与此不同,当调用如 malloc 和 free 这样的 C 语言 标准库函数时,堆可以在运行时动态地扩展和收缩。

共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样共享库的代码和数据的区域。

栈:位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。

内核虚拟存储器:内核总是驻留在内存中,是操作系统的一部分。地址空间顶部的区域是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。

线程详解

在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。进程的个体间是完全独立的,而线程间是彼此依存的。多进程环境中,任何一个进程的终止,不会影响到其他进程。而多线程环境中,父线程终止,全部子线程被迫终止(没有了资源)。

而任何一个子线程终止一般不会影响其他线程,除非子线程执行了 exit() 系统调用。任何一个子线程执行 exit(),全部线程同时灭亡。多线程程序中至少有一个主线程,而这个主线程其实就是有 main 函数的进程。它是整个程序的进程,所有线程都是它的子线程;我们通常把具有多线程的主进程称之为主线程。

线程共享的环境包括:进程代码段、进程的公有数据、进程打开的文件描述符、信号的处理器、进程的当前目录、进程用户 ID 与进程组 ID 等,利用这些共享的数据,线程很容易的实现相互之间的通讯。线程拥有这许多共性的同时,还拥有自己的个性,并以此实现并发性:

线程 ID:每个线程都有自己的线程 ID,这个 ID 在本进程中是唯一的。进程用此来标识线程。

寄存器组的值:由于线程间是并发运行的,每个线程有自己不同的运行线索,当从一个线程切换到另一个线程上时,必须将原有的线程的寄存器集合的状态保存,以便 将来该线程在被重新切换到时能得以恢复。

线程的堆栈:堆栈是保证线程独立运行所必须的。线程函数可以调用函数,而被调用函数中又是可以层层嵌套的,所以线程必须拥有自己的函数堆栈, 使得函数调用可以正常执行,不受其他线程的影响。

错误返回码:由于同一个进程中有很多个线程在同时运行,可能某个线程进行系统调用后设置了 errno 值,而在该 线程还没有处理这个错误,另外一个线程就在此时 被调度器投入运行,这样错误值就有可能被修改。所以,不同的线程应该拥有自己的错误返回码变量。

线程的信号屏蔽码:由于每个线程所感兴趣的信号不同,所以线程的信号屏蔽码应该由线程自己管理。但所有的线程都共享同样的信号处理器。

线程的优先级:由于线程需要像进程那样能够被调度,那么就必须要有可供调度使用的参数,这个参数就是线程的优先级。

线程模型

线程实现在用户空间下

当线程在用户空间下实现时,操作系统对线程的存在一无所知,操作系统只能看到进程,而不能看到线程。所有的线程都是在用户空间实现。在操作系统看来,每一个进程只有一个线程。过去的操作系统大部分是这种实现方式,这种方式的好处之一就是即使操作系统不支持线程,也可以通过库函数来支持线程。

在这在模型下,程序员需要自己实现线程的数据结构、创建销毁和调度维护。也就相当于需要实现一个自己的线程调度内核,而同时这些线程运行在操作系统的一个进程内,最后操作系统直接对进程进行调度。

这样做有一些优点,首先就是确实在操作系统中实现了真实的多线程,其次就是线程的调度只是在用户态,减少了操作系统从内核态到用户态的切换开销。这种模式最致命的缺点也是由于操作系统不知道线程的存在,因此当一个进程中的某一个线程进行系统调用时,比如缺页中断而导致线程阻塞,此时操作系统会阻塞整个进程,即使这个进程中其它线程还在工作。还有一个问题是假如进程中一个线程长时间不释放 CPU,因为用户空间并没有时钟中断机制,会导致此进程中的其它线程得不到 CPU 而持续等待。

线程实现在操作系统内核中

内核线程就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。

程序员直接使用操作系统中已经实现的线程,而线程的创建、销毁、调度和维护,都是靠操作系统(准确的说是内核)来实现,程序员只需要使用系统调用,而不需要自己设计线程的调度算法和线程对 CPU 资源的抢占使用。

使用用户线程加轻量级进程混合实现

在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为 N:M 的关系:

Golang 的协程就是使用了这种模型,在用户态,协程能快速的切换,避免了线程调度的 CPU 开销问题,协程相当于线程的线程。

Linux中的线程

在 Linux 2.4 版以前,线程的实现和管理方式就是完全按照进程方式实现的;在 Linux 2.6 之前,内核并不支持线程的概念,仅通过轻量级进程(Lightweight Process)模拟线程;轻量级进程是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。内核线程只能由内核管理并像普通进程一样被调度。这种模型最大的特点是线程调度由内核完成了,而其他线程操作(同步、取消)等都是核外的线程库(Linux Thread)函数完成的。

为了完全兼容 Posix 标准,Linux 2.6 首先对内核进行了改进,引入了线程组的概念(仍然用轻量级进程表示线程),有了这个概念就可以将一组线程组织称为一个进程,不过内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程;相反,线程仅仅被视为一个与其他进程(概念上应该是线程)共享某些资源的进程(概念上应该是线程)。在实现上主要的改变就是在 task_struct 中加入 tgid 字段,这个字段就是用于表示线程组 id 的字段。在用户线程库方面,也使用 NPTL 代替 Linux Thread,不同调度模型上仍然采用 1 对 1 模型。

进程的实现是调用 fork 系统调用:pid_t fork(void);,线程的实现是调用 clone 系统调用:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)。与标准 fork() 相比,线程带来的开销非常小,内核无需单独复制进程的内存空间或文件描写叙述符等等。这就节省了大量的 CPU 时间,使得线程创建比新进程创建快上十到一百倍,能够大量使用线程而无需太过于操心带来的 CPU 或内存不足。无论是 fork、vfork、kthread_create 最后都是要调用 do_fork,而 do_fork 就是根据不同的函数参数,对一个进程所需的资源进行分配。

内核线程

内核线程是由内核自己创建的线程,也叫做守护线程(Deamon),在终端上用命令 ps -Al 列出的所有进程中,名字以 k 开关以 d 结尾的往往都是内核线程,比如 kthreadd、kswapd 等。与用户线程相比,它们都由 do_fork() 创建,每个线程都有独立的 task_struct 和内核栈;也都参与调度,内核线程也有优先级,会被调度器平等地换入换出。二者的不同之处在于,内核线程只工作在内核态中;而用户线程则既可以运行在内核态(执行系统调用时),也可以运行在用户态;内核线程没有用户空间,所以对于一个内核线程来说,它的 0~3G 的内存空间是空白的,它的 current->mm 是空的,与内核使用同一张页表;而用户线程则可以看到完整的 0~4G 内存空间。

在 Linux 内核启动的最后阶段,系统会创建两个内核线程,一个是 init,一个是 kthreadd。其中 init 线程的作用是运行文件系统上的一系列”init”脚本,并启动 shell 进程,所以 init 线程称得上是系统中所有用户进程的祖先,它的 pid 是 1。kthreadd 线程是内核的守护线程,在内核正常工作时,它永远不退出,是一个死循环,它的 pid 是 2。

协程

协程是用户模式下的轻量级线程,最准确的名字应该叫用户空间线程(User Space Thread),在不同的领域中也有不同的叫法,譬如纤程(Fiber)、绿色线程(Green Thread)等等。操作系统内核对协程一无所知,协程的调度完全有应用程序来控制,操作系统不管这部分的调度;一个线程可以包含一个或多个协程,协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上细纹和栈保存起来,在切换回来时恢复先前保运的寄存上下文和栈。

协程的优势如下:

  • 节省内存,每个线程需要分配一段栈内存,以及内核里的一些资源
  • 节省分配线程的开销(创建和销毁线程要各做一次 syscall)
  • 节省大量线程切换带来的开销
  • 与 NIO 配合实现非阻塞的编程,提高系统的吞吐

比如 Golang 里的 go 关键字其实就是负责开启一个 Fiber,让 func 逻辑跑在上面。而这一切都是发生的用户态上,没有发生在内核态上,也就是说没有 ContextSwitch 上的开销。

Go协程模型

Go 线程模型属于多对多线程模型,在操作系统提供的内核线程之上,Go 搭建了一个特有的两级线程模型。Go 中使用使用 Go 语句创建的 Goroutine 可以认为是轻量级的用户线程,Go 线程模型包含三个概念:

G: 表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

P: Processor,表示逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。

M: Machine,OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

在 Go 中每个逻辑处理器§会绑定到某一个内核线程上,每个逻辑处理器(P)内有一个本地队列,用来存放 Go 运行时分配的 goroutine。多对多线程模型中是操作系统调度线程在物理 CPU 上运行,在 Go 中则是 Go 的运行时调度 Goroutine 在逻辑处理器(P)上运行。

Go 的栈是动态分配大小的,随着存储数据的数量而增长和收缩。每个新建的 Goroutine 只有大约 4KB 的栈。每个栈只有 4KB,那么在一个 1GB 的 RAM 上,我们就可以有 256 万个 Goroutine 了,相对于 Java 中每个线程的 1MB,这是巨大的提升。Golang 实现了自己的调度器,允许众多的 Goroutines 运行在相同的 OS 线程上。就算 Go 会运行与内核相同的上下文切换,但是它能够避免切换至 ring-0 以运行内核,然后再切换回来,这样就会节省大量的时间。

在 Go 中存在两级调度:

  • 一级是操作系统的调度系统,该调度系统调度逻辑处理器占用 cpu 时间片运行;
  • 一级是 Go 的运行时调度系统,该调度系统调度某个 Goroutine 在逻辑处理上运行。

使用 Go 语句创建一个 Goroutine 后,创建的 Goroutine 会被放入 Go 运行时调度器的全局运行队列中,然后 Go 运行时调度器会把全局队列中的 Goroutine 分配给不同的逻辑处理器(P),分配的 Goroutine 会被放到逻辑处理器(P)的本地队列中,当本地队列中某个 Goroutine 就绪后待分配到时间片后就可以在逻辑处理器上运行了。

进程线程协程详解总结

进程是操作系统对一个正在运行的程序的一种抽象,在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。

在现代系统中,一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。

协程是用户模式下的轻量级线程,最准确的名字应该叫用户空间线程(User Space Thread)。

进程线程协程区别

进程协程进程对比

进程概念

进程是系统资源分配的最小单位, 系统由一个个进程(程序)组成 一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。

文本区域存储处理器执行的代码,数据区域存储变量和进程执行期间使用的动态分配的内存,堆栈区域存储着活动过程调用的指令和本地变量。

因此进程的创建和销毁都是相对于系统资源,所以是一种比较昂贵的操作。进程有三个状态:

状态描述
等待态等待某个事件的完成
就绪态等待系统分配处理器以便运行
运行态占有处理器正在运行

进程是抢占式的争夺 CPU 运行自身,而 CPU 单核的情况下同一时间只能执行一个进程的代码,但是多进程的实现则是通过 CPU 飞快的切换不同进程,因此使得看上去就像是多个进程在同时进行。

通信问题:由于进程间是隔离的,各自拥有自己的内存内存资源, 因此相对于线程比较安全, 所以不同进程之间的数据只能通过 IPC(Inter-Process Communication) 进行通信共享。

线程概念

线程属于进程,线程共享进程的内存地址空间并且线程几乎不占有系统资源。

通信问题: 进程相当于一个容器,而线程而是运行在容器里面的,因此对于容器内的东西,线程是共同享有的,因此线程间的通信可以直接通过全局变量进行通信。但是由此带来的例如多个线程读写同一个地址变量的时候则将带来不可预期的后果,因此这时候引入了各种锁的作用,例如互斥锁等。

同时多线程是不安全的,当一个线程崩溃了,会导致整个进程也崩溃了,即其他线程也挂了, 但多进程而不会,一个进程挂了,另一个进程依然照样运行。

进程是系统分配资源的最小单位,线程是 CPU 调度的最小单位。由于默认进程内只有一个线程,所以多核 CPU 处理多进程就像是一个进程一个核心。

协程概念

协程是属于线程的。协程程序是在线程里面跑的,因此协程又称微线程和纤程等,协没有线程的上下文切换消耗。协程的调度切换是用户(程序员)手动切换的,因此更加灵活,因此又叫用户空间线程。

原子操作性。由于协程是用户调度的,所以不会出现执行一半的代码片段被强制中断了,因此无需原子操作锁。

进程线程协程详解

进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程

线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

图解

线程图解如下:

协程图解如下:

进程与线程比较

  1. 地址空间:线程是进程内的一个执行单元,进程内至少有一个线程,它们共享进程的地址空间,而进程有自己独立的地址空间。
  2. 资源拥有:  进程是资源分配和拥有的单位,同一个进程内的线程共享进程的资源。
  3. 线程是处理器调度的基本单位,但进程不是。
  4. 二者均可并发执行。
  5. 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

协程与线程进行比较

  1. 一个线程可以多个协程,一个进程也可以单独拥有多个协程。
  2. 线程进程都是同步机制,而协程则是异步。
  3. 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。

进程线程协程区别总结

进程是系统资源分配的最小单位, 系统由一个个进程(程序)组成 一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。

线程属于进程,线程共享进程的内存地址空间并且线程几乎不占有系统资源。

协程是属于线程的。协程程序是在线程里面跑的,因此协程又称微线程和纤程等,协没有线程的上下文切换消耗。协程的调度切换是用户(程序员)手动切换的,因此更加灵活,因此又叫用户空间线程。

孤儿进程

孤儿进程教程

如果父进程先退出,子进程还没退出那么子进程将被托孤给 init 进程,这时子进程的父进程就是 init 进程(1 号进程)。

案例

创建孤儿进程

我们在 Linux 下使用 vim 新建一个 childprocess.c 的文件,编写如下 C 语言 代码如下:

#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
#include <signal.h>
int main(void)
{
        pid_t pid ;
        signal(SIGCHLD,SIG_IGN);
        printf("before fork pid:%d\n",getpid());
        int abc = 10;
        pid = fork();
        if(pid == -1)
        {
                perror("tile");
                return -1;
        }
        if(pid > 0)           //父进程先退出
        {
                abc++;
                printf("parent:pid:%d \n",getpid());
                printf("abc:%d \n",abc);
                sleep(5);
        }
        else if(pid == 0)    //值进程后退出,被托付给init进程
        {  
                abc++;
                printf("child:%d,parent: %d\n",getpid(),getppid());
                printf("abc:%d",abc);
                sleep(100);
        }
        printf("fork after...\n");
}

我们使用 gcc 编译上述程序,具体命令如下:

gcc childprocess.c -ochildprocess

编译完成后,会在当前目录生成一个 childprocess 的二进制可执行文件,我们使用 ls 命令,查看,如下:

此时,我们直接运行该二进制文件,输入以下命令:

./childprocess

运行成功后,控制台输出如下:

此时,我们在另一终端,使用 ps 命令,查看当前进程的状态,具体命令如下:

ps -elf | grep childprocess

此时,运行后,控制台输出如下:

此时,我们可以看到,有两个 childprocess 进程在运行,稍等一会,我们再次使用 ps 命令查看当前进程状态,此时,运行后,控制台输出如下:

此时,我们可以看到,只有一个 childprocess 进程在运行了,而且此时的 childprocess 进程的父进程变成了 1,也就是我们说的 init 进程。

僵尸进程

僵尸进程教程

如果我们了解过 Linux 进程状态及转换关系,我们应该知道进程这么多状态中有一种状态是僵死状态,就是进程终止后进入僵死状态(zombie)等待告知父进程自己终止后才能完全消失。

但是如果一个进程已经终止了,但是其父进程还没有获取其状态,那么这个进程就称之为僵尸进程。

僵尸进程还会消耗一定的系统资源,并且还保留一些概要信息供父进程查询子进程的状态可以提供父进程想要的信息,一旦父进程得到想要的信息,僵尸进程就会结束。

案例

创建僵尸进程

我们在 Linux 下使用 vim 新建一个 zombie.c 的文件,编写如下 C 语言 代码如下:

#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
int main(void)
{
        pid_t pid ;
        //signal(SIGCHLD,SIG_IGN);
        printf("before fork pid:%d\n",getpid());
        int abc = 10;
        pid = fork();
        if(pid == -1)
        {
                perror("tile");
                return -1;
        }
        if(pid > 0)
        {
                abc++;
                printf("parent:pid:%d \n",getpid());
                printf("abc:%d \n",abc);
                sleep(20);
        }
        else if(pid == 0)
        {
                abc++;
                printf("child:%d,parent: %d\n",getpid(),getppid());
                printf("abc:%d",abc);
                exit(0);
        }
        printf("fork after...\n");
}

我们使用 gcc 编译上述程序,具体命令如下:

gcc zombie.c -ozombie

编译完成后,会在当前目录生成一个 zombie 的二进制可执行文件,我们使用 ls 命令,查看,如下:

此时,我们直接运行该二进制文件,输入以下命令:

./zombie

运行成功后,控制台输出如下:

此时,我们在另一终端,使用 ps 命令,查看当前进程的状态,具体命令如下:

ps -elf | grep zombie

此时,运行后,控制台输出如下:

此时,我们可以看到,zombie 进程后面的状态为 defunct,即此时的 zombie 进程即为僵尸进程。

怎么避免僵尸进程

看程序被注释的那句 signal(SIGCHLD,SIG_IGN),加上就不会出现僵尸进程了。

这是 signal() 函数 的声明 sighandler_t signal(int signum, sighandler_t handler),我们可以得出 signal 函数的第一个函数是 Linux 支持的信号,第二个参数是对信号的操作 ,是系统默认还是忽略或捕获。

我们这是就可以知道 signal(SIGCHLD,SIG_IGN) 是选择对子程序终止信号选择忽略,这是僵尸进程就是交个内核自己处理,并不会产生僵尸进程。

守护进程

守护进程教程

守护进程就是在后台运行,不与任何终端关联的进程。

通常情况下守护进程在系统启动时就在运行,它们以 root 用户或者其他特殊用户(apache 和 postfix)运行,并能处理一些系统级的任务。习惯上守护进程的名字通常以 d 结尾(sshd),但这些不是必须的。

创建守护进程的步骤

  • 调用 fork(),创建新进程,它会是将来的守护进程。
  • 在父进程中调用 exit,保证子进程不是进程组长。
  • 调用 setsid() 创建新的会话区。
  • 将当前目录改成跟目录(如果把当前目录作为守护进程的目录,当前目录不能被卸载他作为守护进程的工作目录)。
  • 将标准输入,标注输出,标准错误重定向到 /dev/null。

案例

创建守护进程

我们在 Linux 下使用 vim 新建一个 daemon.c 的文件,编写如下 C 语言 代码如下:

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/fs.h>
int main(void)
{
    pid_t pid;
    int i;
    pid = fork();    //创建一个新进程,将来会是守护进程
    if(pid == -1)
    {
        return -1;
    }
    else if(pid != 0)
    { 
     //父进程调用exit,保证子进程不是进程组长
        exit(EXIT_SUCCESS);
    }
    if(setsid() == -1//创建新的会话区
    {
        return -1;        
    }
    if(chdir("/") == -1)  //将当前目录改成根目录
    {
        return  -1;
    }
    for(i = 0;i < 1024;i++)
    {
        close(i);
    }
    open("/dev/null",O_RDWR);  //重定向
    dup(0);
    dup(0);
    return 0;
}

我们使用 gcc 编译上述程序,具体命令如下:

gcc daemon.c -odaemon

编译完成后,会在当前目录生成一个 daemon 的二进制可执行文件,我们使用 ls 命令,查看,如下:

此时,我们直接运行该二进制文件,输入以下命令:

./daemon

运行成功后,控制台输出如下:

此时,我们的程序就是守护进程了。

上下文切换

进程切换

  1. 切换页目录以使用新的地址空间
  2. 切换内核栈
  3. 切换硬件上下文

线程切换

  1. 切换内核栈
  2. 切换硬件上下文

进程间通信方式

概述

进程通信(Interprocess Communication,IPC)是一个进程与另一个进程间共享消息的一种通信方式。消息(message)是发送进程形成的一个消息块,将消息内容传送给接收进程。

IPC 机制是消息从一个进程的地址空间拷贝到另一个进程的地址空间。

进程通信的目的

数据传输: 一个进程需要将其数据发送给另一进程,发送的数据量在一个字节到几 M 字节之间。

共享数据: 多个进程操作共享数据。

事件通知: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

资源共享: 多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。

进程控制: 有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

Linux进程间通信(IPC)的发展

Linux 下的进程通信手段基本上是从 Unix 平台上的进程通信手段继承而来的。而对 Unix 发展做出重大贡献的两大主力 AT&T 的贝尔实验室及 BSD(加州大学伯克利分校的伯克利软件发布中心)在进程间通信方面的侧重点有所不同。

前者对 Unix 早期的进程间通信手段进行了系统的改进和扩充,形成了 “system V IPC”,通信进程局限在单个计算机内;后者则跳过了该限制,形成了基于套接口(socket)的进程间通信机制。Linux 则把两者继承了下来。

  • 早期 UNIX 进程间通信
  • 基于 System V 进程间通信
  • 基于 Socket 进程间通信
  • POSIX 进程间通信

UNIX 进程间通信方式包括:管道、FIFO、信号。

System V 进程间通信方式包括:System V 消息队列、System V 信号灯、System V 共享内存

POSIX 进程间通信包括:posix 消息队列、posix 信号灯、posix 共享内存。

由于 Unix 版本的多样性,电子电气工程协会(IEEE)开发了一个独立的 Unix 标准,这个新的 ANSI Unix 标准被称为计算机环境的可移植性操作系统界面(PSOIX)。现有大部分 Unix 和流行版本都是遵循 POSIX 标准的,而 Linux 从一开始就遵循 POSIX 标准。

BSD 并不是没有涉足单机内的进程间通信(socket 本身就可以用于单机内的进程间通信)。事实上,很多 Unix 版本的单机 IPC 留有 BSD 的痕迹,如 4.4BSD 支持的匿名内存映射、4.3+BSD 对可靠信号语义的实现等等。

Linux使用的进程间通信方式

  1. 管道(pipe),流管道(s_pipe)和有名管道(FIFO)
  2. 信号(signal)
  3. 消息队列
  4. 共享内存
  5. 信号量
  6. 套接字(socket)

管道( pipe )

管道这种通讯方式有两种限制,一是半双工的通信,数据只能单向流动,二是只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

流管道 s_pipe: 去除了第一种限制,可以双向传输。

管道可用于具有亲缘关系进程间的通信,命名管道:name_pipe 克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。

信号量( semophore )

信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux 除了支持 Unix 早期信号语义函数 signal 外,还支持语义符合 Posix.1 标准的信号函数 sigaction(实际上,该函数是基于 BSD 的,BSD 为了实现可靠信号机制,又能够统一对外接口,用 sigaction 函数重新实现了 signal 函数);

消息队列( message queue )

消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

消息队列是消息的链接表,包括 Posix 消息队列 system V 消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。

信号 ( singal )

信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

主要作为进程间以及同一进程不同线程之间的同步手段。

共享内存( shared memory )

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

使得多个进程可以访问同一块内存空间,是最快的可用 IPC 形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。

套接字( socket )

套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信

更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由 Unix 系统的 BSD 分支开发出来的,但现在一般可以移植到其它类 Unix 系统上:Linux 和 System V 的变种都支持套接字。

进程间通信各种方式效率比较

类型无连接可靠流控制优先级
普通PIPENYYN
流PIPENYYN
命名PIPE(FIFO)NYYN
消息队列NYYY
信号量NYYY
共享存储NYYY
UNIX流SOCKETNYYN
UNIX数据包SOCKETYYNN

注:无连接: 指无需调用某种形式的OPEN,就有发送消息的能力流控制,如果系统资源短缺或者不能接收更多消息,则发送进程能进行流量控制。

通信方式的比较和优缺点

  1. 管道:速度慢,容量有限,只有父子进程能通讯
  2. FIFO:任何进程间都能通讯,但速度慢
  3. 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
  4. 信号量:不能传递复杂消息,只能用来同步
  5. 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存

如果用户传递的信息较少或是需要通过信号来触发某些行为,前文提到的软中断信号机制不失为一种简捷有效的进程间通信方式。

但若是进程间要求传递的信息量比较大或者进程间存在交换数据的要求,那就需要考虑别的通信方式了。

无名管道简单方便.但局限于单向通信的工作方式.并且只能在创建它的进程及其子孙进程之间实现管道的共享。

有名管道虽然可以提供给任意关系的进程使用,但是由于其长期存在于系统之中,使用不当容易出错,所以普通用户一般不建议使用。

消息缓冲可以不再局限于父子进程,而允许任意进程通过共享消息队列来实现进程间通信,并由系统调用函数来实现消息发送和接收之间的同步,从而使得用户在使用消息缓冲进行通信时不再需要考虑同步问题,使用方便,但是信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合。

共享内存针对消息缓冲的缺点改而利用内存缓冲区直接交换信息,无须复制,快捷、信息量大是其优点。

但是共享内存的通信方式是通过将共享的内存缓冲区直接附加到进程的虚拟地址空间中来实现的,因此,这些进程之间的读写操作的同步问题操作系统无法实现。必须由各进程利用其他同步工具解决。另外,由于内存实体存在于计算机系统中,所以只能由处于同一个计算机系统中的诸进程共享。不方便网络通信。

共享内存块提供了在任意数量的进程之间进行高效双向通信的机制。每个使用者都可以读取写入数据,但是所有程序之间必须达成并遵守一定的协议,以防止诸如在读取信息之前覆写内存空间等竞争状态的出现。

不幸的是,Linux 无法严格保证提供对共享内存块的独占访问,甚至是在您通过使用 IPC_PRIVATE 创建新的共享内存块的时候也不能保证访问的独占性。同时,多个使用共享内存块的进程之间必须协调使用同一个键值。

进程间通信方式的选择

  • PIPE 和 FIFO(有名管道)用来实现进程间相互发送非常短小的、频率很高的消息,这两种方式通常适用于两个进程间的通信。
  • 共享内存用来实现进程间共享的、非常庞大的、读写操作频率很高的数据;这种方法适用于多进程间的通信。
  • 其他考虑用 socket。主要应用在分布式开发中。

线程间通信方式

线程间通信方式主要包括消息队列、使用全局变量和使用事件。

消息队列

消息队列,是最常用的一种,也是最灵活的一种,通过自定义数据结构,可以传输复杂和简单的数据结构。

在 Windows 程序设计中,每一个线程都可以拥有自己的消息队列(UI 线程默认自带消息队列和消息循环,工作线程需要手动实现消息循环),因此可以采用消息进行线程间通信 sendMessage,postMessage。

  1. 定义消息 #define WM_THREAD_SENDMSG=WM_USER+20;
  2. 添加消息函数声明 afx_msg int OnTSendmsg();
  3. 添加消息映射 ON_MESSAGE(WM_THREAD_SENDMSG,OnTSM);
  4. 添加 OnTSM() 的实现函数;
  5. 在线程函数中添加 PostMessage 消息 Post 函数。

全局变量

进程中的线程间内存共享,这是比较常用的通信方式和交互方式。

注:定义全局变量时最好使用 volatile 来定义,以防编译器对此变量进行优化。

使用事件

使用事件 CEvent 类实现线程间通信,Event 对象有两种状态:有信号和无信号,线程可以监视处于有信号状态的事件,以便在适当的时候执行对事件的操作。

  1. 创建一个 CEvent 类的对象:CEvent threadStart; 它默认处在未通信状态;
  2. threadStart.SetEvent(); 使其处于通信状态;
  3. 调用 WaitForSingleObject() 来监视 CEvent 对象。

线程间同步方式

各个线程可以访问进程中的公共变量,资源,所以使用多线程的过程中需要注意的问题是如何防止两个或两个以上的线程同时访问同一个数据,以免破坏数据的完整性。数据之间的相互制约包括:

  1. 直接制约关系,即一个线程的处理结果,为另一个线程的输入,因此线程之间直接制约着,这种关系可以称之为同步关系。
  2. 间接制约关系,即两个线程需要访问同一资源,该资源在同一时刻只能被一个线程访问,这种关系称之为线程间对资源的互斥访问,某种意义上说互斥是一种制约关系更小的同步。

线程间的同步方式有四种

临界区

临界区对应着一个 CcriticalSection 对象,当线程需要访问保护数据时,调用 EnterCriticalSection 函数;当对保护数据的操作完成之后,调用 LeaveCriticalSection 函数释放对临界区对象的拥有权,以使另一个线程可以夺取临界区对象并访问受保护的数据。

PS: 关键段对象会记录拥有该对象的线程句柄即其具有 “线程所有权” 概念,即进入代码段的线程在 leave 之前,可以重复进入关键代码区域。所以关键段可以用于线程间的互斥,但不可以用于同步(同步需要在一个线程进入,在另一个线程 leave)

互斥量

互斥与临界区很相似,但是使用时相对复杂一些(互斥量为内核对象),不仅可以在同一应用程序的线程间实现同步,还可以在不同的进程间实现同步,从而实现资源的安全共享。

PS:

  1. 互斥量由于也有线程所有权的概念,故也只能进行线程间的资源互斥访问,不能由于线程同步;
  2. 由于互斥量是内核对象,因此其可以进行进程间通信,同时还具有一个很好的特性,就是在进程间通信时完美的解决了 “遗弃” 问题。

信号量

信号量的用法和互斥的用法很相似,不同的是它可以同一时刻允许多个线程访问同一个资源,PV 操作

PS: 事件可以完美解决线程间的同步问题,同时信号量也属于内核对象,可用于进程间的通信。

事件

事件分为手动置位事件和自动置位事件。事件 Event 内部它包含一个使用计数(所有内核对象都有),一个布尔值表示是手动置位事件还是自动置位事件,另一个布尔值用来表示事件有无触发。由 SetEvent() 来触发,由 ResetEvent() 来设成未触发。

PS: 事件是内核对象,可以解决线程间同步问题,因此也能解决互斥问题。

Linux进程状态

Linux进程状态教程

Linux 是一个多用户,多任务的系统,可以同时运行多个用户的多个程序,就必然会产生很多的进程,而每个进程会有不同的状态。

Linux 进程状态

状态状态全称描述
RTASK_RUNNING可执行状态
STASK_INTERRUPTIBLE可中断的睡眠状态
DTASK_UNINTERRUPTIBLE不可中断的睡眠状态
TTASK_STOPPED or TASK_TRACED暂停状态或跟踪状态
ZTASK_DEAD - EXIT_ZOMBIE退出状态,进程成为僵尸进程
XTASK_DEAD - EXIT_DEAD退出状态,进程即将被销毁

Linux进程状态详解

R (TASK_RUNNING),可执行状态

只有在该状态的进程才可能在 CPU 上运行。

而同一时刻可能有多个进程处于可执行状态,这些进程的 task_struct 结构(进程控制块)被放入对应 CPU 的可执行队列中(一个进程最多只能出现在一个 CPU 的可执行队列中)。

进程调度器的任务就是从各个 CPU 的可执行队列中分别选择一个进程在该 CPU 上运行。

S (TASK_INTERRUPTIBLE) 可中断的睡眠状态

处于这个状态的进程因为等待某某事件的发生(比如等待 socket 连接、等待信号量),而被挂起。

这些进程的 task_struct 结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。

通过 ps 命令我们会看到,一般情况下,进程列表中的绝大多数进程都处于 TASK_INTERRUPTIBLE 状态(除非机器的负载很高)。毕竟 CPU 就这么一两个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU 又怎么响应得过来。

D (TASK_UNINTERRUPTIBLE) 不可中断的睡眠状态

与 TASK_INTERRUPTIBLE 状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是 CPU 不响应外部硬件的中断,而是指进程不响应异步信号。

绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9 竟然杀不死一个正在睡眠的进程了!于是我们也很好理解,为什么 ps 命令看到的进程几乎不会出现TASK_UNINTERRUPTIBLE 状态,而总是 TASK_INTERRUPTIBLE 状态。

而 TASK_UNINTERRUPTIBLE 状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。

在进程对某些硬件进行操作时(比如进程调用 read 系统调用对某个设备文件进行读操作,而 read 系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用 TASK_UNINTERRUPTIBLE 状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的 TASK_UNINTERRUPTIBLE 状态总是非常短暂的,通过 ps 命令基本上不可能捕捉到。

然后我们可以试验一下 TASK_UNINTERRUPTIBLE 状态的威力。不管 kill 还是 kill -9,这个 TASK_UNINTERRUPTIBLE 状态的父进程依然屹立不倒。

T (TASK_STOPPED or TASK_TRACED),暂停状态或跟踪状态

向进程发送一个 SIGSTOP 信号,它就会因响应该信号而进入 TASK_STOPPED 状态(除非该进程本身处于 TASK_UNINTERRUPTIBLE 状态而不响应信号)。(SIGSTOP 与 SIGKILL 信号一样,是非常强制的。不允许用户进程通过 signal 系列的系统调用重新设置对应的信号处理函数。)

向进程发送一个 SIGCONT 信号,可以让其从 TASK_STOPPED 状态恢复到 TASK_RUNNING 状态。

当进程正在被跟踪时,它处于 TASK_TRACED 这个特殊的状态。“正在被跟踪” 指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在 gdb 中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于 TASK_TRACED 状态。而在其他时候,被跟踪的进程还是处于前面提到的那些状态。

对于进程本身来说,TASK_STOPPED 和 TASK_TRACED 状态很类似,都是表示进程暂停下来。

而 TASK_TRACED 状态相当于在 TASK_STOPPED 之上多了一层保护,处于 TASK_TRACED 状态的进程不能响应 SIGCONT 信号而被唤醒。只能等到调试进程通过 ptrace 系统调用执行 PTRACE_CONT、PTRACE_DETACH 等操作(通过 ptrace 系统调用的参数指定操作),或调试进程退出,被调试的进程才能恢复 TASK_RUNNING 状态。

Z (TASK_DEAD - EXIT_ZOMBIE),退出状态,进程成为僵尸进程

进程在退出的过程中,处于 TASK_DEAD 状态。

在这个退出过程中,进程占有的所有资源将被回收,除了 task_struct 结构(以及少数资源)以外。于是进程就只剩下 task_struct 这么个空壳,故称为僵尸。之所以保留 task_struct,是因为 task_struct 里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。比如在 shell 中,$? 变量 就保存了最后一个退出的前台进程的退出码,而这个退出码往往被作为 if 语句的判断条件。

当然,内核也可以将这些信息保存在别的地方,而将 task_struct 结构释放掉,以节省一些空间。但是使用 task_struct 结构更为方便,因为在内核中已经建立了从 pid 到 task_struct 查找关系,还有进程间的父子关系。释放掉 task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。

父进程可以通过 wait 系列的系统调用(如wait4、waitid)来等待某个或某些子进程的退出,并获取它的退出信息。然后 wait 系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。

子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来 “收尸”。这个信号默认是 SIGCHLD,但是在通过 clone 系统调用创建子进程时,可以设置这个信号。

只要父进程不退出,这个僵尸状态的子进程就一直存在。那么如果父进程退出了呢,谁又来给子进程 “收尸”?

当进程退出的时候,会将它的所有子进程都托管给别的进程(使之成为别的进程的子进程)。托管给谁呢?可能是退出进程所在进程组的下一个进程(如果存在的话),或者是 1 号进程。所以每个进程、每时每刻都有父进程存在。除非它是 1 号进程。1 号进程,pid 为 1 的进程,又称 init 进程。

Linux 系统启动后,第一个被创建的用户态进程就是 init 进程。它有两项使命:

  • 执行系统初始化脚本,创建一系列的进程(它们都是 init 进程的子孙);
  • 在一个死循环中等待其子进程的退出事件,并调用 waitpid 系统调用来完成 “收尸” 工作;

init 进程不会被暂停、也不会被杀死(这是由内核来保证的)。它在等待子进程退出的过程中处于TASK_INTERRUPTIBLE 状态,“收尸” 过程中则处于 TASK_RUNNING 状态。

X (TASK_DEAD - EXIT_DEAD),退出状态,进程即将被销毁

而进程在退出过程中也可能不会保留它的 task_struct。比如这个进程是多线程程序中被 detach 过的进程(进程?线程?参见《linux线程浅析》)。或者父进程通过设置 SIGCHLD 信号的 handler 为 SIG_IGN,显式的忽略了 SIGCHLD 信号。(这是 posix 的规定,尽管子进程的退出信号可以被设置为 SIGCHLD 以外的其他信号。)

此时,进程将被置于 EXIT_DEAD 退出状态,这意味着接下来的代码立即就会将该进程彻底释放。所以 EXIT_DEAD 状态是非常短暂的,几乎不可能通过 ps 命令捕捉到。

进程的初始状态

进程是通过 fork 系列的系统调用(fork、clone、vfork)来创建的,内核(或内核模块)也可以通过 kernel_thread 函数创建内核进程。

这些创建子进程的函数本质上都完成了相同的功能——将调用进程复制一份,得到子进程。(可以通过选项参数来决定各种资源是共享、还是私有。)

那么既然调用进程处于 TASK_RUNNING 状态(否则,它若不是正在运行,又怎么进行调用?),则子进程默认也处于 TASK_RUNNING 状态。

另外,在系统调用调用 clone 和内核函数 kernel_thread 也接受 CLONE_STOPPED 选项,从而将子进程的初始状态置为 TASK_STOPPED。

进程状态变迁

进程自创建以后,状态可能发生一系列的变化,直到进程退出。而尽管进程状态有好几种,但是进程状态的变迁却只有两个方向——从 TASK_RUNNING 状态变为非 TASK_RUNNING 状态、或者从非 TASK_RUNNING 状态变为 TASK_RUNNING 状态。

也就是说,如果给一个 TASK_INTERRUPTIBLE 状态的进程发送 SIGKILL 信号,这个进程将先被唤醒(进入 TASK_RUNNING 状态),然后再响应 SIGKILL 信号而退出(变为 TASK_DEAD 状态)。并不会从 TASK_INTERRUPTIBLE 状态直接退出。

进程从非 TASK_RUNNING 状态变为 TASK_RUNNING 状态,是由别的进程(也可能是中断处理程序)执行唤醒操作来实现的。执行唤醒的进程设置被唤醒进程的状态为 TASK_RUNNING,然后将其 task_struct 结构加入到某个 CPU 的可执行队列中。于是被唤醒的进程将有机会被调度执行。

而进程从 TASK_RUNNING 状态变为非 TASK_RUNNING 状态,则有两种途径:

  • 响应信号而进入 TASK_STOPED 状态、或 TASK_DEAD 状态;
  • 执行系统调用主动进入 TASK_INTERRUPTIBLE 状态(如 nanosleep 系统调用)、或 TASK_DEAD 状态(如 exit 系统调用);或由于执行系统调用需要的资源得不到满足,而进入 TASK_INTERRUPTIBLE 状态或 TASK_UNINTERRUPTIBLE 状态(如 select 系统调用)。

显然,这两种情况都只能发生在进程正在 CPU 上执行的情况下。

线程的几种状态

线程也具有生命周期,主要包括 7 种状态,分别是出生状态、就绪状态、运行状态、等待状态、休眠状态、阻塞状态和死亡状态,如下图所示:

线程的状态

下面对线程生命周期中的 7 种状态做说明:

  • 出生状态:用户在创建线程时所处的状态,在用户使用该线程实例调用 start() 方法之前,线程都处于出生状态。
  • 就绪状态:也称可执行状态,当用户调用 start() 方法之后,线程处于就绪状态。
  • 运行状态:当线程得到系统资源后进入运行状态。
  • 等待状态:当处于运行状态下的线程调用 Thread 类的 wait() 方法时,该线程就会进入等待状态。进入等待状态的线程必须调用 Thread 类的 notify() 方法才能被唤醒。notifyAll() 方法是将所有处于等待状态下的线程唤醒。
  • 休眠状态:当线程调用 Thread 类中的 sleep() 方法时,则会进入休眠状态。
  • 阻塞状态:如果一个线程在运行状态下发出输入/输出请求,该线程将进入阻塞状态,在其等待输入/输出结束时,线程进入就绪状态。对阻塞的线程来说,即使系统资源关闭,线程依然不能回到运行状态。
  • 死亡状态:当线程的 run() 方法执行完毕,线程进入死亡状态。

提示:一旦线程进入可执行状态,它会在就绪状态与运行状态下辗转,同时也可能进入等待状态、休眠状态、阻塞状态或死亡状态。

根据上图所示,可以总结出使线程处于就绪状态有如下几种方法:

  • 调用 sleep() 方法。
  • 调用 wait() 方法。
  • 等待输入和输出完成。

当线程处于就绪状态后,可以用如下几种方法使线程再次进入运行状态:

  • 线程调用 notify() 方法。
  • 线程调用 notifyAll() 方法。
  • 线程调用 intermpt() 方法。
  • 线程的休眠时间结束。
  • 输入或者输出结束。

进程调度

多道程序设计的目标是,无论何时都有进程运行,从而最大化 CPU 利用率。'

分时系统的目的是在进程之间快速切换 CPU,以便用户在程序运行时能与其交互。

为了满足这些目标,进程调度器选择一个可用进程(可能从多个可用进程集合中)到 CPU 上执行。

如果有多个进程,那么余下的需要等待 CPU 空闲并能重新调度。

进程调度的时机和方式

时机

进程调度的时机是什么呢?

也就是说,什么时候会从就绪队列中选取一个进程,分配处理机给它呢?

分为两种情况:当前进程主动放弃处理机 以及 当前进程被动放弃处理机。

  • 当前进程主动放弃处理机:比如进程正常终止、运行过程中发生异常而终止、进程主动请求阻塞(如等待 I/O)
  • 当前进程被动放弃处理机:比如进程的时间片用完、有更紧急的事需要处理(如 I/O 中断)、有更高优先级的进程进入就绪队列

方式

根据进程运行的过程中,处理机能否被其它进程抢占,将调度分为两种方式:

  • 非抢占式: “非抢占” 即 “不能抢占”。一旦把处理机分配给某个进程后,除非该进程终止或者主动要求进入阻塞态,否则会一直运行下去,不允许其它进程抢占自己占有的处理机。
  • 抢占式: 把处理机分配给某个进程 A 后,如果有一个更重要、更紧急的进程 B 需要用到处理机,那么进程 A 会立即暂停,把处理机交给进程 B。

补充

以下情况不会发生进程调度:

  • 处理中断的时候:由于中断处理过程复杂,与硬件密切相关,很难做到在中断处理过程中进行进程切换。
  • 进程在操作系统内核程序临界区的时候:注意是内核程序的临界区。普通临界区依然是有可能发生进程调度的。
  • 进行原子操作的时候。

调度队列

进程在进入系统时,会被加到作业队列,这个队列包括系统内的所有进程。驻留在内存中的、就绪的、等待运行的进程保存在就绪队列上。就绪队列通常用链表实现;其头节点有两个指针,用于指向链表的第一个和最后一个 PCB 块;每个 PCB 还包括一个指针,指向就绪队列的下一个 PCB,如下图。

系统还有其他队列。当一个进程被分配了 CPU 后,它执行一段时间,最终退出,或被中断,或等待特定事件发生如 I/O 请求的完成。假设进程向一个共享设备如磁盘发出 I/O 请求。由于系统具有许多进程,磁盘可能忙于其他进程的 I/O 请求,因此该进程可能需要等待磁盘。等待特定 I/O 设备的进程列表,称为设备队列。每个设备都有自己的设备队列。

进程调度通常用队列图来表示,如下图所示。每个矩形框代表一个队列;这里具有两种队列:就绪队列和设备队列。圆圈表示服务队列的资源;箭头表示系统内的进程流向。

最初,新进程被加到就绪队列;它在就绪队列中等待,直到被选中执行或被分派。当该进程分配到 CPU 并执行时,以下事件可能发生:

  • 进程可能发出 I/O 请求,并被放到 I/O 队列。
  • 进程可能创建一个新的子进程,并等待其终止。
  • 进程可能由于中断而被强制释放 CPU,并被放回到就绪队列。

对于前面两种情况,进程最终从等待状态切换到就绪状态,并放回到就绪队列。进程重复这一循环直到终止;然后它会从所有队列中删除,其 PCB 和资源也被释放。

调度程序

进程在整个生命周期中,会在各种调度队列之间迁移。操作系统为了调度必须按一定方式从这些队列中选择进程。进程选择通过适当调度器或调度程序来执行。

通常,对于批处理系统,提交的进程多于可以立即执行的。这些进程会被保存到大容量存储设备(通常为磁盘)的缓冲池,以便以后执行。长期调度程序(或作业调度程序)从该池中选择进程,加到内存,以便执行。短期调度程序(或 CPU 调度程序)从准备执行的进程中选择进程,并分配 CPU。

两种调度程序的主要区别是执行频率:

  • 短期调度程序必须经常为 CPU 选择新的进程。进程可能执行几毫秒,就会等待 I/O 请求。通常,短期调度程序每 100ms 至少 执行一次。由于执行之间的时间短,短期调度程序必须快速。如果花费 10ms 来确定执行一个运行 100ms 的进程,那么 10/(100 + 10) = 9% 的 CPU 时间会用(浪费)在调度工作上。
  • 长期调度程序执行并不频繁;在新进程的创建之间,可能有几分钟间隔。长期调度程序控制多道程序程度(内存中的进程数量)。如果多道程序程度稳定,那么创建进程的平均速度必须等于进程离开系统的平均速度。因此,只有在进程离开系统时,才需要长期调度程序的调度。由于每次执行之间的更长时间间隔,长期调度程序可以负担得起更多时间,以便决定应该选择执行哪个进程。

重要的是,长期调度程序进行认真选择。通常,大多数进程可分为 I/O 为主或 CPU 为主,I/O 密集型进程执行 I/O 比执行计算需要花费更多时间。相反,CPU 密集型进程很少产生 I/O 请求,而是将更多时间用于执行计算。

长期调度程序应该选择 I/O 密集型和 CPU 密集型的合理进程组合。因为如果 所有进程都是 I/O 密集型的,那么就绪队列几乎总是为空,从而短期调度程序没有什么可做。如果所有进程都是 CPU 密集型的,那么 I/O 等待队列几乎总是为空,从而设备没有得到使用,因而系统会不平衡。

有的系统,可能没有或极少采用长期调度程序。例如,UNIX 或微软 Windows 的分时系统通常没有长期调度程序,只是简单将所有新进程放于内存,以供短期调度程序使用。这些系统的稳定性取决于物理限制(如可用的终端数)或用户的自我调整。如果多用户系统性能下降到令人难以接受,那么有的用户就会退出。

有的操作系统如分时系统,可能引入一个额外的中期调度程序,如下图所示:

进程调度

中期调度程序的核心思想是可将进程从内存(或从 CPU 竞争)中移出,从而降低多道程序程度。之后,进程可被重新调入内存,并从中断处继续执行。这种方案称为交换。

通过中期调度程序,进程可换出,并在后来可换入。为了改善进程组合,或者由于内存需求改变导致过度使用内存从而需要释放内存,就有必要使用交换。

上下文切换

前面提过,中断会导致 CPU 从执行当前任务改变到执行内核程序。这种操作在通用系统中经常发生。当中断发生时,系统需要保存当前运行在 CPU 上的进程的上下文,以便在处理后能够恢复上下文,即先挂起进程,再恢复进程。

切换 CPU 到另一个进程需要保存当前进程状态和恢复另一个进程的状态,这个任务称为上下文切换。

当进行上下文切换时,内核会将旧进程状态保存在其 PCB 中,然后加载经调度而要执行的新进程的上下文。上下文切换的时间是纯粹的开销,因为在切换时系统并没有做任何有用工作。上下文切换的速度因机器不同而有所不同,它依赖于内存速度、必须复制的寄存器数量、是否有特殊指令(如加载或存储所有寄存器的单个指令)。典型速度为几毫秒。

上下文切换的时间与硬件支持密切相关。例如,有的处理器(如 Sun UltraSPARC)提供了多个寄存器组,上下文切换只需简单改变当前寄存器组的指针。当然,如果活动进程数量超过寄存器的组数,那么系统需要像以前一样在寄存器与内存之间进行数据复制。

不仅如此,操作系统越复杂,上下文切换所要做的就越多,高级的内存管理技术在每次上下文切换时,所需切换的数据会更多。例如,在使用下一个进程的地址空间之前,需要保存当前进程的地址空间。如何保存地址空间,需要做什么才能保存等,取决于操作系统的内存管理方法。

处理机调度的三个层次

定义

调度研究的问题是:

面对有限的资源,如何处理任务执行的先后顺序。对于处理机调度来说,这个资源就是有限的处理机,而任务就是多个进程。

故处理机调度研究的问题是:面对有限的处理机,如何从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,从而实现进程的并发执行。

处理机调度共有三个层次,这三个层次也是一个作业从提交开始到完成所经历的三个阶段。

三个层次

作业调度

作业调度也即高级调度,这个阶段可以看作是准备阶段。主要任务是按照一定的规则从外存上处于后备队列的作业中挑选一个或多个作业,为其分配内存,建立 PCB(进程) 等,使它们具备竞争处理机的能力。

这个阶段进程的状态变化是:无 –> 创建态 –> 就绪态

内存调度

内存调度也即中级调度,这个阶段可以看作是优化阶段。主要任务是将暂时不能运行的进程对换到外存中,使它们挂起;而当挂起的进程具备运行条件时,它们会被重新对换回内存,得到激活。这个阶段的主要目的是提高内存利用率和系统吞吐量。

这个阶段进程的状态变化是:静止就绪态 –> 活动就绪态,静止阻塞态 –> 活动阻塞态

进程调度

进程调度即低级调度,这个阶段让进程真正运行起来。主要任务是按照某种算法,从就绪队列中选取一个进程,分配处理机给它。进程调度是最基本、次数最频繁的阶段。

这个阶段进程的状态变化是:就绪态 –> 活动态

进程调度算法

评价指标

  • CPU 利用率:忙碌的时间 / 总时间
  • 系统吞吐量:完成作业量 / 总时间
  • 周转时间:作业完成时间 - 作业提交时间 = 作业实际运行的时间 + 等待时间
  • 平均周转时间:各作业周转时间之和 / 作业数
  • 带权周转时间:周转时间 / 作业实际运行的时间
  • 平均带权周转时间:各作业带权周转时间之和 / 作业数
  • 等待时间:进程或者作业处于等待处理机状态的时间之和,即 周转时间 - 作业实际运行的时间
    • 对于进程来说,等待时间指的是进程建立后等待被服务的时间之和(由于等待 I/O 完成的期间也属于被服务时间,所以这个时间不计入等待时间)
    • 对于作业来说,除了进程建立后的等待时间,还包括作业在外存后备队列中等待的时间

平均等待时间:各作业等待时间之和 / 作业数

响应时间:从用户提交请求到首次产生响应所用的时间

早期批处理系统的调度算法

先来先服务调度算法(FCFS)

FCFS 算法即 “先来先服务” 算法,类似于我们生活中的排队,谁先来,谁就先享受服务。

对于作业调度,它指的是谁先到达后备队列,谁就先出队,进而先被执行;对于进程调度,它指的是谁先到达就绪队列,谁就先出队,进而先被执行。

看下面的例子:

这个就是很自然的谁先到达,谁就先享受服务,所以顺序上就是从 P1 到 P4。注意这里的到达时间,就是前面说过的提交时间。这里不考虑等待 I/O 的情况,否则计算等待时间的时候还需要减去等待 I/O 的时间。

  • FCFS 算法是非抢占式的算法,不存在某个进程在执行的时候被其它进程抢占处理机的情况。
  • 它的优点是公平、算法实现简单,并且不会导致饥饿(不管等多久,所有进程最后都会运行,不存在某个进程永远得不到处理机的情况)
  • 缺点是对长作业有利、对短作业不利 —— 对于长作业,如果它先到,那么它自然无需做过多的等待,而即使是后到,它等待短作业的时间也是不足挂齿的,所以长作业怎么都不亏;对于短作业,如果它先到,自然也无需做过多等待,但是如果它后到,那么它不得不花很长的时间去等待长作业完成,然而它自己运行所需的时间却是很短的,所以说这个算法对短作业不利。在这种情况下,短作业的带权周转时间会很大,也即周转时间远远大于实际运行时间,表示有大量时间用于等待。
  • 有时候也说 FCFS 算法对 CPU 繁忙型作业有利,对 I/O 繁忙型作业不利。这是因为 CPU 繁忙型作业的特点是需要大量的 CPU 时间进行计算,而很少请求 I/O 操作,通常视作长作业。
短作业优先(SJF)调度算法

SJF 算法即 “短作业优先” 算法,前面的算法问题在于对短作业不利,所以 SJF 算法优先顾及短作业,让当前已到达并且运行时间最短的进程先执行。SJF 算法有非抢占式(默认)版本和抢占式版本,抢占式版本也叫做 SRTN 算法,即最短剩余时间优先算法。

先看非抢占式版本的例子:

运行顺序的说明:

注意这里虽然 P1 不是运行时间最短的,但是它是 当前最先到达且运行时间最短 的进程,所以它首先运行,并且在运行过程中,P2,P3,P4 陆续到达就绪队列。在 P1 运行完之后就需要调度了,这时候,就绪队列中满足“当前已到达且运行时间最短”的进程是 P3,所以 P3 运行;P3 运行完之后继续调度其它进程,P2 和 P4 运行时间都一样,不过 P2 首先到达,所以 P2 运行,最后再轮到 P4 运行。

另外,由于这是非抢占式版本,所以除非进程终止或者其它原因,否则其它进程是无法与当前进程竞争处理机的。

接着看抢占式版本的例子:

多了一个调度条件:

由于这是抢占式版本,所以存在着进程之间对于处理机的竞争。也就是说,除了进程正常终止会发生调度之外,每次有新进程进入就绪队列的时候,也可能发生调度。而具体谁会被调度并夺得处理机,则是比较新到达进程的剩余时间与正在运行进程的剩余时间,前者如果更短,那么它将夺得处理机。

下面是抢占式版本的相关指标计算:

注意:

一般可以认为,SJF 算法的平均等待时间、平均周转时间都是最少的(相比于其它算法),但是更准确地说,其实它的抢占式版本,也即 SRTN 算法,各项指标要比 SJF 算法更低。

  • SJF 算法的优点在于,它拥有 “最短的” 平均等待时间和平均周转时间
  • 缺点在于,虽然这次顾及了短作业,但是没有顾及长作业,对长作业是不利的。因为一旦短作业源源不断进入,那么它们就会不断跑在长作业前面,导致长作业永远无法运行,产生“饥饿”甚至“饿死”现象。
  • 另外一个缺点是,在实际实现中,要做到真正意义上的短作业优先,具有一定难度。
HRRN 算法

HRRN 算法即高响应比优先算法,它优先调度响应比高的进程。

响应比 = ( 等待时间+实际运行时间 )/ 实际运行时间 = 等待时间 / 实际运行时间 + 1

可以说它同时综合了 FCFS 算法和 SJF 算法的优点。为什么优先调度响应比高的进程?因为当两个进程的等待时间一样时,响应比越高的进程,它的实际运行时间越短,这一点类似于 SJF 算法,优先顾及运行时间短的进程;而当两个进程的实际运行时间一样时,响应比越高的进程,它的等待时间越长,等待时间越长说明该进程越先到达,这一点类似于 FCFS 算法,优先顾及先到达的进程。

HRRN 是非抢占式的算法,因此只有当前运行进程正常放弃处理机的时候,才会计算哪个进程的响应比高,然后进行调度。

看下面的例子:

注意这里 “要求服务的时间” 就是实际需要运行的时间,等待时间则是从进程到达就绪队列的那一刻起,到发生进程调度这一段所花费的时间。

HRRN 算法的优点是综合考虑了等待时间和实际运行时间,而且也不会导致长作业饥饿的问题(因为长作业等待时间变长之后,它的响应比也会变高,增加了可以被调度的机会)。

总结

上面这几种算法主要关注对用户的公平性、平均周转时间、平均等待时间等评价系统整体性能的指标,但是不关心 “响应时间”,也并不区分任务的紧急程度,因此对于用户来说,交互性很糟糕。

因此它们一般适合用于早期的批处理系统。下面介绍的算法则适合用于交互式系统。

交互式系统的调度算法

RR算法

RR 算法即时间片轮转算法。像前面的算法的话,通常都是非抢占式的,也就是说,一个进程正常运行完,另一个进程才有机会被调度,整体呈现出 “顺序” 的特点;而 RR 算法的特点则在于 “公平分配”,按照进程到达就绪队列的顺序,轮流让每个进程执行一个相等长度的时间片,若在自己的时间片内没有执行完,则进程自动进入就绪队列队尾,并调度队头进程运行。整体呈现出“交替”的特点。因为进程即使没运行完也会发生调度,所以这是一个抢占式的算法。

看下面的例子:

先来看时间片为 2 的情况:

0 时刻: 此时就绪队列为 P1(5),P1 上处理机运行

2 时刻: P2 到达就绪队列队头,同时 P1 时间片用完,到达就绪队列队尾。此时就绪队列为 P2(4) —— P1(3),P2 被调度,上处理机运行。

4 时刻: P3 到达就绪队列队尾,同时 P2 时间片用完,进入就绪队列,紧挨在 P3 后面。此时就绪队列为 P1(3) —— P3(1) ——P2(2),P1 被调度,上处理机运行。

5 时刻: P4 到达就绪队列队尾,P1 时间片还没用完,仍然在运行。此时就绪队列为 P3(1) —— P2(2)——P4(6)

6 时刻: P1 时间片用完,进入就绪队列队尾,此时就绪队列为 P3(1) —— P2(2) —— P4(6) —— P1(1)。P3 被调度,上处理机运行。

7 时刻: 虽然 P3 有 2 个单位的时间片可用,但是它实际上只需要用到一个单位,所以 7 时刻的时候它正常运行完,轮到 P2 被调度。此时就绪队列为 P4(6) —— P1(1)。

9 时刻: P2 时间片用完,同时也正常运行结束。P4 被调度,上处理机运行。此时就绪队列为 P1(1)。

11 时刻: P4 时间片用完,到达就绪队列队尾。此时就绪队列为 P1(1) —— P4(4)。P1 被调度,上处理机运行。

12 时刻: 在 12 时刻的时候,P1 就已经运行结束。此时再次调度 P4 上处理机运行

14 时刻: P4 时间片用完,由于就绪队列中没有其它进程可供调度,所以让 P4 接着运行一个时间片

16 时刻: P4 正常运行结束。

整个过程如下图所示:

再来看时间片为 5 的情况:

0 时刻: 此时就绪队列为 P1(5),P1 上处理机运行

2 时刻: P2 到达就绪队列队头,P1 仍在运行

4 时刻: P3 到达就绪队列队尾,P1 仍在运行

5 时刻: P4 到达就绪队列队尾。P1 正常运行结束,时间片刚好用完。此时就绪队列是 P2(4)——P3(1)——P4(6),所以 P2 被调度上处理机

9 时刻: 尽管时间片没有用完,但是 P2 正常运行结束,所以 P3 会被调度上处理机

10 时刻: P3 正常运行结束,同样调度 P4

15 时刻: P4 时间片用完,但是就绪队列没有可供调度的进程,所以 P4 还得继续运行

16 时刻: P4 正常运行结束

整个过程如下图所示:

这里会发现,效果和使用 FCFS 算法是差不多的。实际上,如果时间片太大,那么 RR 算法会退化成 FCFS 算法,而且会增加进程响应时间,所以时间片应该设置得小一点;另一方面,时间片也不能设置得太小,否则进程切换会过于频繁,导致更多的时间用于切换而不是有效执行进程。

总的来说,RR 算法的优点是公平、响应快,适用于分时操作系统;缺点则是进程切换频率相比其他算法会高一点,因此有一定的开销。另外它不区分任务的紧急程度,再紧急的任务,如果某个运行进程的时间片还没用完,这个任务也不会被调度。

RR 算法不会导致饥饿,因为时间片一到自然就会切换到其它进程,不存在某个进程永远无法被调度的情况。

优先级算法

优先级算法在某种程度上和 HRRN 算法很像,两者可以联系起来进行理解。

前面我们所讲的算法都无法区分进程紧急程度,而优先级算法弥补了这个问题。它会给每个进程一个优先级,调度时会选择当前已到达并且优先级最高的进程。和 HRRN 算法一样,它也有非抢占式和抢占式两个版本。

先看非抢占式版本:

这里和 HRRN 算法是很像的,进程会正常运行,直到结束之后才发生调度,在调度的时候会选择队列中优先级最高的进程。

再看抢占式版本:

这里同样和 HRRN 算法很像。除了正常运行结束会发生调度之外,每次就绪队列有新的进程到达时还会做一次检查,如果新到达进程优先级高于正在运行进程的优先级,那么新到达进程会抢占处理机。

PS:在优先级算法中,就绪队列可能不止有一个,可以按照不同优先级分成很多种队列。另外还要注意,有的地方规定优先数越小,优先级越高,具体看题目要求。

静态优先级和动态优先级:

优先级还包括静态优先级和动态优先级。上面所讲的属于静态优先级,指的是进程的优先级在它创建的时候就确定了,此后一直不会改变;动态优先级则相对灵活很多,它会根据具体情况动态调整进程的优先级。

  • 对于静态优先级,一般认为系统进程优先级要高于用户进程优先级;前台进程优先级高于后台进程优先级;I/O 型进程优先级会比较高。
  • 于动态优先级,会尽量遵循公平的原则。也就是说,如果某个进程实在等得太久,那么不妨提高它的优先级,让他有机会被调度;反之,如果某个进程占用处理机时间过长,那么就要考虑降低它的优先级,不要让他一直“霸占”处理机了。另外,之前说过 I/O 型进程的优先级会很高,所以如果某个进程频繁进行 I/O 操作,也可以考虑提高它的优先级。

总的来说,优先级算法的优点在于区分了各个进程的紧急程度,比较紧急重要的进程会优先得到处理,因此它适用于实时操作系统。另外,由于动态优先级的存在,使得它对进程的调度相对灵活很多。缺点则是,如果源源不断进来了一些高优先级的进程,那么优先级相对较低的进程可能一直无法执行,进而导致饥饿现象的发生。这点和 HRRN 算法也是很像的。(其实也可以把 HRRN 算法看作优先级算法的一种特殊情况,将响应比作为优先级评判的标准)

多级反馈队列算法

多级反馈队列算法是对其他调度算法的折中权衡,它的分析过程会复杂很多。下面我们先给定多级反馈队列算法的几个规则,再结合图片文字理一理具体的过程。

  • 有多个级别的就绪队列,各级队列优先级从高到低,时间片从小到大
  • 每次有新进程到达,都会首先进入第一级队列,并按 FCFS 算法被分配时间片。如果时间片用完了而进程还没执行完,那么该进程将被送到下一级队列队尾。如果当前已经是最后一级,则重新放回当前队列队尾
  • 当且仅当上层级别的队列为空时,下一级队列的进程才有机会被调度
  • 关于抢占:如果某个进程运行的时候,比它所在队列级别更高的队列中有新进程到达,则那个新进程会抢占处理机,而当前正在运行的进程会被送到当前队列队尾

下面我们结合图片来进行理解。

在 0 时刻,P1 首先到达第一级就绪队列

然后,它被调度,来到了处理机这里

在 1 时刻,P1 时间片已经用完,但是进程还没执行完,所以这时候 P1 “降级”进入第二级就绪队列。同时,P2 作为新进程进入第一级就绪队列

P2 被调度进入处理机

在 2 时刻,P2 时间片已经用完,但是进程还没执行完,所以这时候 P2 也“降级”进入第二级就绪队列

像前面所说的,“当且仅当上层级别的队列为空时,下一级队列的进程才有机会被调度”,此时第一级队列为空,所以开始调度第二级队列的进程。队头进程 P1 进入处理机

在 3 时刻,P1 时间片没用完,所以继续执行;在 4 时刻,P1 时间片用完,进程却还没执行完,所以再次“降级”来到第三级就绪队列。

此时,由于 P2 位于优先级更高的队列,所以 P2 被调度,来到处理机

在 5 时刻,P2 时间片还没用完,所以还在正常执行。但是,P3 作为新进程到达了第一级就绪队列

根据前面说的,“如果某个进程运行的时候,比它所在队列级别更高的队列中有新进程到达,则那个新进程会抢占处理机,而当前正在运行的进程会被送到当前队列队尾”,所以这时候 P3 抢占了处理机

在 6 时刻,P3 时间片用完,且刚好进程也执行完了,所以这时候没有 P3 什么事了。由于 P2 所在队列优先级更高,所以此时 P2 被调度,来到处理机

在 7 时刻,P2 时间片没用完,所以继续执行;在 8 时刻,P2 时间片用完了,且刚好进程也执行完了,所以这时候没有 P2 什么事了。此时还没完事的就剩下 P1 了,所以 P1 被调度

从 7 时刻被调度,一直到 10 时刻,P1 时间片用完了,但是进程还没执行完(剩下两个单位的时间),根据前面说的,“如果当前已经是最后一级,则重新放回当前队列队尾”,所以 P1 重新被送到第三级队列。

P1 作为唯一的进程再次被调度,来到处理机

从 10 时刻被调度到 12 时刻,P1 终于执行完毕

最后再做一下总结:

  • 优点:

    • 对各类型进程相对公平(FCFS 的优点):谁先进来,谁就会处于高级队列,优先得到服务

    • 每个新到达的进程都可以很快就得到响应(RR 的优点):新到达的进程首先在高级队列,可以很快得到响应

    • 短进程只用较少的时间就可完成(SPF 的优点):不需要经历过多的队列

    • 可灵活地调整对各类进程的偏好程度,比如 CPU 密集型进程、I/O 密集型进程(拓展:可以将因 I/O 而阻塞的进程重新放回原队列,这样 I/O 型进程就可以保持较高优先级)

    • 对各类型用户友好。

      对于终端型用户来说,他们提交的大多属于较小的交互型作业,系统只要能使这些作业在第一队列所规定的时间片内完成,便可使终端型作业用户都感到满意;对短批处理作业用户来说,只需在第一队列中执行一个时间片,或至多在第二和第三队列中各执行一个时间片即可完成;对长批处理作业用户来说,只要让作业依次在第 1, 2,…. n 个队列中运行,然后再按轮转方式运行,用户不必担心其作业长期得不到处理。

  • 缺点:可能会导致饥饿。若有源源不断的短进程到达第一队列,那么这些进程会持续被调度,使得下面一级的那些进程一直得不到调度,导致饥饿现象的发生。

总结

比起早期的批处理操作系统来说,由于计算机造价大幅降低,

因此之后出现的交互式操作系统(包括分时操作系统、实时操作系统等)更注重系统的响应时间、公平性、平衡性等指标。

而以上这三种算法恰好也能较好地满足交互式系统的需求。

因此这三种算法适合用于交互式系统。( 比如 UNIX 使用的就是多级反馈队列调度算法)

进程调度算法

先来先服务(FCFS)调度算法

处于就绪态的进程按先后顺序链入到就绪队列中,而FCFS调度算法按就绪进程进入就绪队列的先后次序选择当前最先进入就绪队列的进程来执行,直到此进程阻塞或结束,才进行下一次的进程选择调度。

FCFS调度算法采用的是不可抢占的调度方式,一旦一个进程占有处理机,就一直运行下去,直到该进程完成其工作,或因等待某一事件而不能继续执行时,才释放处理机。

操作系统如果采用这种进程调度方式,则一个运行时间长且正在运行的进程会使很多晚到的且运行时间短的进程的等待时间过长。

短作业优先(SJF)调度算法

其实目前作业的提法越来越少,我们姑且把 “作业” 用 “进程” 来替换,改称为短进程优先调度算法,此算法选择就绪队列中确切(或估计)运行时间最短的进程进入执行。

它既可采用可抢占调度方式,也可采用不可抢占调度方式。可抢占的短进程优先调度算法通常也叫做最短剩余时间优先(Shortest Remaining Time First,SRTF)调度算法。短进程优先调度算法能有效地缩短进程的平均周转时间,提高系统的吞吐量,但不利于长进程的运行。

而且如果进程的运行时间是 “估计” 出来的话,会导致由于估计的运行时间不一定准确,而不能实际做到短作业优先。

时间片轮转(RR)调度算法

RR 调度算法与 FCFS 调度算法在选择进程上类似,但在调度的时机选择上不同。RR调度算法定义了一个的时间单元,称为时间片(或时间量)。一个时间片通常在1~100 ms之间。

当正在运行的进程用完了时间片后,即使此进程还要运行,操作系统也不让它继续运行,而是从就绪队列依次选择下一个处于就绪态的进程执行,而被剥夺CPU使用的进程返回到就绪队列的末尾,等待再次被调度。

时间片的大小可调整,如果时间片大到让一个进程足以完成其全部工作,这种算法就退化为FCFS调度算法;若时间片设置得很小,那么处理机在进程之间的进程上下文切换工作过于频繁,使得真正用于运行用户程序的时间减少。

时间片可以静态设置好,也可根据系统当前负载状况和运行情况动态调整,时间片大小的动态调整需要考虑就绪态进程个数、进程上下文切换开销、系统吞吐量、系统响应时间等多方面因素。

高响应比优先(Highest Response Ratio First,HRRF)调度算法

HRRF 调度算法是介于先来先服务算法与最短进程优先算法之间的一种折中算法。先来先服务算法只考虑进程的等待时间而忽视了进程的执行时间,而最短进程优先调度算法只考虑用户估计的进程的执行时间而忽视了就绪进程的等待时间。

HRRF调度算法二者兼顾,既考虑进程等待时间,又考虑进程的执行时间,为此定义了响应比(Rp)这个指标:

Rp=(等待时间+预计执行时间)/执行时间=响应时间/执行时间

上个表达式假设等待时间与预计执行时间之和等于响应时间。HRRF调度算法将选择Rp最大值的进程执行,这样既照顾了短进程又不使长进程的等待时间过长,改进了调度性能。

但HRRF调度算法需要每次计算各各个进程的响应比Rp,这会带来较大的时间开销(特别是在就绪进程个数多的情况下)。

多级反馈队列(Multi-Level Feedback Queue)调度算法

在采用多级反馈队列调度算法的执行逻辑流程如下:

  1. 设置多个就绪队列,并为各个队列赋予不同的优先级。第一个队列的优先级最高,第二队次之,其余队列优先级依次降低。仅当第1~i-1个队列均为空时,操作系统调度器才会调度第i个队列中的进程运行。赋予各个队列中进程执行时间片的大小也各不相同。在优先级越高的队列中,每个进程的执行时间片就越小或越大(Linux-2.4内核就是采用这种方式)。
  2. 当一个就绪进程需要链入就绪队列时,操作系统首先将它放入第一队列的末尾,按FCFS的原则排队等待调度。若轮到该进程执行且在一个时间片结束时尚未完成,则操作系统调度器便将该进程转入第二队列的末尾,再同样按先来先服务原则等待调度执行。如此下去,当一个长进程从第一队列降到最后一个队列后,在最后一个队列中,可使用FCFS或RR调度算法来运行处于此队列中的进程。
  3. 如果处理机正在第i(i>1)队列中为某进程服务时,又有新进程进入第k(k<i)的队列,则新进程将抢占正在运行进程的处理机,即由调度程序把正在执行进程放回第i队列末尾,重新将处理机分配给处于第k队列的新进程。

从MLFQ调度算法可以看出长进程无法长期占用处理机,且系统的响应时间会缩短,吞吐量也不错(前提是没有频繁的短进程)。所以MLFQ调度算法是一种合适不同类型应用特征的综合进程调度算法。

最高优先级优先调度算法

进程的优先级用于表示进程的重要性及运行的优先性。一个进程的优先级可分为两种:静态优先级和动态优先级。静态优先级是在创建进程时确定的。一旦确定后,在整个进程运行期间不再改变。

静态优先级一般由用户依据包括进程的类型、进程所使用的资源、进程的估计运行时间等因素来设置。一般而言,若进程需要的资源越多、估计运行的时间越长,则进程的优先级越低;反之,对于I/O bounded的进程可以把优先级设置得高。

动态优先级是指在进程运行过程中,根据进程执行情况的变化来调整优先级。动态优先级一般根据进程占有CPU时间的长短、进程等待CPU时间的长短等因素确定。

占有处理机的时间越长,则优先级越低,等待时间越长,优先级越高。那么进程调度器将根据静态优先级和动态优先级的总和现在优先级最高的就绪进程执行。

聊聊:什么是线程,线程和进程的区别

这又是一道老生常谈的问题了,从操作系统的角度来回答一下吧。

我们上面说到进程是正在运行的程序的实例,而线程其实就是进程中的单条流向,因为线程具有进程中的某些属性,所以线程又被称为轻量级的进程。浏览器如果是一个进程的话,那么浏览器下面的每个 tab 页可以看作是一个个的线程。

下面是线程和进程持有资源的区别

线程不像进程那样具有很强的独立性,线程之间会共享数据

创建线程的开销要比进程小很多,因为创建线程仅仅需要堆栈指针程序计数器就可以了,而创建进程需要操作系统分配新的地址空间,数据资源等,这个开销比较大。

聊聊:有了进程为什么还要线程

不同进程之间切换实现并发,各自占有CPU实现并行

但是这些也会导致缺点,

一个进程只能做一件事,其他的进程来了会将其阻塞,为此引进了更小的粒度,

线程减少程序在并发执行时所付出的时间和空间开销,提高并发性能

聊聊:什么是进程和进程表

进程就是正在执行程序的实例,比如说 Web 程序就是一个进程,shell 也是一个进程,文章编辑器 typora 也是一个进程。

操作系统负责管理所有正在运行的进程,操作系统会为每个进程分配特定的时间来占用 CPU,操作系统还会为每个进程分配特定的资源。

操作系统为了跟踪每个进程的活动状态,维护了一个进程表。

在进程表的内部,列出了每个进程的状态以及每个进程使用的资源等。

聊聊:并发和并行

  • 并发是指宏观上在一段时间内能同时运行多个程序
  • 并行则指同一时刻能运行多个指令(需要硬件支持,如多流水线、多核处理器或者分布式计算系统)

操作系统通过引入进程和线程,使得程序能够并发运行

  • 并行是指两个或者多个事件在同一时刻发生;

  • 而并发是指两个或多个事件在同一时间间隔发生

并行是在不同实体上的多个事件,并发是在同一实体上的多个事件;

聊聊:多处理系统的优势

随着处理器的不断增加,我们的计算机系统由单机系统变为了多处理系统,多处理系统的吞吐量比较高,多处理系统拥有多个并行的处理器,这些处理器共享时钟、内存、总线、外围设备等。

多处理系统由于可以共享资源,因此可以开源节流,省钱。整个系统的可靠性也随之提高。

聊聊:什么是上下文切换

对于单核单线程 CPU 而言,在某一时刻只能执行一条 CPU 指令。

上下文切换 (Context Switch) 是一种 将 CPU 资源从一个进程分配给另一个进程的机制

从用户角度看,计算机能够并行运行多个进程,这恰恰是操作系统通过快速上下文切换造成的结果。

在切换的过程中,操作系统需要先存储当前进程的状态 (包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。

聊聊:使用多线程的好处是什么

多线程是程序员不得不知的基本素养之一,所以,下面我们给出一些多线程编程的好处

  • 能够提高对用户的响应顺序
  • 在流程中的资源共享
  • 比较经济适用
  • 能够对多线程架构有深入的理解

聊聊:进程终止的方式

进程的终止

进程在创建之后,它就开始运行并做完成任务。然而,没有什么事儿是永不停歇的,包括进程也一样。进程早晚会发生终止,但是通常是由于以下情况触发的

  • 正常退出(自愿的)
  • 错误退出(自愿的)
  • 严重错误(非自愿的)
  • 被其他进程杀死(非自愿的)

正常退出

多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit ,在 Windows 中是 ExitProcess。面向屏幕中的软件也支持自愿终止操作。字处理软件、Internet 浏览器和类似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它锁打开的任何临时文件,然后终止。

错误退出

进程发生终止的第二个原因是发现严重错误,例如,如果用户执行如下命令

cc foo.c

为了能够编译 foo.c 但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。

严重错误

进程终止的第三个原因是由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数是 0 等。在有些系统比如 UNIX 中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。

被其他进程杀死

第四个终止进程的原因是,某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调用是 kill。在 Win32 中对应的函数是 TerminateProcess(注意不是系统调用)。

聊聊:进程间的通信方式

进程间的通信方式比较多,

首先你需要理解下面这几个概念

  • 竞态条件:即两个或多个线程同时对一共享数据进行修改,从而影响程序运行的正确性时,这种就被称为竞态条件(race condition)

  • 临界区:不仅共享资源会造成竞态条件,事实上共享文件、共享内存也会造成竞态条件、那么该如何避免呢?或许一句话可以概括说明:禁止一个或多个进程在同一时刻对共享资源(包括共享内存、共享文件等)进行读写。换句话说,我们需要一种 互斥(mutual exclusion) 条件,这也就是说,如果一个进程在某种方式下使用共享变量和文件的话,除该进程之外的其他进程就禁止做这种事(访问统一资源)。

    一个好的解决方案,应该包含下面四种条件

  1. 任何时候两个进程不能同时处于临界区
  2. 不应对 CPU 的速度和数量做任何假设
  3. 位于临界区外的进程不得阻塞其他进程
  4. 不能使任何进程无限等待进入临界区
  • 忙等互斥:当一个进程在对资源进行修改时,其他进程必须进行等待,进程之间要具有互斥性,我们讨论的解决方案其实都是基于忙等互斥提出的。

进程间的通信用专业一点的术语来表示就是 Inter Process Communication,IPC,它主要有下面 7。

7种通信方式

  • 消息传递:消息传递是进程间实现通信和同步等待的机制,使用消息传递,进程间的交流不需要共享变量,直接就可以进行通信;消息传递分为发送方和接收方
  • 先进先出队列:先进先出队列指的是两个不相关联进程间的通信,两个进程之间可以彼此相互进程通信,这是一种全双工通信方式
  • 管道:管道用于两个相关进程之间的通信,这是一种半双工的通信方式,如果需要全双工,需要另外一个管道。
  • 直接通信:在这种进程通信的方式中,进程与进程之间只存在一条链接,进程间要明确通信双方的命名。
  • 间接通信:间接通信是通信双方不会直接建立连接,而是找到一个中介者,这个中介者可能是个对象等等,进程可以在其中放置消息,并且可以从中删除消息,以此达到进程间通信的目的。
  • 消息队列:消息队列是内核中存储消息的链表,它由消息队列标识符进行标识,这种方式能够在不同的进程之间提供全双工的通信连接。
  • 共享内存:共享内存是使用所有进程之间的内存来建立连接,这种类型需要同步进程访问来相互保护。

聊聊:进程间状态模型

进程的三态模型

当一个进程开始运行时,它可能会经历下面这几种状态

图中会涉及三种状态

  1. 运行态:运行态指的就是进程实际占用 CPU 时间片运行时
  2. 就绪态:就绪态指的是可运行,但因为其他进程正在运行而处于就绪状态
  3. 阻塞态:阻塞态又被称为睡眠态,它指的是进程不具备运行条件,正在等待被 CPU 调度。

逻辑上来说,运行态和就绪态是很相似的。这两种情况下都表示进程可运行,但是第二种情况没有获得 CPU 时间分片。第三种状态与前两种状态不同的原因是这个进程不能运行,CPU 空闲时也不能运行。

三种状态会涉及四种状态间的切换,在操作系统发现进程不能继续执行时会发生状态1的轮转,在某些系统中进程执行系统调用,例如 pause,来获取一个阻塞的状态。在其他系统中包括 UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。

转换 2 和转换 3 都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其他进程运行 CPU 时间片了。当所有其他进程都运行过后,这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3。

程序调度指的是,决定哪个进程优先被运行和运行多久,这是很重要的一点。已经设计出许多算法来尝试平衡系统整体效率与各个流程之间的竞争需求。

当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换 4。如果此时没有其他进程在运行,则立刻触发转换 3,该进程便开始运行,否则该进程会处于就绪阶段,等待 CPU 空闲后再轮到它运行。

进程的五态模型

在三态模型的基础上,增加了两个状态,即 新建终止 状态。

  • 新建态:进程的新建态就是进程刚创建出来的时候

创建进程需要两个步骤:即为新进程分配所需要的资源和空间,设置进程为就绪态,并等待调度执行。

  • 终止态:进程的终止态就是指进程执行完毕,到达结束点,或者因为错误而不得不中止进程。

终止一个进程需要两个步骤:

  1. 先等待操作系统或相关的进程进行善后处理。
  2. 然后回收占用的资源并被系统删除。

聊聊:什么是僵尸进程

僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。僵尸进程通常发生在父子关系的进程中,由于父进程仍需要读取其子进程的退出状态所造成的。

聊聊:什么是守护、僵尸、孤儿进程

  • 守护进程:运行在后台的一种特殊进程,独立于控制终端并周期性地执行某些任务
  • 僵尸进程:一个进程 fork 子进程,子进程退出,而父进程没有wait/waitpid子进程,那么子进程的进程描述符仍保存在系统中,这样的进程称为僵尸进程。
  • 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,这些子进程称为孤儿进程。(孤儿进程将由 init 进程收养并对它们完成状态收集工作)

聊聊:Semaphore(信号量) Vs Mutex(互斥锁)

  • 当用户创立多个线程/进程时,如果不同线程/进程同时读写相同的内容,则可能造成读写错误,或者数据不一致。此时,需要通过加锁的方式,控制临界区(critical section)的访问权限。对于semaphore而言,在初始化变量的时候可以控制允许多少个线程/进程同时访问一个临界区,其他的线程/进程会被堵塞,直到有人解锁。
  • Mutex相当于只允许一个线程/进程访问的semaphore。此外,根据实际需要,人们还实现了一种读写锁(read-write lock),它允许同时存在多个阅读者(reader),但任何时候至多只有一个写者(writer),且不能于读者共存。

聊聊:进程调度策略有哪几种?

  • 先来先服务:非抢占式的调度算法,按照请求的顺序进行调度。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。另外,对I/O密集型进程也不利,因为这种进程每次进行I/O操作之后又得重新排队。
  • 短作业优先:非抢占式的调度算法,按估计运行时间最短的顺序进行调度。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
  • 最短剩余时间优先:最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
  • 时间片轮转:将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。而如果时间片过长,那么实时性就不能得到保证。
  • 优先级调度:为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

聊聊:进程有哪些状态?

进程一共有5种状态,分别是创建、就绪、运行(执行)、终止、阻塞。

进程五种状态转换图
  • 运行状态就是进程正在CPU上运行。在单处理机环境下,每一时刻最多只有一个进程处于运行状态。
  • 就绪状态就是说进程已处于准备运行的状态,即进程获得了除CPU之外的一切所需资源,一旦得到CPU即可运行。
  • 阻塞状态就是进程正在等待某一事件而暂停运行,比如等待某资源为可用或等待I/O完成。即使CPU空闲,该进程也不能运行。

运行态→阻塞态:往往是由于等待外设,等待主存等资源分配或等待人工干预而引起的。阻塞态→就绪态:则是等待的条件已满足,只需分配到处理器后就能运行。运行态→就绪态:不是由于自身原因,而是由外界原因使运行状态的进程让出处理器,这时候就变成就绪态。例如时间片用完,或有更高优先级的进程来抢占处理器等。就绪态→运行态:系统按某种策略选中就绪队列中的一个进程占用处理器,此时就变成了运行态。

…………

版面太有限,

以上已经有50000字啦,

微信说,已经到顶了

不能再贴了

but

还剩余50多个题目,咋办?

请参见《尼恩Java面试宝典 V28版》PDF电子书

PDF文末获取↓


硬核面试题推荐


硬核文章推荐


硬核电子书

本文收录于:《尼恩Java面试宝典》V28版

长按二维码,点击“识别图中二维码”即可查看老架构师尼恩个人微信,发暗号 “领电子书” 给尼恩,获取最新PDF。


  • 最新的《尼恩Java面试宝典》

    极致经典,不断升级,目前最新为V28


  • 尼恩Java高并发三部曲

    《Java高并发核心编程-卷1(加强版)》,不断升级

    《Java高并发核心编程-卷2(加强版)》,不断升级

    《Java高并发核心编程-卷3(加强版)》,不断升级


  • 尼恩架构笔记100篇+,不断添加 

  • 100份简历模板


继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存