OS Basic

本文对操作系统的基础知识做了一些梳理和总结(主要针对面试中常见的一些问题)

1. 操作系统基础

什么是操作系统?

  1. 操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。

  2. 操作系统本质上是一个运行在计算机上的软件程序 ,用于管理计算机硬件和软件资源。 举例:运行在你电脑上的所有应用程序都通过操作系统来调用系统内存以及磁盘等等硬件。

  3. 操作系统存在屏蔽了硬件层的复杂性。 操作系统就像是硬件使用的负责人,统筹着各种相关事项。

  4. 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。

系统调用

介绍系统调用之前,我们先来了解一下用户态和内核态。

根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:

  1. 用户态(user mode) : 用户态运行的进程可以直接读取用户程序的数据。
  2. 内核态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。

从用户态到内核态切换可以通过三种方式

  1. 系统调用:⽐如printf, open, read, write 。其实系统调用本身就是中断,但是软件中断,跟硬中断不同。
  2. 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
  3. 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。

那么什么是系统调用呢?

  • 系统调用是操作系统的最小功能单位。
  • 根据不同的应用场景,不同的Linux发行版本提供的系统调用数量也不尽相同,大致在240-350之间。
  • 这些系统调用组成了用户态跟内核态交互的基本接口,例如:用户态想要申请一块20K大小的动态内存,就需要brk系统调用,将数据段指针向下偏移

我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的系统态级别的子功能就需要系统调用了

  • 也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。

  • 用户是处于用户态,具有的权限是非常有限,肯定是不能直接使用内核态的服务,只能间接通过有访问权限的API函数内嵌的系统调用函数来调用

这些系统调用按功能大致可分为如下几类:

  • 设备管理。完成设备的请求或释放,以及设备启动等功能。
  • 文件管理。完成文件的读、写、创建及删除等功能。
  • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
  • 进程通信。完成进程之间的消息传递或信号传递等功能。
  • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

显然,系统调用运行在系统的内核态。通过系统调用的方式来使用系统功能,可以保证系统的稳定性和安全性,防止用户随意更改或访问系统的数据或命令。系统调用命令式由操作系统提供的一个或多个子程序模块来实现的。

操作系统的启动过程

简单总结:

  • BIOS系统进行硬件自检
  • BIOS读取“启动顺序”
  • 按照”启动顺序”,控制权转交给排在第一位的储存设备,读取第一个扇区(512字节)里的“主引导记录”(最后两个字节可判断该设备是否可用于启动)
  • 读取”主引导记录”前面446字节的机器码之后,由用户选择启动哪一个操作系统
  • 控制权转交给操作系统后,操作系统的内核首先被载入内存
  • 以Linux系统为例,先载入/boot目录下面的kernel。内核加载成功后,第一个运行的程序是/sbin/init。它根据配置文件产生init进程。这是Linux启动后的第一个进程,pid进程编号为1,其他进程都是它的后代。
  • 然后,init线程加载系统的各个模块,比如窗口程序和网络程序,直至执行/bin/login程序,跳出登录界面,等待用户输入用户名和密码。
  • 启动完成

一、第一阶段:BIOS(Basic Input/Output System)

上个世纪70年代初,”只读内存”(read-only memory,缩写为ROM)发明,开机程序被刷入ROM芯片,计算机通电后,第一件事就是读取它。

1.1 硬件自检

BIOS程序首先检查,计算机硬件能否满足运行的基本条件,这叫做”硬件自检”(Power-On Self-Test),缩写为POST

如果硬件出现问题,主板会发出不同含义的蜂鸣,启动中止。如果没有问题,屏幕就会显示出CPU、内存、硬盘等信息。

1.2 启动顺序

硬件自检完成后,BIOS把控制权转交给下一阶段的启动程序。

这时,BIOS需要知道,”下一阶段的启动程序”具体存放在哪一个设备。也就是说,BIOS需要有一个外部储存设备的排序,排在前面的设备就是优先转交控制权的设备。这种排序叫做”启动顺序”(Boot Sequence)。

打开BIOS的操作界面,里面有一项就是”设定启动顺序”。

二、第二阶段:主引导记录

BIOS按照”启动顺序”,把控制权转交给排在第一位的储存设备。

这时,计算机读取该设备的第一个扇区,也就是读取最前面的512个字节。如果这512个字节的最后两个字节是0x55和0xAA,表明这个设备可以用于启动;如果不是,表明设备不能用于启动,控制权于是被转交给”启动顺序”中的下一个设备。

这最前面的512个字节,就叫做“主引导记录”(Master boot record,缩写为MBR)。

2.1 主引导记录的结构

“主引导记录”只有512个字节,放不了太多东西。它的主要作用是,告诉计算机到硬盘的哪一个位置去找操作系统。

主引导记录由三个部分组成:

  (1) 第1-446字节:调用操作系统的机器码。

  (2) 第447-510字节:分区表(Partition table)。

  (3) 第511-512字节:主引导记录签名(0x55和0xAA)。

其中,第二部分”分区表”的作用,是将硬盘分成若干个区。

2.2 分区表

硬盘分区有很多好处。考虑到每个区可以安装不同的操作系统,”主引导记录”因此必须知道将控制权转交给哪个区。

分区表的长度只有64个字节,里面又分成四项,每项16个字节。所以,一个硬盘最多只能分四个一级分区,又叫做”主分区”。

每个主分区的16个字节,由6个部分组成:

  (1) 第1个字节:如果为0x80,就表示该主分区是激活分区,控制权要转交给这个分区。四个主分区里面只能有一个是激活的。

  (2) 第2-4个字节:主分区第一个扇区的物理位置(柱面、磁头、扇区号等等)。

  (3) 第5个字节:主分区类型

  (4) 第6-8个字节:主分区最后一个扇区的物理位置。

  (5) 第9-12字节:该主分区第一个扇区的逻辑地址。

  (6) 第13-16字节:主分区的扇区总数。

最后的四个字节(”主分区的扇区总数”),决定了这个主分区的长度。也就是说,一个主分区的扇区总数最多不超过2的32次方。

如果每个扇区为512个字节,就意味着单个分区最大不超过2TB。再考虑到扇区的逻辑地址也是32位,所以单个硬盘可利用的空间最大也不超过2TB。如果想使用更大的硬盘,只有2个方法:一是提高每个扇区的字节数,二是增加扇区总数

三、第三阶段:硬盘启动

这时,计算机的控制权就要转交给硬盘的某个分区了,这里又分成三种情况。

3.1 情况A:卷引导记录

上一节提到,四个主分区里面,只有一个是激活的。计算机会读取激活分区的第一个扇区,叫做“卷引导记录“(Volume boot record,缩写为VBR)。

“卷引导记录”的主要作用是,告诉计算机,操作系统在这个分区里的位置。然后,计算机就会加载操作系统了。

3.2 情况B:扩展分区和逻辑分区

随着硬盘越来越大,四个主分区已经不够了,需要更多的分区。但是,分区表只有四项,因此规定有且仅有一个区可以被定义成”扩展分区”(Extended partition)。

所谓”扩展分区”,就是指这个区里面又分成多个区。这种分区里面的分区,就叫做”逻辑分区”(logical partition)。

计算机先读取扩展分区的第一个扇区,叫做“扩展引导记录”(Extended boot record,缩写为EBR)。它里面也包含一张64字节的分区表,但是最多只有两项(也就是两个逻辑分区)。

计算机接着读取第二个逻辑分区的第一个扇区,再从里面的分区表中找到第三个逻辑分区的位置,以此类推,直到某个逻辑分区的分区表只包含它自身为止(即只有一个分区项)。因此,扩展分区可以包含无数个逻辑分区。

但是,似乎很少通过这种方式启动操作系统。如果操作系统确实安装在扩展分区,一般采用下一种方式启动。

3.3 情况C:启动管理器

在这种情况下,计算机读取”主引导记录”前面446字节的机器码之后,不再把控制权转交给某一个分区,而是运行事先安装的“启动管理器”(boot loader),由用户选择启动哪一个操作系统。

Linux环境中,目前最流行的启动管理器是Grub

四、第四阶段:操作系统

控制权转交给操作系统后,操作系统的内核首先被载入内存。

以Linux系统为例,先载入/boot目录下面的kernel。内核加载成功后,第一个运行的程序是/sbin/init。它根据配置文件(Debian系统是/etc/initab)产生init进程。这是Linux启动后的第一个进程,pid进程编号为1,其他进程都是它的后代。

然后,init线程加载系统的各个模块,比如窗口程序和网络程序,直至执行/bin/login程序,跳出登录界面,等待用户输入用户名和密码。

至此,全部启动过程完成。

2. 进程和线程

表格总结

进程与线程的概念,以及为什么要有进程线程,其中有什么区别,他们各自又是怎么同步的?

1. 基本概念:

  • 进程:

    • 是对运行时程序的封装
    • 系统进行资源调度和分配的的基本单位,实现了操作系统的并发
  • 线程:

    • 是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
    • 线程是操作系统可识别的最小执行和调度单位
    • 每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态
    • 每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源(共享进程的虚拟内存)。

2. 区别:

  • 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在

  • 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。

    • 资源分配给进程,同一进程的所有线程共享该进程的所有资源。
    • 同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)
    • 但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。
  • 进程是资源分配的最小单位,线程是CPU调度的最小单位**; **

  • 系统开销:

    • 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等。
    • 因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。
    • 类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。
    • 线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。
    • 可见,进程切换的开销也远大于线程切换的开销。
  • 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的系统中,线程的切换、同步和通信都无须操作系统内核的干预

  • 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。

  • 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉

  • 进程适应于多核、多机分布;线程适用于多核

进程有哪几种状态?

我们一般把进程大致分为 5 种状态,这一点和线程很像!

  • 创建状态(new) :进程正在被创建,尚未到就绪状态。

  • 就绪状态(ready) :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。

  • 运行状态(running) :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。

  • 阻塞状态(waiting) :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。

  • 结束状态(terminated) :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。

进程间的通信方式

参考文章

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

几种方式:

  1. 管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。半双工、数据只能一个方向流动。
  2. 有名管道(Names Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循**先进先出(first in first out)**。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
  3. 信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
  4. 消息队列(Message Queuing)
    • 消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。
    • 管道和消息队列的通信数据都是先进先出的原则。
    • 与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。
    • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。
    • 消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  5. 信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
  6. 共享内存(Shared memory) :(需要打破进程间内存隔离的限制,建立共享内存区域)使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
  7. 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

消息队列和管道的对比

  1. 匿名管道是跟随进程的,消息队列是跟随内核的,也就是说进程结束之后,匿名管道就死了,但是消息队列还会存在(除非显示调用函数销毁)

  2. 管道是文件,存放在磁盘上,访问速度慢,消息队列是数据结构,存放在内存,访问速度快

  3. 管道是数据流式存取,消息队列是数据块式存取

进程调度算法

  1. 先来先服务 first-come first-serverd(FCFS)
  • 非抢占式的调度算法,按照请求的顺序进行调度。

  • 有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。

  1. 短作业优先 shortest job first(SJF)

    • 非抢占式的调度算法,按估计运行时间最短的顺序进行调度。

    • 长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

  2. 最短剩余时间优先 shortest remaining time next(SRTN)

    • 最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。

    • 如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

  3. 时间片轮转

    • 所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片

    • 当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。

    • 时间片轮转算法的效率和时间片的大小有很大关系:

      • 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。

      • 而如果时间片过长,那么实时性就不能得到保证。

  4. 优先级调度

    • 为每个进程分配一个优先级,按优先级进行调度。可根据内存要求,时间要求或任何其他资源要求来确定优先级

    • 为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级

  5. 多级反馈队列

    • 一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
  • 多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。
    • 这种方式下,之前的进程只需要交换 7 次。每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
  • 可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合

线程间的同步的方式

线程同步是两个或多个共享关键资源的线程的并发执行。应该同步线程以避免关键的资源使用冲突。操作系统一般有下面三种线程同步的方式:

  1. **互斥量(Mutex)**:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
  2. 信号量(Semaphore) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
  3. 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

一个进程可以创建多少线程,和什么有关?

这个要分不同系统去看:

  • 如果是32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
  • 如果是64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。

顺便多说一句,过多的线程将会导致大量的时间浪费在线程切换上,给程序运行效率带来负面影响,无用线程要及时销毁。

多线程

我们这里讨论的是用户态的多线程模型,同一个进程内部有多个线程,所有的线程共享同一个进程的内存空间,进程中定义的全局变量会被所有的线程共享,比如有全局变量int i = 10,这一进程中所有并发运行的线程都可以读取和修改这个i的值,而多个线程被CPU调度的顺序又是不可控的,所以对临界资源的访问尤( 一次仅允许一个线程使用的资源)其需要注意安全

我们必须知道,做一次简单的i = i + 1在计算机中并不是原子操作,涉及内存取数,计算和写入内存几个环节,而线程的切换有可能发生在上述任何一个环节中间,所以不同的操作顺序很有可能带来意想不到的结果。

但是,虽然线程在安全性方面会引入许多新挑战,但是线程带来的好处也是有目共睹的。首先,原先顺序执行的程序(暂时不考虑多进程)可以被拆分成几个独立的逻辑流,这些逻辑流可以独立完成一些任务(最好这些任务是不相关的)。

比如 QQ 可以一个线程处理聊天一个线程处理上传文件,两个线程互不干涉,在用户看来是同步在执行两个任务,试想如果线性完成这个任务的话,在数据传输完成之前用户聊天被一直阻塞会是多么尴尬的情况。

对于线程,我认为弄清以下两点非常重要:

  • 线程之间有无先后访问顺序(线程依赖关系)
  • 多个线程共享访问同一变量(同步互斥问题)

另外,我们通常只会去说同一进程的多个线程共享进程的资源,但是每个线程特有的部分却很少提及,除了标识线程的tid,每个线程还有自己独立的栈空间,线程彼此之间是无法访问其他线程栈上内容的。

而作为处理机调度的最小单位,线程调度只需要保存线程栈、寄存器数据和PC即可,相比进程切换开销要小很多。

线程相关接口不少,主要需要了解各个参数意义和返回值意义。

线程创建和结束

  • 背景知识:

    在一个文件内的多个函数通常都是按照main函数中出现的顺序来执行,但是在分时系统下,我们可以让每个函数都作为一个逻辑流并发执行,最简单的方式就是采用多线程策略。在main函数中调用多线程接口创建线程,每个线程对应特定的函数(操作),这样就可以不按照main函数中各个函数出现的顺序来执行,避免了忙等的情况。线程基本操作的接口如下。

  • 相关接口:

    • 创建线程:int pthread_create(pthread_t *tidp,const pthread_attr_t *attr, void *(*start_rtn)(void*),void *arg);

      创建一个新线程,pthread和start_routine不可或缺,分别用于标识线程和执行体入口,其他可以填NULL。

      • pthread:用来返回线程的tid,*pthread值即为tid,类型pthread_t == unsigned long int。
      • attr:指向线程属性结构体的指针,用于改变所创线程的属性,填NULL使用默认值。
      • start_routine:线程执行函数的首地址,传入函数指针。
      • arg:通过地址传递来传递函数参数,这里是无符号类型指针,可以传任意类型变量的地址,在被传入函数中先强制类型转换成所需类型即可。
    • 获得线程ID:pthread_t pthread_self();

      调用时,会打印线程ID。

    • 等待线程结束:int pthread_join(pthread_t tid, void** retval);

      主线程调用,等待子线程退出并回收其资源,类似于进程中wait/waitpid回收僵尸进程,调用pthread_join的线程会被阻塞。

      • tid:创建线程时通过指针得到tid值。
      • retval:指向返回值的指针。
    • 结束线程:pthread_exit(void *retval);

      子线程执行,用来结束当前线程并通过retval传递返回值,该返回值可通过pthread_join获得。

      • retval:同上。
    • 分离线程:int pthread_detach(pthread_t tid);

      主线程、子线程均可调用。主线程中pthread_detach(tid),子线程中pthread_detach(pthread_self()),调用后和主线程分离,子线程结束时自己立即回收资源。

      • tid:同上。

线程属性值修改

  • 背景知识:

    线程属性对象类型为pthread_attr_t,结构体定义如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    typedef struct{
    int etachstate; // 线程分离的状态
    int schedpolicy; // 线程调度策略
    struct sched_param schedparam; // 线程的调度参数
    int inheritsched; // 线程的继承性
    int scope; // 线程的作用域
    // 以下为线程栈的设置
    size_t guardsize; // 线程栈末尾警戒缓冲大小
    int stackaddr_set; // 线程的栈设置
    void * stackaddr; // 线程栈的位置
    size_t stacksize; // 线程栈大小
    } pthread_arrt_t;
  • 相关接口:

    对上述结构体中各参数大多有:pthread_attr_get()和pthread_attr_set()系统调用函数来设置和获取。这里不一一罗列。

多进程

每一个进程是资源分配的基本单位。

进程结构由以下几个部分组成:代码段、堆栈段、数据段。代码段是静态的二进制代码,多个程序可以共享。

实际上在父进程创建子进程之后,父、子进程除了pid(进程ID,作为进程的身份标识)外,几乎所有的部分几乎一样

父、子进程共享全部数据,但并不是说他们就是对同一块数据进行操作,子进程在读写数据时会通过写时复制机制将公共的数据重新拷贝一份,之后在拷贝出的数据上进行操作

如果子进程想要运行自己的代码段,还可以通过调用execv()函数重新加载新的代码段,之后就和父进程独立开了。

我们在shell中执行程序就是通过shell进程先fork()一个子进程(fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程)再通过execv()重新加载新的代码段的过程。

进程创建与结束

  • 背景知识:

    进程有两种创建方式:一种是操作系统创建的一种是父进程创建的。从计算机启动到终端执行程序的过程为:0号进程 -> 1号内核进程 -> 1号用户进程(init进程) -> getty进程 -> shell进程 -> 命令行执行进程。所以我们在命令行中通过 ./program执行可执行文件时,所有创建的进程都是shell进程的子进程,这也就是为什么shell一关闭,在shell中执行的进程都自动被关闭的原因。从shell进程到创建其他子进程需要通过以下接口。

  • 相关接口:

    • 创建进程:pid_t fork(void);

      返回值:出错返回-1;父进程中返回pid > 0;子进程中pid == 0

    • 结束进程:void exit(int status);

      • status是退出状态,保存在全局变量中S?,通常0表示正常退出。
    • 获得PID:pid_t getpid(void);

      返回调用者pid。

    • 获得父进程PID:pid_t getppid(void);

      返回父进程pid。

  • 其他补充:

    • 正常退出方式:exit()、_exit()、return(在main中)。

      exit()和_exit()区别:exit()是对_exit()的封装,都会终止进程并做相关收尾工作,最主要的区别是_exit()函数关闭全部描述符和清理函数后不会刷新流,但是exit()会在调用_exit()函数前刷新数据流。

      return和exit()区别:exit()是函数,但有参数,执行完之后控制权交给系统。return若是在调用函数中,执行完之后控制权交给调用进程,若是在main函数中,控制权交给系统。

    • 异常退出方式:abort()、终止信号。

Linux进程控制

  • 进程地址空间(地址空间)

    • 虚拟存储器为每个进程提供了独占系统地址空间的假象。
    • 尽管每个进程地址空间内容不尽相同,但是他们的都有相似的结构。X86 Linux进程的地址空间底部是保留给用户程序的,包括文本、数据、堆、栈等,其中文本区和数据区是通过存储器映射方式将磁盘中可执行文件的相应段映射至虚拟存储器地址空间中。
    • 有一些”敏感”的地址需要注意下,对于32位进程来说,代码段从0x08048000开始。从0xC0000000开始到0xFFFFFFFF是内核地址空间,通常情况下代码运行在用户态(使用0x00000000 ~ 0xC00000000的用户地址空间),当发生系统调用、进程切换等操作时CPU控制寄存器设置模式位,进入内和模式,在该状态(超级用户模式)下进程可以访问全部存储器位置和执行全部指令。
    • 也就说32位进程的地址空间都是4G,但用户态下只能访问低3G的地址空间,若要访问3G ~ 4G的地址空间则只有进入内核态才行
  • 进程控制块(处理机)

    进程的调度实际就是内核选择相应的进程控制块,被选择的进程控制块中包含了一个进程基本的信息。

  • 上下文切换

    • 内核管理所有进程控制块,而进程控制块记录了进程全部状态信息。每一次进程调度就是一次上下文切换,所谓的上下文本质上就是当前运行状态,主要包括通用寄存器、浮点寄存器、状态寄存器、程序计数器、用户栈和内核数据结构(页表、进程表、文件表)等。
    • 进程执行时刻,内核可以决定抢占当前进程并开始新的进程,这个过程由内核调度器完成,当调度器选择了某个进程时称为该进程被调度,该过程通过上下文切换来改变当前状态。
    • 一次完整的上下文切换通常是进程原先运行于用户态,之后因系统调用或时间片到切换到内核态执行内核指令,完成上下文切换后回到用户态,此时已经切换到进程B。

进程的同步

背景

我们把异步环境下的一组并发进程因直接制约而互相发送消息、进行互相合作、互相等待,使得各进程按一定的速度执行的过程称为进程间的同步。具有同步关系的一组并发进程称为合作进程,合作进程间互相发送的信号称为消息或事件。 如果我们对一个消息或事件赋以唯一的消息名,则我们可用过程 wait (消息名)  表示进程等待合作进程发来的消息,而用过程 signal (消息名) 表示向合作进程发送消息。

作用:

多进程虽然提高了系统资源利用率和吞吐量,但是由于进程的异步性可能造成系统的混乱。进程同步的任务就是对多个相关进程在执行顺序上进行协调,使并发执行的多个进程之间可以有效的共享资源和相互合作,保证程序执行的可再现性。

一些概念

  • 临界资源:一次只允许一个进程使用的资源称为临界资源,如打印机、磁带机等)
  • 中断:指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行。
  • 原语:所谓原语,一般是指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断。 在操作系统中,某些被进程调用的操作,如队列操作、对信号量的操作、检查启动外设操作等,一旦开始执行,就不能被中断,否则就会出现操作错误,造成系统混乱。

四种方法

  • 临界区

    • 对临界资源进行访问的那段代码称为临界区。

      为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。

      1
      2
      3
      // entry section
      // critical section;
      // exit section
  • 同步与互斥

    • 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
    • 互斥:多个进程在同一时刻只有一个进程能进入临界区。
  • 信号量

    信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

    • down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
    • up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。

    down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。

    如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    typedef int semaphore;
    semaphore mutex = 1;
    void P1() {
    down(&mutex);
    // 临界区
    up(&mutex);
    }

    void P2() {
    down(&mutex);
    // 临界区
    up(&mutex);
    }

    使用信号量实现生产者-消费者问题

    问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。

    因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。

    为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。

    其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。

    注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。

    消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    #define N 100
    typedef int semaphore;
    semaphore mutex = 1;
    semaphore empty = N;
    semaphore full = 0;

    void producer() {
    while(TRUE) {
    int item = produce_item();
    down(&empty);
    down(&mutex);
    insert_item(item);
    up(&mutex);
    up(&full);
    }
    }

    void consumer() {
    while(TRUE) {
    down(&full);
    down(&mutex);
    int item = remove_item();
    consume_item(item);
    up(&mutex);
    up(&empty);
    }
    }
  • 管程

    使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。

    c 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    monitor ProducerConsumer
    integer i;
    condition c;

    procedure insert();
    begin
    // ...
    end;

    procedure remove();
    begin
    // ...
    end;
    end monitor;

    管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。

    管程引入了 条件变量 以及相关的操作:wait()signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

3. 死锁

场景

  • 当有A,B 两个线程,同时申请X,Y两个资源,但A线程先申请X资源再申请Y资源,B线程先申请Y资源再申请X资源,A,B两个线程同时运行,A先锁了X,B先锁了Y,然后A去申请Y,就会一直阻塞,同时,B再去申请X,也会一直阻塞,这样就会造成死锁

什么是死锁

  • 死锁是指两个或两个以上的进程/线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,它们都将无法推进下去,此时称系统处于死锁态或系统产生了死锁,这些永远在互相等待的进程称为死锁

引起死锁的原因

  • 竞争不可抢占资源引起死锁

    也就是说我们说的第一种情况,而这都在等待对方占有的不可抢占的资源

  • 竞争可消耗资源引起死锁

    有p1,p2,p3 三个进程,p1向p2发送消息并接受p3消息,p2向p3发送消息并接受p1消息,p3向p1发送消息并接受p2消息,如果设置是先接收消息再发送消息,则所有的消息都不能发送,这就造成死锁

  • 进程推进顺序不当引起死锁

    有进程p1,p2,都需要资源A,B,当p1,p2同时运行时,p1先锁A再阻塞等待B,p2先锁B再阻塞等待A,这样也会造成互相抢占资源的死锁

死锁产生的必要条件

注意四个条件需要同时成立,才会产生死锁

  • 互斥条件:(资源角度)某资源只能被一个进程使用,其它进程请求该资源时,只能等待 ,直到资源使用完毕后释放资源
  • 请求和保持条件:(程序角度)程序己经保持了至少一个资源,但是又提出了新的要求,而这个资源被其它进程占用, 自己占用资源却保持不放
  • 不可抢占条件:(资源角度)进程己获得的资源没有使用完,不能被抢占
  • 循环等待条件:必然存在一个循环链

处理死锁的思路

  • 预防死锁:破坏死锁的四个必要条件中的一个或多个来预防死锁(但是都会不同程度得降低进程运行的效率和资源使用率)

    • 互斥条件:使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源往往是不能同时访问的,所以这种做法在大多数的场合是行不通的。

    • 破坏请求和保持条件:(静态分配策略)所有进程开始前,必须一次性的申请所需的全部资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从面预防死锁发生。

      静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源是在额外的情况下才是用的,这样就可能造成了一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。

    • 破坏不可抢占条件:当一个己经保持了某种不可抢占资源的进程,提出了新的资源请求不能被满足时,它必须释放己经保持的所有资源,以后需要时再重新申请

    • 破坏循环等待条件:(层次分配策略)在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略

  • 避免死锁:和预防死锁的区别是,它的角度是允许系统中同时存在四个必要条件,但在资源动态分配过程中,用某种方式防止系统进入不安全状态

    • 最具有代表性的避免死锁算法就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法通过先试探分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程
    • 死锁的避免(银行家算法)改善解决了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做安全性检查,需要花费较多的时间。
  • 检测死锁:系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。

  • 解除死锁:当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:

    • 立即结束所有进程的执行,重新启动操作系统 :这种方法简单,但以前所在的工作全部作废,损失很大。
    • 撤销涉及死锁的所有进程,解除死锁后继续运行 :这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。
    • 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。
    • 抢占资源 :从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。

4. 操作系统内存管理

操作系统的内存管理

内存管理主要是做什么?

操作系统的内存管理主要负责:

  • 内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存)
  • 地址转换:也就是将逻辑地址转换成相应的物理地址

内存管理有哪几种方式?

简单分为连续分配管理方式非连续分配管理方式这两种。连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如 块式管理 。同样地,非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如页式管理段式管理

  1. 块式管理 : 远古时代的计算机操作系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
  2. 页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相比于块式管理的划分粒度更小,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址
  3. 段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页并无任何实际意义。 段式管理把主存分为一段段的,段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
  4. 段页式管理机制:结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说段页式管理机制中段与段之间以及段的内部的都是离散的。

简单来说:页是物理单位,段是逻辑单位。分页可以有效提高内存利用率,分段可以更好满足用户需求。

快表和多级页表

页表管理机制中有两个很重要的概念:快表和多级页表,这两个东西分别解决了页表管理中很重要的两个问题:

  1. 虚拟地址到物理地址的转换要快。
  2. 解决虚拟地址空间大,页表也会很大的问题。

快表

为了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 快表 来加速虚拟地址到物理地址的转换。我们可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。

使用快表之后的地址转换流程是这样的:

  1. 根据虚拟地址中的页号查快表;
  2. 如果该页在快表中,直接从快表中读取相应的物理地址;
  3. 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
  4. 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。

看完了之后你会发现快表和我们平时经常在我们开发的系统使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。

多级页表

引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景,具体可以查看下面这篇文章

总结

为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表(即 TLB)的概念。 不论是快表还是多级页表实际上都利用到了程序的局部性原理,局部性原理在后面的虚拟内存这部分会介绍到。

分页与分段技术的区别

虚拟内存、分页、分段的理解

  • 这两个技术都是为了利用和管理好计算机的资源——内存

  • 在分段技术之前,程序运行需要从内存中分配足够多的连续的内存,然后把整个程序装载进去。如某个程序大小是10M,就需要有连续的10M内存空间才能把这个程序装载到内存里面。如果无法找到连续的10M内存,就无法把这个程序装载进内存里面,程序也就无法得到运行。这种方法(也是直接使用物理内存的问题)有三个问题:

    • 地址空间不隔离:不同的程序之间可能由于对错误的内存地址的操作产生相互影响

    • 程序运行时地址不确定:无法保证每次对同一地址的操作是相同的,因为程序装载进内存的位置会发生改变

    • 内存使用率低下:在内存占满后想要运行别的程序必须把内存中的程序换出到磁盘上,再把想运行的换入,会导致内存空间可能有一部分无法被利用

  • 分段技术可以解决第一第二个问题:它把虚拟地址空间映射到了物理地址空间,并且你写的程序操作的是虚拟地址。没有解决第三个问题因为每个程序仍然需要被完整地换入和换出

  • 分页技术可以解决第三个问题:分页这个技术仍然是一种虚拟地址空间到物理地址空间映射的机制。但是,粒度更加的小了。单位不是整个程序,而是某个“页”,一段虚拟地址空间组成的某一页映射到一段物理地址空间组成的某一页。

  • 在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。

逻辑地址和物理地址,虚拟寻址

我们编程一般只有可能和逻辑地址打交道,比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内存单元真正的地址。

现代处理器使用的是一种称为 虚拟寻址(Virtual Addressing) 的寻址方式。使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。 实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有一个被称为 内存管理单元(Memory Management Unit, MMU) 的硬件。如下图所示:

相比于直接访问物理内存的缺陷(上一部分解释过),通过虚拟地址访问内存有以下优势:

  • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区
  • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
  • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。

虚拟地址到物理地址的转换

可以借助进程的页表将逻辑地址转换为物理地址。

通常会在系统中设置一个页表寄存器(PTR),存放页表在内存中的起始地址F和页表长度M。进程未执行时,页表的始址和页表长度放在进程控制块(PCB) 中,当进程被调度时,操作系统内核会把它们放到页表寄存器中。

注意:页面大小是2的整数幂 设页面大小为L,逻辑地址A到物理地址E的变换过程如下:

img img

例:若页面大小L为1K字节,页号2对应的内存块号b=8,将逻辑地址A=2500转换为物理地址E。 等价描述:某系统按字节寻址,逻辑地址结构中,页内偏移量占10位(说明一个页面的大小为2^10B = 1KB),页号2对应的内存块号 b=8,将逻辑地址A=2500转换为物理地址E。

①计算页号、页内偏移量 页号P=A/L = 2500/1024 = 2; 页内偏移量W= A%L = 2500%1024 = 452

②根据题中条件可知,页号2没有越界,其存放的内存块号b=8

③物理地址E=b*L+W=8 * 1024+ 425 = 8644

在分页存储管理(页式管理)的系统中,只要确定了每个页面的大小,逻辑地址结构就确定了。因此,页式管理中地址是-维的。即,只要给出一个逻辑地址,系统就可以自动地算出页号、页内偏移量两个部分,并不需要显式地告诉系统这个逻辑地址中,页内偏移量占多少位。

虚拟内存

虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。目前,大多数操作系统都使用了虚拟内存,如 Windows 家族的“虚拟内存”;Linux 的“交换空间”等。

局部性原理

是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。

局部性原理表现在以下两个方面:

  1. 时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
  2. 空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。

时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。

img

技术实现

虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存实质上就是把物理内存中暂时用不到的内容暂时换出到外存里,空出内存放置现阶段需要的数据。虚拟内存的实现有以下三种方式:

  1. 请求分页存储管理 :建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。
  2. 请求分段存储管理 :建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。
  3. 请求段页式存储管理

这里多说一下?很多人容易搞混请求分页与分页存储管理,两者有何不同呢?

请求分页存储管理建立在分页管理之上。他们的根本区别是是否将程序全部所需的全部地址空间都装入主存,这也是请求分页存储管理可以提供虚拟内存的原因,我们在上面已经分析过了。

它们之间的根本区别在于是否将一作业的全部地址空间同时装入主存。请求分页存储管理不要求将作业全部地址空间同时装入主存。基于这一点,请求分页存储管理可以提供虚存,而分页存储管理却不能提供虚存。

不管是上面那种实现方式,我们一般都需要:

  1. 一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
  2. 缺页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
  3. 虚拟地址空间 :逻辑地址到物理地址的变换。

页面置换算法

地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断 。

缺页中断 就是要访问的不在主存,需要操作系统将其调入主存后再进行访问。 在这个时候,被内存映射的文件实际上成了一个分页交换文件。

当发生缺页中断时,如果当前内存中并没有空闲的页面,操作系统就必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。用来选择淘汰哪一页的规则叫做页面置换算法,我们可以把页面置换算法看成是淘汰页面的规则。

最佳置换法(OPT)

最佳置换算法(OPT,Optimal) :每次选择淘汰的页面将是以后永不使用,或者在最长时间内不再被访问的页面,这样可以保证最低的缺页率。

最佳置换算法可以保证最低的缺页率,但实际上,只有在进程执行的过程中才能知道接下来会访问到的是哪个页面。操作系统无法提前预判页面访问序列。因此,最佳置换算法是无法实现的

先进先出置换算法(FIFO)

先进先出置换算法(FIFO) :每次选择淘汰的页面是最早进入内存的页面 实现方法:把调入内存的页面根据调入的先后顺序排成一个队列,需要换出页面时选择队头页面队列的最大长度取决于系统为进程分配了多少个内存块。

Belady异常—当为进程分配的物理块数增大时,缺页次数不减反增的异常现象。

只有FIFO算法会产生Belady异常,而LRU和OPT算法永远不会出现Belady异常。另外,FIFO算法虽然实现简单,但是该算法与进程实际运行时的规律不适应,因为先进入的页面也有可能最经常被访问。因此,算法性能差

FIFO的性能较差,因为较早调入的页往往是经常被访问的页,这些页在FIFO算法下被反复调入和调出,并且有Belady现象。所谓Belady现象是指:采用FIFO算法时,如果对—个进程未分配它所要求的全部页面,有时就会出现分配的页面数增多但缺页率反而提高的异常现象

最近最久未使用置换算法(LRU)

最近最久未使用置换算法(LRU,least recently used) :每次淘汰的页面是最近最久未使用的页面 实现方法:赋予每个页面对应的页表项中,用访问字段记录该页面自.上次被访问以来所经历的时间t(该算法的实现需要专门的硬件支持,虽然算法性能好,但是实现困难,开销大)。当需要淘汰一个页面时,选择现有页面中t值最大的,即最近最久未使用的页面。

LRU性能较好,但需要寄存器和栈的硬件支持。LRU是堆栈类算法,理论上可以证明,堆栈类算法不可能出现Belady异常。

在手动做题时,若需要淘汰页面,可以逆向检查此时在内存中的几个页面号。在逆向扫描过程中最后一个出现的页号就是要淘汰的页面。

时钟置换算法(CLOCK)

最佳置换算法性OPT能最好,但无法实现;先进先出置换算法实现简单,但算法性能差;最近最久未使用置换算法性能好,是最接近OPT算法性能的,但是实现起来需要专门的硬件支持,算法开销大。

所以操作系统的设计者尝试了很多算法,试图用比较小的开销接近LRU的性能,这类算法都是CLOCK算法的变体,因为算法要循环扫描缓冲区像时钟一样转动。所以叫clock算法。

时钟置换算法是一种性能和开销较均衡的算法,又称CLOCK算法,或最近未用算法(NRU,Not Recently Used)

简单的CLOCK算法实现方法:为每个页面设置一个访问位,再将内存中的页面都通过链接指针链接成一个循环队列。当某页被访问时,其访问位置为1。当需要淘汰-一个页面时,只需检查页的访问位。如果是0,就选择该页换出;如果是1,则将它置为0,暂不换出,继续检查下一个页面,若第- - ~轮扫描中所有页面都是1,则将这些页面的访问位依次置为0后,再进行第二轮扫描(第二轮扫描中一定会有访问位为0的页面,因此简单的CLOCK算法选择–个淘汰页面最多会经过两轮扫描)

改进型的时钟置换算法

简单的时钟置换算法仅考虑到一个页面最近是否被访问过。事实上,如果被淘汰的页面没有被修改过,就不需要执行I/O操作写回外存。只有被淘汰的页面被修改过时,才需要写回外存。

因此,除了考虑一个页面最近有没有被访问过之外,操作系统还应考虑页面有没有被修改过。在其他条件都相同时,应优先淘汰没有修改过的页面,避免I/O操作。这就是改进型的时钟置换算法的思想。修改位=0,表示页面没有被修改过;修改位=1,表示页面被修改过。

为方便讨论,用(访问位,修改位)的形式表示各页面状态。如(1, 1)表示一个页面近期被访问过,且被修改过。

改进型的Clock算法需要综合考虑某一内存页面的访问位和修改位来判断是否置换该页面。在实际编写算法过程中,同样可以用一个等长的整型数组来标识每个内存块的修改状态。访问位A和修改位M可以组成一下四种类型的页面。

算法规则:将所有可能被置换的页面排成–个循环队列

第一轮:从当前位置开始扫描到第一个(A =0, M = 0)的帧用于替换。表示该页面最近既未被访问,又未被修改,是最佳淘汰页 第二轮:若第一轮扫描失败,则重新扫描,查找第一个(A =1, M = 0)的帧用于替换。本轮将所有扫描过的帧访问位设为0。表示该页面最近未被访问,但已被修改,并不是很好的淘汰页。 第三轮:若第二轮扫描失败,则重新扫描,查找第一个(A =0, M = 1)的帧用于替换。本轮扫描不修改任何标志位。表示该页面最近已被访问,但未被修改,该页有可能再被访问。 第四轮:若第三轮扫描失败,则重新扫描,查找第一个A =1, M = 1)的帧用于替换。表示该页最近已被访问且被修改,该页可能再被访问。

由于第二轮已将所有帧的访问位设为0,因此经过第三轮、第四轮扫描一定会有一个帧被选中,因此改进型CLOCK置换算法选择- -个淘汰页面最多会进行四轮扫描

动态分区分配算法

1. 首次适应算法

算法思想:每次都从低地址开始查找,找到第–个能满足大小的空闲分区。

如何实现:空闲分区以地址递增的次序排列。每次分配内存时顺序查找空闲分区链( 或空闲分[表),找到大小能满足要求的第-一个空闲分区。

2. 最佳适应算法

算法思想:由于动态分区分配是一种连续分配方式,为各进程分配的空间必须是连续的一整片区域。因此为了保证当“大进程”到来时能有连续的大片空间,可以尽可能多地留下大片的空闲区,即,优先使用更小的空闲区

如何实现:空闲分区按容量递增次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。

3. 最坏适应算法

又称最大适应算法(Largest Fit)

算法思想:为了解决最佳适应算法的问题—即留下太多难以利用的小碎片,可以在每次分配时优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用。

如何实现:空闲分区按容量递减次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。

4. 邻近适应算法

算法思想:首次适应算法每次都从链头开始查找的。这可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都要经过这些分区,因此也增加了查找的开销。如果每次都从上次查找结束的位置开始检索,就能解决上述问题。

如何实现:空闲分区以地址递增的顺序排列(可排成-一个循环链表)。每次分配内存时从上次查找结束的位置开始查找空闲分区链(或空闲分区表),找到大小能满足要求的第一个空闲分区。

5. 总结

首次适应不仅最简单,通常也是最好最快,不过首次适应算法会使得内存低地址部分出现很多小的空闲分区,而每次查找都要经过这些分区,因此也增加了查找的开销。邻近算法试图解决这个问题,但实际上,它常常会导致在内存的末尾分配空间分裂成小的碎片,它通常比首次适应算法结果要差。

最佳导致大量碎片,最坏导致没有大的空间。

进过实验,首次适应比最佳适应要好,他们都比最坏好。

5. 其他

程序从开始运行到结束的完整过程

四个过程:

预编译 主要处理源代码文件中的以“#”开头的预编译指令。处理规则见下

  1. 删除所有的#define,展开所有的宏定义。
  2. 处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
  3. 处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
  4. 删除所有的注释,“//”和“/**/”。
  5. 保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重 复引用。
  6. 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是 能够显示行号。

编译 把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

  1. 词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
  2. 语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
  3. 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
  4. 优化:源代码级别的一个优化过程。
  5. 目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
  6. 目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。

汇编

  • 将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。
  • 经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Linux下)、xxx.obj(Windows下)。

链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接:

  • 静态链接:函数和数据被编译进一个二进制文件。
    • 在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
    • 空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
    • 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序
    • 优点:在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
  • 动态链接:动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
    • 共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;
    • 更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
    • 性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。

注意与静态语言和动态语言概念的区别:(变量何时确定类型有关)

  • 动态语言(弱类型语言)是运行时才确定数据类型的语言,变量在使用之前无需申明类型,通常变量的值是被赋值的那个值的类型。比如Php、Asp、JavaScript、Python、Perl等等。

  • 静态语言(强类型语言)是编译时变量的数据类型就可以确定的语言,大多数静态语言要求在使用变量之前必须生命数据类型。比如Java、C、C++、C#等。

典型的锁

背景:

多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。

最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。

如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。

锁的作用

  • 保障原子性:互斥,一个锁一次只能被一个线程持有,保证了临界区代码一次只能被一个线程执行,这使得临界区代码所执行的代码具有不可分割的特性,即保障了原子性
  • 保障可见性:获取锁时刷新处理器缓存,释放锁时冲刷处理器缓存
  • 保障有序性:注意:临界区内的内存操作依旧存在重排序

读写锁

  • 多个读者可以同时进行读
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理(所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本),所以互斥锁在加锁操作时涉及两次上下文的切换

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁

条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。

而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。

总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

自旋锁

自旋锁是通过 CPU 提供的CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

一般加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

乐观锁与悲观锁:做事的心态有何不同?

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。

可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程

这里举一个场景例子:在线文档

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;

当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

总结

开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。

另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。

相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。

不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。

中断和异常

中断

大家都知道,当我们在敲击键盘的同时就会产生中断,当硬盘读写完数据之后也会产生中断,所以,我们需要知道,中断是由硬件设备产生的,而它们从物理上说就是电信号,之后,它们通过中断控制器发送给CPU,接着CPU判断收到的中断来自于哪个硬件设备(这定义在内核中),最后,由CPU发送给内核,有内核处理中断。下面这张图显示了中断处理的流程:

异常

我们在学习《计算机组成原理》的时候会知道两个概念,CPU处理程序的时候一旦程序不在内存中,会产生缺页异常;当运行除法程序时,当除数为0时,又会产生除0异常。所以,大家也需要记住的是,异常是由CPU产生的,同时,它会发送给内核,要求内核处理这些异常,下面这张图显示了异常处理的流程:

相同点

  • 最后都是由CPU发送给内核,由内核去处理
  • 处理程序的流程设计上是相似的

不同点

  • 产生源不相同,异常是由CPU产生的,而中断是由硬件设备产生的
  • 内核需要根据是异常还是中断调用不同的处理程序
  • 中断不是时钟同步的,这意味着中断可能随时到来;异常由于是CPU产生的,所以它是时钟同步的
  • 当处理中断时,处于中断上下文中;处理异常时,处于进程上下文中

在执行malloc申请内存的时候,操作系统是怎么做的?

从操作系统层面上看,malloc是通过两个系统调用来实现的: brk和mmap

  • brk是将进程数据段(.data)的最高地址指针向高处移动,这一步可以扩大进程在运行时的堆大小
  • mmap是在进程的虚拟地址空间中寻找一块空闲的虚拟内存,这一步可以获得一块可以操作的堆内存。

通常,分配的内存小于128k时,使用brk调用来获得虚拟内存,大于128k时就使用mmap来获得虚拟内存

进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才算完成。

守护进程、僵尸进程和孤儿进程

守护进程

指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的,如web服务器进程http等

创建守护进程要点:

  1. 让程序在后台执行。方法是调用fork()产生一个子进程,然后使父进程退出。
  2. 调用setsid()创建一个新对话期。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid()使进程成为一个会话组长。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离。
  3. 禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。再一次通过fork()创建新的子进程,使调用fork的进程退出。
  4. 关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。
  5. 将当前目录更改为根目录。
  6. 子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权。为防止这一点,使用unmask(0)将屏蔽字清零。
  7. 处理SIGCHLD信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。
孤儿进程

如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。(注:任何一个进程都必须有父进程)。

一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。

孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

僵尸进程

如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程(实质上就是已经死亡但是还没有给exit赋值的进程)。

设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。

如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)。

如何避免僵尸进程

  • 通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN);表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。
  • 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
  • 如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。
  • 通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。

第一种方法忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信