Skip to content

Latest commit

 

History

History
969 lines (542 loc) · 90.9 KB

2.进程与线程.md

File metadata and controls

969 lines (542 loc) · 90.9 KB

2.进程与线程

为什么要引入进程的概念?

我们知道I/O操作是耗时的,CPU在执行的一个程序的时候这个程序可能会有一些耗时的操作,而CPU的执行是非常快的,那如果这种情况下,CPU一直等待这个程序执行完耗时的操作再继续执行,就会导致CPU的使用率大大降低,为了提高CPU的使用率,就引入了多道程序设计,也就是说有多个程序执行,当执行到一个程序时如果遇到耗时的操作,那CPU就切换到另一个程序继续执行,等第一个程序的耗时操作执行完后再切换到第一个程序执行,这个切换过程不能只切换PC的指针,还需要记录一些其他的程序的信息,所以为了描述这种程序的信息,引入了进程的概念。

事实表明,人们常常希望同时运行多个程序。比如:在使用计算机或者笔记本的时候,我们会同时运行浏览器、邮件、游戏、音乐播放器,等等。实际上,一个正常的系统可能会有上百个进程同时在运行。如果能实现这样的系统,人们就不需要考虑这个时候哪一个CPU是可用的,使用起来非常简单。因此我们的挑战是:如何提供有许多CPU的假象?(虽然只有少量的物理CPU可用,但是操作系统如何提供几乎有无数个CPU可用的假象?)

操作系统通过虚拟化(virtualizing)CPU来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟CPU的假象。这就是时分共享(time sharing)CPU技术,允许用户如愿运行多个并发进程。潜在的开销就是性能损失,因为如果CPU必须共享,每个进程的运行就会慢一点。要实现CPU的虚拟化,要实现得好,操作系统就需要一些低级机制以及一些高级智能。我们将低级机制称为机制(mechanism)。机制是一些低级方法或协议,实现了所需的功能。例如,我们稍后将学习如何实现上下文切换(context switch),它让操作系统能够停止运行一个程序,并开始在给定的CPU上运行另一个程序。所有现代操作系统都采用了这种分时机制。

时分共享(time sharing)是操作系统共享资源所使用的最基本的技术之一。通过允许资源由一个实体使用一小段时间,然后由另一个实体使用一小段时间,如此下去,所谓的资源(例如,CPU或网络链接)可以被许多人共享。时分共享的自然对应技术是空分共享,资源在空间上被划分给希望使用它的人。例如,磁盘空间自然是一个空分共享资源,因为一旦将块分配给文件,在用户删除文件之前,不可能将它分配给其他文件。

为了虚拟化CPU,操作系统需要以某种方式让许多任务共享物理CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。然而,在构建这样的虚拟化机制时存在一些挑战。第一个是性能:如何在不增加系统开销的情况下实现虚拟化?第二个是控制权:如何有效地运行进程,同时保留对CPU的控制?控制权对于操作系统尤为重要,因为操作系统负责资源管理。如果没有控制权,一个进程可以简单地无限制运行并接管机器,或访问没有权限的信息。因此,在保持控制权的同时获得高性能,这是构建操作系统的主要挑战之一。

抽象:进程概念 操作系统为正在运行的程序提供的抽象,就是所谓的进程(process)。正如我们上面所说的,一个进程只是一个正在运行的程序。在任何时刻,我们都可以清点它在执行过程中访问或影响的系统的不同部分,从而概括一个进程。

多道程序系统与分时操作系统

  • 所谓多道程序设计指的是允许多个程序同时进入一个计算机系统的主存储器并启动进行计算的方法。也就是说,计算机内存中可以同时存放多道(两个以上相互独立的)程序,它们都处于开始和结束之间。从宏观上看是并行的,多道程序都处于运行中,并且都没有运行结束;从微观上看是串行的,各道程序轮流使用CPU,交替执行。引入多道程序设计技术的根本目的是为了提高CPU的利用率,充分发挥计算机系统部件的并行性,现代计算机系统都采用了多道程序设计技术。
  • 分时操作系统是使一台计算机同时为几个、几十个甚至几百个用户服务的一种操作系统。把计算机与许多终端用户连接起来,分时操作系统将系统处理机时间与内存空间按一定的时间间隔,轮流地切换给各终端用户的程序使用。由于时间间隔很短,每个用户的感觉就像他独占计算机一样。分时操作系统的特点是可有效增加资源的使用率。例如UNIX系统就采用剥夺式动态优先的CPU调度,有力地支持分时操作。
  • 分时操作系统是给不同用户提供程序的使用,而多道程序系统则是不同程序间的穿插运行。

进程概念

程序是指令和数据的集合。

狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。

广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

进程的概念主要有两点:

  • 第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。每个进程访问自己的私有虚拟地址空间(virtual address space)(有时称为地址空间,address space),操作系统以某种方式映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。对于正在运行的程序,它完全拥有自己的物理内存。
  • 第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。可以简单的理解为:进程 = 资源 + 指令执行序列。

进程是60年代初首先由麻省理工学院MULTICS系统和IBM公司的CTSS/360系统引入的。

进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。

为了理解构成进程的是什么,我们必须理解它的机器状态(machine state):程序在运行时可以读取或更新的内容。在任何时刻,机器的哪些部分对执行该程序很重要? 进程的机器状态有一个明显组成部分,就是它的内存。指令存在内存中。正在运行的程序读取和写入的数据也在内存中。因此进程可以访问的内存(称为地址空间,address space)是该进程的一部分。 进程的机器状态的另一部分是寄存器。许多指令明确地读取或更新寄存器,因此显然,它们对于执行该进程很重要。

进程由三部分组成:

  • 一段可执行的程序
  • 程序所需要的相关数据(变量、工作空间、缓冲区等)
  • 程序的执行上下文

最后一部分是根本。执行上下文(execution context)又称为进程状态(process state),是操作系统用来管理和控制进程所需的内部数据。这种内部信息和进程是分开的,因为操作系统信息不允许被进程直接访问。上下文包括操作系统管理进程及处理器正确执行进程所需的所有信息,包括各种处理器寄存器的内容,如程序计数器和数据寄存器。它还包括操作系统使用的信息,如进程优先级及进程是否在等待I/O事件的完成。

上图是一种进程管理方法。两个进程A和B存在于内存中的某些部分,给每个进程(包含程序、数据和上下文信息)分配了一块存储器区域,并且在由操作系统建立和维护的进程表中进行了记录。进程表包含记录每个进程的表项,表项内容包括指向包含进程的存储块地址的指针,还包括该进程的部分和全部上下文。执行上下文的其余部分存放在别处,可能和进程本身存在一起,通常还可能保存在内存中的一块独立区域。进程索引寄存器(process index register)包含当前正在控制处理器的进程在进程表中的索引。程序计数器(program counter)指向该进程中下一条待执行的指令。基址寄存器中保存该存储器区域的开始地址。

图中表示,进程索引寄存器表明进程B正在执行。以前执行的进程被临时中断,在A中断的同时,所有寄存器的内容被记录在其执行上下文环境中,以后操作系统就可以执行进程切换来恢复进程A的执行。进程切换过程中包括保存B的上下文和恢复A的上下文。在程序计数器中载入指向A的程序区域的值时,进程A自动恢复执行。

在任何多道程序设计系统中,CPU由一个进程快速切换至另一个进程,使每个进程各运行几十或几百毫秒。严格来说,在某一个瞬间,CPU只能运行一个进程。但在1秒钟内,它可能运行多个进程,这样就产生并行的错觉。CPU在各进程之间来回切换,这种快速的切换称为多道程序设计。

多道程序设计模型

采用多道程序设计可以提高CPU利用率。严格地说,如果进程用于计算的平均时间是进程在内存中停留时间的20%,且内存中同时有5个进程,则CPU将一直满负载运行。然而,这个模型在现实中过于乐观,因为它假设这5个进程不会同时等待I/O。

更好的模型是从概率的角度来看CPU的利用率。假设一个进程等待I/O操作的时间与其停留在内存的时间比为p。当内存中同时有n个进程时,则所有n个进程都在等待I/O(此时CPU空转)的概率为p的n次方。

img

从上图中可以看到,如果进程花费80%的时间等待I/O,为使CPU的浪费低于10%,至少要有10个进程同时在内存中。

进程的特性

  1. 动态性

    动态性是进程的最基本特征,它是程序执行过程,它是有一定的生命期。它由创建而产生、由调度而执行,因得不到资源而暂停,并由撤消而死亡。而程序是静态的,它是存放在介质上一组有序指令的集合,无运动的含义。

  2. 并发性

    并发性是进程的重要特征,同时也是OS的重要特征。并发性指多个进程实体同存于内存中,能在一段时间内同时运行。而程序是不能并发执行。

  3. 独立性

    进程是一个能独立运行的基本单位,即是一个独立获得资源和独立调度的单位,而程序不作为独立单位参加运行。

  4. 异步性

    进程按各自独立的不可预知的速度向前推进,即进程按异步方式进行,正是这一特征,将导致程序执行的不可再现性,因此OS必须采用某种措施来限制各进程推进序列以保证各程序间正常协调运行。

进程的状态(五状态进程模型)

  • 就绪态:进程做好了准备,只要有机会就开始执行
  • 运行态:进程正在执行
  • 堵塞/等待态:进程在某些事件发生前不能执行,如I/O操作完成
  • 新建态:刚刚创建的进程,操作系统还未把它加入可执行进程组,它通常是进程控制块已经创建但还未加载到内存中的新进程
  • 退出态:操作系统从可执行进程组中释放出的进程,要么它自身已停止,要么它因某种原因被取消

挂起态

当内存中的所有进程都处于堵塞态时,操作系统可把其中的一个进程置为挂起态,并将它移到磁盘。此时内存所释放的空间就可被调入的另一个进程使用。操作系统执行换出操作后,将进程取到内存中的方式有两种:接纳一个新近创建的进程,或调入一个此前挂起的进程。显然,操作系统更倾向于调入一个此前挂起的进程,并为它服务,而非增加系统的总负载数。

挂起进程等价于不在内存中的进程。不在内存中的进程,不论他是否在等待一个时间,都不能立即执行。

进程由哪几部分组成?

进程是程序的一次运行过程,它是由程序段、数据段和进程控制块PCB组成的一个实体,其中:

  • 程序段:对应程序的操作代码部分,用于描述进程所需要完成的功能。
  • 数据段:对应程序执行时所需要的数据部分,包括数据,堆栈和工作区。
  • 进程控制块(Process Control Block, PCB) :描述进程的基本信息和运行状态,记录了进程运行时所需要的全部信息,它是进程存在的唯一标识,与进程一一对应。所谓的创建进程和撤销进程,都是指对PCB的操作。

进程控制块(PCB)

又称进程描述符、进程属性,是操作系统用于管理进程的一个专门数据结构。PCB是操作系统感知进程存在的唯一标志。进程和PCB是一一对应的。

进程执行的任意时刻,都可由如下元素来表征:

  • 标识符:与进程相关的唯一标识符,用于标识、区分一个进程,通常有外部标识符和内部标识符两类。外部标识符通常是由字母、数字所组成的一个字符串,用户或其他进程访问该进程时使用。内部标识符是操作系统为每个进程赋予的唯一一个整数,是作为内部识别而设置的。
  • 状态:若进程正在执行,则进程处于运行态。进程状态指明进程当前的状态,作为进程调度和对换时的依据;
  • 优先级:相对于其他进程的优先顺序,说明进程使用CPU的优先级别,其中优先级高的进程将优先获得CPU。
  • 程序计数器:程序中即将执行的下一条指令的地址
  • 内存指针:包括程序代码和进程相关数据的指针,以及与其他进程共享内存块的指针
  • 上下文数据:进程执行时处理器的寄存器中的数据
  • I/O状态信息:包括显式I/O请求、分配给进程的I/O设备和被进程使用的文件列表等
  • 记账信息:包括处理器时间总和、使用的时钟数总和、时间限制、及账号等

上述列表信息存放在一个进程控制块(process control block)的数据结构中,控制块由操作系统创建和管理。系统通过PCB感知进程的存在,并对其进行有效管理和控制。系统创建一个新进程时,为它建立一个PCB;当进程结束时,系统又收回其PCB,该进程也随之消亡。

进程表: 所有进程的PCB集合。

进程创建

操作系统决定创建一个新进程时,会按如下步骤操作:

  1. 为新进程分配一个唯一的进程标识符。此时,主进程表中会添加一个新表项,每个进程一个表项。
  2. 为进程分配空间。这包括进程映像中的所有元素。因此,操作系统必须知道私有用户地址空间(程序和数据)和用户栈需要多少空间。
  3. 初始化进程控制块。进程表示部分包括进程id和其他相关的id,如父进程id等。处理器状态信息部分的多数项目通常初始化为0,但程序计数器(置为程序入口点)和系统栈指针(定义进程栈边界)除外。进程控制信息部分根据标准的默认值和该进程请求的特性来初始化。例如,进程的状态通常初始化为就绪或就绪/挂起。
  4. 设置正确的链接。例如,若操作系统将每个调度队列都维护为一个链表,则新进程必须放在继续或就绪/挂起链表中。
  5. 创建或扩充其他数据结构。例如,操作系统可因编制账单和/或评估性能,为每个进程维护一个记账文件。

进程切换

进程表(Process Table)

操作系统为了执行进程间的切换,会维护着一张表格,这张表就是进程表(process table)。每个进程占用一个进程表项。该表项包含了进程状态的重要信息,包括程序计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其他在进程由运行态转换到就绪态或阻塞态时所必须保存的信息,从而保证该进程随后能再次启动,就像从未被中断过一样。

image

上面是典型的进程表表项中的一些字段:

第一列内容与进程管理有关,第二列内容与存储管理有关,第三列内容与文件管理有关。

操作系统最底层的就是调度程序,在它上面有许多进程。所有关于中断处理、启动进程和停止进程的具体细节都隐藏在调度程序中。事实上,调度程序只是一段非常小的程序。

表面上看,进程切换很简单。在某个时刻,操作系统中断一个正在运行的进程,将另一个进程置于运行模式,并把控制权交给后者。然而,这会引发若干个问题。首先,什么事件触发了进程的切换? 其次,必须认识到模式切换和进程切换键的区别。

进程切换可在操作系统从当前正运行进程中获得控制权的任何时刻发生。首先考虑系统终端。实际上,大多数操作系统都会区分两种系统终端:一种称为终端,另一种称为陷阱。前者与当前正运行进程无关的某种外部事件相关,如完成一次I/O操作。后者与当前正运行进程产生的错误或异常条件相关,如非法的文件访问。对于普通终端,控制权首先转给终端处理器,终端处理器完成一些基本的辅助工作后,再将控制权给与已发生的特定中断相关的操作系统进程。示例如下:

  • 时钟中断:操作系统确定当前正运行进程的执行时间是否已超过最大允许时间段(时间片,即进程中断前可以执行的最大时间段)。若超过,进程就切换到就绪态,并调入另一个进程。
  • I/O中断:操作系统确定是否已发生I/O活动。若I/O活动是一个或多个进程正在等待的事件,则操作系统就把所处于堵塞态的进程转换为就绪态( 堵塞/挂起态进程转换为就绪/挂起态)。操作系统必须决定是继续执行当前处于运行态的进程,还是让具有高优先级的就绪态进程抢占这个进程。
  • 内存失效:处理器遇到一个引用不在内存中的字的虚存地址时,操作系统就必须从外存中把包含这一引用的内存块(页或段)调入内存。发出调入内存块的I/O请求后,内存失效进程将进入堵塞态。操作系统然后切换进程,恢复另一个进程的执行。在等该期望的内存块调入内存后,该进程置为就绪态。

对于陷阱(trap),操作系统则确定错误或异常条件是否致命。致命时,当前正运行进程置为退出态,并切换进程。不致命时,操作系统的动作将取决于错误的性质和操作系统的设计,操作系统可能会尝试恢复程序,或简单的通知用户。操作系统可能会切换进程或继续当前运行的进程。

并发

在单处理器多道程序设计系统中,进程会被交替的执行,因而表现出一种并发执行的外部特征。即使不能实现真正的并行处理,并且在进程间来回切换也需要一定的开销,但是交替执行在处理效率和程序结构上还是会带来很多好处。在多处理系统中,不仅可以交替的执行进程,而且可以重叠执行进程。

进程的相对执行速度不可预测,它取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略,这样就会有以下问题:

  1. 全局资源的共享充满了危险。例如,如果两个进程都使用同一个全局变量,并且都对该变量执行读写操作,那么不同的读写执行顺序是非常关键的。
  2. 操作系统很难对资源进行最优化分配。例如,进程A可能请求使用一个特定的I/O通道,并获取控制权,但它在使用这个通道前已被堵塞,而操作系统仍然锁定这个通道以防止其他进程使用,这是最难以令人满意的。事实上,这种情况有可能导致死锁。
  3. 定位程序设计错误非常困难。这是因为结果通常是不确定的和不可再现的。

所以由于并发带来的这些问题,操作系统必须关注的问题如下:

  1. 操作系统必须能够跟踪不同的进程,这可以使用进程控制块来实现。
  2. 操作系统必须为每个活动进程分配和释放各种资源。
  3. 操作系统必须保护每个进程的数据和物理资源,避免其他进程的无意干扰。
  4. 一个进程的功能和输出结果必须与执行速度无关。

互斥的要求

  1. 必须强制实施互斥。在于相同资源或共享对象的临界区有关的所有进程中,一次只允许一个进程进入临界区。
  2. 一个在非临界区停止的进程不能干涉其他进程。
  3. 绝不允许出现需要访问临界区的进程被无限延迟的情况,即不会死锁或饥饿。
  4. 没有进程在临界区中时,任何需要进入临界区的进程必须能够立即进入。
  5. 对相关进程的执行速度和处理器的数量没有任何要求和限制。
  6. 一个进程驻留在临界区中的时间必须是有限的。

进程间通信(Inter Process Communication,IPC)

以Linux为例,进程间的信息交换,具体内容分为:控制信息交换和数据交换,控制信息的交换为低级通信,数据的交换为高级通信。

  • Pipe管道通信

    人们最常使用的通信手段就是对白。对白的特点就是一方发出声音,另一方接收声音。而声音的传递则通过空气(当面或无线交谈)、线缆(有线电话)进行传递。类似,进程对白就是一个进程发出某种数据信息,另外一方接收数据信息,而这些数据信息通过一片共享的存储空间进行传递。在这种方式下,一个进程向这片存储空间的一端写入信息,另一个进程从存储空间的另外一端读取信息。这看上去像什么?管道。管道所占的空间既可以是内存,也可以是磁盘。就像两人对白的媒介可以是空气,也可以是线缆一样。要创建一个管道,一个进程只需调用管道创建的系统调用即可。该系统调用所做的事情就是在某种存储介质上划出一片空间,赋给其中一个进程写的权利,另一个进程读的权利即可。

    从根本上说,管道是一个线性字节数组(实为内核缓冲区),是一种伪文件的方式,类似文件,可以使用文件读写的方式进行访问。但却不是文件。因为通过文件系统看不到管道的存在。另外,我们前面说了,管道可以设在内存里,而文件很少设在内存里。 管道的原理:管道实为内核使用环形队列机制,借助内核缓冲区(4k)实现。

    在两个进程之间,可以建立一个通道,一个进程向这个通道里写入字节流,另一个进程从这个管道中读取字节流。管道是同步的,当进程尝试从空管道读取数据时,该进程会被阻塞,直到有可用数据为止。发送进程以字符流形式将大量数据送入管道,接收进程可从管道接收数据,二者利用管道进行通信,管道是单向的、先进先出的,它把一个进程的输出和另一个进程的输入连接在一起。一个进程(写进程)在管道的尾部写入数据,另一个进程(读进程)从管道的头部读出数据。每次只有一个进程能够真正地进入管道,其他的只能等待。

    管道分为无名管道和命名管道,前者用于父子进程通信,后者用于任意进程通信。

    入先出队列 FIFO 通常被称为 命名管道(Named Pipes),命名管道的工作方式与常规管道非常相似,但是确实有一些明显的区别。未命名的管道没有备份文件:操作系统负责维护内存中的缓冲区,用来将字节从写入器传输到读取器。一旦写入或者输出终止的话,缓冲区将被回收,传输的数据会丢失。相比之下,命名管道具有支持文件和独特 API ,命名管道在文件系统中作为设备的专用文件存在。当所有的进程通信完成后,命名管道将保留在文件系统中以备后用。命名管道具有严格的 FIFO 行为,写入的第一个字节是读取的第一个字节,写入的第二个字节是读取的第二个字节,依此类推。

    管道的局限性:

    • 数据自己读不能自己写
    • 数据一旦被读走,便不在管道中存在,不可反复读取。
    • 由于管道采用半双工通信(数据只能一个方向写,另一个方向读)方式。因此,数据只能在一个方向上流动。
    • 只能在有公共祖先的进程间使用管道。

创建管道:

    int pipe(int pipefd[2]);           成功:0;失败:-1,设置errno

函数调用成功返回r/w两个文件描述符。无需open,但需手动close。规定:fd[0] → r; fd[1] → w,就像0对应标准输入,1对应标准输出一样。向管道文件读写数据其实是在读写内核缓冲区。 管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。

  1. 父进程调用pipe函数创建管道,得到两个文件描述符fd[0]、fd[1]指向管道的读端和写端。
  2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。
  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出。由于管道是利用环形队列实现的,数据从写端流入管道,从读端流出,这样就实现了进程间通信。

linux创建管道:

#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>

void sys_err(const char *str)
{
    perror(str);
    exit(1);
}

int main(void)
{
    pid_t pid;
    char buf[1024];
    int fd[2];
    char *p = "test for pipe\n";
    
   if (pipe(fd) == -1) 
       sys_err("pipe");

   pid = fork();
   if (pid < 0) {
       sys_err("fork err");
   } else if (pid == 0) {
        close(fd[1]);
        int len = read(fd[0], buf, sizeof(buf));
        write(STDOUT_FILENO, buf, len);
        close(fd[0]);
   } else {
       close(fd[0]);
       write(fd[1], p, strlen(p));
       wait(NULL);
       close(fd[1]);
   }
    
    return 0;
}

管道的优劣 优点:简单,相比信号,套接字实现进程间通信,简单很多。 缺点:1. 只能单向通信,双向通信需建立两个管道。 2. 只能用于父子、兄弟进程(有共同祖先)间通信。该问题后来使用fifo有名管道解决。

  • Socket套接字

    套接字的功能非常强大,可以支持不同层面、不同应用、跨网络的通信。使用套接字进行通信需要双方均创建一个套接字,其中一方作为服务器方,另外一方作为客户方。服务器方必须先创建一个服务区套接字,然后在该套接字上进行监听,等待远方的连接请求。欲与服务器通信的客户则创建一个客户套接字,然后向服务区套接字发送连接请求。服务器套接字在收到连接请求后,将在服务器方机器上创建一个客户套接字,与远方的客户机上的客户套接字形成点到点的通信通道。之后,客户方和服务器方就可以通过send和recv命令在这个创建的套接字通道上进行交流了。

    socket 提供端到端的双相通信。一个套接字可以与一个或多个进程关联。就像管道有命令管道和未命名管道一样,套接字也有两种模式,套接字一般用于两个进程之间的网络通信,网络套接字需要来自诸如TCP(传输控制协议)或较低级别UDP(用户数据报协议)等基础协议的支持。

  • Signals信号

    管道和套接字虽然提供了丰富的通信语义,并且也得到了广泛应用,但它们也存在某些缺点,并且在某些时候,这两种通信机制会显得很不好用。首先,如果使用管道和套接字方式来通信,必须事先在通信的进程间建立连接(创建管道或套接字),这需要消耗系统资源。其次,通信是自愿的。即一方虽然可以随意向管道或套接字发送信息,但对方却可以选择接收的时机。即使对方对此充耳不闻,你也奈何不得。再次,由于建立连接消耗时间,一旦建立,我们就想进行尽可能多的通信。而如果通信的信息量微小,如我们只是想通知一个进程某件事情的发生,则用管道和套接字就有点“杀鸡用牛刀”的味道,效率十分低下。因此,我们需要一种不同的机制来处理如下通信需求:想迫使一方对我们的通信立即做出回应。我们不想事先建立任何连接,而是临时突然觉得需要与某个进程通信。传输的信息量微小,使用管道或套接字不划算。应付上述需求,我们使用的是信号(signal)。

那么信号是什么呢?在计算机里,信号就是一个内核对象,或者说是一个内核数据结构。发送方将该数据结构的内容填好,并指明该信号的目标进程后,发出特定的软件中断。操作系统接收到特定的中断请求后,知道是有进程要发送信号,于是到特定的内核数据结构里查找信号接收方,并进行通知。接到通知的进程则对信号进行相应处理。信号非常类似我们生活当中的电报。如果你想给某人发一封电报,就拟好电文,将报文和收报人的信息都交给电报公司。电报公司则将电报发送到收报人所在地的邮局(中断),并通知收报人来取电报。发报时无需收报人事先知道,更无需进行任何协调。如果对方选择不对信号做出响应,则将被操作系统终止运行。

A给B发送信号,B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕再继续执行。与硬件中断类似——异步模式。但信号是软件层面上实现的中断,早期常被称为“软中断”。

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延迟时间非常短,不易察觉。 每个进程收到的所有信号,都是由内核负责发送的,内核处理。

int kill(pid_t pid, int sig);
两个或多个进程可以通过简单的信号进行合作,可以强迫一个进程在某个位置停止,直到它接收到一个特定的信号。信号量是一个特殊的变量。用于进程间传递信息的一个整数值。

信号是 UNIX 系统最先开始使用的进程间通信机制,因为 Linux 是继承于 UNIX 的,所以 Linux 也支持信号机制,通过向一个或多个进程发送`异步事件信号`来实现,信号可以从键盘或者访问不存在的位置等地方产生;信号通过 shell 将任务发送给子进程。

你可以在 Linux 系统上输入 `kill -l` 来列出系统使用的信号。

进程可以选择忽略发送过来的信号,但是有两个是不能忽略的:`SIGSTOP` 和 `SIGKILL` 信号。SIGSTOP 信号会通知当前正在运行的进程执行关闭操作,SIGKILL 信号会通知当前进程应该被杀死。除此之外,进程可以选择它想要处理的信号,进程也可以选择阻止信号,如果不阻止,可以选择自行处理,也可以选择进行内核处理。如果选择交给内核进行处理,那么就执行默认处理。

操作系统会中断目标程序的进程来向其发送信号、在任何非原子指令中,执行都可以中断,如果进程已经注册了新号处理程序,那么就执行进程,如果没有注册,将采用默认处理的方式。

信号效率非常高,但是可携带的数据有限,只能写到一个标志位,无法携带其他数据。

不建议使用信号完成进程间通信,因为信号的优先级太高,产生信号之后会打断程序的执行。
  • 信号量

    信号量(semaphore)是由荷兰人E.W.Dijkstra在20世纪60年代所构思出的一种程序设计构造。其原型来源于铁路的运行:在一条单轨铁路上,任何时候只能有一列列车行驶在上面。而管理这条铁路的系统就是信号量。任何一列火车必须等到表明铁路可以行驶的信号后才能进入轨道。当一列列车进入单轨运行后,需要将信号改为禁止进入,从而防止别的火车同时进入轨道。而当列车驶出单轨后,则需要将信号变回允许进入状态。这很像以前的旗语,在计算机里,信号量实际上就是一个简单整数。一个进程在信号变为0或者1的情况下推进,并且将信号变为1或0来防止别的进程推进。当进程完成任务后,则将信号再改变为0或1,从而允许其他进程执行。需要注意的是,信号量不只是一种通信机制,更是一种同步机制。

image

image

  • Shared Memory共享内存

    管道、套接字、信号、信号量,虽然满足了多种通信需要,但还是有一种需要未能满足。这就是两个进程需要共享大量数据。这就像两个人,他们互相喜欢,并想要一起生活时(共享大量数据量),打电话、握手、对白等就显得不够了,这个时候需要的是拥抱,只有将其紧紧拥抱于怀,感觉才最到位,也才能尽可能地共享。进程的拥抱就是共享内存。
    共享内存就是两个进程共同拥有同一片内存。对于这片内存中的任何内容,二者均可以访问。要使用共享内存进行通信,一个进程首先需要在内核中创建一片内存空间专门作为通信用,然后该进程与这片内存空间进行关联,而其他进程则将该片内存映射到自己的(虚拟)地址空间。
    这样,读写自己地址空间中对应共享内存的区域时,就是在和其他进程进行通信。乍一看,共享内存有点像管道,有些管道不也是一片共享内存吗?这是形似而神不似:- 首先,使用共享内存机制通信的两个进程必须在同一台物理机器上;

  • 其次,共享内存的访问方式是随机的,而不是只能从一端写,另一端读,因此其灵活性比管道和套接字大很多,能够传递的信息也复杂得多。

共享内存的缺点是管理复杂,且两个进程必须在同一台物理机器上才能使用这种通信方式。共享内存的另外一个缺点是安全性脆弱。因为两个进程存在一片共享的内存,如果一个进程染有病毒,很容易就会传给另外一个进程。就像两个紧密接触的人,一个人的病毒是很容易传染另外一个人的。这里需要注意的是,使用全局变量在同一个进程的进程间实现通信不称为共享内存。

共享内存是进程通信中最快的一种方法,因为数据不需要在进程间复制,而可以直接映射到各个进程的地址空间中。在共享内存中要注意的一个问题是:当多个进程对共享内存区域进行访问时,要注意这些进程之间的同步问题。    

例如,如果server正在向共享内存区域中写数据,那么client进程就不能访问这些数据,直到server全部写完之后,client才可以访问。
为了实现进程间的同步问题,通常使用前面介绍过的信号量来实现这一目标。一个共享内存段可以由一个进程创建,然后由任意共享这一内存段的进程对它进行读写。
当进程间需要通信时,一个进程可以创建一个共享内存段,然后需要通信的各个进程就可以在信号量的控制下保持同步,在这里交换数据,完成通信。 创建共享内存:

#include <sys/ipc.h>
#include <sys/shm.h>
// 创建或者获得共享内存ID
int shmget(key_t key, size_t size, int shmflg);
// 连接共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • FIFO

FIFO FIFO常被称为命名管道,以区分管道(pipe)。管道(pipe)只能用于“有血缘关系”的进程间。但通过FIFO,不相关的进程也能交换数据。 FIFO是Linux基础文件类型中的一种。但,FIFO文件在磁盘上没有数据块,仅仅用来标识内核中一条通道。各进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信。 创建方式:

  1. 命令:mkfifo 管道名 2. 库函数:int mkfifo(const char *pathname, mode_t mode); 成功:0; 失败:-1 一旦使用mkfifo创建了一个FIFO,就可以使用open打开它,常见的文件I/O函数都可用于fifo。如:close、read、write、unlink等。
  • 存储映射    存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。于是当从缓冲区中取数据,就相当于读文件中的相应字节。于此类似,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。
       使用这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。
    总结:使用mmap时务必注意以下事项:
    1. 创建映射区的过程中,隐含着一次对映射文件的读操作。 2. 当MAP_SHARED时,要求:映射区的权限应 <=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制。 3. 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭。 4. 特别注意,当映射文件大小为0时,不能创建映射区。所以:用于映射的文件必须要有实际大小!!   mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。 5. munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++操作。 6. 如果文件偏移量必须为4K的整数倍 7. mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。

  • 匿名映射

    使用映射区完成文件读写操作十分方便,父子进程间通信比较容易。但缺陷是,每次创建映射区一定要依赖一个文件才能实现。通常为了建立映射区要open一个temp文件,创建好了再unlink、close掉,比较麻烦。可以直接使用匿名映射来代替。其实linux系统给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区。 需要借助标志位参数来指定: 使用MAP_ANONYMOUS或MAP_ANON,例如:

int *p = mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
// 4是随意举例,该位置表示大小,可依据实际需要填写
  • Message Queue消息传递系统/消息队列

    消息队列是一列具有头和尾的消息排列。新来的消息放在队列尾部,而读取消息则从队列头部开始,消息队列乍一看,这不是管道吗?一头读、一头写?没错。这的确看上去像管道,但它不是管道。首先,它无需固定的读写进程,任何进程都可以读写(当然是有权限的进程)。其次,它可以同时支持多个进程,多个进程可以读写消息队列。即所谓的多对多,而不是管道的点对点。另外,消息队列只在内存中实现。最后,它并不是只在UNIX和类UNIX操作系统中实现。几乎所有主流操作系统都支持消息队列。

    进程与其它的进程进行通信而不必借助共享数据,通过互相发送和接收消息,建立一条通信链路。

进程调度

当一个计算机是多道程序设计系统时,会频繁的有很多进程或者线程来同时竞争 CPU 时间片。当两个或两个以上的进程/线程处于就绪状态时,就会发生这种情况。如果只有一个 CPU 可用,那么必须选择接下来哪个进程/线程可以运行。操作系统中有一个叫做调度程序(scheduler) 的角色存在,它就是做这件事儿的,该程序使用的算法叫做调度算法(scheduling algorithm) 。进程调度就是处理器调度(上下文切换)。

调度级别

  • 高级调度

    作业调度,把后备作业调入内存运行

  • 中级调度

    在虚拟存储器中引入,在内,外存交换区进行进程对换

  • 低级调度

    进程调度,把就绪队列里的某个进程获得CPU执行权

调度方式

  • 可剥夺

    当一个进程运行时,基于某种原则,剥夺已经分配给它的处理器,将之分配给其他进程,原则有:优先权原则,短进程优先原则,时间片原则。

  • 不可剥夺

    一单处理器分配给某进程,遍让它一直运行下去,直到进程完成或者发生某种时间而阻塞,才分配给其他进程。

调度算法

  • 先来先服务(first-come,first-serverd)

    按照进入就绪队列的进程顺序,不加其他条件干涉。但是也存在缺陷,例如一个进程本来需要5秒就能执行完成,但是在它前面的进程确需要1个小时,它不得不等待一个小时。

  • 最短作业优先(Shortest Job First)

    优先选出就绪队列中CPU执行时间最短的进程,例如:就绪队列有4个进程P1,P2,P3,P4,执行时间为:16,12,4,3 按照短进程优先,则周转时间(从进程提交到进程完成的时间间隔)分别为:35,19,7,3 平均周转时间:16,平均周转时间越小,调度性能越好。这种算法的核心是所有的程序并不都一样,而是有优先级的区分。具体来说,就是短任务的优先级比长任务的高,而我们总是安排优先级高的程序先运行。就像晚辈在公交汽车上见到长辈需要让座一样。

    在所有的进程都可以运行的情况下,最短作业优先的算法才是最优的。

  • 最短剩余时间优先(Shortest Remaining Time Next)

    使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。

  • 轮转法

    一种最古老、最简单、最公平并且最广泛使用的算法就是轮询算法(round-robin)。每个进程都会被分配一个时间段,称为时间片(quantum),在这个时间片内允许进程运行。如果时间片结束时进程还在运行的话,则抢占一个 CPU 并将其分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换。轮询算法比较容易实现。调度程序所做的就是维护一个可运行进程的列表,当一个进程用完时间片后就被移到队列的末尾。

    • 简单轮转: 就绪进程按FIFO排队,按照一定时间间隔让处理机分配给队列中的进程,就绪队列中所有队列均可获得一个时间片的处理器运行。
    • 多级队列: 让系统中所有进程分成若干类,每类一级。
  • 优先级调度

    每个进程都被赋予一个优先级,优先级高的进程优先运行。优先级调度的优点是可以赋予重要的进程以高优先级以确保重要任务能够得到CPU时间。其缺点则是低优先级的进程可能会“饥饿”。

    优先级倒挂(priority inversion)其所指的是一个低优先级任务持有一个被高优先级认为所需要的共享资源。这样高优先级任务因资源缺乏而处于受阻状态,一直到低优先级任务释放资源为止。这样实际上造成了这两个任务的优先级倒挂。如果此时有其他优先级介于二者之间的任务,并且其不需要这个共享资源,则该中级优先级的进程将获得CPU控制,从而超越这两个任务,导致高优先级进程被临界区外的低优先级进程阻塞。在某些时候,优先级倒挂并不会造成损害。高优先级任务的延迟并不会注意到。因为低优先级进程最终会释放资源。但在其他一些时候,优先级倒挂则可能引起严重后果。如果一个高优先级进程一直不能获得资源,有可能造成系统故障,或激发事先定义的纠正措施,如系统复位。例如,美国的火星探测器Mars Path-finder就是因为优先级倒挂而出现故障。

内核栈与用户栈的区别

每个进程一般会有两个栈,一个用户栈,一个内核栈,存在于内核空间。

当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容时用户堆栈的地址,使用的是用户栈。

当进程在内核空间时,cpu堆栈指针寄存器里的内容是内核栈空间的地址,使用内核栈。

内核栈是内存中属于操作系统空间的一块区域,主要用途为:

  • 保护中断现场
  • 保护操作系统子程序间相互调用的参数、返回值、返回点以及子程序函数的局部变量

用户栈是用户进程空间中的一块区域,用于保存用户进程的子程序间相互作用的参数、返回值以及相关局部变量

当进程因为中断或者系统调用而陷入内核态,进程所使用的堆栈也要从用户栈转到内核栈。进程陷入内核态后,先把用户态堆栈的地址保存在内核栈中,然后设置堆栈指针寄存器的内容为内核栈的地址,这样就完成了用户栈到内核栈的转换。当进程从内核态恢复为用户态时,在内核态之后的最后将保存在内核栈里面的用户栈地址恢复到堆栈指针寄存器即可。这样就实现了内核栈和用户栈的互转。

注意:每次进程从用户态陷入内核的时候得到的内核栈都是空的,所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器即可。

Android进程结构

如同传统的Linux系统一样,Android的第一个用户空间进程是init(init的PID值是0),它是所有其他进程的根。然而,Android的init启动的守护进程是不同的,这些守护进程更多的聚焦于底层细节(管理文件系统和硬件访问),而不是高层用户设施,例如调度定时任务。Android还有一层额外的进程,它们运行Dalvik的Java语言环境,负责执行系统中所有以Java实现的部分。

img

如上图,首先是init进程,它产生了一些底层守护进程,init进程是所有用户进程的鼻祖。其中一个守护进程是zygote,它是高级Java语言进程的根。Android的init不以传统的方法运行shell,因为典型的Android设备没有本地控制台用于shell访问。作为替代,系统进程adbd监听请求shell访问的远程连接(例如通过USB),按要求为它们创建shell进程。因为Android大部分是用Java语言编写的,所以zygote守护进程以及由它启动的进程是系统的中心。由zygote启动的第一个进程称为system_server,它包含全部核心操作服务,其关键部分是电源管理、包管理、窗口管理和活动管理。

其他进程在需要的时候由zygote创建。这些进程中有一些是“持久的”进程,它们是基本操作系统的组成部分,例如phone进程中的电话栈,它必须保持始终运行。另外的应用程序进程将在系统运行的过程中按需创建和终止。

应用程序通过调用操作系统提供的库与操作系统进行交互,这些库合起来构成Android框架(Android framework)。这些库中有一些可以在进程内部执行其工作,但是许多库需要与其他进程执行进程间通信,或者通常是在system_server进程中提供服务的。

ServiceManager

Android Binder的管理服务。

对于Binder驱动而言,ServiceManager是一个守护进程,更是Android系统各个服务的管理者。Android系统中的各个服务,都是添加到ServiceManager中进行管理的,而且每个服务都对应一个服务名。当Client获取某个服务时,则通过服务名来从ServiceManager中获取相应的服务。

Zygote

系统中运行的第一个Dalvik虚拟机程序叫做zygote,该名称的意义是“一个卵”,因为接下来的所有Dalvik虚拟机进程都是通过这个卵孵化出来的。Zygote是在启动时就运行在DVM上的一个进程。

zygote进程汇总包含两个主要模块,分别如下:

  • Socket服务端:该Socket服务端用于接收启动新的Dalvik进程的命令。
  • Framework共享类及共享资源。当zygote进程启动后,会装载一些共享的类及资源,其中共享类是在preload-classes文件中被定义,共享资源是在preload-resources中被定义。因为zygote进程用于孵化出其他Dalvik进程,因此,这些类和资源装载后,新的Dalvik进程就不需要再装载这些类和资源了,这也就是所谓的共享。

每当出现创建进程的请求时,Zygote就会产生一个新的DVM虚拟机。Zygote通过在内存中尽可能多的共享内容来最小化产生一个新DVM所消耗的时间。通常而言,许多应用程序都会使用核心库的类和相应的堆结构,而这些内容都是只读的。也就是说,被大部分应用程序使用的这些共享数据和类都是只读而不能改变的。因此,当Zygote加载时,就预加载和初始化了应用程序运行时可能用到的Java核心库和资源。当Zygote创建一个新的DVM时,这部分类不会被分配到新内存。Zygote只是简单地把子进程的这些内存页映射到父进程的相应位置。

实际上,几乎不需要更多的映射页。如果一个类被一个子进程自己的DVM改写,那么Zygote会将受影响的内存复制到子进程中。这种即写即复制的行为在使得最大化共享内存的同时,还能保证应用程序间不会相互影响,并在跨应用程序和进程的边界时保证安全性。

zygote进程对应的具体程序是app_process,该程序存在于system/bin目录下,启动该程序的指令是在init.rc中进行配置的。

System Server进程

System Server进程是由zygote进程fork而来,System Server是zygote孵化的第一个Dalvik进程,SystemServer仅仅是该进程的别名,而该进程具体对应的程序依然是app_process,因为System是从app_process中孵化出来的。System Server负责启动和管理整个Java framework,SystemServer进程在Android的运行环境中扮演了“神经中枢”的作用,APK应用中能够直接交互的大部分系统服务都在该进程中运行,常见的有WindowManagerServer(WmS)、ActivityManagerService(AmS)、PackageManagerServer(PmS)等,这些系统服务都是以一个线程的方式存在于SystemServer进程中。SystemServer的main()函数会首先创建一个ServerThread对象,该对象是一个线程,然后直接运行该线程。而在ServerThread的run()方法内部真正启动各种服务线程,都有:

  • EntropyService:提供伪随机数
  • PowerManagerService:电源管理服务
  • ActivityManagerService:最核心的服务之一,管理Activity
  • TelephonyRegistry:通过该服务注册电话模块的事件响应,比如重启、关闭、启动等
  • PackageManagerService:程序包管理服务
  • AccountManagerService:账户管理服务,是指联系人账户,而不是Linux系统账户
  • ContentService:ContentProvider服务,提供跨进程数据交互
  • BatteryService:电池管理服务
  • LightsService:自然光强度感应传感器服务
  • VibratorService:振动器服务
  • AlarmManagerService:定时器管理服务,提供定时提醒服务
  • WindowManagerService:Framework最核心的服务之一,负责窗口管理
  • BluetoothService:蓝牙服务
  • DevicePolicyManagerService:提供一些系统级别的设置及属性
  • StatusBarManagerService:状态栏管理服务
  • ClipboardService:系统剪切板服务
  • InputMethodManagerService:输入法管理服务
  • NetStatService:网络状态服务
  • NetworkManagementService:网络管理服务
  • ConnectivityService:网络连接管理服务
  • NotificationManagerService:通知栏管理服务
  • LocationManagerService:地理位置服务
  • AudioService:音频管理服务
  • ....

SystemServer中创建了一个Socket客户端,并有AmS负责管理该客户端,之后所有的Dalvik进程都将通过该Socket客户端间接被启动。当需要启动新的APK进程时,AmS中会通过该Socket客户端向zygote进程的Socket服务端发送一个启动命令,然后zygote会孵化出新的进程。

从系统架构的角度来看,先创建一个zygote并加载共享类的资源,然后通过该zygote去孵化新的Dalvik进程,该架构的特点有两个:

  • 每一个进程都是一个Dalvik虚拟机,而Dalvik虚拟机是一个类似于Java虚拟机的程序,并且从开发的过程来看,与标准的Java程序开发基本一致。因此对于程序员来讲,不须要学习新的语言,并可以使用Java程序在过去几十年中已经成熟的各种类库资源。
  • zygote进程预先会装载共享类和共享资源,这些类及资源实际上就是SDK中定义的大部分类和资源,因此,当通过zygote孵化出新的进程后,新的APK进程只需要去装载APK自身包含的类和资源即可,这就有效的解决了多个APK共享Framework资源的问题。

Android进程模型

Linux的传统进程模型是用fork指令来创建新进程,然后用exec指令使用待运行的源码初始化该进程并开始执行。shell负责实现进程执行、创建新进程、执行所需的进程来运行shell指令。当指令结束时,进程被从Linux中移除。

Android使用的进程有些不同。活动管理器是Android负责正在运行的应用程序的管理的一部分。活动管理器协调新应用程序进程的启动,决定哪些应用程序能在其中运行,哪些已不再需要。

启动进程

为了启动新进程,活动管理器需要与zygote通信。活动管理器首先创建一个与zygote相连的专用接口,通过接口发送一条指令,表示它需要启动一个进程。这条指令主要描述需要创建的沙箱、新进程运行所需要的UID以及需要遵守的安全性制约。zygote需要作为根来运行:创建新进程时,它合理配置运行所需的UID,最终下放权限,将进程改为该UID。

img

上图展示了一个新进程中启动活动的流程:

  1. 某个现有进程(如应用程序启动器)调用活动管理器,发出意图,描述它想要启动的新活动。
  2. 活动管理器要求封装管理器将这个意图解析为一个明确的组件。
  3. 活动管理器判断这个应用程序的进程并未正在运行,然后向zygote请求一个具有合适UID的新进程。
  4. zygote进行一次fork指令,克隆自己来创造一个新进程,下放权限并配置新进程的UID和沙箱,初始化该进程的Dalvik,使得Java runtime开始完全执行。例如,它需要在fork后启动垃圾收集等线程。
  5. 新进程如今是一个zygote的克隆,并运行着完全配置好的Java环境。它回调活动管理器,询问后者“我该做什么”。
  6. 活动管理器返回即将启动的应用程序的完整信息,如源码位置等。
  7. 新进程读取应用程序的源码,开始运行。
  8. 活动管理器将所有即将进行的操作发送给新进程,在此处为“启动活动X”。
  9. 新进程收到指令,启动活动,实体化合适的Java类并执行。

注意,当活动启动时,应用程序的进程可能正在运行了。在这种情况下,活动管理器会直接跳转到末尾,向该进程发送一条新指令,让它实体化并执行合适的组件。如果合适,这会导致一个额外的活动实例在应用程序中运行。

Android系统启动的核心流程如下:

  1. 启动电源以及系统启动:当电源按下时引导芯片从预定义的地方(固化在ROM)开始执行(Boot ROM),Boot ROM会去加载引导程序BootLoader到RAM,然后执行。

  2. 引导程序BootLoader:BootLoader是在Android系统开始运行前的一个小程序,主要用于把系统OS拉起来并运行。

  3. Linux内核启动:当内核启动时,设置缓存、被保护存储器、计划列表、加载驱动。当其完成系统设置时,会先在系统文件中寻找init.rc文件,并启动init进程。

  4. init进程启动(是所有用户进程的父进程(或者父父进程)):初始化和启动属性服务,init进程会孵化出eadbd、logd、等用户守护进程,还会启动ServiceManager(binder服务管家)、botanic(开机动画)等服务,并且启动Zygote进程。

  5. Zygote进程启动(zygote是所有上层Java进程的父进程,zygote的父进程是init进程):zygote进程是Android系统的第一个Java进程(即虚拟机进程),它是所有Java进程的父进程。它会创建JVM并为其注册JNI方法,创建服务器端Socket,启动SystemServer进程。并且,zygote进程在启动的时候会创建DVM或者ART。因此通过从zygote进程fork创建的应用程序进程和systemserver进程都可以在内部获取一个DVM或者ART的实例副本。它还会提前加载类preloadClasses和提前加载资源preloadResouces。

  6. SystemServer进程启动:System Server是zygote孵化的第一个进程,它会启动Binder线程池和SystemServiceManager,并且启动各种系统服务,包括ActivityManagerService、WindowManagerService、PackageManagerService、PowerManagerService等服务。

  7. Media Server进程,是由init进程fork而来,负责启动和管理整个C++ framework,包括AudioFlinger,Camera Service等服务。

  8. Launcher启动:是zygote孵化的第一个App进程,被SystemServer进程启动的AmS会启动Launcher,Launcher启动后会将已安装应用的快捷图标显示到系统桌面上。zygote还会创建Broweer、Phone、Email等App进程,每个App至少运行在一个进程上。

对于IPC(Inter-Process Communication, 进程间通信),Linux现有管道、消息队列、共享内存、套接字、信号量、信号这些IPC机制,Android额外还有Binder IPC机制,Android OS中的Zygote进程的IPC采用的是Socket机制,在上层system server、media server以及上层App之间更多的是采用Binder IPC方式来完成跨进程间的通信。对于Android上层架构中,很多时候是在同一个进程的线程之间需要相互通信,例如同一个进程的主线程与工作线程之间的通信,往往采用的Handler消息机制。

线程

为什么要有线程呢?

进程是运转的程序,是为了在CPU上实现多道编程而发明的一个概念。但是进程在一个时间只能干一件事情。如果想同时干两件事,例如同时看两场电影,我们自然想到传说中的分身术,就像孙悟空那样同时变出多个真身。当然,人在现实中进行分身是办不到的。但进程却可以办到,办法就是线程。线程就是我们为了让一个进程能够同时干多件事情而发明的“分身术”。

如果说,在操作系统中引入进程的目的是使多个程序并发执行以改善资源利用率及提高系统的吞吐量;那么,在操作系统中再引入线程,则是为了减少程序并发执行时所付出的时空开销,使操作系统具有更好的并发性。为了说明这一点,首先回顾进程的两个基本属性:一是进程是一个可拥有资源的独立单位;二是进程同时又是一个可以独立调度和分派的基本单位。正是由于进程具有这两个基本属性,才使之成为一个能独立运行的基本单位,从而也就构成了进程并发执行的基础。

简而言之,由于进程是一个资源拥有者,因而在进程的创建、撤销和切换中,系统必须为之付出较大的时空开销。也正因如此,在系统中所设置的进程数目不宜过多,进程切换的频率也不宜过高,但这也就限制了并发程度的进一步提高。 如何能使多个程序更好地并发执行,同时又尽量减少系统的开销,这已成为近年来设计操作系统时所追求的重要目标。有不少操作系统的学者们考虑:可否将进程的上述两个属性分开,由操作系统分开进行处理?即对作为调度和分派的基本单位,不同时作为独立分配资源的单位,以使之轻装运行。正是在这种思想的指导下,产生了线程概念。这样线程就保留了并发的优点,避免了进程切换的代价。

线程的定义

在引入线程的操作系统中,线程是进程的一个实体,是被系统独立调度和分派的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程;同一进程中的多个线程之间可以并发执行。

在传统的操作系统中,拥有资源的基本单位和独立调度、分派的基本单位都是进程。而在引入线程的操作系统中,则把线程作为调度和分派的基本单位,而把进程作为资源拥有的基本单位,使传统进程的两个属性分开,线程便能轻装运行,从而可显著地提高系统的并发程度。在同一进程中,线程的切换不会引起进程切换;在由一个进程中的线程切换到另一个进程中的线程时,将会引起进程切换。所以多线程技术是指把执行一个应用程序的进程划分为可以同时运行的多个线程。

线程不像是进程那样具备较强的独立性。同一个进程中的所有线程都会有完全一样的地址空间,这意味着它们也共享同样的全局变量。由于每个线程都可以访问进程地址空间内每个内存地址,「因此一个线程可以读取、写入甚至擦除另一个线程的堆栈」。线程之间除了共享同一内存空间外,还具有如下不同的内容:

image

上图左边的是同一个进程中每个线程共享的内容,上图右边是每个线程中的内容。也就是说左边的列表是进程的属性,右边的列表是线程的属性。

「线程之间的状态转换和进程之间的状态转换是一样的」

每个线程都会有自己的堆栈,如下图所示:

image

线程的创建

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);

功能: 创建一个线程
参数:

  • thread: 线程标识符地址
  • attr: 线程属性结构体地址
  • start_routine: 线程函数的入口地址
  • arg: 传给线程函数的参数

成功返回0,失败返回非0.

与进程fork()函数不同的是pthread_create()创建的线程不与父线程在同一点开始运行,而是从指定的函数开始运行,该函数运行完后,该线程也就退出了。

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *thread_fun(void *arg) {
    printf("thread start run");
}

int main() {
    printf("main thread run");
    pthread_t thread;
    if (pthread_create(&thread, NULL, thread_fun, NULL) != 0) {
        perror("fail to create thread");
        exit(1);
    }
    // 由于进程结束后,进程中所有的线程都会强制退出,所以现阶段不要让进程退出
    // 等待子线程执行,不然程序到这里直接结束,子线程还没来的及运行
    system("sleep 3"); 
    return 0;
}

线程退出

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

功能: 堵塞等待一个子线程的退出,可以接收到某一个子线程调用pthread_exit时设置的退出状态值。

参数:

  • thread: 指定线程的id
  • retval: 保存子线程的退出状态值,如果不接受则设置为NULL。

线程和进程的区别

单个线程的状态与进程状态非常类似。线程有一个程序计数器(PC),记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。所以,如果有两个线程运行在一个处理器上,从运行一个线程(T1)切换到另一个线程(T2)时,必定发生上下文切换(context switch)。线程之间的上下文切换类似于进程间的上下文切换。对于进程,我们将状态保存到进程控制块(Process Control Block,PCB)。现在,我们需要一个或多个线程控制块(Thread Control Block,TCB),保存每个线程的状态。但是,与进程相比,线程之间的上下文切换有一点主要区别:地址空间保持不变(即不需要切换当前使用的页表)。

线程和进程之间的另一个主要区别在于栈。在简单的传统进程地址空间模型 [我们现在可以称之为单线程(single-threaded)进程] 中,只有一个栈,通常位于地址空间的底部,然而,在多线程的进程中,每个线程独立运行,当然可以调用各种例程来完成正在执行的任何工作。不是地址空间中只有一个栈,而是每个线程都有一个栈。假设有一个多线程的进程,它有两个线程,结果地址空间看起来不同。如下图: Image

在图中,可以看到两个栈跨越了进程的地址空间。因此,所有位于栈上的变量、参数、返回值和其他放在栈上的东西,将被放置在有时称为线程本地(thread-local)存储的地方,即相关线程的栈。你可能注意到,多个栈也破坏了地址空间布局的美感。以前,堆和栈可以互不影响地增长,直到空间耗尽。多个栈就没有这么简单了。幸运的是,通常栈不会很大(除了大量使用递归的程序)。

线程系统调用

系统调用:用户在编程时可以调用的操作系统功能。 系统调用是操作系统提供给编程人员的唯一接口。 可以使CPU状态从用户态陷入内核态。每个操作系统都提供几百种系统调用(进程控制、进程通信、文件使用、目录操作、设备管理、信息维护等).

image

进程通常会从当前的某个单线程开始,然后这个线程通过调用一个库函数(比如 thread_create)创建新的线程。线程创建的函数会要求指定新创建线程的名称。创建的线程通常都返回一个线程标识符,该标识符就是新线程的名字。

当一个线程完成工作后,可以通过调用一个函数(比如 thread_exit)来退出。紧接着线程消失,状态变为终止,不能再进行调度。在某些线程的运行过程中,可以通过调用函数例如 thread_join ,表示一个线程可以等待另一个线程退出。这个过程阻塞调用线程直到等待特定的线程退出。在这种情况下,线程的创建和终止非常类似于进程的创建和终止。

另一个常见的线程是调用 thread_yield,它允许线程自动放弃 CPU 从而让另一个线程运行。这样一个调用还是很重要的,因为不同于进程,线程是无法利用时钟中断强制让线程让出 CPU 的。

线程实现

线程的调度却与进程调度有稍许不同。由于线程是在进程的基础上产生的概念(进程里面的一个执行序列),其调度可以由进程负责。当然,我们也可以将线程的调度交给操作系统。而这两种不同的调度推手就形成了线程的两种实现:用户态实现和内核态实现。由进程自己管理就是用户态线程的实现,由操作系统管理就是内核态线程实现。用户态和内核态的判断以线程表所处的位置为依据:位于内核叫内核态实现,位于用户层叫用户态实现。所以线程的实现主要有三种实现方式:

  • 在用户空间中实现线程;
  • 在内核空间中实现线程;
  • 在用户和内核空间中混合实现线程。

下面我们分开讨论一下

在内核中实现线程

由操作系统来管理线程有很多好处,最重要的好处是用户编程简单。因为线程的复杂性由操作系统承担,用户程序员在编程时无需管理线程的调度,即无需担心线程什么时候会执行、什么时候会挂起。另外一个重要好处是,如果一个线程执行阻塞操作,操作系统可以从容地调度另外一个线程执行。因为操作系统能够监控所有的线程。

那么内核态线程实现有什么缺点呢?首先是效率较低。因为线程在内核态实现,每次线程切换都需要陷入到内核,由操作系统来进行调度。而从用户态陷入到内核态是要花时间的。另外,内核态实现占用内核稀缺的内存资源,因为操作系统需要维护线程表。操作系统所占内存空间一旦装载结束后就已经固定,无法动态改变。由于线程的数量通常大大多于进程的数量,因此随着线程数量的增加,操作系统内核空间将迅速耗尽。

如果要建立进程线程,但内核空间不够了,怎么办?我们可以做的选择有:“杀死”别的进程;创建失败;让它等一下。前面说过,“杀死”别的进程是一件很不好的事情,因为将造成服务不确定性。宣称创建失败也很差。因为创建失败有可能意味着某个进程无法往前推进,这违反了我们前面说过的进程模型的时序推进要求。让创建者等一下,这要看创建的是什么进程和线程了。如果是系统进程线程,等一下可能意味着关键服务无法按时启动;如果是用户进程线程,等一下可能引起用户的强烈不满。而且,等多久谁也不知道。那在内核空间满了后,应该怎么办呢?打一个战场上的比方就清楚了。如果战场上对手太厉害了,想再调个师的军队,结果没有,怎么办?投降。也就是说,如果内核空间溢出,操作系统将停止运转。因为要创立的进程可能很重要,所以不能不创建。所以最好的结局是“死掉”。别人发现系统“死了”就会采取行动来补救。如果操作系统还要运转,却不能正确地运转,那是很危险的事情。操作系统采取的这种行动在灾难应对领域称为“无害遽止”。但上面两个缺点还不是最致命的。最致命的是内核态实现需要修改操作系统,这在线程概念提出之初是一件很难办到的事情。试想,如果你作为研究人员提出了线程概念,然后你去找一家操作系统研发商,要求其修改操作系统,加入线程的管理,结果会怎样?操作系统开发商会请你走开。有谁敢把一个还未经证明的新概念加入到对计算机影响甚大的操作系统里?除非我们先证明线程的有效性,否则很难说服他人修改操作系统。这样,就有了线程的用户态实现。

现在我们考虑使用内核来实现线程的情况,此时不再需要运行时环境了。另外,每个进程中也没有线程表。相反,在内核中会有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用通过对线程表的更新来完成线程创建或销毁工作。

当某个线程希望创建一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用通过对线程表的更新来完成线程创建或销毁工作。

image

内核中的线程表持有每个线程的寄存器、状态和其他信息。这些信息和用户空间中的线程信息相同,但是位置却被放在了内核中而不是用户空间中。另外,内核还维护了一张进程表用来跟踪系统状态。

所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以进行选择,是运行在同一个进程中的另一个线程(如果有就绪线程的话)还是运行一个另一个进程中的线程。由于在内核中创建或者销毁线程的开销比较大,所以某些系统会采用可循环利用的方式来回收线程。当某个线程被销毁时,就把它标志为不可运行的状态,但是其内部结构没有受到影响。稍后,在必须创建一个新线程时,就会重新启用旧线程,把它标志为可用状态。

如果某个进程中的线程造成缺页故障后,内核很容易的就能检查出来是否有其他可运行的线程,如果有的话,在等待所需要的页面从磁盘读入时,就选择一个可运行的线程运行。这样做的缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止)比较多,就会带来很大的开销。

在用户空间中实现线程

把整个线程包放在用户空间中,内核对线程一无所知,它不知道线程的存在。所有的这类实现都有同样的通用结构:

image

运行时系统(Runtime System) 也叫做运行时环境,该运行时系统提供了程序在其中运行的环境。此环境可能会解决许多问题,包括应用程序内存的布局,程序如何访问变量,在过程之间传递参数的机制,与操作系统的接口等等。编译器根据特定的运行时系统进行假设以生成正确的代码。通常,运行时系统将负责设置和管理堆栈,并且会包含诸如垃圾收集,线程或语言内置的其他动态的功能。

在用户空间管理线程时,每个进程需要有其专用的线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态。该线程表由运行时系统统一管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程的所有信息,与内核在进程表中存放的信息完全一样。

在用户空间中实现线程要比在内核空间中实现线程具有这些方面的优势:考虑如果在线程完成时或者是在调用 pthread_yield 时,必要时会进程线程切换,然后线程的信息会被保存在运行时环境所提供的线程表中,然后,线程调度程序来选择另外一个需要运行的线程。保存线程的状态和调度程序都是本地过程所以启动他们比进行内核调用效率更高。因而不需要切换到内核,也就不需要上下文切换,也不需要对内存高速缓存进行刷新,因为线程调度非常便捷,因此效率比较高

在用户空间实现线程还有一个优势就是它允许每个进程有自己定制的调度算法。例如在某些应用程序中,那些具有垃圾收集线程的应用程序(知道是谁了吧)就不用担心自己线程会不会在不合适的时候停止,这是一个优势。用户线程还具有较好的可扩展性,因为内核空间中的内核线程需要一些表空间和堆栈空间,如果内核线程数量比较大,容易造成问题。

尽管在用户空间实现线程会具有一定的性能优势,但是劣势还是很明显的,你如何实现阻塞系统调用呢?假设在还没有任何键盘输入之前,一个线程读取键盘,让线程进行系统调用是不可能的,因为这会停止所有的线程。所以,使用线程的一个目标是能够让线程进行阻塞调用,并且要避免被阻塞的线程影响其他线程

与阻塞调用类似的问题是缺页中断问题,实际上,计算机并不会把所有的程序都一次性的放入内存中,如果某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令,这就称为缺页故障。而在对所需的指令进行读入和执行时,相关的进程就会被阻塞。如果只有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会把整个进程阻塞直到磁盘 I/O 完成为止,尽管其他的线程是可以运行的。

另外一个问题是,如果一个线程开始运行,该线程所在进程中的其他线程都不能运行,除非第一个线程自愿的放弃 CPU,在一个单进程内部,没有时钟中断,所以不可能使用轮转调度的方式调度线程。除非其他线程能够以自己的意愿进入运行时环境,否则调度程序没有可以调度线程的机会。

在用户和内核空间中混合实现线程

鉴于用户态和内核态的线程模型都存在缺陷,因此现代操作系统将二者结合起来使用。用户态的执行系统负责进程内部线程在非阻塞时的切换;内核态的操作系统负责阻塞线程的切换。即我们同时实现内核态和用户态线程管理。其中内核态线程数量较少,而用户态线程数量较多。每个内核态线程可以服务一个或多个用户态线程。换句话说,用户态线程被多路复用到内核态线程上。例如,某个进程有5个线程,我们可以将5个线程分成两组,一组3个线程,另一组2个线程。每一组线程使用一个内核线程。这样,该进程将使用两个内核线程。如果一个线程阻塞,则与其同属于一组的线程皆阻塞,但另外一组线程却可以继续执行。

结合用户空间和内核空间的优点,设计人员采用了一种内核级线程的方式,然后将用户级线程与某些或者全部内核线程多路复用起来

image

在这种模型中,编程人员可以自由控制用户线程和内核线程的数量,具有很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。

线程从用户态进入内核态

什么情况会造成一个线程从用户态进入到内核态呢?首先,如果在程序运行过程中发生中断或异常,系统将自动切换到内核态来运行中断或异常处理机制。此外,程序进行系统调用也将造成从用户态进入到内核态的转换。

例如,一个C++程序调用函数cin。cin是标准命名空间STD中的一个流对象,它调用C函数库里面的scanf()库函数,scanf()库函数则进一步调用操作系统的READ函数来真正获取用户输入。这里的READ函数实际上是由操作系统提供的一个系统调用(需要注意的是,很多高级语言的函数库里也包含名为READ的库函数,不过此READ非彼READ)。其执行过程如下:

  1. 执行汇编语言里面的系统调用指令(如syscall)。
  2. 将调用的参数SYS_READ、file number,size存放在指定的寄存器或栈上(事先约好)。
  3. 当处理器执行到"syscall"指令时,察觉这是一个系统调用指令,将进行如下操作:
    • 设置处理器至内核态。
    • 保存当前寄存器(栈指针、程序计数器、通用寄存器)。
    • 将栈指针设置指向内核栈地址。
    • 将程序计数器设置为一个事先约定的地址上。该地址上存放的是系统调用处理程序的起始地址。
  4. 系统调用处理程序执行系统调用,并调用内核里面的READ函数。这样就实现了从用户态到内核态的转换,并完成系统调用所要求的功能。

线程同步

引入线程后,也引入了一个巨大的问题,即多线程程序的执行结果有可能是不确定的。而不确定则是我们人类非常反感的东西。那么如何在保持线程这个概念的同时,消除其执行结果的不确定性呢?答案是线程的同步。

线程同步的目的就是不管线程之间的执行如何穿插,其运行结果都是正确的。或者说,要保证多线程执行下结果的确定性。而在达到这个目标的同时,要保持对线程执行的限制越少越好。除此之外,线程同步的另外一个目的涉及执行效率。除了前面说过的多线程执行的结果是不确定的之外,其执行效率也是不确定的。比如,在某段时间内,线程A执行了5条指令,而线程B只执行了3条指令。线程A比线程B多执行了两条指令。但这并不是问题的关键。问题的关键是到底线程A是否比线程B执行的多,或者是多多少等,皆是不确定的。如果我们想使其变得确定,就需要进行线程同步。

锁有两个基本操作:

  • 闭锁

    闭锁就是将锁锁上,其他人进不来

  • 开锁

    开锁就是你做的事情做完了,将锁打开,别的人可以进去了。闭锁操作有两个步骤,分别如下:

    • 等待锁达到打开状态
    • 获得锁并锁上

死锁

如果有一组线程,每个线程都在等待一个事件的发生,而这个事件只能由该组线程里面的另一线程发出,则称这组线程发生了死锁。

死锁的出现需要同时满足下面四个条件

  • 互斥(Mutual Exclusion):一次只能有一个进程使用资源。如果另一个进程请求该资源,则必须延迟请求进程,直到释放该资源为止。
  • 保持并等待(Hold and Wait):必须存在一个进程,该进程至少持有一个资源,并且正在等待获取其他进程当前所持有的资源。
  • 无抢占(No Preemption):资源不能被抢占,也就是说,在进程完成其任务之后,只能由拥有它的进程自动释放资源。
  • 循环等待(Circular Wait) :必须存在一组 {p0,p1,..... pn} 的等待进程,使 p0 等待 p1 持有的资源,p1 等待由 p2 持有的资源, pn-1 正在等待由 pn 持有的资源,而 pn 正在等待由 p0 持有的资源。

多道编程或者多线程编程除了要应对死锁之外,还有一个问题必须面对:饥饿。更准确地说是资源饥饿。资源饥饿指的是某个线程一直等不到它所需要的资源,从而无法向前推进。这就像一个人因饥饿而无法成长一样。刚听上去饥饿像是一个与死锁不同的问题。但仔细考虑,却发现它实际上是死锁的一种通例。因为处于死锁状态的所有线程均无法获得其需要的资源,所以都处于饥饿状态。死锁实际上就是饥饿。对于处于饥饿状态的线程来说,它因为无法往前推进,又有一点死锁的味道。因此,饥饿有时也称为死锁的“孪生兄弟”。不过,这里需要注意的是,饥饿和死锁还是有所不同的。处于死锁的线程必须多于1个,且处于一个死锁线程组的线程所需资源均为该组里其他线程所持有。而处于饥饿的线程可以只有1个,并且其所需要的资源可以被任何线程所占有。

除此之外,饥饿还有另外一种特例:活锁。在活锁状态下,处于活锁线程组里的线程状态可以改变,但是整个活锁组的线程无法推进。活锁可以用两个人过一条很窄的小桥来比喻:为了让对方先过,两个人都想给对方让路而闪身到一边,但由于两个人都同时进行此种动作,有可能两个人都同时运动到左边,然后又同时运动到右边。这样,虽然两个人的状态一直在变化,但却都无法往前推进。由此可见,活锁是死锁的通例。

从上述分析可以看出,死锁、活锁、饥饿是一个包含与被包含的关系。死锁是活锁的特例,而活锁又是饥饿的特例。在多道编程时,我们不只要应对死锁,还需要应对活锁和饥饿,而由于它们之间存在类似性,死锁应对的手段也可以在一定程度上用来应对活锁和饥饿,因为活锁和饥饿是死锁的通例,且死锁是无法完全避免的,活锁和饥饿也是不可完全避免的。

Image

死锁解决方法

  • 鸵鸟策略:不理睬
  • 预防策略:破坏产生条件中任意一个
  • 避免策略: 精心分配资源,动态避免死锁
  • 检测与解除死锁:系统自动检测,并且解除

进程和线程的区别

进程有如下两个特点:

  • 资源所有权:进程包括存放进程映像的虚拟地址空间,进程映像是程序、数据、栈和进程控制块中定义的属性集。进程总具有对资源的控制权和所有权,这些资源包括内存、I/O通道、I/O设备和文件。操作系统能提供预防进程间发生不必要资源冲突的保护功能。
  • 调度/执行:进程执行时采用一个或多程序的执行路径,不同进程的执行过程会交替执行。因此进程具有执行态和分配给其的优先级,是可被操作系统调度和分派的实体。

上面这两个特点是独立的,因此操作系统能分别处理它们。为了区分这两个特点,通常将分派的单位成为线程或轻量级进程,而将拥有资源所有权的单位成为进程或任务。

  • 线程(thread):可分派的工作单元。它包括处理器上下文环境(包含程序计数器和栈指针)和栈中自身的数据区域。线程顺序执行且可以中断,因此处理器可以转到另一个线程。
  • 进程(process):一个或多个线程和相关系统资源(如包含程序和代码的存储空间、打开的文件和设备)的集合。它严格对应于一个正在执行的程序的概念。通过把一个应用程序分解成多个线程,程序员可以很大程度上控制应用程序的模块性及相关事件的时间安排。

线程比进程更轻量级,所以它们比进程更容易(更快)创建,也更容易撤销。在许多系统中,创建一个线程比创建一个进程要快10~100倍。线程会共享它所在进程的地址空间和其他资源。

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

  1. 简而言之,一个程序至少有一个进程,一个进程至少有一个线程。
  2. 线程的划分尺度小于进程,使得多线程程序的并发性高。
  3. 另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
  4. 线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
  5. 从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

一个程序的执行过程

#include <stdio.h>
int main(int argc, char *argv[]) {
    puts("hello world");
    return 0;
}
  1. 用户通过命令或者图标点击等通知操作系统执行hello world程序。
  2. 操作系统会去找到hello world程序的相关信息,检查其类型是否是可执行文件,并通过程序的首部信息,确定代码和数据在可执行文件中的位置并计算出对应的磁盘块地址。
  3. 操作系统创建一个新的进程,并将hello world程序的执行文件映射到该进程结构,表示由该进程执行该hello world程序。
  4. 操作系统为hello world程序设置cpu上下文环境并跳到程序开始处。
  5. 执行hello world程序的第一条指令,这时候会发生缺页异常(内存中没有该程序)
  6. 操作系统开始分配一页物理内存,并将前面计算出的磁盘块地址将代码从磁盘读入内存,然后继续执行hello world程序。
  7. hello world程序执行puts函数(系统调用),想要在显示器上写入字符串。
  8. 操作系统找到要将字符串送往的显示设备,通常设备是由一个进程控制的,所以,操作系统将要写的字符串送给该进程。
  9. 控制设备的进程告诉设备的窗口系统它要显示字符串。窗口系统确定这是一个合法的操作,然后将字符串转换成像素,将像素写入设备的存储影响区。
  10. 视频硬件将像素转换成显示器可接收的一组控制/数据信号。
  11. 显示器解释信号,激发液晶屏。
  12. 这样我们就能在屏幕上看到了"hello world"。