Scj System Analyst

进程剖析


本文将根据自己的经验站在进程的设计者和实用者的双重高度,揭开进程的神秘面纱, 具体内容将涉及到进程的由来、进程的结构、进程的生命周期、进程信息的捕捉、进程的调试等

在现代操作系统中,进程是一个最基本的概念,它是线程(处理机)和资源的容器,是操作系统管理软硬件资源的基本管理单位。 你可以把进程看做对软硬件资源的封装,既然是封装,必将涉及到数据结构及其对数据结构的使用策略(换句话说,就是建立在数据结构基础上 的一些操作或数据结构管理策略,这是内部管理的需要)和外界使用它需要的接口(这是为外部提供服务的需要)。

进程的由来

进程是由操作系统系统引入的,那么进程的由来和操作系统的发展史分不开的。事实上,进程就是伴随着操作系统的进化而发展起来的。

操作系统简史

最开始是没有操作系统的,是通过人工控制开关的方式以卡片作为媒介把输入、计算、输出打印等过程衔接起来的。这种人工方式是非常低效的。 于是人们试图将这些过程自动化。而磁带(后来则出现了硬盘)作为输入输出的缓冲和存储设备使自动化得以实现。如何顺利实现这些设备的衔接呢?如何在 一个作业输入完成后马上输入另一个作业,计算完一个作业之后马上计算另一个作业等?这至少有两种解决方案:

  • 由前一个作业负责后一个作业的调度:

即,后一个作业的调度信息写在前一个作业中,这样就可以等前一个作业执行完之后马上调度下一个作业。比如,第一个作业输入完之后马上启动第二个作业 的输入,第一个作业并马上开始计算,计算完之后马上计算第二个作业……,如此便实现了无缝衔接和完全自动化。

不过这种方案耦合性太强,因为需要知道作业执行的一次顺序才能依次添加后续作业的调度信息,而且一旦上一个作业执行出错而没有执行到下一个作业的调度 信息处,那么很有可能所有作业都将中断,此时免不了人工干预来恢复正常。

  • 把调度信息从作业中分离开来,当做成为监督程序

监督程序负责自动的、成批的处理一个或多个用户的作业(作业包括程序、数据、命令),包括作业之间的切换。实际上这就是批处理系统的雏形。 不过这是输入输出是联机完成的,CPU 需要大量干预输入输出操作。可见,,批处理系统使得主机从输入和输出中某种程度的解放出来了。因为输入和输出通常是比较慢的, 如果联机输入和输出的话,必然浪费具有更快速度的主机时间。 只有使输入、输出与主机运算达到并行才能尽可能的提高主机利用率。

为了克服与缓解告诉主机与慢速外设(输入输出设备),提高 CPU 利用率,用又引入了脱机批处理系统,即输入输出脱离主机控制。出现了脱机批处理系统。不过, 此时内存中仍然只能驻留一道程序(毕竟内存太小了,而且程序是整体载入的,没有把程序分段加载),这就意味着,在输入输出结束(没有空闲的输入输出设备用于下一个 作业)时,CPU 仍然需要等待,即使下一个作业暂时不需要 IO 设备(因为内存中只能存放一道程序,在输入输出时,该道程序不能脱离内存来支持 IO 操作)而只需要 CPU 计算。

随着内存的增大和硬盘的出现以及容量的增大,此时出现了多道程序假脱机技术。内存中可以:

  • 驻留多道程序;
  • 输入可以先输入到硬盘,然后再通过硬盘输入到内存;
  • 输出则可以先输出到硬盘,然后再通过硬盘输出到设备。

此处的硬盘相当于输入输出的缓冲设备。这就好像泉水一点点的涌出(如同慢速设备),而你需要一大桶水,需要你直接用桶去装,并且等着它满,那你多少回有点不耐烦, 至少你会打开手机去做其他事情;这种情况,你不妨在泉水涌出的地方挖一个池子,下次你需要水的时候,提着桶一下就可以打满了(甚至可能打好几桶水),而这一切你不再需要等待, 这就是池子作为缓冲作用带来的好处。于是 CPU 就从输入输出中解放出来了。那 CPU 多出的时间用来干嘛呢?不妨让正在输入输出的程序脱离 CPU 的控制,将 CPU 让给同时 驻留在内存中的其他程序。你可能会问,输入输出设备不是还被占用着么?其实,不同的程序需要的设备不一样,同一设备使用的时间长短和频次也不一样,计算和输入输出的时间比例 也不一样,甚至同一个程序中使用设备的先后顺序无关紧要(这使改变程序使用设备的次序成为可能)。这样就极有可能找到一种方法,使这些过程穿插进行 ,提高设备和 CPU 的使用效率。比如 A 程序正在占用打印机进行输出,那么 B 程序就可以同时用 CPU 进行计算和使用显示器进行输出。可见,CPU 与设备之间、不同的 设备之间、不同的程序之间是可以并行的。

前面还是出现了一个低效的问题:一个设备自始至终只能由一个程序使用,除非它主动放弃。那需要使用该设备的其他用户就会等得不耐烦,这是就对在前面的空间 并行(不同设备之间)的基础上提出了时间分段使用设备的需求,即不同用户不同程序可以在一个时间段的不同时间片中使用同一设备,此时用户就会感觉自己的 程序也同时再跑,而且不断地输出结果,这就是分时系统

分时系统虽然提高了程序的响应时间和减少了等待时间,但是增加了程序从开始执行到完成时所需要的时间,因为其中一段时间中该程序只能使用一小片时间,这必然比程序独占 所有设备完成任务的时间要长,从而推迟了任务完成的时间。有些紧急用户就等不及了,便要求:我的任务必须在某某时间前完成,即提出了时间截的要求,于是实时系统 应运而生。

后来,随着计算机的普及,不同人使用计算机的时间不同,这就导致有的人的计算机性能不能满足要求(毕竟提高单机的性能代价太高),而有的人的计算机则太空闲了。于是人们想到: 能不能把不同人的计算机,不同地域的计算机都用起来以共同完成一项任务,这样就可能充分的利用闲散的计算机软硬件资源,同时降低对单机性能的要求,并降低成本。此时网络 操作系统分布式操作系统就出现了。

进程简史

没有操作系统的那个时候还没有进程这个概念,因为根本不需要,反正一道程序或作业霸占所有软硬件资源,此时的程序可以直接操作硬件资源(包括内存等),有时甚至需要人工观察 程序的进度,适时的打开相应设备的开关。随着监督程序的出现和程序的完全自动化,进入到了批处理系统时代,出现了作业的概念。作业把用户任务的提交、执行到完成 并输出结果看成一个整体,其输入、执行、输出可以单独进行,但输入(需要在输入队列中等待)、执行、输出(需要在输出队列中等待)的每一个阶段中都不可中断,也就是说,某个 作业一旦占用某个设备,必须等它用完之后,其他程序才能用,中间不允许作业之间穿插使用。

之后多道程序设计假脱机技术中断技术的出现改变了作业独占设备的特性,使得有些设备可以被多个作业交替穿插使用。不过也带来了管理上的难度,之前设备 只需要两个状态(忙、空闲)就可以了,但是现在还需要记录“忙”的时候是被那个程序占用,当 A 程序被 B 程序抢占时,A 程序执行到哪里,此时设备的各个部件的状态是怎么样的 等等都需要记录下来,以便再次执行 A 程序时能够恢复到原来的状态。不仅如此,CPU 也可以被抢占、内存中不再只有一道程序,程序在内存的物理地址不再总是从零开始。所以 CPU 的状态和程序在内存中的位置都需要记录,有时需要换人换出等还需要记录被换出到硬盘中的位置,如此多的信息需要记录和处理,之前的作业概念已经不够用了。

而且程序在内存中走走停停,需要不断的保护现场和恢复现场、不同用户对不同程序的要求不一样(有的要求尽快完成、有的要求尽快响应、有的要求尽可能的节约费用等),这就需要 在管理好如此多的信息的基础上不断优化调度,实现设备利用率和用户满意度最大化。

作业这个模型要求程序要么不执行要么执行完成,不能中断或暂停,这显然不再适应引入了多道程序和中断技术而允许程序执行过程中走走停停的系统。于是提出了 进程模型。进程可以简单理解城进行中的程序(程序已经开始执行,但还没有完成)。相比作业而言,进程作业中的执行过程从作业输入队列和 作业输出队列中脱离开来,单独把作业的执行作为研究对象,并把该执行过程进一步分解成可以并发或并行的子过程,以便进一步提高 CPU、内存、其他输入输出设备等的利用率和 吞吐量。

进程相关的数据结构

这里不会详细列出进程相关的数据结构,只是粗略谈谈其应有的数据成分。上一节讲了进程简史,应该对进程提出时的北京有所了解了。进程是在多道程序和中断技术得到 应用的情况下提出的。后面将基于这个事实展开讨论。

进程如何应对“多道程序”

所谓多道程序,可以简单理解成:多道程序同时驻留内存并可在同一时间段并发执行。可见,多道程序必将共享“之前只能一道程序独享的所有软硬件资源”,当然一般而言,所有 设备同一时刻只能供一道程序使用,只不过多道程序可以在不同时刻使用同一资源、同一时刻使用不同资源、同一时刻使用同一资源的不同部分。

为支持“多道程序”,也就是支持进程模型,需要哪些数据结构或数据字段呢?

值得注意的是,进程作业进化而来,按照用户的观点而言,不论你采用作业还是进程模型都不应该影响到任务的执行和完成,换句话说,进程至少应该带给用户与作业模型 相同的体验,甚至更好的体验。

作业独占内存和处理机及其他资源,所以只要程序和程序的执行环境一定,其结果必须是确定的,而且结果和程序及其所属用户是对应起来的,也就是说,用户要结果的时候,能够从众多用户和程序 中识别出来。前面已经提及过了,进程作业的执行环境是不同的,但是至少要达到没有引入进程之前相同的效果。以下讨论要达到相同效果应具备的条件:

  • 用户角度:

用户在多道程序多用户的环境下,需要识别出属于本用户的进程及其统计信息(以便付费、改善程序或者找出程序中的错误),并且需要保证本用户的程序和数据不被其他用户使用(安全性要求)。这 就需要进程标识符来区分和归类进程,以根据不同的进程做不同的处理(比如实施不同的安全策略),还需要一些记账信息(比如使用了哪些设备,执行了多长时间等,以便计费)。

  • 管理者角度:

不同的用户有不同的需求,由于资源有限,管理者需要根据不同的需求和资源情况给出不同的对待方式(如哪一个用户的程序优先执行,哪一个进程优先使用打印机等), 以便尽量使更多的用户更满意,同时使资源的利用率更高。这就需要给不同的进程和设备设定不同的优先级等以便对进程、处理机和资源进行调配,而且需要根据情况使用不同的 调度策略等,这就需要给出进程调度信息

内存中同时存在多道程序,需要共享处理机和其他资源,那就需要记录处理机的状态和资源状态,以便根据状态结合进程的请求来分配处理机和资源,这就需要给出处理机状态资源分配 信息。当进程使用完处理机和资源后则需要回收,这也需要登记已回收资源以便重新调度资源。同时需要记录资源被使用情况,以便计费或下次运行相同或类似程序时提供优化的 可能。

有时候需要满足特殊用户特别紧急的需要,不得不抢占处理机或其他资源,这就需要保存中断时被抢断进程执行到的位置信息和处理机信息以及其他被抢断设备的 状态信息,即保护现场,等呗抢断的进程需要再度被执行时,则需要恢复现场。这种进程切换需要进程控制信息的支持。

有时,用户为了抢占更多的资源以更快的完成任务,会把一个程序分解城多个进程来执行(毕竟进程是操作系统的分配资源的基本单位,这样一个用户)或者虽然提交的是一个进程,但是该进程又创建了一个进程。 管理者需要识别出这种情况,也就是说,管理者需要识别并记录进程之间的关系,以便更好的调度,同时尽可能地节约资源完成这些进程之间的通信。这需要```进程亲属关系信息``的支持。 `可见,进程是用户之间、用户与管理者之间、管理者与资源之间博弈的结果。

以上种种,都是为了让多道程序中的每道程序都好像自己独占所有资源一样(目的),而进程描述信息就是为了实现这一目的而添加的。这就好像“撒了一个谎,就要一千个谎来圆”一样(前面 的目的就是第一个谎,而进程描述信息以及其他的配套设施就是后面的一千个谎 ^_^)。

进程如何应对“中断技术”

为了应对和更好的使用“中断技术”,进程需要记录中断时 CPU、内存及其他设备的状态信息以便中断返回或再次回到中断前的运行状态时恢复现场。中断包括硬中断软中断,硬中断 是硬件报告的状态信息,一般来说这种中断是不能屏蔽的,而软中断是进程报告其运行进度和请求的状态信息,这种中断是可以根据某种规则屏蔽的或者自行定制中断处理程序。软中断最有名 的就是信号机制

为更好的使用中断技术,演化出了信号机制

信号机制的相关数据结构也被与进程关联起来,以支持虚拟终端(把终端也改造成了多个,让多用户多道程序误以为自己独占终端)。信号机制很多情况只面向特点的用户、用户组和特定的 终端。硬件中断将会影响所有进程,而信号(软中断)则只影响特定的进程或特定范围内的进程。

如果你是一个嵌入式开发者,很可能需要简化操作系统,因为嵌入式设备只需运行特定的少量进程,所以可以简化一下进程调度信息、信号机制等模块、数据结构或调度策略。设置你可以实现一个 简单的操作系统用于该嵌入式设备。

兼容作业模型

作业模型中有一张描述资源的全局表,进程模型为了兼容作业模型,同时为了统筹分配资源,也有一些全局性的记录资源情况的表格。这些信息是为了支持全局性的需求,比如,回答诸如“计算机 的外部设备有哪些?”之类的存在性问题,它是进程资源分配的基础。

进程组织方式

上一节只谈到了如何区分进程,而没有说如何区分没有正在运行的程序和正在运行的程序(进程)。实际上,操作系统是以进程控制块(PCB)作为进程存在的唯一标志。PCB 实际上是上一节 已经说过的进程描述信息的数据结构。操作系统管理进程和资源都是通过 PCB 来完成的。

计算机在同一时段可能存在许多进程,也就意味着有许多 PCB,那这些 PCB 如何组织呢?PCB 的组织方式实际上就是进程的组织方式。进程组织起来的目的是为了更好更快地完成进程切换和调度,提高 CPU 和其他资源的利用率。而进程切换实际上就是进程状态的改变,因此进程的组织应根据进程的状态来进行,同时需要适应进程切换和调度算法。

系统中有许多处于不同状态的进程,同时阻塞(进程的一个具体状态)的原因也可能各不相同,所以需要不同的队列将它们组织起来,以便对所有进程进行有效管理。这就需要适当的方式将 PCB 组织 起来。 有三种通用的队列组织方式:线性方式、链接方式和索引方式。

  • 线性方式:

把所有进程的 PCB 都放在一个线性表中。该线性表是静态分配空间。为了采用某种调度算法,必须扫描整个线性表,从而降低了调度效率。这种方式适合状态多或切换时机多但进程少的情况。

  • 链接方式:

根据不同的进程状态和阻塞原因或其他某种特定需求(如优先级,每个优先级一个队列)分成不同的队列。从而减少了每次扫描的 PCB 数,提高了灵活性和效率。灰机上,只要组织恰当,只需要在队列两端 操作即可,不需要扫描。这种方式适合进程多且进程切换时机相对较少的情况。

  • 索引方式:

索引方式是线性方式的一种改进,结合了链接方式的优点。可以认为索引方式是用静态链表的方式来实现的链接方式。这种方式可以结合“链接方式”以实现更快速的定位,例如操作系统如果打算只支持 128 个优先级,那么就可以将 128 个队列的队头存入有 128 个单元的数组(线性表)中(实质上是一种哈希表),如此便可以随机定位到队头(如果该队列给出了尾指针,也可以随机定位到队尾),那么此时 所有队列的所有操作的时间复杂度就是 O(1)。这种方式适合进程多且进程切换时机也较多的情况。

前面说的组织方式是针对 PCB 之间的,那 PCB 内部该如何组织诸多的项目呢?

随着外部设备和 CPU 数目的增加以及各种需求的增加,导致描述进程的数据项越来越多,有的操作系统一个 PCB 就占了上 k 的内存,有几百个数据项。如此多的数据项,如果不组织好的话,很难适应变化 着的需求,也不能客观反映进程间和需求间的关系。比如许多进程同属于一个用户,安全控制策略相同,操作的数据相同而只是数据处理的方式不同等。这样就可以采取某种措施将可能相同的部分单独分离 出来,然后不同的进程用一个指针指向它就可以了,并且增加一个计数,当没有指向它的进程时再释放这部分内存。也可以将经常需要访问的数据单独成为一部分,然后把所有进程的这部分以最快最适合的 方式组织起来,换句话说,根据数据项本省的特点以及需求可以将 PCB 中的数据项分门别类,然后分别以最合适的方式组织起来,这使得以最低的代价应对快速变化的需求以及最真实最高效的反映客观 规律成为可能。例如,在进程结束生命周期之后,其他进程需要统计有关该进程的一些信息,那就没有必要将 PCB 中的所有项都长期驻留在内存等待统计,只把需要的信息留下来即可,但是如果事先的 PCB 没有把这些信息较好的组织起来,很有可能需要把这些信息重新拷贝到特定的区域,很显然太浪费时间了;要是一开始就合理的分离了这些信息,只需要保留这部分信息并保存指向存储这部分信息的指针即可。

有些数据项只在进程执行的某个阶段有效,其他阶段已经失去了意义,其实针对这种情况,也可以把它们单独分离开来,然后组织起来,适时进行处理货释放,以便更大程度的节约资源和提高效率。不过值得 提醒的是,这些高效方式很可能增加 PCB 的设计复杂度,有时需要权衡。

进程的生命周期

进程是操作系统分配处理机(当有线程概念的操作系统,则线程是处理机的分配单位)和其他资源(如内存)分配的基本单位。所以进程的生命周期与处理机和资源的申请、等待资源、拥有、被剥夺、放弃占有 等行为和状态密切相关。

进程状态

进程的生命周期可以用进程的状态来描述,根据进程在执行过程中不同情况需要定义不同的状态。

三态模型:
  • 运行态:进程正占用处理器。
  • 就绪态:只缺处理器资源。
  • 等待(阻塞)态:正在等待某个时间完成或资源空闲而不具备运行条件

进程三态模型

五态模型

在很多系统中,增加两个进程状态:新建态和终止态。

  • 新建态:

对应于进程被创建时的状态。又是将根据系统性能的要求或主存容量的限制推迟新建态进程的提交。创建进程需要两个步骤:
①为新进程分配所需资源,建立必要的 管理信息;
②设置此进程为就绪态,等待被调度执行。

  • 终止态:

处于终止态的进程不再被调度执行,下一步将被系统撤销,最终从系统中消失。类似地,进程终止也要通过两个步骤实现:
①等待操作系统或相关进程进行善后处理 (如抽取信息);
②回收被占用的资源并由系统删除进程。

进程终止通常由下列条件引起:
  • 正常退出(自愿的);
  • 出错退出(自愿的);
  • 严重错误(非自愿的);
  • 被其他进程杀死(非自愿的)。

具有挂起功能的进程状态

很多系统引入了挂起状态。所谓挂起状态,实际上就是一种静止状态。一个进程被挂起之后,不管它是否在就绪状态,系统都不分配给它处理机。

引起挂起状态的原因:
  • 终端用户的请求
  • 父进程请求考察子进程的活动
  • 系统负荷的需要:资源紧缺,挂起不重要的进程。
  • 操作系统的需要:检查和统计运行中的资源使用情况。

挂起状态又引入了两个新状态:挂起就绪态和挂起等待态。挂起就绪态表明进程具备运行条件,但目前在辅存中,只有当进程被兑换到主存时才能被调度执行;挂起等待态则 表明进程正在等待某一事件发生且进程在辅存中。

引进挂起状态后,进程状态可分为新建态、活动就绪态、运行、活动阻塞、静止就绪、静止阻塞和终止状态。

进程状态转换

进程上下文切换

中断和异常是激活操作系统的仅有方法,它暂停当前运行进程的执行,把处理器切换至核心态,内核获得处理器的控制权之后,如果需要就可以实现进程切换。所以,进程切换 必定在核心态而非用户态发生。这种切换通过核心栈来完成。

内核在下列情况会发生上下文切换:

内核上下文切换

进程在运行过程中执行系统调用、产生中断或异常时,操作系统从当前运行进程那里获得控制权,此后,进程切换可以在任何时刻发生。

在执行进程上下文切换时,保存老进程的上下文且装入被保护的新进程的上下文,以便新进程运行。进程切换的实现步骤如下:

上下文切换步骤

进程的切换会引起进程状态的改变,进程的切换也需要以进程现有的状态作为依据,并根据进程调度策略来切换进程。从前面进程状态的描述和进程上下文切换中可以看出, 进程切换的时机或引起进程切换的原因是不同的,这也是选取进程组织方式的一个参考因素,也使得程序员尽量为自己的进程尽可能地夺取资源的做法提供了可能。 比如程序员可以通过合理安排申请和释放资源的时机、合理地利用多进程多线程等途径提高自身进程的竞争力。当然作为一个系统程序员,则需要尽可能地防止个别进程或用户多度消耗资源。 这就引出了安全性问题和公平性问题:

  • 安全问题:

用户进程可能破坏或擅自侵占已分配给其他用户或进程的资源,导致其他用户的数据遭到窃取或损坏,甚至致使其他进程出现异常。更有甚者,有意或无意的破坏操作系统,从而 破坏所有进程的执行环境,最后只能重启或重装系统。

  • 公平问题:

有的用户或进程为了尽快的执行完任务,会大量申请资源或长时间占有资源,导致其他用户或进程出现缺少运行的基本条件而长时间等待,这就失去了公平性,也不利于操作系统 尽可能地提高资源利用率和改善大多数用户体验。

为此,操作系统在进程切换的基础上引入了模式切换。

进程模式切换

进程模式切换,实际上就是处理器的模式切换,与进程上下文切换有关的是 CPU 模式切换,用户态和核心态之间的相互切换(称为“模式切换”), 此时仍然在同一个进程中进行。仍在自己的上下文中执行。可见,进程不论是在核心态还是用户态,都是进程状态的延续,换句话说,进程在核心态执行时也可能出现进程上下文的切换,当然 这种切换尽量避免为妙。

模式切换的步骤如下:

模式切换步骤

模式切换不同于进程切换,它不一定会引起进程状态的转换,在大多数情况下,也不一定引起进程切换,在完成系统调用服务或中断处理之后,可通过逆向模式切换来恢复 被中断进程的运行。

CPU 上所执行进程在任何时刻必定处于三个活动范围之内:

运行态不同情形

进程间竞合关系

进程有自己的地址空间和所需资源等私有资源,在某个时间这是排他性质的,但计算机总的系统资源是有限的,这导致了进程间的竞争关系;而有些资源可以在时间和空间上实现共享,以便减少拷贝(如共享内存) 和设备数量(如虚拟终端),由于资源是操作系统垄断和管理的,所以需要操作系统作为中间人提供共享服务;同时进程间有时需要共同完成某些任务,必然需要同步,这时操作系统需要提供同步信号传递服务。

进程间关系

由于多道程序技术、多处理技术、分布式处理技术等导致了进程并发,并且并发会在不同的上下文中出现:

  • 多个应用程序;
  • 同一个应用程序内部;
  • 操作系统自身内部。

支持并发进程必需解决进程间的同步、互斥和通信问题。并发在单处理机上表现为进程的交替执行,在多处理机上表现为重叠执行。并发必然要求资源共享,共享的资源包括 全局数据、硬件软件资源等,而且对共享资源的访问或读写顺序不同可能得到的结果也不同。所以,需要操作系统控制好进程对资源的互斥访问和顺序访问。

并发带来的困难:
  • 全局资源的共享充满了危险,不同进程使用的时机不同,资源被使用的前后状态也不同。
  • 操作系统很难对资源进行最优化分配,可能导致死锁。
  • 定位程序设计错误是非常困难的。这是因为结果通常是不确定的和不可再现的。

操作系统需要为并发做的工作如下:

  • 记录各个活跃进程的状态,为进程的同步、互斥等管理工作收集信息。
  • 为每个活跃进程分配和释放各种资源。
  • 必须保护每个进程的数据和物理资源。
  • 保证一个进程的功能和输出结果与执行速度无关。

进程的交互

进程的交互

实际情况并不总是像上表中给出的那么清晰,多个进程可能既表现出竞争,又表现出合作。

临界资源

竞争进程面临三个控制问题(互斥、死锁和饥饿)。首先是互斥的要求。这涉及到不可共享或同时访问的资源互斥访问问题。该类资源称之为临界资源。不论硬件临界资源,还是软件临界 资源,多个进程必需互斥对其进行访问。每个进程中访问临界资源的那段代码称为临界区

所以,若能保证各进程互斥地进入临界区,便可实现各进程对临界资源的互斥访问。为此,必须在临界区前面增加一段用于检查临界资源是否在使用的代码,该段代码称为 “进入区”;相应地,在临界区后面再加一段用于设置刚用完临界资源的状态,以便临界资源被其他进程使用的代码,该代码称为“退出区”,其他部分代码 称为“剩余区”。

进程同步机制

进程同步是指有协作关系的进程之间不断地调整它们之间的相对速度或执行过程,以保证临界资源的合理利用和进程的顺利执行。实现进程同步的机制称为进程同步机制。

同步机制应遵循的规则:

为实现进程互斥地进入自己的临界区,可用软件或硬件方法。不过所有同步机构都应遵循下列准则:

  • 空闲让进;
  • 忙则等待;
  • 有限等待;
  • 让权等待:当进程不能进入自己的临界区时,应立即释放处理机。

实现临界区管理的设施

硬件设施:
  • 关中断:在多处理机环境下,很难有效工作。
  • 专用机器指令:用于保证两个动作的原子性。
    • 如比较和交换指令(使用了忙等待或者自旋等待)。

忙等待自旋等待指的是这样一种技术:进程在得到临界区访问权之前,它只能继续执行测试变量的指令来得到访问权,除此之外不能做其他事情。

机器指令方法的特点

软件算法实现互斥
  • 锁机制:

实现互斥的一种软件方法是采用锁机制,即提供一对上锁和开锁原语,以及一个锁变量 w 或者是锁位。进入临界区之前不断地检测 w 的状态,若没有上锁则进入临界区,否则继续 测试 w 的状态;进入后上锁,退出时开锁。

  • 信号量机制

信号量机制是一种广义的锁机制或者成为计数锁的同步机制,既能解决互斥,又能解决同步。后来发展成了 P 操作(原语)和 V 操作(原语)。P 操作用于检测和申请临界资源, V 操作用于释放临界资源。

信号量也叫信号灯,是在信号量同步机制中用于实现进程的同步和互斥的有效数据结构。可以为每类资源设置一个信号量。信号量有多种类型的数据结构,如整型信号量, 记录型信号量、AND 型信号量及信号量集等。

信号量类型举例:
  • 整型信号量:

它是信号量的最简单的类型,也是各种信号量类型中必须包含的类型。整型信号量的数值表示当前系统中可用的该类临界资源的数量。如,

整型信号量

  • 记录型信号量

记录型信号量 记录型信号量

  • AND 型信号量

AND 同步机制的基本思想是将进程在整个运行过程中需要的所有资源,一次性全部分配给进程,待进程使用完成后再一起释放。只要有一个资源尚未分配给进程,其他所有 可能分配的资源也不能分配给它。

AND 型信号量集机制可描述如下:

AND 型信号量

  • 信号量集

如果某进程一次需要 N 个某类资源时,就要进行 N 次 wait 操作,这使系统的效率较低,有可能造成死锁。

信号量集

信号量实现互斥:

信号量实现互斥

经典同步问题

生产–消费者问题

  • 问题的描述:

生产消费者问题描述

  • 问题的分析:

生产消费者问题分析 生产消费者问题分析

  • 算法程序:

生产消费者问题算法 生产消费者问题算法 生产消费者问题算法

  • 注意事项:

生产消费者问题注意事项 生产消费者问题注意事项

读者–写者问题

  • 问题的提出:

读者歇者问题提出

  • 问题的分析:

读者歇者问题分析 读者歇者问题分析 读者歇者问题分析

  • 算法程序:

读者歇者问题算法 读者歇者问题算法 读者歇者问题算法

  • 注意事项:

读者歇者问题注意事项

哲学家进餐问题

  • 问题的提出:

哲学家进餐问题的提出

  • 问题的分析:

哲学家进餐问题的分析

  • 算法程序:

哲学家进餐问题的算法

  • 其他算法:

哲学家进餐问题的其他算法

理发师问题

  • 问题提出:

理发师问题的提出

  • 问题分析:

理发师问题的分析

  • 算法程序:

理发师的算法程序

管程(进程高级同步)

虽然 PV 操作可以解决进程间的同步互斥问题,但用于同步互斥的共享变量及信号量的操作被分散于各个进程中,它是否能达到同步互斥的功能还需要依靠程序员的正确编写。

PV 同步机制的缺点:
  • 易读性差:

因为要了解对于一组共享变量及信号量的操作是否正确,则必须通读整个系统或者并发程序。

  • 不利于修改和维护:

因为程序的局部性很差,所以任一组变量或一段代码的修改都可能影响全局。

  • 正确性难以保证:

因为操作系统或并发程序通常很大,要保证这样一个复杂的系统没有逻辑错误是很难的。

为了克服 PV 同步机制的缺点,提出了管程的概念。

管程定义:

管程是一种抽象数据类型。它将描述共享资源的数据(私有数据)及操作这些数据的一组过程或方法(公有,当需要通过”管程名.方法名”的方式调用,不过也有内部函数, 只允许管程方法使用,对外部隐藏)封装在一个具有名字的对象中。该对象可以引用外部方法或变量。可见管程是用于管理资源的公用数据结构(而进程是占有资源 的私有数据结构),管程和调用它的进程不能同时工作(而进程之间可以并发),而且管程是语言或操作系统的成分,不必创建和撤销。

管程组成:

  • 名称:

即,管程名称。对不同类的共享资源可能有不同管程,而且也需要引用管程名来调用其中的方法。

  • 数据结构说明:

局部于管程的共享变量说明,也是该管程所管理的共享资源的清单。

  • 对该数据结构进行操作的一组过程/函数
  • 初始化语句:规定数据结构中数据的初始值。

管程的属性:

  • 共享性:通过调用管程的过程或方法进行共享。
  • 安全性:管程内变量(私有变量)只允许管程的过程访问。
  • 互斥性:任一时刻最多只有一个调用者能真正引入管程,其他进程将在管程入口处等待。
  • 易用性:进入管程的互斥由编译器负责,从而减轻了写管程的程序员工作。

莞城

管程基本形式:

管程的基本形式

具体例子:

管程的具体例子

条件变量:

前面提到的管程(并不完整)实现了临界资源的正常进入和退出,但没有考虑临界区内因为某种原因必须中途暂时退出的情况。也就是说,还需要一种方法使得进程在临界区 内其他资源不能满足而无法继续运行时被阻塞,等条件满足之后再次运行。而条件变量同步机制,以及在其上操作的仅有的两个同步原语 wait 和 signal 的引入就是为了解决这一问题。

当进程中途等待资源时将被加入资源等待队列(称为紧急等待队列),该队列由相应的条件变量维护,资源等待队列可以有多个,每种资源一个队列。紧急等待队列的 优先级应当高于入口等待队列的优先级。

  • 当一个管程过程发现无法继续执行下去时,它将在相应的条件变量上执行 wait ,这个操作引起调用进程阻塞;当然,这是允许先前被挡在管程之外的一个进程进入管程。
  • 另一个进程可以通过对其伙伴在等待的同一个条件变量上执行 signal 操作来唤醒等待进程。
  • wait 和 signal 是两条原语,在执行时不允许被中断。它们分别表示把某个进程加入等待使用资源的条件变量的等待队列,从等待资源的条件变量的等待队列上释放一个 进程。
  • 当执行 wait 之后,相应的进程被置成等待状态,同时开放管程,允许其他进程调用管程中的过程或方法。
  • 当执行 signal 之后,指定条件变量上的一个进程被释放。

某个进程(P)在管程内运行时可能中途释放某个条件变量(及时尽早释放临界资源和条件变量的原则),这将唤醒等待该条件变量的等待队列队首进程(Q), 按照条件变量机制,该被唤醒的进程将再次进入管程(P 仍在管程内),很显然是不允许。可采用两种方法来防止这种现象的出现。

  • 进程 P 释放管程转为等待直至进程 Q 退出管程,或者进程 Q 等待另一条件(中途又被阻塞);
  • 进程 Q 等待直至进程 P 退出管程(类似“非剥夺式”),或者进程 P 等待另一个条件(被阻塞);
  • 规定唤醒为管程中最后一个可执行的操作(即,最后统一释放所有的条件变量,统一唤醒)。

霍尔采用了第一种办法,而汉森选择了第三种方法,进程执行 signal 操作后立即退出管程,因而,进程 Q 马上被恢复执行。

注意事项:

虽然条件变量也是一种信号量,但它并不是 P、V 操作中所论述的纯粹计数信号量,不能像信号量那样积累供以后使用,仅仅起到维护等待进程队列的作用。

当一个条件变量上不存在等待条件变量的进程时,signal 操作发出的信号将丢失,等于做了一次空操作。wait 操作一般应在 signal 操作之前发出,这一规则大大简化了 实现。

管程实现互斥和同步

  • 互斥:

进入管程的互斥由编译器负责,写管程的人无需关心。

  • 同步:

管程实现同步,需设置:

管程实现同步 管程实现同步

具体例子:
  • 生产者消费者问题:

管程解决生产者消费者问题 管程解决生产者消费者问题

  • 哲学家用餐问题:

哲学家进餐问题 哲学家进餐问题 哲学家进餐问题 哲学家进餐问题 哲学家进餐问题

  • 读者写者问题:

读者写者问题 读者写者问题 读者写者问题 读者写者问题 读者写者问题

除了前面说过的进程同步互斥机制外,有些操作系统还支持原子事务。对于事务的细节可以参考数据库原理。而且消息传递机制(参本文后面章节) 也可以解决进程互斥和同步问题。

进程通信

进程之间互相交换信息的工作成为进程通信。通信分为两大类:低级通信和高级通信。

  • 低级通信

将进程间控制信息的交换称为低级通信,如信号量通信机制、信号通信机制。

  • 高级通信:

进程之间大批量数据的交换称为高级通信

通信方式列举:
  • 信号通信机制;
  • 信号量通信机制;
  • 管道通信机制;
  • 消息传递通信机制;
  • 共享主存通信机制;
  • 网络进程通信机制。

信号通信机制

信号是一种软终端,是传递短消息的简单通信机制,通过发送指定信号来通知进程某个异步事件发生,以迫使进程执行信号处理程序(在用户态下执行)。信号处理完毕后, 被中断进程将恢复执行。一般地,分成操作系统标准信号和应用进程定义信号,这种机制模拟硬中断,但部分优先级,简单且有效,但不能传送数据,故能力较弱。

信号通信机制

管道通信机制

管道是指用于连接一个读进程和一个写进程,以实现它们之间通信的一个共享文件(又名“pipe 文件”)。

向管道(共享文件)提供输入的发送进程(写进程),以字符流的形式将大量的数据送入管道;而接收管道输出的接收进程(“读进程”),则从管道接收(读)数据。由于 发送进程和接收进程是利用管道进行通信的,故称为管道通信

管道通信必须提供以下能力:
  • 互斥:

即当一个进程正在对 pipe 执行读/写操作时,其他进程必需等待。

  • 同步:

同步是指,当写(输入)进程把一定数量的数据写入 pipe,便去等待,直到读(输出)进程取走数据后,再把它唤醒;当读进程读一空 pipe 时,也应睡眠等待,直至写 进程将数据写入管道后才将之唤醒。

pipe 通信

管道是一种功能机制很强的通信机制,但仅用于连接具有共同祖先的进程,使用时需要临时建立,难以提供全局服务。为了克服这些缺点, UNIX 推出管道的一个变种, 称为有名管道FIFO 通信机制,用来在不同的地址空间之间进行通信,特别为服务器通过网络与多个客户进行交互而设计。

共享主存通信机制

共享存储通信有两种方式:

  • 基于共享数据结构的通信方式:

公用数据结构的设置及对进程间同步的处理,都是程序员的职责,而操作系统只需提供共享存储器。因此,通信效率低,只适用于传递相对少量的数据。

  • 基于共享存储区的通信方式:

进程在通信钱,先向系统申请获得共享存储区中的一个分区,并指定该分区的关键字:若系统已经给其他进程分配了这样的分区,则将该分区的描述符返回给申请者,然后, 由申请者把获得的共享存储区连接到本进程上;此后,便可以像读写普通存储器一样访问该公用存储分区。实际上,很多系统可以通过系统调用来操控共享分区。

共享主存通信

消息传递机制

不论单机系统、多机系统还是计算机网络,消息传递机制都是应用最为广泛的一种进程间通信的机制。在消息传递系统中,进程间的数据交换是以格式化的消息(Message)为 单位的;在计算机网络中,又把 Message 称为报文。程序员直接利用系统提供的一组通信命令进行通信。操作系统隐藏了实现通信的细节,提高了透明性,因而获得了较为 广泛的使用。因实现方式不同可分为直接通信方式和间接通信方式。

  • 直接通信方式:这种通信固定在一对进程之间。
  • 间接通信方式:

又称为“信箱通信”方式。信箱是一种数据结构,逻辑上可分为信箱头和信箱体两部分。

  • 信箱头包含信箱体的结构信息以及多进程共享信箱体时的同步互斥信息。
  • 信箱体由多个格子构成,它实际上就是一个有界缓冲器。

信箱通信的同步、互斥方式与生产者消费者问题的方式类似。它一般是进程之间的双向通信。

消息传递的复杂性在于:地址空间的隔离,发送进程无法将消息直接复制到接收进程的地址空间中,这项工作只能由操作系统来完成。为此,消息传递机制至少需要提供两条 原语 send 和 receive。为了实现异步通信,必须采用简洁的通信方式。

简洁通信解除了发送进程和接收进程之间的直接联系,在消息的使用上加大了灵活性。一个进程可以分别与多个进程共享信箱。于是,一个进程可以同时和多个进程通信, 一对一关系允许在两个进程间建立不受干扰的专用通信链接;多对一关系对客户服务器间的交互非常有用;

一个进程为其他进程提供服务,这时的信箱又称为端口,端口通常划归接收进程所有并由接收进程创建,服务进程被撤销时,其端口也随之消失。当然还有多对多关系 的公用信箱。

信箱的设置:

信箱可以在用户空间或系统空间开辟。

  • 用户空间信箱:创建者进程撤销时,信箱也随之消失,这时必须通知所有使用者。
  • 系统空间设置公用信箱:可以充分利用预留空间(如果在系统空间内分别开辟私有空间则很难确定分配多大,当然可以延迟到接收时分配)。

通信进程的同步

两个进程间的消息通信就隐含着某种程度的同步,当发送进程执行 send 发出消息后,本身执行可分为两种情况:

  • 同步的(阻塞型),等待接收进程回答消息后才继续进行;
  • 异步的(非阻塞型),将消息传送到接收进程的信箱中,允许继续运行,直到某个时刻需要接收进程送来回答消息(如信箱已满)时,才查询和处理。

对于接收进程而言,执行 receive 后也可以是阻塞型和非阻塞型,前者指直到消息交付完成(一有消息就要停下来接收消息)它都处于等待消息的状态; 后者则不要求接收进程等待,当他需要消息时,再接收并处理消息。

消息传递机制解决进程的互斥和同步问题

  • 解决进程互斥问题

解决互斥问题 解决互斥问题

  • 解决同步问题:

生产者消费者问题的一种解法

解决同步问题 解决同步问题 解决同步问题 解决同步问题 解决同步问题

消息缓冲队列通信机制

消息缓冲队列 消息缓冲队列 消息缓冲队列 消息缓冲队列

进程调度

这里所说的进程调度(进程切换的时机和策略)可以认为是处理机调度,在没有线程概念的操作系统中,处理机调度的单位是进程,而有线程概念或轻量级进程概念的 操作系统中,处理机调度的单位是线程。所以有必要讨论下线程。

线程

如果说操作系统中引入进程的目的是为了使多个程序并发执行,以便改善资源利用率和提高系统效率,那么,在进程之后再引入线程的概念,则是为了减少程序并发执行 (进程切换)时所付出的时空开销,使得并发粒度更细,并发性更好。此时,进程成为了独立分配资源的基本单位,无须频繁地切换;而线程则作为系统(处理机) 调度和分派的基本单位,会被频繁地调度和切换。进一步产生了多线程进程。

线程和进程的比较如下:

线程和进程对比 多线程

线程的组成部分有:

线程组成部分 进程和线程任务分配

线程的状态:

线程和进程一样,也有自己的状态。线程有 3 种基本状态,即执行、阻塞和就绪,但没有挂起(由于线程不是资源的拥有单位,挂起状态对于线程是没有意义的)。

进程中可能有多个线程,至于单个线程是否要阻塞整个进程取决于系统实现。有的系统只有所有的线程都阻塞之后才阻塞整个进程(这种方式更能体现多线程的优越性)。

线程切换:

针对线程的 3 种基本状态,存在 5 种基本操作来转换线程的状态。

  • 派生:线程在进程中派生出来,也可再派生线程。
  • 调度;
  • 阻塞;
  • 激活;
  • 结束。

多线程程序设计的优点

多线程优点 多线程优点

线程的组织

线程的组织

多线程实现

多线程的实现分为三类:用户级线程(ULT)、内核级线程(KLT)或者混合方式。

  • 用户级线程:

其由用户应用程序建立,并由用户应用程序负责调度和管理,操作系统内核不知道有用户级线程的存在。

ULT 的优点:

ULT 的优点

ULT 的缺点:

ULT 的缺点

  • 内核级线程:

内核级线程中所有线程的创建、调度和管理全部由操作系统内核负责完成,一个应用程序可按多线程方式编写程序,其他交给内核处理。

KLT 的优缺点

多线程技术利用线程库提供一整套有关线程的过程调用或系统调用来支持多线程运行,有的操作系统直接支持多线程,有的语言则提供线程库。因而,线程库可分为用户空间 线程库和内核空间库。线程库实际上是多线程应用程序的开发和运行环境。

多线程模型

许多系统都提供对用户和内核线程的支持,从而有不同的多线程模型。以下是 3 种常用类型:

  • 多对一模型

该模型将许多用户线程映射到一个内核线程。详情请参看“用户级线程”。

  • 一对一模型(参见“内核级线程”)
  • 多对多模型

多对多模型多路复用了许多用户线程到同样数量或更小数量的内核线程上。开发人员可创建任意多的必要用户线程,并且相应内核线程能在多处理器系统上并行执行。而且, 当一个线程执行阻塞系统调用时,内核能调用另一个线程来执行。为了防止无限制的创建线程,可使用线程池。

处理器调度

某些进程花费了绝大多数时间在计算上(注意,某些 I/O 活动可以看做是计算),称之为“计算密集型进程”;而有些进程则在等待 I/O 上花费了绝大多数时间, 称之为“I/O 密集型进程”。如果需要运行 I/O 密集型进程,那么就应该让它尽快得到机会,以便发出磁盘请求并保持始终忙碌。

调度时机

CPU 调度决策可以在如下四种环境下发生:

调度时机

分级调度

一个批处理型作业,从进入系统并驻留在外存的后备队列上开始,直至作业运行完毕,可能要经历以下三级调度:作业调度、对换和进程调度。

  • 高级调度

其又称为作业调度、长程调度。用于选择把外存上处于后备队列中的哪些作业调入内存,并为它们创建进程、分配必要的资源,然后,再将新创建的进程排在就绪队列上, 准备执行。高级调度控制多道程序的道数,被选择进入主存的作业越多,每个作业所获得的 CPU 时间就越少,所以有时为了满足某种特定需求, 需要限制道数。每当有作业执行完毕并撤离时,作业调度会选择一个或多个作业补充进入主存。此外,如果 CPU 的空闲时间超过一定的阈值,系统也会引出作业调度选择 后备作业。可见,高级调度负责作业的调入和撤离,与交换(对换或中级调度)有着很大的区别。

作业流程

  • 中级调度

中级调度又称为“平衡调度、中程调度”,根据主存资源决定主存中所能容纳的进程数目,并根据进程的当前状态来决定辅助存储器和主存中的进程的对换。当主存 资源紧缺时,会把暂时不能运行的进程换出主存,此时这个进程处于“挂起”状态,不参与低级调度;当进程具备运行条件且主存资源有空闲时,再将进程重新调回主存工作, 起到短期均衡系统负载的作用,充分提高主存的利用率和系统吞吐率。

  • 低级调度

低级调度又称为进程调度/线程调度、短程调度和微观调度,其主要功能是:根据某种原则决定就绪队列中的哪个进程/内核级线程获得处理器,并将处理器出让给它还用。 低级调度是操作系统最为核心的部分,执行十分频繁,其调度策略的优劣将直接影响整个系统的性能,因而,这部分代码要求精心设计,并常驻内存。

进程调度可分为如下两种方式:
  • 非抢占方式:

不允许进程抢占已经分配出去的处理机。该方式的优点是实现简单、系统开销小,适用于大多数的批处理系统环境。但它很难满足紧急任务的要求。因而可能造成难以预料的 后果。显然,在要求比较严格的实时系统中,不宜采用这种调度方式。

  • 抢占方式:

抢占方式允许调度程序根据某种原则暂停某个正在执行的进程,将处理机收回,重新分配给另一个进程。抢占的原则有优先权原则、短作业(或进程)优先原则、时间片原则等。

各级调度的关系:

各级调度的关系 各级调度的关系 各级调度的关系 各级调度的关系 各级调度的关系 各级调度的关系

调度算法的评价及准则

在操作系统的设计中,如何选择作业调度及进程调度的方式和算法取决于操作系统的类型和目标。显然,根据不同的目标,会有不同的调度算法。

面向用户的准则:
  • 公平性
  • 周转时间短:

周转时间是指,作业被提交给系统开始,到作业终止为止的这段时间间隔。

  • 响应时间快:

响应时间指的是,从用户提交一个作业请求开始,直至系统首次产生响应(如屏幕显示提示信息)为止的时间。

  • 截止时间保证

截止时间是指,某任务必须开始执行的最晚时间,或必须完成的最晚时间。

面向系统的准则:

这是为了提高整个系统的效率。

  • 系统的吞吐量:

吞吐量是指,在单位时间内系统所完成的作业数,它与批处理作业的平均长度有密切关系。

  • 处理机的利用率
  • 各类资源的平衡利用。
  • 尽量保持系统所有部分尽可能忙碌

调度算法分类

不同的环境需要不同的调度算法。

调度算法分类

调度机制

从概念上来看,调度机制由 3 个逻辑功能程序模块组成:

  • 队列管理程序
  • 上下文切换程序
  • 分派程序:转入上下文,开始执行获得 CPU 的进程。

调度算法

在操作系统中,存在多种调度算法,有的算法仅适用于作业调度,有的算法仅适用于进程/线程调度,但大多数调度算法对两者都适用。有的调度算法适合批处理系统或其他 特定的系统,但一些算法既适合批处理系统也适合交互式系统等。

批处理系统中的调度算法

  • 先来先服务(FCFS)

易于理解和实现,但没有考虑作业的特点和用户的实际需要,所以无法满足用户的大部分需求,也不能充分利用系统资源。不过,它是其他算法的基础,当各种条件都一样时, 此时,就需要先来先服务原则来保证公平性。

  • 最短作业优先(SJF)

该算法一般是非抢占式的,所以这里的最短作业指的是,调度的当时是最短(虽然有时很难估计时间)的。而不是在该作业运行期间(如,后面又来了一个更短的作业)。

  • 最短剩余时间优先:SRTF 和最短作业优先一样,有时该时间是很难预先知道的。
  • 高响应比优先:

HRN 调度算法为了克服短作业优先算法的缺点,采用了一种折中的方法,既让短作业优先,又考虑到系统内等待时间过长的作业。

高相应比

交互式系统中的调度

下面的调度算法也可以用于批处理系统的调度器中,尽管三级调度不大可行,但两级调度是可行的。

  • 时间片轮转调度:

时间片设得太短会导致过多的进程切换开销;而设得太长有可能引起对短交互请求的响应变差。一般设为 20-50 ms。

  • 优先级调度:

优先级可以是静态的,也可以是动态的,系统和用户均可指定优先级。优先级调度可以是抢占式的,也可以是非抢占式的。不过,可能造成高优先级的进程无限制执行下去, 而低优先级的进程处于饥饿状态,所以优先级标准和如何变化将会影响用户体验和系统性能。

  • 多级反馈队列调度算法

不论哪一种算法都无法满足不同的需要。为此,可以将不同的需求分到不同的队列中,而且不同的队列具有不同的优先级,不同队列中可以根据具体的需求采用最适合该队列 的调度算法。而且进程根据不同的运行情况会被动态的分配到不同的队列中。此种算法称为“多级反馈队列调度算法 MLFQ ”或 “反馈循环队列”。可见,该算法中,同一个 进程随着占用 CPU 的次数的增加,优先级在不断递减。

MLFQ 调度算法具有较好的性能,能满足各类应用的需要。但仍会导致“饥饿”问题。例如,一个耗时很长的作业,最终将进入优先级最低的队列,然后,系统不断的进入新的 作业,那么,该长作业就很难得到再运行的机会。为此,可以允许使用高响应比来提升优先级(通常只允许降低优先级)。

  • 彩票调度算法

其基本思想是:为进程/线程发放针对各种资源(如 CPU 时间)的彩票,当调度程序需要作出决策时,随机选择一张彩票,彩票的持有者将获得相应的系统资源。对于 CPU 调度, 系统可能每秒钟抽取彩票 50 次,中奖者每次可以获得 20 ms 的运行时间。

一般情况下,所有进程都是平等的,不过某些进程需要更多机会,所以需要得到额外的彩票以增加中奖的机会。进程拥有多少彩票份额,就能获得多少资源。合作进程如果愿意, 可以交换彩票,以便相应进程得到更多的机会。可见,彩票调度法很灵活,而且反应非常迅速,因为中奖机会与其持有的彩票数成正比。

  • 公平分享调度

该算法考虑了进程的拥有者。主要应对不同作业拥有的进程数是不一样的情况,如果不考虑拥有者,则拥有更多进程的作业显然获得 CPU 时间更多。

实时系统调度

实时系统通常分为硬实时系统和软实时系统。前者意味着存在必须满足的时间限制;后者意味着偶尔超过时间限制是可以容忍的。实时系统根据响应的事件可进一步 分为周期性(每隔一段固定时间发生)和非周期性(在不可预知的时间发生)。一个系统更可能必须响应多个周期的事件流。根据每个事件需要多长的处理时间, 系统可能根本来不及处理所有事件。

实时调度算法可以是静态的或动态的。前者在系统启动之前完成所有的调度决策;后者在运行时做出调度决策。如果使用静态调度算法,必须预先知道足够多的需要做的工作 和必须满足的约束的时间信息。

  • 单比率调度算法

对于周期性事件,单比率调度是视周期长度而定的抢占式策略:周期越短,优先级越高。

  • 限期调度算法

当一个事件发生时,对应的实时进程就被加入就绪队列,此队列按照截止期限排序。对于周期性事件,截止期限即事件下一次发生的时间。系统检测队首截止期限是否比 当前运行者早,以决定是否剥夺当前运行的进程资源。

  • 最少裕度法

最少裕度法

多处理机调度算法

多处理机调度的设计要点有 3 个:为进程分配处理机、在单个处理机上是否使用多道程序设计技术和实际指派进程的方法。

  • 负载共享调度算法:

进程并不被指派到特定的处理机上,系统维护全局性进程就绪队列,当处理机空闲时,就选择进程的一个线程去运行。可见,该算法没有考虑同一个进程的多个线程的同步和 切换问题,因为具有同步和互斥等关系的线程很难被按照一定顺序执行,也不能保证被切换的进程在原有的处理机上再次执行,从而增加了切换开销。

具体的负载共性调度算法有:先来先服务、最少线程数优先和剥夺式最少线程数优先等。

  • 群调度算法

其基本思想是:给予一对一原则,一群相关线程被同时调度到一组处理机上运行。紧密相关线程的并行执行能够减少同步阻塞,从而减少进程切换,降低调度代价。当进程的 相关线程数小于处理机数时会造成处理机资源空闲。

  • 专用处理机调度算法

将同属于一个进程的一组线程同时分派到一组处理机上运行。是群调度的一种极端方式。不过该方式也会使有些处理机因线程等待事件阻塞时空闲。然而,对于数目很大的处理机 群而言,个别处理机的使用率只是代价的一小部分,对整体影响不大。

  • 动态调度算法

针对能够动态改变线程数的应用程序。其基本思想是:由操作系统和应用进程共同作出调度决策,操作系统负责在应用进程之间分配处理机;应用进程所分配的处理机上执行 可运行线程的子集,这些处理机如何分配到具体的线程完全是应用进程的任务,可借助于运行时库函数完成。

动态调度算法

注意:

多处理机调度不宜采用复杂的调度算法,
复杂的调度算法意味着过多的时间开销,
然后这些开销乘以期间空闲的处理机数,
将使开销被放大。

Linux 下进程分析

前面章节已经从理论上简单剖析了进程,如果忘记了,可以简单看下前面的目录。本章节将在 Linux 系统下收集进程信息,验证前面提及过的理论,并展现进程在真实系统中的 表现,同时会给出 Linux 下有关进程的一些命令,这将帮助你了解自己的程序到底是怎么执行的,到底是怎么组成的,占用了多少空间、使用了哪些系统调用、函数调用情况、 程序执行效率等,以便写出更高效更健壮的程序。或许还可以帮助你分析别人写的程序(如开源项目),或者做逆向分析等。

静态分析

在分析进程之前,先了解一下源程序和编译出的可执行文件的基本信息,这些信息相对于进程而言是静态的,但它可以为进程的动态分析提供帮助。

源文件

进程是进行中的程序,那先从源程序文件开始分析。有时候需要了解源文件的类型、权限、大小等信息,以便知道如何处理该文件(如要先改变权限吗?是可执行文件吗?这 文件可以删除吗?……)。

ls

在终端下使用 ls 命令可以看到当前目录下所有文件(普通文件、目录文件、设备文件等)的基本信息。常用的有:

  • ls:只显示名称
  • ls -a:显示所有文件名称,包括隐藏文件
  • ls -l:显示详细信息

至于 ls 详细的参数请参考其他资料或者在终端使用命令man ls得到帮助,下图是对 ls -l 命令执行后结果的一行信息解读:

ls -l命令结果解读

上图中设计到的概念和命令分解如下:

文件类型

Linux 系统中的文件类型与文件后缀名(后缀名只是给系统使用者或应用程序使用的)无关 ,Linux 系统判断文件类型只与创建文件的命令有关。

  • 普通文件(-):touch 文件名vi 文件名等;
  • 目录(d): mkdir 路径名,注意ls -a 出现的 ...分别表示当前目录和上级目录。
  • 符号链接(l): ln -s 被链接的文件名 符号名(快捷方式)
  • 管道文件(p): mknod 管道名称 p或者mkfifo 管道名称
  • 字符设备文件(c): mknod 文件名 c 主设备号 从设备号,如 mknod char_device c 1 1
  • 块设备文件(b): mknod 文件名 b 主设备号 从设备号
  • 套接口文件(s):

mksock 文件名(Redhat系统中)或nc -l 端口号(开端口等待连接,需要netcat),另开一个终端输入nc 127.0.0.1 同一个端口号 (建立链接),然后再开一个终端,输入netstat 同一个端口号即可看到含有 socket 字样的路径名,然后用ls -l 路径名即可知道其为套接口文件。 或者通过程序调用 socket 系统调用创建套接字文件。

文件权限

文件权限见图,修改文件权限或递归修改权限可以是用命令chmod(具体请参考其他资料)。平时创建文件的时候并没有指明文件权限,那权限哪里来的? 这可以通过umask来设定默认文件权限。

硬连接数

硬连接数可以通过ln 被连接的文件名 文件名来增加硬连接数,此后两文件用ls -l得到的描述信息是完全相同的(可以认为是备份)。无法区分。 即使信息相同,也不一定互为硬连接,可以通过ls -il命令查看信息得知,只要第一列中的数字(inode number)相同,即互为硬连接。所以要找出所有硬连接 (查看软连接就简单多了,只需要ls -l即可,文件名会有箭头标识,实在不行介先自己创建软连接,然后再查看变化),可以通过 inode 号来实现。 使用find 查找的目录 -inum inode号即可找出。

大家可能很早就发现:空目录的硬连接数为 2。原因在于当前目录含有*.*文件(通过ls -a可以看到),而其上级目录也有指向该目录的文件,此时硬连接数 正好为 2.当然为了验证这个猜想,你可以在空目录下新建几个空目录,再看一下,是不是硬连接数增量等于新建目录的个数。

查看某个文件的当期目录下所有的软链接可以使用find . -lname 文件名。也可以使用:

  • ls -i得到 inode 号;
  • find . -follow -inum inode号即可列出该文件所有的软硬连接。

以后找个机会说下上面操作的原理。

所属用户或用户组

修改所属用户和用户组,可以分别用命令chownchgrp

文件名

修改文件名可以使用命令mvrename

find

find 用来在给定的目录下查找符合给定条件的文件。

find [OPTIONS] [查找起始路径] [查找条件] [处理动作]

首先讲一下查找条件:

  • -name-iname:根据名称(或不区分大小写)查找。如 find ./ -name ‘a*’
  • -regex:正则匹配整个路径,如 find / -regex /t./test/test.
  • 根据文件从属关系查找:-user-group-uid-gid-nouser-nogroup, 如 find ./ -user root
  • -type:根据文件类型查找,如 find ./ -type f
    • f:普通文件
    • d:目录文件
    • l:符号链接文件
    • b:块设备文件
    • c:字符设备文件
    • p:管道文件
    • s:套接字文件
  • -size [+|-][c|k|M|G]:根据文件大小(+、-号表示大于小于)查找,如 find ./ -size 63k
  • 根据时间戳查找:如 -atime [+|-],加号表示大于等于,减号表示小于,time的单位为天,min 的单位为分钟。
    • 文件最后访问时间:-atime-amin
    • 文件最后修改时间:mtimemmin
    • 文件最后改变时间:ctimecmin
  • -perm [/|-]mode:根据权限查找,其中 mode 是用于表达权限的数字
    • mode:精确权限匹配,如 find ./ -perm 664
    • /mode:任何一类用户(u,g,o)的权限中的任何一位(r,w,x)符合条件即满足;9位权限之间存在“或”关系; 如 find ./ -perm /122 -ls
    • -mode:每一类用户(u,g,o)的权限中的每一位(r,w,x)同时符合条件即满足。9位权限之间存在“与”关系; 如 find ./ -perm -664 -ls

查找到指定文件之后,有时候会接着对文件执行一些操作,这就是“处理动作”的职责:

  • -print:这是默认动作
  • -ls:执行 ls 命令
  • -delete:删除查找到的文件
  • -fls file_name:将查找的文件信息保存到指定文件 file_name 中
  • -ok COMMAND {} \:待用户确认方可执行之后的命令
  • -exec COMMAND {} \:对查找到的每个文件执行由 COMMAND 表示的命令;

【注意】:find 传递查找到的文件路径至后面的命令时,是先查找出所有符合条件的文件路径, 并一次性传递给后面的命令;但是有些命令不能接受过长的参数,此时命令执行会失败;下面这种方式可规避此问题:

find | xargs COMMAND

file

使用命令file 文件名即可直观地查看文件类型和文件编码。对ELF 文件显示的信息较多。

stat

stat命令可以查看到文件或目录更为详细的信息,包括创建、访问、更改时间等。

wc

wc显示文件的行数、单词数和字符数。

查看文件的内容

可以通过headtailtailfmorecattaclessnlvim等命令或应用查看文件内容。

查看打开该文件的所有进程

有时候需要知道哪些进程在使用某个文件,可以使用ps -fe | grep 文件名

比较两个文件内容

同一个文件可能在不同地方有备份,但是有个本分的内容被修改了,其他备份没有得到更新,此时就需要比较两个文件的异同或者找出最新的那个文件, 可以先使用stat得到最新修改的文件(最新版本),然后使用diff比较文件具体的内容。

ELF 二进制文件

ELF(Executable and Linkable Format)即可执行连接文件格式,是 Linux,SVR4 和 Solaris2.0 默认的目标文件格式,目前标准接口委员会 TIS 已将 ELF 标准化 为一种可移植的目标文件格式,运行于 32-bit Intel 体系微机上,可与多种操作系统兼容。分析 elf 文件有助于理解一些重要的系统概念, 例如程序的编译和链接,程序的加载和运行等。

ELF 文件类型

  • 可重定位文件: 用户和其他目标文件一起创建可执行文件或者共享目标文件,例如 lib*.a 文件。
  • 可执行文件: 用于生成进程映像,载入内存执行,例如编译好的可执行文件 a.out。
  • 共享目标文件:用于和其他共享目标文件或者可重定位文件一起生成elf目标文件或者和执行文件一起创建进程映像,例如 lib*.so 文件。
  • 核心转储文件(Core File):.

由于时间的关系,这里不打算对 ELF 文件进行详细的分析(网上的资料很多,等有时间再专门写篇文章吧),以下只给出几个分析 ELF 文件的命令。

ELF 文件内容查看

  • file: 查看 ELF 文件的少量基本信息;
  • readelf: 读取 ELF 文件信息(可以man readelf得到帮助)
    • -a:显示全部信息;
    • -h:显示文件头信息;
    • -l:显示程序头(段头)信息;
    • -S:显示节头信息;
    • -g:显示(-S)节的详细信息;
    • -s:显示符号表段中的项;
    • -e:显示全部头信息;
    • -r:显示可重定位段信息;
    • -d:显示动态段信息;
    • -D:使用动态段中的符号表显示动态段信息;
    • -x:以 16 进制显示。
    • 其他:(略)。
  • size:列出目标文件各个部分所占的字节数。可以用于分析 C 语言的内存分布。
  • objcopy:实现 ELF 文件的部分转储(如增删 section),以便瘦身或更改格式。
  • objdump: 查看目标文件或者可执行文件的构成,类似readelf,但可读性更强。
  • nm:names 用于显示二进制目标文件的符号表。
  • ldd:list dynamic dependencies 列出可执行文件所需的共享库。
  • cmp:比较二进制文件内容,类似dif(比较文本文件内容)。也可以用vim -bd
  • hexdumpxxd:以 16 进制的形式显示文件内容。
  • strings:在对象文件或二进制文件中查找可打印的字符串。
  • od:以常用编码方式显示文件内容,通常用于显示或查看文件中不能直接显示在终端的字符。

ELF 文件编辑

可以使用 biewhexeditvim -bsedddxxd等。

动态分析

前面已经介绍了可执行文件在运行之前收集信息的方法或命令,接下来将阐述进程跑起来之后,如何收集信息,观察进程的行为。

main 函数前后

网上有不少博客分析了 main 函数前后的执行过程,在这里只是宏观的提一下。main 函数没有关于可执行文件如何装入内存、如何建堆栈、如何申请资源、如何 释放内存和资源等说明信息,但根据操作系统的基本知识可知,这些都是需要的。既然如此,这些不是 main 函数干的,那必然有其他的函数或代码做这些事情。也就是说, 在 main 函数之前或之后编译器插入了代码以完成初始化和收尾工作,也意味着,程序员也可以采用某种方式让某些代码在 main 函数前后执行。从作用域的观点来看, 在 main 函数作用域之外(或属于全局性质的,比如 全局变量static 对象main 函数参数等)的变量或资源都是在 main 前初始化、 在 main 后销毁。

main 函数前后实例:
#include<stdio.h>
#include<stdlib.h>
//#include<unistd.h>

__attribute((constructor)) void before_main()
{
  printf("%s -> ",__FUNCTION__);
}

__attribute((destructor)) void after_main()
{
  printf("%s -> ",__FUNCTION__);
  printf("game over!\n");
}

void atexit_func()
{
  printf("%s -> ",__FUNCTION__);
}


int main()
{
  printf("%s -> ",__FUNCTION__);
  atexit(atexit_func);
  printf("main exit right now -> ");

  //_exit(0); /*放开此行有惊喜*/
  //至于exit(0)和_exit(0)的区别,自己搜吧
  //abort();
  
  return 0;
}

上面的程序自己运行,然后分析结果哈,我就不啰嗦了。

进程信息捕捉

进程控制块是进程存在的唯一标志,既然是捕捉进程信息,那 PCB 中的信息是少不了的。同时 Linux (一切皆文件)会把进程的信息存入 /proc/<pid>文件夹下对应文件(注意:由于安全考虑,proc 有可能需要手动 mount)中, 只要你非常了解该文件夹下的内容(说白了,就是文本文件而已),那么你完全可以使用上节中类似“源文件”的处理方法 处理本节涉及到的内容。

进程基本信息

查看进程的基本信息(如 所属用户UID、进程 IDPID、父进程 IDPPID、CPU 使用率%CPU、所占内存百分比%MEM、 虚拟内存大小VSZ、驻留空间大小RSS、进程相关终端TTY、进程状态STAT、进程使用 CPU 总时间TIME、被执行的命令行COMMAND、 进程优先级NI、进程等待的内核事件名WCHAN、进程启动时间START、会话 IDSIDUSER、进程优先级编号PRI、 与进程相关的数字标识FLAGS、线程组 IDTGID、进程组 IDPGID、控制终端进程组ID TPGID等)可以使用ps命令。 ps 命令带有 2 种不一样的风格,分别是 BSD 和 UNIX。在 BSD 风格的语法选项前不带连字符,如ps aux;在 linux 风格的语法选项前面有一个连字符, 如ps -ef。两种风格可以混用,如ps ax -f。ps 命令参数太多了,下面只给出常用的组合参数:

  • au-ef:列出所有的进程;
  • aux-ef -f:累出所有进程的详细信息;
  • -C:通过进程名得到 pid;
  • ps aux --sort=-pcpu,+pmem:特定字段排序(如 cpu);
  • auxf:进程树状图(显示进程父子关系,可以使用pstree更直观)。
  • e:显示进程环境变量。
  • -o:因字段过多,可自定义显示,如ps -o pid,ppid,pcpu(注意字段名要小写),ps -o pid=process_id
  • 联合管道:

有时 ps 显示的信息太长,一行无法显示完全(默认情况下也不会换行),也无法对齐显示,此时可以使用管道 |来优化显示和阅读,如:

  • ps aux | moreps aux | less
  • ps aux | head -5:只显示前 5 行;
  • ps aux | sort -k 5n | tail -5:输出占用内存最多的 5 条(也可以用-e -o pid,pmen替代aux);
  • ps -ef | grep 你要找的进程名 | grep -v "grep" | awk '{print $2}'

该命令可以找出某个进程名对应的进程 ID;当不知道进程的全名时,可以使用正则表达式;还可以使用类似pgrep 进程模糊名 | xargs ps -u --pid命令, 其中 pgrep 得到的结果作为 ps -u –pid 的参数(xargs)。

要得到更详细的信息可以到/proc/<pid>文件夹中去查看。比如ls -al /proc/1234

进程状态 STAT 说明:
  • D:不可中断的睡眠状态;
  • R: 正在运行或可运行(在运行队列排队中);
  • S: 可中断睡眠 (阻塞,等待事件发生);
  • T: 已停止的 进程收到SIGSTOP, SIGSTP, SIGTIN, SIGTOU信号后停止运行;
  • Z: 僵尸进程 进程已终止, 但进程描述符存在,等待收尸;
  • <:高优先级别
  • N:低优先级别
  • L: 页面锁定在内存(实时和定制的IO);
  • s: 一个信息头;

session leader 进程,一般启动时要设置 SID 的,这种进程脱离控制终端。一般的 deamon 都要调用 setsid 把自己设置为 session leader, 与控制终端脱离关系,这样控制终端退出产生的 SIGHUP 信号就不会发送到这些进程了。这个行为与用 nohup 执行应用的作用相同。

  • l:多线程。

进程关系

ps 命令可以给出基本信息,但这是不够的,至少是直观的。

进程父子关系:
  • pstree -p 1234:查看进程号为 1234 的所有字进程树。
  • ps --ppid 1234:查看进程号为 1234 的所有直接子进程。
  • ps --pid 1234 -o ppid:可以得到进程 1234 的父进程 ID,如此递归(1 进程是所有用户进程的祖先)就可以找出其所有的祖先。 当然也可以通过 pstree 顺着树往上走就可得到所有祖先。

进程关系已经知道了,但有的进程是多线程的,所以有必要知道某个进程的所有线程。

查看线程:
  • ps -xH:配合grep管道可行;
  • ps -mq 1234
  • ps mp 1234 -o THREAD,tid
  • ps -Lf 1234
  • ps -Lo pid,ppid,pgid,nlwp,lwp,stat,command -p 1234:自定义显示;
  • ps -T -p 1234
  • top -Hp 1234

有时候并不需要知道线程的详细信息,而只需要知道某进程有多少线程。

线程计数:
  • ls /proc/1234/task | wc -l;
  • cat /proc/1234/status | grep Threads
  • ps hH p 1234 | wc -l;
  • ps -Lo nlwp -p 1234 | head -2

在多进程编程时,有时候需要统计某个程序的进程数

子进程计数:
  • pstree -p 1234 | wc -l:统计所有子进程数,包括子进程的子进程;或者使用pgrep 进程名称 | wc -l
  • cat /proc/*/status | grep PPid | grep 1234 | wc -l:只包括直接的子进程,不包括子进程的子进程等。
  • ps -ef -o ppid | grep 1234 | wc -l

下面给出测试以上命令的示例程序:

#include<stdio.h>
#include<unistd.h>

int main()
{
  printf("Hello World!\n...");
  
  for(int i=0; i<4; ++i){
    fork();
  }

  while(1){};

  return 0;
}

进程占用的内存

编写 C/C++ 程序,一不小心就会出现内存泄漏,或者过多占用内存的情况,即使是系统维护人员也需要了解内存情况,以便知道进程消耗资源 的占比,然后做出对应的决策。

top 命令

top 命令是 Linux 下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况,类似于 Windows 的任务管理器。这里只讲述一些实用的内容,其他具体内容 请自行查询。

  • 不带参数: 见上段描述;
  • -p <pid>:只显示进程 ID 为 pid 的进程信息,多个 pid 用逗号隔开;
  • -u 用户名:显示特定用户的进程信息;
  • 定制要显示的列:

输入 top 命令之后,按f即可看到所有列名意义的说明,同时可以用上下方向键选择是否显示的列,空格键显示隐藏这个列(显示的列前面有一个星号);

  • 调整列的位置:

出现 top 界面后,按左右方向键可以选定(或取消)需要改变位置的列,然后按上下方向键可调整选定列的位置,最后按回车确定调整位置;或者, 按 o 键,选择要调整位置的列(如 K:CUP Usageage),按动一下 大写 K 则显示位置往上调整,按动一下小写 K 则显示位置往下调整。

  • 列排序:执行 top 命令后,按 shift + f(小写),进入选择排序列页面,再按要排序的列的代表字母即可;
  • c:显示完整的 command 列;
  • T:输入 top 命令后回车,再按T,即可固定行次序(否则行次序一直在变),以便于观察。
  • h:出现 top 界面之后,按h即可得到简单帮助,详细请在终端输入man top
  • q:退出 top;

实际上,还可以用之前的 ps 命令来粗略的查看一下进程占用内存的情况。

ps 命令

可以使用 ps 定制命令来单独查看内存使用情况。比如,ps --pid <pid> -o pid,rsz,vsz,cmd

pmap 命令

pmap 命令用于报告进程的内存映射关系,是 Linux 调试及运维一个很好的工具。比如,pman -d <pid>

/proc/<pid> 文件夹

对于不了解该文件夹的童鞋可以使用man proc得到帮助。可以使用以下操作之一可得到内存占用情况不同程度的说明:

  • cat /proc/<pid>/status;这里可以看到概貌的内存统计。
  • cat /proc/<pid>/smaps:对应每个映射的内存开销详情。
  • cat /proc/<pid>/statm:列出的项分别为,size(VmSize)、resident(VMRSS)、share(shared pages)、text、lib、data(data+stack)、dt(dirty pages) 的大小, 应用程序正在使用的物理内存(resident)的大小 VmRSS/4,为实际使用内存数值的四分之一。
  • cat /proc/<pid>/maps:进程与可执行程序或动态库文件相关的映射信息。
# smaps 信息过多,可以过滤一下
cat /proc/$pid/smaps  | awk '/Size|Rss|Pss|Shared|Private|Referenced|Swap/{val_name=gensub(/([a-zA-Z_]*).*/,"\\1",1,$1); list[val_name]+=$2; }END{for(val in list)print val,list[val];}'  

系统内存信息

有时候需要知道整个系统内存的使用情况,可使用以下命令:

  • cat /proc/meminfo:查看系统内存状态信息。
  • free:查看机器可用内存。也可使用free -m
  • top:top 界面头信息含有内存情况。
  • vnstat:可对操作系统的虚拟内存、进程、CPU活动进行监控。
  • dmesg |grep [mM][eE][mM]:系统的真实内存大小。

进程打开的文件

要删除某个文件或者要写某个文件时,可能提示某个进程正在使用,无法删除等信息,此时就有必要查出到底被哪些进程 占用。可以使用 fuser(find files or sockets’ user) 命令,并对相应进程执行操作。

反过来,自己编写的程序有时候需要打开多个文件,这就需要知道有多少文件被该进程打开了,打开文件的生命周期如何, 打开的文件是否在合理的时机被关闭了等信息,可以使用 lsof(list open file)命令。由于 Linux 下一切皆 文件,所以也可以查看套接字和连接等。

查看某个进程对应的映像文件(即二进制文件)所在的完整路径,可以使用ll /proc/进程号命令,具体有:

  • cwd: 符号链接的是进程的启动目录,可以使用ll /proc/进程号/cwd
  • exe:符号链接的是进程对应程序的绝对路径;
  • cmdline:执行程序时输入的命令行;
  • environ:进程运行时的环境变量;
  • fd目录:进程打开或使用的文件的符号链接。

进程可能一开始执行就崩溃了或者执行了很久由于出现某种异常(如,收到了某种信号,信号默认行为退出)而崩溃, 此时你可能迫切需要查找原因,想知道 core 文件 放在哪里(特别是服务器程序),一般就放在进程的启动目录 下。

进程使用的动态库

为了节约内存或者重用,回尽量用动态库。那进程用到了哪些动态库呢?实际上就是确定进程的依赖关系。

查看可执行程序的共享库依赖关系

  • ldd /path/to/program:如 ldd /usr/bin/gcc

注意:ldd 可能会直接调用可执行程序来明确其库文件依赖关系,如果该可执行程序时第三方程序,可能会对你的 电脑造成安全问题。

  • objdump -p /path/to/program | grep NEEDED:这是个安全的方式用于显示一个未知应用程序二进制文件 的库文件依赖。

查看进程的共享库依赖关系

  • sudo pldd ;
  • sudo pmap :pmap 是一个命令行工具。
  • strace:可是查看程序启动时加载的动态库等。

需要注意的是,在启动程序时,可能会提示找不到动态库,这就需要告诉系统所需动态库的位置。至少有以下三种 方式(这也是系统寻找动态库的顺序):

  • 加入 LD_LIBRARY_PATH 变量:如 export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH;
  • 修改 /etc/ld.so.cache:但不能直接修改它,而需要先修改/etc/ld.so.conf文件,在其中另 起一行加入指定路径,如 /usr/local/lib,然后不要忘记在终端中执行sudo ldconfig命令,这样就应用到 了 ld.so.cache 中。
  • /lib/usr/lib目录下建立相应动态库的软连接。
  • 程序中主动调用 dlopen打开相关动态库(或者在编译连接时用 -l选项和--as-needed, 可以只加载需要的共享库)。

注意:共享库可能会冲突,越明确隔离的共享库位置越不容易冲突,所以当使用以上某种方式提示冲突时,就需要 更换其他方式(先取消使用的方式)。

进程使用的系统调用

在 Linux 下可以使用strace、ltrace、truss来跟踪进程,查看进程用到的系统调用情况。实际上, 这三个常用的调试工具来快速诊断软件的”疑难杂症”。

你不仅可以从命令行调试一个新开始的程序,也可以把itruss、strace 或 ltrace 绑定到一个已有的 PID 上来调试一个 正在运行的程序。用调试工具实时跟踪软件的运行情况不仅是诊断软件”疑难杂症”的有效的手段, 也可帮助我们理清软件的”脉络”,即快速掌握软件的运行流程和工作原理,不失为一种学习源代码的辅助方法。

前台后台进程切换

常用的命令有 fg、bg、jobs、&、ctrl + z ,这些命令都是合后台运行、关闭等有关。而且可以用于 终端运行 GUI 程序后继续使用该终端,例如,sudo gedit test.txt &,则该终端仍可以执行其他命令,否则 是不能的。除非关闭 gedit。

  • &:这个用在一个命令的最后,然后这个命令就会被放到后台执行(同时会打印出 pid)。
  • ctrl+z:可以将一个正在前台执行的命令或程序放在后台,并且暂停。
  • jobs:查看当前有多少命令在后台运行。
  • fg

将后台中的命令调至前台继续运行,如果后台中有多个命令,可以用 fg %jobnumber 将选中的命令调出, %jobnumber 是通过 jobs 命令查到的后台正在执行的命令的序号(不是 pid)。

  • bg

将一个在后台暂停的命令,变成继续执行如果后台中有多个命令,可以用 bg %jobnumber 将选中的命令调出, %jobnumber 是通过 jobs 命令查到的后台正在执行的命令的序号(不是 pid)

  • kill:kill 可以杀死进程,也可以杀死 job(任务),执行 kill %job号。
  • ctrl+c:终止当前前台进程。
  • nohup:起到守护进程的作用,但不是严格意义的守护进程;

一般这种程序即使使用 & 结尾,如果终端关闭,那么程序也会被关闭。为了能够后台运行,我们需要使 用 nohup 这个命令,如 nohup /root/start.sh & 即可,当 shell 中提示了 nohup 成功后还需要按终端上键盘 任意键退回到 shell 输入命令窗口,然后通过在 shell 中输入 exit 来退出终端(而不是直接通过关闭窗口 按钮来关闭终端)。

使用 nohup 命令后,原程序的的标准输出被自动改向到当前目录下的 nohup.out 文件,起到了 log 的作用, 实现了完整的守护进程功能。如果想要监控标准输出可以使用:

tail -f nohup.out

进程间通信信息

进程间通信的方式有多种,接下来简单讲下如何捕捉这些通信信息。

网络通信:

netstat 命令用于显示各种网络相关信息,如网络连接,路由表,接口状态等等,如,监控端口 25,可以: ①netstat -anp |grep :25;②lsof -i:25。lsof也可以借助文件来访问网络连接和硬件。tcpdump 可以用用于抓取通信数据包,以便进一步分析网络情况。Linux 中查看 socket 状态:

  • cat /proc/net/sockstat:用于 ipv4;
  • cat /proc/net/sockstat6:用于 ipv6;

跟踪网络有关的所有系统调用,可以使用strace -e trace=network

查看共享内存和消息队列

查看共享内存和消息队列的命令主要用ipcs,主要有以下参数(以 - 为前缀):

  • m:查看共享内存信息;
  • q:显示所有的消息队列;
  • qt:显示消息队列的创建时间,发送和接收最后一条消息的时间;
  • qp:显示往消息队列中放消息和从消息队列中取消息的进程 ID;
  • ql:显示消息队列的限制信息;
  • ipcs -q -i msgid:显示该消息队列结构体中的消息信息;
  • ipcrm -m|-q|-s shm_id :删除 ipc;

跟踪所有与进程通讯有关的系统调用,可以使用strace -e trace=ipc

跟踪进程信号

可以使用strace -e trace=signal或者strace -e signal=set跟踪所有与系统信号有关的系统调用。

栈调用关系跟踪

在发生段错误的时候,打印函数的调用栈信息是定位问题很好的手段。一般来讲,我们可以捕获 SIGSEGV 信号, 在信号处理函数中将函数调用栈的关系打印出来。gdb 调试中的 backtrace,简称 bt 就是这个作用。 调用的 GNU 的 backtrace 函数,也可以打印函数的调用栈信息。

  • glibc 中的 backtrace 函数:
#include <execinfo.h>
void do_gnu_backtrace()
{
#define BACKTRACE_SIZ 100
  void *array[BACKTRACE_SIZ];
  size_t size, i;
  char **strings;

  size = backtrace(array, BACKTRACE_SIZ);
  strings = backtrace_symbols(array, size);

  for (i = 0; i < size; ++i) {
      printf("%p : %s\n", array[i], strings[i]);
  }

  printf("---------------------------------------------------------\n");
  free(strings);
}

使用该函数,在编译时,需要加上 -rdynamic 选项,否则符号表信息无法打印。

通过 backtrace 返回调用的栈帧,然后通过 backtrace_symbols 把地址转换为字符串。最后,在 Linux 下有个 工具 addr2line 可以将地址转换为文件名和行号!通过管道调用 addr2line,最后打印调用栈帧。

#include <execinfo.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
 * 打印栈帧
 * 
 * 通过backtrace,backtrace_symbols获取栈帧信息,然后建立管道,通过addr2line解析
 * 
 */

int32_t myexec(const char *cmd) 
{
    FILE *pp = popen(cmd, "r"); //建立管道
    if (!pp) 
    {
        return -1;
    }

    char tmp[1024];
    while (fgets(tmp, sizeof(tmp), pp) != NULL) 
    {
        if (tmp[strlen(tmp) - 1] == '\n') 
        {
            tmp[strlen(tmp) - 1] = '\0'; //去除换行符
        }
        
        printf("%-30s",tmp);
    }
    printf("\n");
    pclose(pp); //关闭管道
    return 0;
}

void parseName(char * str,char *exeName,char *addr)
{
    char *strTemp = str;
    char * addrTemp;
    while (*strTemp != NULL)
    {
        if (*strTemp == '(')
            memcpy(exeName, str, strTemp - str);

        if (*strTemp == '[')
            addrTemp = strTemp;

        if (*strTemp == ']')
            memcpy(addr, str + (addrTemp - str) + 1, strTemp - addrTemp - 1);
        strTemp++;
    }
}

void print_trace(void)
{
    void *array[10];
    size_t size;
    char **strings;

    size = backtrace(array,10);
    strings = backtrace_symbols(array,size);
    
    printf("Obtained %zd stack frames.\n",size);
    char cmd[500] = {0}; 
    char exeName[100] = {0};
    char addr[100] = {0};
    for(size_t i = 0;i < size;i++)
    {
      memset(cmd,0,sizeof(cmd));
      memset(exeName,0,sizeof(exeName));
      memset(addr,0,sizeof(addr));
      
      parseName(strings[i],exeName,addr);
      printf("%-15s",addr);
      sprintf(cmd,"addr2line -f -e %s %s",exeName,addr);
      myexec(cmd);
    }     
}

void dummp_function(void)
{
    print_trace();
}

int main(int argc,char *argv[])
{
    dummp_function();
    return 0;
}    

编译,gcc -Wall -g backtrace.cpp -o bt 运行,./bt

  • 使用 ebp

函数调用一般遵循:如果有 N 个参数,将 N 个参数压栈,然后是将返回地址压栈,最后是将 ebp 压栈保存起来。 如果我们只传递一个参数给某个函数,那么我们完全可以根据参数的地址推算出 ebp 存放的地址, 进而得到 ebp 的值。参数地址 -4(32位系统指针的长度为 4Byte)可以得到返回地址的位置。 参数的地址 -8 得到 ebp 在栈存放的地址。我们一旦得到 ebp,我们就可以回朔出整个栈调用。

第一步:getEBP
void **getEBP(int dummy)
{
    void **ebp = (void **)&dummy -2 ;
    return( *ebp );
}

原理很简单,就是入参的地址下面是返回地址,返回地址的下面是被保存的 ebp 的地址。第二步,有了 ebp, 我们可以一步一步前回退,得到调用者的栈的 ebp,调用者的调用者的栈的 ebp,……直到 NULL。

while( ebp )
{
  ret = ebp + 1;
  dladdr( *ret, &dlip );
  printf("Frame %d: [ebp=0x%08x] [ret=0x%08x] %s\n",
          frame++, *ebp, *ret, dlip.dli_sname );
  ebp = (void**)(*ebp);
  /* get the next frame pointer */
}

不过只能拿到栈的信息,和返回地址的信息,拿不到函数名。但可以利用 libdl.so 的 laddr这个函数得到距离 入参地址最近的符号表里面的 symbol。最后总结如下:

#include <dlfcn.h>

void **getEBP( int dummy )
{
    void **ebp = (void **)&dummy -2 ;
    return( *ebp );
}

void print_walk_backtrace( void )
{
    int dummy;
    int frame = 0;
    Dl_info dlip;
    void **ebp = getEBP( dummy );
    void **ret = NULL;
    printf( "Stack backtrace:\n" );
    while( ebp )
    {
        ret = ebp + 1;
        dladdr( *ret, &dlip );
        printf("Frame %d: [ebp=0x%08x] [ret=0x%08x] %s\n",
                frame++, *ebp, *ret, dlip.dli_sname );
        ebp = (void**)(*ebp);
        /* get the next frame pointer */
    }
    printf("---------------------------------------------------------\n");
}

注意:编译的时候加上 -rdynamic,同时链接 libdl.so 即加上 -ldl 选项。

  • libunwind
#include <libunwind.h>
void do_unwind_backtrace()
{
  unw_cursor_t    cursor;
  unw_context_t   context;

  unw_getcontext(&context);
  unw_init_local(&cursor, &context);

  while (unw_step(&cursor) > 0) {
   unw_word_t  offset, pc;
   char        fname[64];

   unw_get_reg(&cursor, UNW_REG_IP, &pc);

   fname[0] = '\0';
   (void) unw_get_proc_name(&cursor, fname, sizeof(fname), &offset);

   printf ("%p : (%s+0x%x) [%p]\n", pc, fname, offset, pc);
  }
  printf("---------------------------------------------------------\n");
}

编译的时候加上 -lunwind -lunwind-x86 ,如果是X86_64,则是 -lunwind -lunwind-x86_64 优点是不需要 -rdynamic选项,不需要 -g 选项。

动态函数调用追踪

基于 Gnu/Gprof 运行时剖析工具。Gnu/Gprof 是类 Unix 平台下对 c/c++ 开源项目的一个 profile 分析工具, 它能在程序运行过程中记录下函数间的调用关系,每个函数被调用的次数,每个函数消耗的时间等代码级信息。 它的实现原理是通过编译和链接源程序的时候在 gcc 编译器的命令行参数中加入“-pg”调试选项,gcc 编译器就 会在程序的每个函数中加入一个名为“mcout”(或“_mcount”,依赖于编译器或操作系统)的函数,该函数在内存 中保存了一张函数调用图,可利用函数调用堆栈的形式查找子函数和父函数的地址,从而获得函数间的调用关系, 以及每个函数调用次数、运行时间等信息。

使用步骤:
  • 如 gcc -pg test.c -o tes
  • 执行程序:./test,之后就会生成 gmon.out的二进制数据;
  • 分析:gprof test gmon.out

使用 GDB 堆栈跟踪

程序“调用堆栈”是当前函数之前的所有已调用函数的列表(包括当前函数)。每个函数及其变量都被分配了一个“ 帧”,最近调用的函数在 0 号帧中(“底部”帧)。要打印堆栈,发出命令 ‘bt’(’backtrace’ [回溯] 的缩写): (gdb) bt

异常堆栈跟踪

在 Linux 中做 C/C++ 开发经常会遇到一些不可预知的问题导致程序崩溃,同时崩溃后也没留下任何代码运行痕迹,因此, 堆栈跟踪技术就显得非要重要了。不过编码的时候还是以预防异常为主,最好养成避免已经出现过类似异常的行为习惯 (也就是说,在你的编码人生中,该类异常最好只能出现一次)。先看看常见的异常吧:

  • 使用空指针引用数据成员或调用函数;
  • 使用已经删除内存的指针(没有置空,也没有判空);
  • 不恰当的指针转换及其使用;
  • 容器原地操作,又读又写,导致迭代器失效,但循环或者操作又依赖迭代器的判断;
  • 使用了无意义的变量值(操作前应初始化和作判断):如除 0
  • 函数栈溢出:
    • 在函数内部定义了太大的局部变量或对象;
    • 函数嵌套调用或递归层次太深;
  • 数组越界:破坏了函数栈;
  • 变量未赋值就参与运算;
  • 内存不足:内存泄漏或碎片过多;
  • 锁使用不当:
    • 多线程或多进程不恰当的使用 new 或 delete 及其相关指针;
    • 使用了线程不安全函数;
    • 资源申请或释放资源在进程间或线程间协调不当,导致前述情形;
  • 浅层拷贝和深层拷贝:导致操作同一块内存(可能导致 delete 多次)或者操作未初始化的指针。
  • 其他情况间接导致上述情形的发生:如,
    • 数据库连接出现问题,导致无法获取数据,接下来依赖该数据的操作可能出现随机的崩溃(当数据库连接不稳定时);
    • 网络故障,类似上一点;
    • 信号处理不当或没有处理;

针对特定的信号,应用程序可以写对应的信号处理函数。如果不指定,则采取默认的处理方式, 默认处理是 coredump 的信号 如下:SIGQUIT、SIGILL、SIGABRT、SIGFPE 、SIGSEGV、SIGBUS、SIGSYS、SIGTRAP、SIGXCPU、SIGXFSZ、SIGIOT 等

上面也只是一个大概,以后遇到类似的问题会补充上去。为了减少上面的问题,请参考遵守本人博客“C++ 编程规范”中的 编码建议和习惯养成。不过有时候还是会疏忽出现程序崩溃,可以通过 gdb 调试跟踪栈信息,一般会未运行完就会崩溃停止, gdb 会打印出崩溃的原因,也比较容易定位到对应的文件、函数、行数;当然如果是线上程序,不可停止(这不是废话吗? 都崩溃了还不下线?有时候并不是一开始就崩溃,而是可能遇到特殊的输入或情形时才出现,况且有些服务器守护进程会 把崩溃的程序在有限的时间内重新启动,此类崩溃就相当隐秘了),可以用 gdb 发送信号给进程产生 core 文件,然后再 分析 core 文件(可以使用 gdb 加载 core 文件,使用gdb 对应的core文件名,然后使用 bt 等命令);极限条件下 不能使用 gdb,则编码的时候记得在关键位置打上 log,然后通过检索归类日志来查找问题; 剩下的只能看设计看源码查问题了。

强行产生 core 文件:
  • gdb:
    • 首先 gdb attach 进程号回车;
    • 然后generate-core-file回车即可;
    • detach(这一步不能少);
    • 然后退出 gdb
  • gcore:使用gcore 进程号回车即可;

以上在线上进程卡死或不能打断点重新运行的情况下很实用。如果不能产生 core 文件,可以设置一下 core 文件的大小和 路径。

好的编码习惯应该是:

写出的程序应:方便调试、方便单元测试、方便查找问题,总之,好的编码习惯应该兼顾开发、调试、测试、维护效率。

运行日志查看和分析

服务器程序大多会通过打印日志来检索问题所在(当然对于崩溃等问题还是会用 gdb 在调试机上查找问题), 所以适当地(打 log 的时机)产生日志文件是很重要的手段。接下来说说如何高效地检索日志文件。在服务器上, 一般采用系统自带的命令(vim 等第三方编辑器命令可能会因日志文件过大而加载缓慢甚至出现假死的情况)。 其实日志文件就是普通的文本文件,所以适合处理文本文件所有命令都可以用来处理日志文件(可参见本博客中“源文件”一节)。

Linux 系统日志文件一般放在目录/var/log下,但日志文件的存放位置是可以设置的,所以首先得知道你要查看的日志文件 所在的目录,然后才能使用相应的命令。下面只介绍查看日志文件相对高效的命令。

  • tail: 输出文件末尾的内容。
    • -f:可以实时查看(监视)日志新增的内容(可使用 ctrl+x 退出)。
    • -f -n 5:实时查看最后 5 行的日志。
    • -f -n -r 5:实时逆序查看最后 5 行的日志。
  • watchwatch -d -n 1 cat 日志文件可以每 1 秒刷新高亮打印新增日志文件。
  • head -n 5:显示文件最开始的 5 行。
  • grep:全称Global Regular Expression Print,强大的文本搜索工具。
    • 文件中搜索一个子串:例如 grep “match_pattern” file_name1 file_name2
    • -n:显示匹配字符串的行号
    • -v:反向匹配,grep -v “match_pattern” file_name
    • -w:匹配整个单词而不是子串
    • -i:不区分大小写
    • -l:找出含有这个字符串的文件
    • -r:递归搜索,不放过子目录
    • -A:显示匹配项之后的[number]行,例如 grep -A 3 “match_pattern” file_name
    • -B:显示匹配项之前的[number]行,例如 grep -B 3 “match_pattern” file_name
    • -C:显示匹配行上下文的[number]行,例如 grep -C number “match_pattern” file_name
    • -c:统计匹配的行数,例如 grep -c “match_pattern” file_name
    • :显示匹配多个关键字中的至少一个,例如 grep “match_pattern1” | “match_pattern2” file_name
    • :显示匹配所有关键字的行,例如 grep “match_pattern1” file_name | grep “match_pattern2”
    • -E:使用正则表达式,如 grep -E “[1-9]+”
    • -o:只输出文件中匹配到的部分,如 echo this is a test line. | grep -o -E “[a-z]+.” 的结果是 line.

tail -f 可以和 grep 联合使用可以实时监控日志并且按要求只显示匹配行,例如 tail -f /var/log/test.log grep -w “test”, grep只有使用正则表达式才能发挥其强大的文本搜索功能,使用正则时,需要grep -Eegrep。下面简单列出匹配 规则:

限定符 描述
. 匹配任意的一个字符
? 匹配前面的子表达式,最多一次
* 匹配前面的子表达式零次或多次
+ 匹配前面的子表达式一次货多次
{N} 匹配前面的子表达式 N 次
{N,} 匹配前面的子表达式 N 次或更多
{N,M} 匹配前面的子表达式 N 到 M 次
- 表示序列的范围
^word 匹配以 word 开头的行
word$ 匹配以 word 结束的行
[list] 匹配 list 集合中的一个字符
[^list] 匹配 list 集合中以外的一个字符
\<word 以 word 开头的单词
word\> 以 word 结尾的单词
\ 转义字符
| 以或的方式匹配多个字符串
() 匹配整个括号内的字符串
\w 匹配[A-Za-z0-9]
\b 匹配一个单词前后的空字符串
\B 匹配一个单词中间的空字符串
[:alnum:] 字母数字字符
[:alpha:] 字母字符
[:blank:] 空字符: 空格键符和制表符
[:digit:] 数字: ‘0 1 2 3 4 5 6 7 8 9’
[:lower:] 小写字母
[:upper:] 大写字母
[:space:] 空格字符: 制表符、换行符、垂直制表符、换页符、回车符和空格键符

上面没有给出例子,不过可以通过类似 echo this is a test line. | grep -o -E "[a-z]+\." 的形式在终端进行试验验证。 但还是在下面给出几个例子:

  • grep "^[^48]" test.txt 显示输出行首不是字符“48”的行)
  • grep "[Mm]ay" test.txt 设置大小写查找:显示输出第一个字符以“M”或“m”开头,以字符“ay”结束的行)
  • grep "[A-Z][9]D" test.txt 显示输出第一个字符的范围是“A-D”,第二个字符是“9”,第三个字符的是“D”的所有的行
  • grep "9\{2,3\}" test.txt 模式出现几率查找:显示输出字符“9”重复出现的次数在一定范围内,重复出现2次或3次所有行
  • grep -nwE "g(oo|la)d" test.txt 匹配 good 或 glad
  • grep -n "^$" test.txt 显示输出空行的行号
  • '\bgrep\b' 只匹配 grep。

【注意】本文属于作者原创,欢迎转载!转载时请注明以下内容:

(转载自)ShengChangJian's Blog编程技术文章地址:

https://ShengChangJian.github.io/2017/11/process-anatomy.html

主页地址:https://shengchangjian.github.io/


Similar Posts

上一篇: 探秘 C 指针

下一篇: 设计模式笔记

Comments

【目录】