计算机操作系统

现代操作系统阅读笔记

第一章 引论

操作系统定义

操作系统是运行在内核态的软件,它执行两个基本上独立的任务:

  1. 隐藏计算机底层硬件的实现,为用户及应用程序提供一个资源集的清晰抽象
    • 操作系统通过抽象硬件资源,使得用户和应用程序无需直接与硬件交互。它提供了统一的接口,使得应用程序能够方便地使用各种硬件资源(如CPU、内存、磁盘和网络等)。
    • 这种抽象有助于提高软件的可移植性和兼容性,因为应用程序不需要关心底层硬件的具体实现。
  2. 管理计算机硬件资源
    • 操作系统负责管理和分配计算机的各种硬件资源,包括处理器、内存、存储设备和外设等。它需要在多个应用程序和用户之间合理分配资源,以确保系统的高效运行和资源的公平使用。
    • 通过管理硬件资源,操作系统还能提高系统的可靠性和安全性,防止资源争用和冲突。

系统调用集

任何操作系统的核心是它可处理的系统调用集。系统调用是应用程序与操作系统之间的接口,应用程序通过系统调用请求操作系统提供服务。系统调用集真实地说明了操作系统所做的工作。以下是一些常见的系统调用类别:

  • 进程管理
    • 创建和终止进程:fork(), exec(), exit()
    • 进程间通信:pipe(), shmget(), shmat()
    • 进程调度和控制:wait(), kill(), nice()
  • 内存管理
    • 内存分配和释放:malloc(), free()
    • 内存映射:mmap(), munmap()
    • 虚拟内存管理:brk(), sbrk()
  • 文件系统
    • 文件操作:open(), read(), write(), close()
    • 文件描述符管理:dup(), dup2(), fcntl()
    • 目录操作:mkdir(), rmdir(), opendir(), readdir()
  • 设备管理
    • 设备输入输出:ioctl(), read(), write()
    • 设备驱动加载和卸载:insmod(), rmmod()
  • 网络通信
    • 套接字操作:socket(), bind(), listen(), accept(), connect()
    • 数据传输:send(), recv(), sendto(), recvfrom()

2. 计算机运行模式

多数计算机系统有两种运行模式:内核态和用户态。

内核态与用户态

内核态

内核态(Kernel Mode)是操作系统运行的特权模式。在这种模式下,操作系统具有对所有硬件资源的完全访问权限,可以执行计算机能够运行的任何指令。内核态通常包括以下特性:

  • 完全硬件访问:操作系统可以直接访问和控制计算机的所有硬件资源,如CPU、内存、存储设备和外设。
  • 特权指令:在内核态下,操作系统可以执行所有的机器指令,包括那些用于管理硬件和系统状态的特权指令。这些指令在用户态下是被禁止的,以保护系统安全和稳定性。
  • 内存保护:操作系统可以访问所有的内存空间,包括内核空间和用户空间,从而能够管理和调度系统资源。

内核态的操作系统功能包括进程管理、内存管理、文件系统管理、设备驱动程序以及网络协议栈等。

用户态

用户态(User Mode)是大多数应用程序运行的非特权模式。在这种模式下,应用程序只能访问受限的硬件资源和指令集。用户态的特性包括:

  • 受限硬件访问:应用程序无法直接访问大多数硬件资源。它们必须通过系统调用接口向操作系统请求服务,如文件读写、内存分配和网络通信等。
  • 受限指令集:在用户态下,应用程序只能执行非特权指令,无法执行那些可能影响系统稳定性和安全性的特权指令。
  • 内存保护:用户态应用程序只能访问属于自己的内存空间,无法直接访问内核空间或其他应用程序的内存。这种内存保护机制有助于防止应用程序之间的相互干扰和数据泄露。

内核态和用户态的切换

操作系统和硬件共同管理内核态和用户态之间的切换。通常,以下情况会导致从用户态切换到内核态:

  1. 系统调用:当用户态的应用程序需要操作系统提供的服务时,它会通过系统调用请求操作系统的帮助。系统调用触发上下文切换,使得CPU从用户态进入内核态。
  2. 硬件中断:当外部设备(如键盘、鼠标或网络接口)发出中断请求时,CPU会暂停当前执行的用户态程序,切换到内核态以处理中断请求。
  3. 异常处理:当用户态程序发生异常(如除零错误、非法指令或内存访问违例)时,CPU会切换到内核态,由操作系统进行异常处理。

在这些情况下,CPU的模式从用户态切换到内核态,操作系统接管控制权,完成所需的操作后,再切换回用户态继续执行用户程序。

计算机运行模式

3. shell 与 GUI

在现代计算机系统中,用户与系统交互的主要方式有两种:基于文本的Shell和基于图形的图形用户界面(GUI)。尽管它们并不是操作系统本身的一部分,但它们都是运行在用户态的用户接口程序,为用户提供了访问和控制系统资源的手段。

Shell

Shell 是一种基于文本的用户接口,它允许用户通过命令行输入来与操作系统进行交互。Shell 的特点和功能包括:

  1. 命令解释器:Shell 作为命令解释器,负责读取用户输入的命令,解析命令,并调用相应的系统调用或执行程序。
  2. 脚本编程:Shell 允许用户编写脚本(如bash脚本),这些脚本可以包含多个命令和逻辑控制语句,用于自动化任务。
  3. 灵活性和可扩展性:用户可以通过编写和使用自定义脚本和工具来扩展Shell的功能。
  4. 远程管理:Shell 通常支持远程访问,使得系统管理员可以通过网络远程管理和维护计算机系统。

常见的Shell有Bash(Bourne Again Shell)、Zsh(Z Shell)、Tcsh(TENEX C Shell)和PowerShell(Windows上的Shell)。

示例命令:

1
2
3
ls -l
cd /home/user
echo "Hello, World!"

图形用户界面(GUI)

图形用户界面(GUI)是一种基于图形的用户接口,使用窗口、图标和菜单等图形元素来与用户进行交互。GUI 的特点和功能包括:

  1. 图形元素:GUI使用图形元素(如按钮、窗口、菜单和图标)来表示和操作系统资源,使得用户可以通过点击、拖动和选择等操作来与系统交互。
  2. 直观易用:GUI 通常比Shell更直观和易用,适合不熟悉命令行的普通用户。
  3. 多任务管理:GUI通常支持多任务管理,用户可以同时打开多个窗口和应用程序,并在它们之间进行切换。
  4. 丰富的交互方式:GUI 支持多种输入设备(如鼠标、触摸屏和手写笔),提供了丰富的交互方式。

常见的GUI有Windows操作系统的Windows图形界面、MacOS的Aqua界面和Linux的GNOME、KDE界面。

示例操作:

  • 点击图标打开应用程序。
  • 拖动窗口调整其位置和大小。
  • 通过菜单选项执行特定操作。

Shell 和 GUI 的运行环境

  • 用户态:Shell 和 GUI 都运行在用户态下,它们作为用户接口程序,为用户提供了与操作系统交互的界面。
  • 系统调用:无论是Shell还是GUI,当用户执行某些操作(如文件操作、进程管理)时,它们都通过系统调用接口请求操作系统服务。
  • 应用程序层:Shell 和 GUI 都属于应用程序层,位于操作系统之上,利用操作系统提供的接口和服务来实现其功能。

Shell 与 GUI 的对比

特性 Shell GUI
用户接口类型 基于文本 基于图形
交互方式 命令行输入 点击、拖动、选择等
适用用户 技术用户、系统管理员 普通用户、非技术用户
可编程性 支持脚本编程 通常不支持(但可通过宏或插件实现)
远程访问 强(通过SSH等工具) 弱(需要远程桌面或VNC等工具)
学习曲线 较陡峭 较平缓

4. 对于抽象的理解

抽象在现代计算机系统中的应用

抽象是现代计算机系统中管理复杂性的一种关键技术。通过抽象,复杂的系统可以被分解为更易于理解和管理的部分。这种技术在操作系统的设计和实现中尤为重要。

抽象的基本概念

抽象的核心思想是将复杂系统分解为更小、更简单的部分,每个部分代表系统的某一特定方面或功能。好的抽象有以下几个特点:

  1. 隐藏复杂性:通过抽象,系统的复杂性被隐藏起来,用户和开发者无需关心内部实现细节,只需了解抽象的接口和功能。
  2. 模块化:抽象有助于将系统划分为多个模块,每个模块独立实现特定功能。这种模块化设计有助于系统的开发、测试和维护。
  3. 可重用性:抽象定义了通用的接口和功能,可以在不同的上下文中重复使用,从而提高了系统的灵活性和可扩展性。

抽象在操作系统中的应用

操作系统的核心任务之一是创建、实现和管理各种抽象,以便为用户和应用程序提供一致的、易于使用的接口。这些抽象包括:

  1. 进程和线程
    • 抽象定义:进程和线程是操作系统为应用程序提供的执行单位。进程是一个独立的运行环境,而线程是进程内的执行流。
    • 实现和管理:操作系统负责创建、调度和终止进程和线程,并管理它们的状态和资源。
  2. 虚拟内存
    • 抽象定义:虚拟内存是一种内存管理技术,它为每个进程提供一个独立的、连续的地址空间,隐藏了物理内存的复杂性。
    • 实现和管理:操作系统通过页表和内存映射技术实现虚拟内存,并负责内存分配、回收和页面置换。
  3. 文件系统
    • 抽象定义:文件系统是操作系统提供的一种存储抽象,它为用户和应用程序提供了统一的文件和目录接口。
    • 实现和管理:操作系统实现了各种文件系统(如EXT4、NTFS),并负责文件的读写、权限管理和存储设备的挂载。
  4. 设备驱动
    • 抽象定义:设备驱动程序为硬件设备提供统一的抽象接口,使得应用程序无需了解硬件的具体实现细节。
    • 实现和管理:操作系统提供了设备驱动框架,负责加载、初始化和管理设备驱动程序。

抽象的层次

操作系统的抽象通常分为多个层次,每个层次提供不同的功能和接口:

  1. 硬件抽象层(HAL)
    • 这是操作系统与硬件之间的接口层,它提供了一组标准化的硬件接口,使得操作系统可以在不同硬件平台上运行而无需修改代码。
  2. 内核抽象
    • 内核提供了对进程、内存、文件系统和设备的基本管理功能。这些抽象直接由操作系统内核实现,提供了对底层硬件资源的控制。
  3. 系统调用接口
    • 系统调用接口是用户态程序与操作系统之间的桥梁,它提供了一组系统调用,使得用户态程序可以请求操作系统提供服务。
  4. 用户空间库和框架
    • 用户空间库(如C标准库)和框架(如GUI库、网络库)提供了更高层次的抽象,使得应用程序开发更加简便和高效。

5. 多路复用资源方式

时间复用与空间复用

在计算机系统中,资源的分配和管理是操作系统的关键任务之一。资源可以通过两种主要方式进行复用:时间复用和空间复用。这两种复用方式能够有效地利用系统资源,提升系统性能和效率。

时间复用

时间复用(Time Sharing)是指不同的程序或用户在不同时段轮流使用同一资源。操作系统通过调度算法,将资源的使用时间划分为多个时间片(time slice),并在这些时间片之间切换不同的程序或用户,从而实现资源的共享。时间复用的主要特点和应用包括:

  1. 多任务处理
    • 操作系统通过时间复用技术实现多任务处理,使得多个程序能够并发运行。尽管在任何给定的时刻只有一个程序在运行,但由于时间片切换速度很快,用户感知到的效果是多个程序同时在运行。
    • 常见的调度算法有轮转调度(Round Robin)、优先级调度和多级反馈队列调度等。
  2. CPU时间复用
    • CPU是典型的时间复用资源。操作系统将CPU时间分成多个时间片,并在不同程序之间轮流分配。每个程序在其时间片内运行,当时间片用完时,操作系统会将CPU切换到下一个程序。
    • 通过这种方式,系统可以为多个用户或应用程序提供CPU计算能力。
  3. 提高资源利用率
    • 时间复用能够提高系统资源的利用率。例如,当一个程序等待I/O操作完成时,CPU可以切换到其他程序继续运行,从而避免了资源的空闲。
  4. 实时系统
    • 在实时操作系统中,时间复用被用来确保关键任务在规定的时间内完成。调度算法会根据任务的优先级和时间约束,合理分配时间片。

空间复用

空间复用(Space Sharing)是指每个客户(程序或用户)获得资源的一部分,同时使用资源。空间复用的主要特点和应用包括:

  1. 内存空间复用
    • 内存是典型的空间复用资源。操作系统将物理内存划分为多个内存块,并为每个运行的程序分配一个或多个内存块。通过虚拟内存技术,每个程序认为自己独占一块连续的内存空间。
    • 内存管理包括分页(paging)和分段(segmentation)技术。分页将内存划分为固定大小的页,分段将内存划分为不同大小的段。
  2. 磁盘存储复用
    • 磁盘存储是另一个常见的空间复用资源。文件系统将磁盘划分为多个块,并为每个文件分配一定数量的块。不同的文件共享同一个磁盘空间。
    • 文件系统管理包括目录结构、文件分配表(FAT)和索引节点(inode)等技术。
  3. 网络带宽复用
    • 在计算机网络中,带宽是空间复用的资源。路由器和交换机将可用的网络带宽划分为多个通道,并为每个连接分配一定的带宽,使多个连接可以同时传输数据。
    • 通过使用如多路复用(multiplexing)技术,可以在同一物理链路上同时传输多个数据流。
  4. I/O设备复用
    • 多个用户或程序可以同时使用I/O设备(如打印机、显示器)。操作系统通过队列和缓冲区管理I/O请求,将资源分配给不同的请求者。
    • 例如,多个程序可以同时向打印机发送打印任务,操作系统会将这些任务排队,并按顺序发送给打印机。

时间复用与空间复用的对比

特性 时间复用(Time Sharing) 空间复用(Space Sharing)
资源利用方式 不同程序在不同时段轮流使用资源 不同程序同时使用资源的不同部分
主要应用 CPU调度、多任务处理、实时系统 内存管理、磁盘存储、网络带宽管理
典型技术 时间片、调度算法、上下文切换 分页、分段、文件系统、虚拟内存
优点 提高资源利用率,支持多任务 资源独占性高,减少冲突
缺点 上下文切换开销,实时性要求高 资源分配复杂,可能出现碎片

6. I/O 设备的结构

I/O(输入/输出)设备是计算机系统中与外界交互的桥梁,负责处理数据的输入和输出操作。I/O设备通常由两个主要部分组成:设备控制器和设备本身。

设备控制器

设备控制器(Device Controller)是管理和控制I/O设备的硬件组件。它通常插在主板上的一块芯片或一组芯片上,负责接收来自操作系统的命令,并将这些命令传达给设备。设备控制器的主要任务包括:

  1. 命令接收:设备控制器从操作系统接收命令,这些命令通常包括读、写、初始化和状态查询等操作。
  2. 数据传输:设备控制器负责在主存和I/O设备之间传输数据。它可以通过直接内存访问(DMA)或程序控制方式进行数据传输。
  3. 状态报告:设备控制器将设备的当前状态信息传回给操作系统,例如设备是否准备好、是否发生错误等。
  4. 中断管理:设备控制器在完成操作后,会向CPU发出中断信号,通知操作系统完成了特定的I/O操作。

设备驱动程序

设备驱动程序(Device Driver)是专门用于与设备控制器通信的软件组件。它位于操作系统内核中,负责向设备控制器发送命令并处理控制器的响应。设备驱动程序的主要功能包括:

  1. 命令发送:设备驱动程序向设备控制器发送特定的命令,以执行I/O操作。
  2. 数据管理:设备驱动程序管理从设备控制器接收的数据,并将其传输到操作系统或应用程序。
  3. 中断处理:设备驱动程序处理设备控制器发出的中断信号,完成相应的I/O操作。
  4. 错误处理:设备驱动程序负责检测和处理I/O操作过程中发生的错误,并向操作系统报告这些错误。

设备驱动程序必须被装入操作系统内核中,以便在核心态中运行。这允许设备驱动程序直接访问硬件资源和处理器特权指令,从而实现高效的I/O操作。

I/O 空间

设备控制器通过寄存器与操作系统进行通信。这些寄存器通常包含在一个特定的I/O空间中。I/O空间是由一组用于通信的寄存器组成的内存地址区域。I/O空间的主要特点和功能包括:

  1. 寄存器集合:每个设备控制器都有少量用于通信的寄存器。这些寄存器可以存储命令、状态信息和数据。
  2. I/O地址映射:I/O空间中的寄存器通常映射到特定的内存地址,操作系统可以通过这些地址访问设备控制器的寄存器。
  3. 内存映射I/O:有些系统使用内存映射I/O(MMIO),将设备寄存器映射到主存地址空间中,使得CPU可以使用标准的内存访问指令来操作设备寄存器。
  4. 端口映射I/O:另一些系统使用端口映射I/O(PMIO),将设备寄存器映射到一个单独的I/O地址空间,使用特定的I/O指令(如IN和OUT指令)来访问设备寄存器。

设备控制器的工作流程

设备控制器的典型工作流程如下:

  1. 初始化:设备驱动程序向设备控制器发送初始化命令,设置设备的初始状态和参数。
  2. 命令发送:设备驱动程序向设备控制器发送读/写命令,指定要执行的I/O操作。
  3. 数据传输:设备控制器执行命令,将数据从设备传输到主存,或从主存传输到设备。
  4. 状态报告:设备控制器将操作的完成状态和可能的错误信息报告给设备驱动程序。
  5. 中断处理:设备控制器在完成操作后,发出中断信号通知CPU。设备驱动程序响应中断并处理相应的I/O操作。

7. IDE 概念

IDE(Integrated Drive Electronics)是计算机存储设备的一种标准接口,主要用于连接硬盘和光驱等设备。它也被称为ATA(Advanced Technology Attachment)。IDE接口将硬盘控制器集成到驱动器本身,从而简化了硬盘和主板之间的连接。

IDE 的主要特点和功能

  1. 集成控制器
    • 在IDE接口中,硬盘控制器与硬盘集成在一起,而不是像早期系统那样单独存在。这种集成使得硬盘的连接和配置更加简便,因为主板只需要提供一个标准化的接口即可。
  2. 并行传输
    • IDE 使用并行传输技术,通过多条数据线同时传输数据。标准的IDE接口有40根或80根引脚的连接电缆,用于数据传输和控制信号。
  3. 主从设备配置
    • IDE接口通常可以连接两个设备,一个被设置为主设备(Master),另一个被设置为从设备(Slave)。主从设备的配置通过硬盘上的跳线(Jumper)来实现。
    • 在一个IDE接口上,主板可以通过控制信号选择与主设备或从设备通信,从而在同一个接口上实现多设备连接。
  4. 数据传输速度
    • 随着技术的发展,IDE接口的标准也在不断演进,数据传输速度逐渐提高。从最早的ATA-1标准(3.3 MB/s)到后来发展的ATA-7标准(133 MB/s),传输速率有了显著提升。
  5. 兼容性和普及性
    • 由于其设计简便、成本低廉和较高的兼容性,IDE接口在上世纪80年代和90年代得到了广泛的应用,成为了大多数个人计算机的主流硬盘接口标准。

IDE 发展历程

IDE接口经历了多个版本的演变,每个版本在数据传输速度和功能上都有所提升:

  1. ATA-1
    • 这是最早的IDE标准,支持3.3 MB/s的数据传输速率,主要用于早期的硬盘驱动器。
  2. ATA-2(EIDE, Enhanced IDE)
    • 增加了对更大容量硬盘和更高数据传输速率的支持(最高16.6 MB/s)。EIDE还引入了对光驱和其他ATAPI(ATA Packet Interface)设备的支持。
  3. ATA-3
    • 引入了一些增强功能,如SMART(Self-Monitoring, Analysis, and Reporting Technology)技术,用于监控硬盘的健康状态。
  4. ATA-4(Ultra DMA/33)
    • 支持Ultra DMA模式,数据传输速率提高到33 MB/s。
  5. ATA-5(Ultra DMA/66)
    • 数据传输速率进一步提高到66 MB/s,并引入了80线IDE电缆,以减少数据传输中的干扰和错误。
  6. ATA-6(Ultra DMA/100)
    • 数据传输速率达到100 MB/s,继续提升性能。
  7. ATA-7(Ultra DMA/133)
    • 数据传输速率达到133 MB/s,是IDE标准的最后一个版本,之后逐渐被SATA(Serial ATA)接口取代。

IDE 与其他接口的比较

特性 IDE SATA
传输方式 并行传输 串行传输
数据线 40针或80针数据线 7针数据线
数据传输速率 最高133 MB/s(ATA-7) 最高6 Gb/s(SATA III)
设备连接 每个通道支持2个设备(主/从) 每个通道支持1个设备
电缆长度 最长18英寸(约46厘米) 最长1米
接口简便性 接口复杂,占用空间大 接口简便,占用空间小

IDE(集成驱动电子设备)接口是计算机存储设备的一种标准接口,广泛用于连接硬盘和光驱等设备。它通过将硬盘控制器集成到驱动器本身,简化了硬盘和主板之间的连接。随着技术的发展,IDE接口逐渐演变,提高了数据传输速率和功能。然而,随着串行ATA(SATA)接口的出现,IDE接口逐渐被取代,但其在计算机历史上的重要地位依然不可忽视。

8. 实现输入输出的三种方式

第一种方式,用户程序发出一个系统调用,内核将其翻译成一个对应设备驱动程序的过程调用。然后设备驱动程序启动 I/O 并在一个连续不断的循环中检查该设备,看该设备是否完成了工作。当 I/O 结束后,设备驱动程序把数据送到指定的地方(若有此需要),并返回。然后操作系统将控制返回给调用者。这种方式称为忙等待(busy waiting),其缺点是要占据CPU ,CPU 一直轮询设备直到对应的 I/O 操作完成。

第二种方式,设备驱动程序启动设备并且让该设备在操作完成时发出一个中断。设备驱动程序在这个时刻返回。操作系统接着在需要时阻塞调用者并安排其他工作进行。当设备驱动程序检测到该设备的操作完毕时,它发出一个中断通知操作完成。

终端实例

第三种方式,为I/O使用一种特殊的直接存储器访问(Direct Memory Access,DMA)芯片,它可以控制在内存和某些控制器之间的位流,而无须持续的CPU干预。

9. CMOS 存储器

CMOS(Complementary Metal-Oxide-Semiconductor,互补金属氧化物半导体)存储器是一种低功耗的易失性存储器,常用于计算机中保存系统设置和其他重要信息。尽管 CMOS 存储器是易失性的,但它在计算机关机时仍能保持数据,这得益于一个小型电池的支持。

CMOS 存储器的特点和功能

  1. 易失性存储器
    • CMOS存储器是易失性的,这意味着在没有电源供应时,存储在其中的数据会丢失。然而,通过一个小电池供电,CMOS存储器在计算机关机时仍能保持数据。
  2. 低功耗
    • CMOS技术的一个显著优点是其低功耗特性。这使得CMOS存储器非常适合用于保存系统设置和其他需要长期保存的少量数据。
  3. 存储系统设置
    • CMOS存储器通常用于保存计算机的系统设置,包括启动顺序、硬件配置、时间和日期等。这些设置由BIOS(Basic Input/Output System)在系统启动时读取。
  4. 时钟电路
    • CMOS存储器内置了一个递增时间的时钟电路。这个时钟电路负责维护系统的当前时间和日期,即使在计算机关机时也能继续运行。
  5. 电池支持
    • CMOS存储器和时钟电路由一块小电池(通常是锂电池或纽扣电池)驱动。这块电池可以在计算机断电或关机时为CMOS存储器供电,确保系统时间和设置的持续性。

10. USB 概念

USB 是通用串行总线,是用来将所有的慢速 I/O 设备,诸如键盘和鼠标,与计算机相连。USB 是一种集中式总线,其根设备每 1ms 轮询一次 I/O 设备,看是否有消息收发。所有的 USB 设备共享一个 USB 设备驱动器,于是就不需要为新的 USB 设备安装新的设备驱动器了。

11. 即插即用概念

在计算机系统中,操作系统需要了解并配置连接到计算机上的各种外部设备。这种需求促使Intel和微软设计了一种名为即插即用(Plug and Play, PnP)的I/O系统。即插即用系统使得操作系统能够自动识别、配置和管理新安装的硬件设备,而无需用户手动设置硬件参数。

即插即用系统的工作原理

即插即用系统的主要目标是简化硬件设备的安装和配置过程。它通过自动化设备识别和配置步骤,使用户无需手动设置硬件资源(如中断请求级别和I/O地址)。即插即用系统的工作流程大致如下:

  1. 设备识别
    • 当新设备连接到计算机时,即插即用系统会通过总线扫描和设备查询来识别新设备。设备的唯一标识符(如供应商ID和设备ID)帮助系统确定设备的类型和型号。
  2. 资源分配
    • 即插即用系统根据设备的需求和系统的可用资源,自动分配中断请求级别(IRQ)、I/O地址和DMA通道等资源。这些资源分配是动态的,可以根据系统状态和设备需求进行调整。
  3. 设备配置
    • 即插即用系统通过发送配置命令,将分配的资源信息传递给设备。设备接收到这些命令后,会调整自身的设置,以使用分配的资源。
    • 操作系统会在设备驱动程序中记录这些资源分配信息,以便在设备操作时使用。
  4. 驱动程序加载
    • 即插即用系统会查找并加载相应的设备驱动程序。驱动程序是与设备通信并控制其操作的软件组件。系统会根据设备标识符找到合适的驱动程序,并将其加载到操作系统中。
  5. 设备初始化和测试
    • 驱动程序加载后,即插即用系统会初始化设备,并进行功能测试,确保设备正常工作。这可能包括设备的自检、配置验证和功能测试。

即插即用系统的组成部分

即插即用系统包括硬件、固件和软件三个主要组成部分:

  1. 硬件支持
    • 即插即用硬件设备在设计时内置了即插即用功能,能够通过标准接口(如PCI、USB)与即插即用系统通信,并响应配置命令。
  2. BIOS/固件支持
    • 现代计算机的BIOS(基本输入输出系统)或UEFI(统一可扩展固件接口)固件支持即插即用功能。BIOS在系统启动时会识别并初始化即插即用设备,并将设备信息传递给操作系统。
  3. 操作系统支持
    • 操作系统中的即插即用管理器负责设备识别、资源分配、驱动程序加载和设备配置。操作系统内核提供即插即用的核心功能,并与设备驱动程序和用户层软件进行交互。

即插即用前后的对比

在即插即用系统出现之前,安装和配置硬件设备通常需要用户手动设置设备的资源,如中断请求级别(IRQ)和I/O地址。这种方式存在诸多问题,包括资源冲突、配置复杂性和用户错误等。即插即用系统通过自动化这些过程,极大地简化了硬件安装和配置,提升了用户体验和系统稳定性。

特性 即插即用之前 即插即用(PnP)
设备识别 用户手动识别 系统自动识别
资源分配 用户手动设置IRQ、I/O地址等 系统自动分配资源
驱动程序安装 用户手动安装和配置 系统自动查找和加载驱动程序
设备配置和初始化 用户手动配置和初始化 系统自动配置和初始化
资源冲突和管理 易发生资源冲突,难以管理 自动管理资源,减少冲突
用户体验 复杂且易出错 简单且直观

即插即用的优势

即插即用系统的主要优势包括:

  1. 简化安装和配置
    • 用户无需手动设置硬件资源,大大简化了设备安装和配置过程。
  2. 减少资源冲突
    • 即插即用系统自动管理资源分配,减少了手动设置可能导致的资源冲突问题。
  3. 提高系统稳定性
    • 自动化的设备配置和驱动程序加载流程提高了系统的稳定性和可靠性。
  4. 增强用户体验
    • 用户可以更加便捷地安装和使用新设备,无需具备专业的硬件知识。

即插即用(PnP)系统通过自动化设备识别、资源分配和驱动程序加载,大大简化了硬件设备的安装和配置过程。它不仅减少了手动设置的复杂性和错误风险,还提高了系统的稳定性和用户体验。在即插即用系统的支持下,用户可以更加轻松地管理和使用各种外部设备,使计算机系统的操作更加高效和便捷。

12. 计算机的启动

Pentium 的启动过程

Pentium处理器的启动过程涉及多个步骤,从硬件初始化到操作系统的加载和配置。以下是该过程的详细描述:

1. BIOS 开始运行

  1. 电源自检(POST,Power-On Self Test)
    • 当计算机通电时,BIOS首先运行电源自检程序。POST测试包括检查CPU、内存、键盘、显示器、硬盘和其他关键硬件组件是否正常工作。
    • 如果POST测试检测到硬件故障,会发出错误信息或蜂鸣提示,提示用户进行相应的维修或更换。
  2. 初始化硬件设备
    • BIOS初始化基本的硬件设备,如设置CPU的工作频率,检测和配置内存模块,初始化显卡等。
    • 设置硬件中断和I/O端口,确保系统的基本硬件能够正常工作。

2. 扫描并记录总线所连设备

  1. 设备扫描
    • BIOS扫描系统中的各种总线(如PCI、USB、SATA等),识别连接到这些总线的所有设备。每个设备都有唯一的设备标识符(如供应商ID和设备ID),通过这些标识符可以识别设备类型和型号。
    • 扫描过程中,BIOS会记录每个设备的资源需求,包括中断请求(IRQ)、I/O地址、DMA通道等。
  2. 设备配置
    • BIOS根据设备的资源需求和系统的可用资源,分配相应的资源,避免资源冲突。
    • 将分配好的资源信息写入设备的配置寄存器,以便操作系统启动后能够正确识别和使用这些设备。

3. 依次搜索启动设备,导入操作系统

  1. 启动设备选择
    • BIOS按照预设的启动顺序(通常可以在BIOS设置中配置),依次搜索启动设备。常见的启动设备包括硬盘、光驱、USB驱动器和网络启动等。
    • BIOS在每个启动设备中查找主引导记录(MBR)或GUID分区表(GPT),以确定是否存在可引导的操作系统。
  2. 加载引导加载程序
    • 如果找到可引导设备,BIOS将引导加载程序(如GRUB、LILO、Windows Boot Manager等)从启动设备的MBR或GPT中加载到内存中。
    • 引导加载程序负责将操作系统内核加载到内存中,并将控制权交给操作系统内核。

4. 操作系统询问 BIOS,获得配置信息,获取所有设备的驱动程序并调入内核

  1. 操作系统初始化
    • 操作系统内核开始运行,并从BIOS中获取系统的配置信息。这些配置信息包括硬件设备列表、资源分配情况和系统设置等。
    • 操作系统初始化基本的系统数据结构和内存管理系统,为接下来的设备驱动程序加载和系统服务初始化做好准备。
  2. 加载设备驱动程序
    • 操作系统根据BIOS提供的硬件设备信息,加载相应的设备驱动程序。这些驱动程序负责与硬件设备通信,并提供统一的接口供操作系统和应用程序使用。
    • 驱动程序加载后,操作系统对设备进行初始化,确保设备能够正常工作。

5. 初始化有关表格,创建需要的任何后台进程,并在每个终端上启动登录程序或GUI

  1. 初始化系统表格
    • 操作系统初始化各种系统表格和数据结构,如进程表、文件系统表、内存管理表等。这些表格用于管理系统资源和调度任务。
  2. 启动后台进程
    • 操作系统启动必要的后台进程(守护进程),这些进程负责处理系统服务和后台任务。例如,网络服务、打印服务、系统日志记录等。
    • 后台进程通常在操作系统启动时自动启动,并在整个系统运行期间保持活跃。
  3. 启动用户界面
    • 操作系统在每个终端上启动登录程序(如Linux的getty或Windows的登录界面),允许用户输入用户名和密码进行登录。
    • 如果系统配置为使用图形用户界面(GUI),操作系统将启动图形界面管理程序(如Windows的explorer.exe或Linux的X Window System),提供图形化的桌面环境。
  4. 用户会话管理
    • 用户成功登录后,操作系统为每个用户创建独立的会话环境。用户会话包括用户的桌面设置、启动的应用程序和用户特定的配置文件。
    • 操作系统在用户会话期间管理用户的权限和资源访问,确保系统安全和稳定运行。

13. 操作系统分类

大型机操作系统、服务器操作系统、多处理器操作系统、个人计算机操作系统、掌上计算机操作系统、嵌入式操作系统、传感器节点操作系统、实时操作系统、智能卡操作系统

14. 实时操作系统的基本概念

实时操作系统(Real-Time Operating System, RTOS)是一类操作系统,特别设计用于处理实时应用,其中时间是关键参数。RTOS需要确保任务在规定的时间内执行,以满足系统的时间约束和响应需求。

实时操作系统的特征

  1. 确定性
    • RTOS必须提供确定性保证,即在可预测的时间内响应和处理事件。确定性是RTOS最重要的特征,因为它确保系统的时间约束得到满足。
  2. 低延迟
    • RTOS需要具备低中断延迟和低任务切换延迟,确保系统能够快速响应外部事件并进行处理。
  3. 高可靠性
    • RTOS通常应用在需要高可靠性的领域,如航空航天、工业控制、医疗设备等。因此,RTOS需要具备高稳定性和容错能力。
  4. 优先级调度
    • RTOS使用优先级调度算法,确保高优先级任务能够抢占低优先级任务的执行时间。常见的调度算法包括优先级继承、优先级天花板和最早截止时间优先(EDF)。
  5. 时间管理
    • RTOS必须具备精确的时间管理功能,包括定时器、时钟和延时机制,以确保任务能够在预定的时间内执行。

硬实时操作系统

硬实时操作系统(Hard Real-Time Operating System)是指系统中某些规定的动作必须绝对地在规定的时刻(或规定的时间范围)发生。硬实时系统的特点如下:

  1. 严格的时间约束
    • 硬实时系统对任务的时间约束要求非常严格,任何超时都可能导致系统功能失效或严重后果。
  2. 任务的不可预见性
    • 硬实时系统中的任务通常是不可预见的,需要在最短时间内响应外部事件。例如,航空航天系统中的导航控制、工业机器人中的动作控制等。
  3. 高优先级任务的抢占
    • 硬实时系统使用高优先级调度算法,确保高优先级任务能够随时抢占低优先级任务的执行时间。
  4. 关键应用领域
    • 硬实时系统应用在对时间精度要求极高的领域,如航空航天、军事、核能、医疗设备等。

软实时操作系统

软实时操作系统(Soft Real-Time Operating System)是指系统中任务的时间约束较为宽松,偶尔违反最终时限是不希望的,但可以接受,并且不会引起任何实时性的损害。软实时系统的特点如下:

  1. 灵活的时间约束
    • 软实时系统对任务的时间约束相对宽松,允许偶尔的时间超时。例如,多媒体播放、在线交易系统等。
  2. 优先级调度和时间管理
    • 软实时系统仍然使用优先级调度和时间管理,但对任务的执行时间和响应时间的要求不如硬实时系统严格。
  3. 非关键应用领域
    • 软实时系统应用在对时间精度要求较低的领域,如多媒体系统、网络通信、桌面应用等。
  4. 容错能力
    • 软实时系统具备一定的容错能力,能够接受和处理偶尔的时间超时,而不会导致系统功能失效。

对比:硬实时操作系统 vs. 软实时操作系统

特性 硬实时操作系统(Hard RTOS) 软实时操作系统(Soft RTOS)
时间约束 非常严格,绝对不能超时 相对宽松,偶尔超时可接受
应用领域 航空航天、军事、核能、医疗设备 多媒体、网络通信、桌面应用
优先级调度 高优先级任务随时抢占低优先级任务 仍使用优先级调度,但不如硬实时严格
任务响应 快速且确定性高 响应较快,但允许一定的不确定性
容错能力 容错能力低,超时导致系统失效 容错能力高,偶尔超时不会影响功能

实时操作系统在许多需要精确时间管理的应用中发挥着关键作用。通过严格的时间约束、优先级调度和高可靠性,RTOS确保系统能够满足实时应用的需求。硬实时操作系统和软实时操作系统分别适用于不同的应用领域,根据时间约束的严格程度和系统对超时的容忍度进行选择。

15. UID

在操作系统中,尤其是在类Unix系统(如Linux和macOS)中,用户标识符(UID, User Identifier)和组标识符(GID, Group Identifier)是用于管理系统访问权限的重要机制。每个进程和文件都有一个UID和GID,用于确定其访问权限。

用户标识符(UID)

  1. UID的分配
    • 每个用户在创建时,系统管理员会为其分配一个唯一的UID。UID是一个正整数,通常在系统中唯一标识一个用户。
    • 特定的UID值有特殊意义,例如,UID为0的用户通常是超级用户(root),具有系统最高权限。
  2. 进程的UID
    • 每个被启动的进程都有一个启动该进程的用户的UID。这个UID用于确定该进程的权限和能够访问的资源。
    • 进程的UID可以通过系统调用getuid()geteuid()获取。其中,getuid()返回实际用户ID,geteuid()返回有效用户ID。
  3. UID的继承
    • 当一个进程创建子进程时,子进程继承父进程的UID。通过这种方式,系统能够跟踪每个进程的创建者,并确保权限的一致性。
  4. 权限管理
    • 文件和目录的访问权限通过UID和文件的所有者信息进行控制。只有UID与文件所有者匹配的用户,或者是超级用户,才能修改文件的权限。

组标识符(GID)

  1. GID的分配
    • 每个用户除了拥有唯一的UID外,还可以是一个或多个组的成员。每个组都有一个唯一的GID(Group Identifier),也是一个正整数。
    • 用户在创建时会被分配一个主组GID,此外还可以被添加到其他附加组中。
  2. 进程的GID
    • 进程不仅有UID,还会继承启动该进程的用户的主组GID。进程的GID用于确定该进程在组级别的权限。
    • 进程的GID可以通过系统调用getgid()getegid()获取。其中,getgid()返回实际组ID,getegid()返回有效组ID。
  3. 文件和目录的组属性
    • 文件和目录不仅有所有者(UID),还具有一个关联的组(GID)。文件的组属性决定了哪些组成员可以访问文件。
    • 文件权限分为所有者权限、组权限和其他用户权限,通过设置不同的权限,可以控制文件的访问。

实际用户ID和有效用户ID

在操作系统中,实际用户ID(Real UID, RUID)和有效用户ID(Effective UID, EUID)用于区分一个用户的实际身份和操作系统为执行特定操作而授予的权限。

  1. 实际用户ID(RUID)
    • 实际用户ID标识启动进程的用户。系统调用getuid()返回实际用户ID。
    • 实际用户ID通常在进程的生命周期内保持不变,用于标识进程的创建者。
  2. 有效用户ID(EUID)
    • 有效用户ID决定进程的权限。系统调用geteuid()返回有效用户ID。
    • 有效用户ID可以被临时更改,通过setuid()系统调用,允许进程执行某些操作时具有更高或不同的权限。

组和用户权限管理

  1. 用户和组文件
    • 系统中的用户和组信息通常存储在/etc/passwd/etc/group文件中。
    • /etc/passwd文件包含每个用户的UID、用户名、主目录和登录shell等信息。
    • /etc/group文件包含每个组的GID、组名以及组成员信息。
  2. 权限位
    • 每个文件和目录都有一组权限位(rwx),用于控制所有者、组成员和其他用户的读、写和执行权限。
    • 例如,一个文件的权限可能表示为-rwxr-xr--,表示所有者有读、写和执行权限,组成员有读和执行权限,其他用户只有读权限。
  3. 更改文件权限和所有者
    • 可以使用chown命令更改文件的所有者和组。例如,chown user:group file将文件的所有者更改为user,组更改为group
    • 可以使用chmod命令更改文件的权限。例如,chmod 755 file将文件权限设置为所有者可以读、写和执行,组成员和其他用户可以读和执行。

16. 文件路径

在 UNIX 中,绝对路径名包含了从根目录到该文件的所有目录清单,它们之间用正斜线 / 隔开。最开始的正斜线标识这是从根目录开始的绝对路径。

在 MS-DOS 和 Windows 中,用反斜线 \ 作为分隔符。

17. 文件系统安装

UNIX 一个重要概念是安装文件系统。几乎所有的个人计算机都有一个或多个光盘驱动器,可以插入 CD-ROM 和 DV D。它们几乎都有 USB 接口,可以插入 USB 存储棒(实际是固态磁盘驱动器)。为了提供一个出色的方式处理可移动介质,UNIX 允许把在 CD-ROM 或 DVD 上的文件系统接入到主文件树上。 mount 系统调用允许把在 CD-ROM 上的文件系统连接到程序所希望的根文件系统上。

18. 特殊文件

提供特殊文件是为了使 I/O 设备看起来像文件一般。这样,就像使用系统调用读写文件一样,I/O 设备也可通过同样的系统调用进行读写。

有两类特殊文件:块特殊文件和字符特殊文件。

块特殊文件指那些由可随机存取的块组成的设备,如磁盘等。比如打开一个块特殊文件,然后读第4块,程序可以直接访问设备的第4块而不必考虑存放该文件的文件系统结构。

字符特殊文件用于打印机、调制解调器和其他接收或输出字符流的设备。按照惯例,特殊文件保存在 /dev 目录中。例如,/dev/lp是打印机。

19. 文件保护

UNIX 操作系统通过对每个文件赋予一个9位的二进制保护代码,对 UNIX 中的文件实现保护。该保护代码有三个3位字段,一个用于所有者,一个用于所有者同组(用户被系统管理员划分成组)中的其他成员,而另一个用于其他人。每个字段中有一位用于读访问,一位用于写访问,一位用于执行访问。这些位就是知名的 rwx 位。

20. 系统调用概念

如果一个进程正在用户态中运行一个用户程序,并且需要一个系统服务,比如从一个文件读数据,那么它就必须执行一个陷阱或系统调用指令,将控制转移到操作系统。操作系统接着通过参数检查,找出所需要的调用进程。然后,它执行系统调用,并把控制返回给在系统调用后面跟随着的指令。在某种意义上,进行系统调用就像进行一个特殊的过程调用,但是只有系统调用可以进入内核,而过程调用则不能。

21. POSIX

UNIX 有很多不兼容的版本,从而导致了混乱。为了能使编写的程序能够在任何版本的 UNIX 系统运行,IEEE提出了一个 UNIX 标准,称为 POSIX,目前大多数 UNIX 版本都支持他。 POSIX 标准定义了凡是 UNIX 必须支持的小型系统调用接口。

22. Windows Win32 API

Windows 和 UNIX 的主要差别在于编程方式。一个 UNIX 程序包括做各种处理的代码以及从事完成特定服务的系统调用。相反,一个 Windows 程序通常是一个事件驱动程序。其中主程序等待某些事件发生,然后调用一个过程处理该事件。

在 UNIX 中,系统调用(如read)和系统调用所使用的库过程(如read)之间几乎是一一对应的关系。换句话说,对于每个系统调用,差不多就涉及一个被调用的库过程。

在 Windows 中,情况就大不相同了。首先,库调用和实际的系统调用是几乎不对应的。微软定义了一套过程,称为应用编程接口(Application Program Interface,Win32 API),程序员用这套过程获得操作系统的服务。

Win32 并不是非常统一的或有一致的接口。其主要原因是由于 Win32 需要与早期的在 Windows 3.x 中使用的16位接口向后兼容。

Windows 中没有类似 UNIX 中的进程层次,所以不存在父进程和子进程的概念。在进程创建之后,创建者和被创建者是平等的。

23. 操作系统结构

单体结构、层次式结构、微内核、客户机-服务器模式、虚拟机、外核、

24. 微内核的概念

在微内核设计背后的思想是,为了实现高可靠性,将操作系统划分成小的、良好定义的模块,只有其中一个模块——微内核——运行在内核态上,其余的模块,由于功能相对弱些,则作为普通用户进程运行。特别地,由于把每个设备驱动和文件系统分别作为普通用户进程,这些模块中的错误虽然会使这些模块崩溃,但是不会使得整个系统死机。

25. 机制与策略分离原则

策略指的是做什么,机制指的是怎么做。例如一个比较简单的调度算法是,对每个进程赋予一个优先级,并让内核执行在具有最高优先级进程中可以运行的某个进程。这里,机制(在内核中)就是寻找最高优先级的进程并运行之。而策略(赋予进程以优先级)可以由用户态中的进程完成。在这个方式中,机制和策略是分离的,从而使系统内核变得更小。

26. make 程序

在 UNIX 系统中,有个名为 make 的程序(其大量的变体如 gmake、pmake 等),它读入 Makefile ,该 Makefile 说明哪个文件与哪个文件相关。make 的作用是,在构建操作系统二进制码时,检查此刻需要哪个目标文件,而且对于每个文件,检查自从上次目标文件创建之后,是否有任何它依赖(代码和头文件)的文件已经被修改了。如果有,目标文件需要重新编译。在大型项目中,创建 Makefile 是一件容易出错的工作,所以出现了一些工具使该工作能够自动完成。

第二章 进程与线程

一、进程

1. 进程模型

在进程模型中,计算机上所有可运行的软件,包括应用程序和操作系统,都是由多个顺序进程组成的。进程是计算机系统中的一个基本执行单位,它是正在执行的程序的实例。每个进程都包含以下几个关键元素:

  • 程序代码:这是进程要执行的指令序列。
  • 程序计数器:它记录了进程将要执行的下一条指令的位置。
  • 寄存器:用于存储进程执行期间需要使用的数据和地址。
  • 变量:进程在执行过程中使用的所有变量和数据。

进程的执行与切换

在多任务操作系统中,CPU的资源是有限的,而进程的数量通常远远超过CPU的处理能力。为了高效利用CPU资源,操作系统采用了时间片轮转的方式,使得CPU可以在多个进程之间快速切换。这种切换过程称为上下文切换。

  • 上下文切换:当操作系统从一个进程切换到另一个进程时,需要保存当前进程的上下文(即程序计数器、寄存器和变量的值),并加载即将运行的进程的上下文。这种快速的切换让每个进程看起来像是同时在运行,尽管实际上每个时刻只有一个进程在真正使用CPU。

由于CPU在各进程之间来回快速切换,所以每个进程执行其运算的速度是不确定的。影响进程执行速度的因素包括:

  • 系统负载:系统中同时运行的进程数量和类型。
  • 优先级调度:操作系统根据进程的优先级决定CPU的分配。
  • I/O操作:进程执行的I/O操作会引起等待,从而影响其执行速度。

编程时的时序假设

由于上述因素,进程的执行时间和速度通常是不可预测的。当同一进程再次运行时,其运算速度也通常不可再现。因此,在对进程编程时决不能对时序做任何确定的假设。这意味着:

  • 不要依赖具体的执行时间:编程时不要假设某段代码的执行会在特定的时间内完成。
  • 考虑并发问题:多个进程可能会同时访问和修改共享资源,编程时需要考虑同步和互斥的问题,以避免数据竞争和死锁。
  • 使用适当的同步机制:例如信号量、互斥锁和条件变量,确保进程间的协调和通信正确。

2. 进程的创建

进程的创建是操作系统管理中的一个基本操作,通常由于以下四种主要事件导致:

系统初始化

在操作系统启动时,通常会创建若干个进程。这些进程可以分为前台进程和后台进程。

  • 前台进程:与用户交互并完成用户请求的进程。例如,用户打开的应用程序。
  • 后台进程:执行系统后台任务的进程,与特定用户无关。这些进程通常称为守护进程(daemon),处理诸如电子邮件、Web页面、新闻和打印等任务。

执行了正在运行的进程所调用的进程创建系统调用

一个正在运行的进程经常需要创建一个或多个新进程来协助完成工作。这在工作可以分割成若干相关但独立的部分时特别有效。例如,一个Web服务器进程可以为每个新到来的客户端请求创建一个新的进程,以便并行处理多个请求。

用户请求创建一个新进程

在交互式系统中,用户可以通过输入命令或点击图标来启动一个新程序。这两个动作都会触发操作系统创建一个新进程,并在其中运行所选择的程序。例如,用户在命令行中输入 python script.py 或者双击桌面上的一个应用程序图标。

一个批处理作业的初始化

在大型机的批处理系统中,用户提交批处理作业。操作系统会在资源可用时创建一个新进程来运行这些作业。批处理作业通常是远程提交的,当系统资源允许时,操作系统从输入队列中选择下一个作业并创建相应的进程来执行。

UNIX 系统中的进程创建

在 UNIX 系统中,进程创建主要通过 fork 系统调用实现。调用 fork 后,操作系统创建一个新的进程,称为子进程。子进程是父进程的一个副本,拥有相同的存储映像、环境字符串和打开的文件。

  • fork 系统调用:创建子进程,子进程继承父进程的大部分状态。
  • exec 系统调用:通常与 fork 配合使用,将子进程的存储映像替换为新的程序。这样,子进程可以执行不同于父进程的代码。
1
2
3
4
5
6
7
8
pid_t pid = fork();
if (pid == 0) {
// 子进程代码
execlp("/bin/ls", "ls", NULL);
} else {
// 父进程代码
wait(NULL); // 等待子进程完成
}

Windows 系统中的进程创建

在 Windows 系统中,进程创建主要通过 CreateProcess 函数实现。与 UNIX 的 forkexec 不同,CreateProcess 同时负责创建进程和加载可执行文件。

  • CreateProcess 函数:处理进程创建,并将指定的程序装入新进程中。
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
28
STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));

if (!CreateProcess(NULL, // No module name (use command line)
"C:\\Windows\\System32\\notepad.exe", // Command line
NULL, // Process handle not inheritable
NULL, // Thread handle not inheritable
FALSE, // Set handle inheritance to FALSE
0, // No creation flags
NULL, // Use parent's environment block
NULL, // Use parent's starting directory
&si, // Pointer to STARTUPINFO structure
&pi) // Pointer to PROCESS_INFORMATION structure
)
{
printf("CreateProcess failed (%d).\n", GetLastError());
return;
}

// Wait until child process exits.
WaitForSingleObject(pi.hProcess, INFINITE);

// Close process and thread handles.
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

在 UNIX 和 Windows 系统中,父进程和子进程在创建后拥有各自独立的地址空间。如果其中一个进程在其地址空间中修改了数据,这个修改对其他进程是不可见的。这种内存隔离保证了进程间的独立性和稳定性,防止进程间的无意干扰。

3. 进程的终止

进程在创建后会开始运行并完成其任务,但进程的生命周期是有限的,最终都会终止。进程的终止可以由以下几种情况引起:

正常退出(自愿的)

进程在完成其预定的任务后,通常会自愿终止。这样的终止是有序的,意味着进程在退出之前会进行必要的清理操作,如释放资源和关闭文件。

  • UNIX 系统:进程通过调用 exit 函数来正常退出。exit 函数可以传递一个退出状态码,通知操作系统进程的退出状态。例如,exit(0) 通常表示成功退出,而非零值表示有错误发生。

    1
    2
    // 在进程中完成任务后
    exit(0); // 正常退出
  • Windows 系统:在 Windows 中,进程通过调用 ExitProcess 函数来正常退出。ExitProcess 同样可以传递一个退出代码给操作系统。

    1
    2
    // 在进程中完成任务后
    ExitProcess(0); // 正常退出

出错退出(自愿的)

进程在运行过程中可能会遇到严重错误,使其无法继续正常工作。遇到这种情况时,进程会选择终止以避免进一步的错误或不稳定状态。

  • UNIX 系统:进程可以调用 exit 并传递一个错误代码来指示异常退出。例如,exit(1) 可能表示有错误发生。

    1
    2
    // 在遇到错误时
    exit(1); // 出错退出
  • Windows 系统:在 Windows 中,进程也可以通过 ExitProcess 函数退出,并传递一个错误代码。

    1
    2
    // 在遇到错误时
    ExitProcess(1); // 出错退出

严重错误(非自愿)

进程可能因为程序中的错误或异常情况而非自愿地终止。例如,内存访问违规、除零错误、或非法操作等都会导致进程崩溃。

  • UNIX 系统:操作系统通常会通过发送信号(如 SIGSEGV)来通知进程发生了严重错误。这些信号可以触发进程的终止。

    1
    2
    3
    // 示例:访问非法内存导致段错误
    int *ptr = NULL;
    *ptr = 10; // 会导致 SIGSEGV 信号,进程崩溃
  • Windows 系统:操作系统会生成异常并终止进程。例如,访问无效内存会引发访问冲突(Access Violation),导致进程终止。

    1
    2
    3
    // 示例:访问非法内存导致异常
    int *ptr = NULL;
    *ptr = 10; // 会导致访问冲突,进程崩溃

被其他进程杀死(非自愿)

有时候,进程会被其他进程强制终止。通常这是由系统管理员或其他进程发出的终止请求造成的。

  • UNIX 系统:使用 kill 系统调用来终止进程。kill 可以发送多种信号,包括 SIGKILL 用于强制终止进程。

    1
    kill -9 <pid> # 强制终止进程
  • Windows 系统:使用 TerminateProcess 函数来强制终止进程。此操作会立即终止进程,不执行任何清理操作。

    1
    2
    3
    HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
    TerminateProcess(hProcess, 1); // 强制终止进程
    CloseHandle(hProcess);

总的来说,进程终止的原因可以分为自愿和非自愿两类。自愿终止通常是在进程完成其任务或遇到错误时的正常退出,而非自愿终止则是由于系统错误或外部干预造成的。这些机制确保了操作系统的稳定性和资源的有效管理。

4. 进程的层次结构

在某些操作系统中,进程间的层次关系是重要的概念,这种层次关系可以帮助系统管理和调度进程。以下是 UNIX 和 Windows 系统中进程层次的详细说明:

UNIX 系统中的进程层次

在 UNIX 系统中,进程之间可以形成一个明确的层次结构,这种结构有助于管理进程和处理进程间的通信。具体来说:

  • 进程树:每个进程(除了最初的系统进程)都有一个父进程,而它可能又有一个或多个子进程。这种关系形成了一棵进程树,其中每个节点代表一个进程。

    1
    2
    3
    4
    Parent Process
    ├── Child Process 1
    │ └── Grandchild Process 1
    └── Child Process 2
  • 进程组:在 UNIX 中,进程组是一组相关联的进程。所有进程组的成员可以接收组信号,这使得管理和控制这些进程变得更加高效。一个进程组可以包含一个或多个进程,其子进程和后代进程共同组成一个进程组。

  • 会话:进程组可以进一步组织成会话。会话代表了一组进程,这些进程在同一用户会话中启动。会话可以包括多个进程组。

  • 系统调用

    • fork:用于创建子进程,父进程和子进程之间形成层次关系。
    • setpgid:用于设置进程组 ID,进程组管理和组织进程。
    • kill:用于向进程组或单个进程发送信号。
    1
    2
    3
    4
    5
    6
    7
    8
    // 使用 fork 创建子进程
    pid_t pid = fork();
    if (pid == 0) {
    // 子进程代码
    } else {
    // 父进程代码
    wait(NULL); // 等待子进程完成
    }

Windows 系统中的进程管理

在 Windows 系统中,进程之间没有明确的层次关系。所有进程被视为地位相同的平级进程。尽管如此,Windows 提供了一些机制来控制进程,部分机制可以间接反映出某种层次关系:

  • 进程句柄:当一个进程创建另一个进程时,父进程会获得一个进程句柄。这个句柄允许父进程对子进程进行控制,例如终止、暂停等。然而,父进程可以将这个句柄传递给其他进程,进而失去对原子子进程的控制,进而没有明确的层次关系。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    PROCESS_INFORMATION pi;
    ZeroMemory(&pi, sizeof(pi));

    // 创建新进程
    if (!CreateProcess(NULL, "C:\\Windows\\System32\\notepad.exe", NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {
    // 错误处理
    }

    // 使用进程句柄控制子进程
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
  • 进程标识符(PID):每个进程在系统中都有一个唯一的标识符。虽然 Windows 没有内建的进程层次,但可以通过 PID 来标识和管理进程。

  • 进程与线程:Windows 的进程可以创建和管理线程,线程在进程内共享相同的地址空间。线程间的关系与进程间的层次结构不同,但线程也可以创建子线程形成类似于层次的组织结构。

总结而言,UNIX 系统中的进程层次结构通过进程树和进程组来管理进程,而 Windows 系统中的进程管理主要依赖于进程句柄和标识符,没有明确的进程层次结构。UNIX 提供了明确的层次关系,有助于管理和调度进程,而 Windows 系统则更侧重于平级的进程控制。

5. UNIX 启动时的初始化

在许多 UNIX 系统中,init 进程是系统启动后的第一个进程,它具有特殊的角色和功能。init 进程是系统的根进程,所有其他进程都是它的子进程或后代进程。以下是有关 init 进程的详细说明:

init 进程的角色

  • 系统初始化init 是系统启动时由内核创建的第一个进程。它的进程 ID(PID)通常是 1。init 进程负责启动系统的其他进程,并进行系统初始化工作。

  • 读取配置文件init 进程会读取一个配置文件,通常是 /etc/inittab 文件(在不同的 UNIX 系统中,文件的位置和格式可能不同)。这个文件包含了系统启动时需要运行的各种程序和进程的信息,包括终端数量和初始化任务。

    1
    2
    3
    4
    5
    # 示例 /etc/inittab 文件内容
    id:3:initdefault:
    si::sysinit:/etc/rc.d/rc.sysinit
    l1:1:wait:/etc/rc.d/rc 1
    l2:2:wait:/etc/rc.d/rc 2

    其中,si::sysinit 行表示 init 在系统初始化阶段执行的脚本,而 l1l2 表示不同的运行级别。

启动终端进程

  • 创建终端进程init 进程根据配置文件中的信息,为每个终端设备创建一个新的进程。每个终端进程会等待用户的登录请求。典型的终端进程包括 gettymingetty,它们负责显示登录提示符并处理用户的登录请求。

    1
    2
    3
    # 例如,配置文件中可能包含类似以下内容:
    T0:23:respawn:/sbin/mingetty tty1
    T1:23:respawn:/sbin/mingetty tty2

用户登录和 Shell 启动

  • 用户登录:当用户通过终端进行登录时,gettymingetty 等终端进程会验证用户的凭据。如果登录成功,这些终端进程会启动一个新的 Shell 进程。Shell 进程是用户与系统交互的接口,它接收用户的命令并执行这些命令。

    1
    # 用户登录后,`bash` 或 `sh` 等 Shell 进程被启动
  • 启动更多进程:用户在 Shell 中输入命令时,Shell 会启动更多的进程以执行这些命令。每个命令可能会启动一个新的子进程。这些子进程继续执行并可能创建更多的子进程,从而形成进程树。

进程树的形成

  • 进程树:整个系统中的所有进程都可以追溯到 init 进程。init 进程作为树的根节点,所有其他进程都是这棵树的子节点或后代节点。进程树的结构反映了进程的创建和终止关系,以及进程间的父子关系。

    1
    2
    3
    4
    5
    6
    7
    init (PID 1)
    ├── getty (tty1)
    │ └── bash (用户登录后)
    │ └── 子进程1
    │ └── 子进程2
    ├── getty (tty2)
    └── 其他进程
  • 管理进程init 进程通过启动和管理其他进程,确保系统按预期运行。它还负责处理系统运行级别的切换,例如从多用户模式切换到单用户模式等。

init 的变种

  • Systemd:在许多现代 Linux 发行版中,init 进程的传统实现(如 SysVinit)已被 systemd 取代。systemd 提供了更先进的特性,如并行启动服务、依赖管理、服务监控等,尽管它仍然履行类似的系统初始化角色。

  • Upstart:另一种较早的替代方案是 Upstart,它在某些 Linux 发行版中使用,旨在提供更灵活的事件驱动服务管理。

6. 进程的状态

进程的状态表示了它在系统中的当前运行情况,通常分为以下三种基本状态:运行态、就绪态和阻塞态。进程的状态之间可以相互转换,这些转换是由操作系统根据进程的执行情况和外部事件来管理的。以下是这三种状态及其转化关系的详细说明:

1. 运行态(Running)

定义:运行态是指进程正在占用 CPU 执行其指令的状态。在任何时刻,只有一个进程可以在单核 CPU 上处于运行态。对于多核 CPU,多个进程可以同时处于运行态,每个核心上有一个进程在运行。

转化到其他状态

  • 从运行态到就绪态:当操作系统的调度程序决定将当前进程从 CPU 上移除,并将其放回到就绪队列中,以便其他进程可以获得 CPU 时间。例如,时间片耗尽或较高优先级的进程到达时,当前进程会被挂起,转移到就绪态。
  • 从运行态到阻塞态:如果运行中的进程需要等待某个事件(如 I/O 操作完成、信号量释放等),它会被挂起并转移到阻塞态,直到事件发生。

2. 就绪态(Ready)

定义:就绪态指的是进程已准备好执行,但由于 CPU 资源被其他进程占用而暂时不能运行。进程在这个状态下处于等待队列中,操作系统的调度程序会决定何时将其从就绪队列中选择出来,分配 CPU 时间给它。

转化到其他状态

  • 从就绪态到运行态:当调度程序选择了该进程并将其从就绪队列中调度到 CPU 上时,该进程状态变为运行态。调度程序通常基于优先级或其他调度策略来选择进程。
  • 从就绪态到阻塞态:如果在就绪态时,进程要求的资源不可用(例如,等待某个外部事件),它会被移到阻塞队列中,转为阻塞态。

3. 阻塞态(Blocked / Waiting)

定义:阻塞态是指进程因等待某个事件而暂时不能运行。例如,进程可能正在等待 I/O 操作完成、等待信号量或条件变量变为可用等。在阻塞态时,进程不能继续执行。

转化到其他状态

  • 从阻塞态到就绪态:当进程等待的事件发生时(例如 I/O 操作完成),阻塞的进程会被移到就绪队列中,等待 CPU 的调度。此时,进程状态转为就绪态。
  • 从阻塞态到运行态:当进程从就绪队列中被调度程序选中并分配到 CPU 上时,它的状态会从阻塞态转为运行态。

状态转换示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
+------------+
| |
| 运行态 |<------------------+
| | |
+------------+ |
| |
| |
v |
+------------+ +-------------+
| | | |
| 就绪态 |<---------->| 阻塞态 |
| | | |
+------------+ +-------------+

状态转换总结

  1. 运行态 → 就绪态:当进程时间片耗尽或调度程序决定让其他进程执行时,进程被挂起并转为就绪态。
  2. 运行态 → 阻塞态:当进程需要等待某些条件(如 I/O 操作)时,它会进入阻塞态。
  3. 就绪态 → 运行态:当调度程序决定给进程分配 CPU 资源时,进程从就绪队列中被移到运行态。
  4. 就绪态 → 阻塞态:当进程在等待某个条件或事件时,即使在就绪队列中,它也可能需要等待外部事件而转为阻塞态。
  5. 阻塞态 → 就绪态:当等待的事件发生时,阻塞态进程被移回就绪队列,准备重新获得 CPU 时间。
  6. 阻塞态 → 运行态:从阻塞态转为运行态的前提是该进程被调度程序选中,并且 CPU 时间片可用。

这种状态和状态转换机制帮助操作系统有效地管理进程,确保 CPU 时间的合理分配,并且提供响应能力和高效的资源利用。

7. 进程的实现

进程的实现涉及操作系统如何跟踪和管理每个进程的状态和资源。为此,操作系统维护一张进程表(Process Table),其中每个进程都有一个对应的进程表项(Process Control Block, PCB)。PCB 是操作系统用来存储与进程相关的所有信息的关键数据结构。以下是进程表项的详细内容及其作用:

进程表项(PCB)中的内容

  1. 进程状态(Process State)
    • 定义:记录进程当前的状态,例如运行态、就绪态或阻塞态。
    • 作用:帮助操作系统跟踪进程的状态,并在进程从一个状态转移到另一个状态时更新信息。
  2. 程序计数器(Program Counter, PC)
    • 定义:指示当前进程正在执行的指令的内存地址。
    • 作用:当进程从运行态转移到就绪态或阻塞态时,程序计数器的值会被保存,以便进程可以从中断的位置继续执行。
  3. 堆栈指针(Stack Pointer, SP)
    • 定义:指向进程堆栈的当前顶端位置。
    • 作用:堆栈用于保存局部变量、函数调用信息和中断处理信息。堆栈指针的保存确保进程可以恢复到之前的堆栈状态。
  4. 内存管理信息(Memory Management Information)
    • 定义:包括进程的页表、段表或其他内存分配结构。
    • 作用:用于管理进程的虚拟内存,包括进程的代码段、数据段和堆栈段的分配情况。
  5. 打开文件表(Open File Table)
    • 定义:记录进程所打开的文件的文件描述符和状态信息。
    • 作用:确保进程在恢复时可以继续访问和操作之前打开的文件。
  6. 账号信息(Accounting Information)
    • 定义:包括进程的用户标识符(UID)、组标识符(GID)、进程使用的 CPU 时间、内存使用量等。
    • 作用:用于资源管理、审计和访问控制。
  7. 调度信息(Scheduling Information)
    • 定义:包括进程优先级、调度策略、时间片等调度相关的参数。
    • 作用:帮助操作系统的调度程序决定进程的调度顺序和 CPU 时间的分配。
  8. 信号处理信息(Signal Handling Information)
    • 定义:包括进程的信号处理程序和信号掩码。
    • 作用:管理和处理进程收到的各种信号,例如中断信号、终止信号等。
  9. 进程标识符(Process Identifier, PID)
    • 定义:唯一标识进程的数字。
    • 作用:用于系统内部识别和管理进程。
  10. 父进程和子进程信息(Parent and Child Process Information)
    • 定义:记录进程的父进程和子进程列表。
    • 作用:支持进程间的父子关系管理和进程树的维护。

进程的状态保存与恢复

  • 从运行态到就绪态:当进程的时间片耗尽或操作系统决定切换进程时,当前进程的状态信息会被保存到 PCB 中,包括程序计数器、堆栈指针、内存管理信息等。此时,进程状态转为就绪态,并被放回就绪队列中。

  • 从运行态到阻塞态:当进程需要等待某个事件(例如 I/O 操作完成)时,它会进入阻塞态。此时,进程的状态信息也会保存到 PCB 中,操作系统会更新进程的阻塞信息,并将其移到阻塞队列中。

  • 从阻塞态到就绪态:当阻塞事件发生时,进程会从阻塞队列中移到就绪队列中,准备重新获取 CPU 资源。PCB 中的进程状态和其他信息会被更新为就绪态。

  • 从就绪态到运行态:当调度程序选择该进程执行时,进程的 PCB 中的状态信息会被加载到 CPU 中,进程的程序计数器、堆栈指针等信息会恢复到之前的状态,使进程能够从之前中断的位置继续执行。

进程表的作用

  • 进程管理:进程表是操作系统管理进程的核心数据结构。它帮助操作系统跟踪进程的状态和资源使用情况,实现进程的调度、资源分配和状态恢复。

  • 调度决策:调度程序利用 PCB 中的调度信息做出调度决策,决定哪个进程应该获得 CPU 时间。

  • 上下文切换:在进程切换时,操作系统使用 PCB 中保存的信息进行上下文切换,确保进程能够在重新调度后从上次停止的位置继续执行。

8. 多道程序设计模型

多道程序设计(Multiprogramming)通过在内存中同时保持多个进程,提高了 CPU 的利用率。具体来说,它通过在多个进程间切换,使得 CPU 在一个进程等待 I/O 操作时可以执行其他进程,从而减少了 CPU 空转的时间。

CPU 利用率的概率分析

假设

  • 一个进程等待 I/O 操作的时间与其在内存中时间的比为 ( p )。
  • 这是一个概率 ( p ),表示一个进程处于等待 I/O 状态的概率。
  • 反过来,( 1 - p ) 表示一个进程在内存中运行的概率。

目标: 计算在内存中有 ( n ) 个进程时,所有 ( n ) 个进程都在等待 I/O 操作的概率,并由此推导出 CPU 的利用率。

概率分析

  1. 单个进程的状态
    • 一个进程在等待 I/O 操作的概率是 ( p )。
    • 因此,单个进程在运行的概率是 ( 1 - p )。
  2. 所有 ( n ) 个进程都在等待 I/O 的概率
    • 如果内存中有 ( n ) 个进程,且每个进程等待 I/O 操作的事件是独立的,那么所有 ( n ) 个进程都在等待 I/O 操作的概率是 ($ p^n$ )。
    • 这是因为每个进程都需要在等待 I/O 的状态下,且这些状态的发生是独立的,所以总概率是 ( p p p )(共 ( n ) 次),即 ($ p^n$ )。
  3. CPU 利用率
    • CPU 利用率是 CPU 实际处于工作状态的概率。即 CPU 不空转的概率。
    • CPU 空转的概率是所有进程都在等待 I/O 的概率,即 ($ p^n$ )。
    • 因此,CPU 利用率是 1 减去 CPU 空转的概率:

[$ = 1 - p^n $]

公式的解释

  • 当 ( p ) 较小:如果进程等待 I/O 的概率 ( p ) 较小,那么 ( 1 - p ) 较大,即进程在内存中执行的概率较大。在这种情况下,即使 ( n ) 个进程在内存中,所有进程同时等待 I/O 操作的概率 ($ p^n$ ) 也会较小,因此 CPU 利用率会较高,接近于 1。

  • 当 ( p ) 较大:如果进程等待 I/O 的概率 ( p ) 较大,所有进程都在等待 I/O 的概率 ($ p^n$ ) 也会较大。随着 ( n ) 的增加,这种概率 ($ p^n$ ) 会迅速接近于 1,从而导致 CPU 利用率降低,接近于 0。

  • 当 ( n ) 较大:内存中的进程数 ( n ) 增加时,如果 ( p ) 的值保持不变,所有 ( n ) 个进程都在等待 I/O 的概率 ($ p^n$ ) 会迅速增加。因此,CPU 利用率会减少。换句话说,CPU 的有效工作时间减少,因为更多的进程同时等待 I/O。

实际应用

多道程序设计的目标是保持 CPU 的高利用率,通过并行管理多个进程,以便在一个进程等待 I/O 时,其他进程可以继续执行。公式 ( $ = 1 - p^n $) 提供了一种量化多道程序设计效率的方式,使我们可以通过调整进程数 ( n ) 和进程的 I/O 等待概率 ( p ),来优化 CPU 的利用率。

通过这个公式,操作系统设计者可以在系统设计和调度策略上做出决策,以确保高效利用 CPU 资源,减少 CPU 空转时间。

二、线程

1. 线程的使用原因

多线程在现代应用程序中变得越来越重要,主要原因涉及简化程序设计、提高创建效率以及提升性能。以下是对这些原因的详细解释:

1. 程序设计的简化

在许多应用程序中,尤其是那些涉及用户交互、网络通信或并行计算的应用程序中,存在多个同时进行的活动。这些活动通常包括计算任务、I/O 操作、用户界面更新等。在传统的单线程程序设计模型中,这些活动需要依次处理,可能导致复杂的控制流和状态管理问题。

多线程的优势

  • 并行处理:将应用程序分解为多个线程,使得可以同时处理不同的任务。例如,一个线程可以处理用户输入,而另一个线程可以进行后台数据处理,这样可以避免阻塞用户界面。
  • 简化设计:通过将程序的不同活动分配到不同的线程,可以更自然地映射到实际的应用场景,使程序设计更符合现实世界的需求。每个线程可以专注于特定的任务,简化了控制流和状态管理。

示例

  • 在一个图形用户界面(GUI)应用程序中,用户界面线程可以处理用户输入和界面更新,而后台线程可以进行文件读写操作或网络请求,这样用户界面不会因为后台操作而变得不响应。

2. 线程的轻量级特性

线程比进程更轻量级,主要体现在以下几个方面:

  • 创建和销毁速度:线程的创建和销毁比进程要快很多。在许多系统中,创建一个线程的速度是创建一个进程的10到100倍。这是因为线程共享进程的资源和内存,减少了需要分配和管理的开销。
  • 资源消耗:线程之间共享进程的内存空间和资源,减少了上下文切换和资源分配的开销。而进程则拥有独立的内存空间和资源,进程切换的开销较大。

示例

  • 在一个需要处理大量并发请求的服务器应用程序中,使用线程可以更高效地管理请求处理,每个请求可以分配一个线程而不是一个进程,从而提高系统的整体性能和响应速度。

3. 性能提升

多线程能够提高应用程序的性能,尤其是在存在计算密集型和 I/O 密集型操作时:

  • 计算密集型操作:在多核处理器上,多个线程可以并行执行计算任务,从而提高处理速度。如果应用程序能够有效地分配计算任务到多个线程上,它将能够利用多个处理器核心,提高总体计算能力。
  • I/O 密集型操作:在 I/O 密集型应用中,例如网络通信或文件读写,线程可以在等待 I/O 操作完成时进行其他工作。多个线程可以在等待 I/O 操作的同时继续处理其他任务,从而减少程序的总体等待时间。

示例

  • 一个文件下载应用程序可以使用一个线程下载文件,另一个线程处理用户界面交互,另外的线程进行进度更新。这样,当文件下载过程阻塞时,其他线程可以继续进行操作,提高应用程序的响应性和总体效率。

线程在实际应用中的例子

  1. Web 服务器:现代的 Web 服务器(如 Apache 和 Nginx)使用多线程来处理并发请求。每个请求通常由一个线程处理,这样即使有大量的并发请求,服务器仍能有效响应。

  2. 视频播放器:在视频播放应用中,一个线程可以负责视频解码,另一个线程可以处理用户界面和控制,第三个线程可以管理网络数据传输,从而提供流畅的视频播放体验。

  3. 游戏开发:在游戏开发中,多线程用于处理图形渲染、物理计算和用户输入等任务,确保游戏能够在高帧率下流畅运行。

2. 线程模型

进程拥有一个执行的线程,通常简写为线程。在线程中有一个程序计数器,用来记录接着要执行哪一条指令。线程拥有寄存器,用来保存线程当前的工作变量。线程还拥有一个堆栈,用来记录执行历史,其中每一帧保存了一个已调用的但是还没有从中返回的过程。尽管线程必须在某个进程中执行,但是线程和它的进程是不同的概念,并且可以分别处理。进程用于把资源集中到一起,而线程则是在CPU上被调度执行的实体。

线程给进程模型增加了一项内容,即在同一个进程环境中,允许彼此之间有较大独立性的多个线程执行。在同一个进程中并行运行多个线程,是对在同一台计算机上并行运行多个进程的模拟。

3. 在用户空间中实现线程

用户级线程(User-Level Threads)是由用户空间的线程库管理的线程,不需要操作系统内核的直接支持。这些线程库通常提供了一组用于线程创建、终止、同步等操作的接口。以下是用户级线程的详细解释,包括优缺点和相关的管理过程。

用户线程实现

用户级线程(User-Level Threads)是由用户空间的线程库管理的线程,不需要操作系统内核的直接支持。这些线程库通常提供了一组用于线程创建、终止、同步等操作的接口。以下是用户级线程的详细解释,包括优缺点和相关的管理过程。

用户级线程的工作原理

在用户空间管理线程时,操作系统内核对线程的存在一无所知。线程的管理和调度完全由用户空间的线程库(运行时系统)处理。线程库提供了一系列操作线程的过程,例如:

  • pthread_create:创建新线程。
  • pthread_exit:终止线程。
  • pthread_join:等待线程结束。
  • pthread_yield:让出 CPU 使用权。

用户级线程在进程的用户空间内管理,每个进程都有一个线程表(类似于内核的进程表),用于跟踪该进程中的线程状态。这个线程表由线程库维护,包含了线程的状态信息(如就绪、阻塞、运行等)。

优点

  1. 操作系统无须支持线程
    • 用户级线程可以在不支持线程的操作系统上实现。因为线程的管理完全在用户空间中进行,操作系统只需要管理进程,不需要知道线程的存在。
  2. 快速的线程切换
    • 线程切换在用户空间中可以在几条指令内完成,比进行系统调用(陷入内核)要快得多。线程切换只涉及用户空间的上下文切换,不需要内核的参与,因此效率更高。
  3. 高效的线程状态保存和调度
    • 保存线程状态和调度线程的过程都是本地的,不涉及系统调用。这样可以避免陷入内核、上下文切换、内存缓存刷新等开销,使线程调度更加高效。
  4. 定制化调度算法
    • 线程库可以为每个进程提供定制的调度算法。这使得不同应用程序可以根据其特定需求和性能要求选择不同的调度策略。

缺点

  1. 阻塞系统调用的问题
    • 当一个线程进行阻塞系统调用(如读键盘输入),整个进程会被阻塞,导致所有线程都不能继续执行。这是因为操作系统内核通常不识别用户级线程,只能以整个进程为单位进行管理。
  2. 页面故障处理
    • 如果一个线程引起页面故障,操作系统内核通常会将整个进程阻塞,直到页面故障得到解决。尽管其他线程可能仍然可以运行,但由于内核无法识别用户级线程,这导致了资源利用不充分。
  3. 线程无法并行运行
    • 在多核处理器上,用户级线程的调度完全由用户空间管理,操作系统内核无法对线程进行并行调度。如果一个线程正在运行,其他线程无法同时运行,除非主动让出 CPU。这限制了线程的并行性和效率。
  4. 适用场景限制
    • 用户级线程特别适合那些经常发生阻塞的应用程序,例如网络服务或用户界面应用。然而,对于 CPU 密集型应用程序(主要进行计算而很少有 I/O 阻塞),用户级线程可能没有明显的性能优势,因为线程调度和切换的优势在这种情况下不明显。

用户级线程在不需要操作系统内核直接支持的情况下提供了线程管理的能力,其优点包括快速线程切换、高效的状态保存和调度以及定制化的调度算法。然而,它也存在一些缺点,如阻塞系统调用的问题、页面故障处理不当、线程无法并行运行等。因此,用户级线程的使用适合那些经常发生阻塞的应用场景,而在高性能、多核处理环境下,操作系统支持的内核级线程(如 POSIX 线程)通常能提供更好的性能和兼容性。

4. 在内核中实现线程

在内核中实现线程涉及到内核对线程的全面管理,包括线程的创建、调度和撤销。与用户级线程相比,内核级线程具有不同的管理和调度机制,这对系统性能和线程行为有重要影响。

内核级线程的工作原理

  1. 线程表
    • 内核维护一个线程表,用来记录系统中所有的线程。线程表包含了线程的状态、寄存器信息、堆栈指针、调度信息等。
    • 每当创建或撤销线程时,系统调用会更新线程表中的相关条目。这确保了内核可以正确管理线程的生命周期和状态。
  2. 系统调用
    • 创建或撤销线程需要通过系统调用来完成。例如,在 POSIX 系统中,线程创建通常通过 pthread_create 完成,这个调用会涉及内核对线程的资源分配和初始化。
    • 当线程执行阻塞操作(如等待 I/O 操作)时,它会进行系统调用。此时,内核会根据调度策略决定是运行同一进程中的其他线程(如果有)还是运行其他进程中的线程。
  3. 线程调度
    • 内核级线程调度由操作系统内核负责。内核可以在系统中所有线程之间进行调度,包括不同进程中的线程。
    • 当一个线程阻塞时,内核会选择另一个线程(可以是同一进程中的其他线程或其他进程中的线程)来运行。这样,即使一个线程被阻塞,系统中的其他线程仍然可以继续执行,从而提高了系统的整体利用率。

内核线程实现

与用户级线程的比较

  1. 阻塞操作
    • 内核级线程:阻塞操作需要通过系统调用与内核交互。线程阻塞时,内核可以选择其他线程继续执行。如果没有其他线程可运行,内核会选择其他进程中的线程来运行。这种机制允许系统在处理阻塞操作时仍能保持高效的线程执行。
    • 用户级线程:阻塞操作在用户空间处理,用户级线程库负责线程的管理。当一个线程执行阻塞操作时,整个进程可能会被阻塞,导致所有线程都无法继续运行。这是因为操作系统内核并不知道用户级线程的存在,只能以进程为单位进行调度。
  2. 线程切换
    • 内核级线程:线程切换需要进行系统调用,这可能涉及到上下文切换,包括保存和恢复线程状态。尽管代价较高,但由于内核了解所有线程,能够更有效地调度和管理线程。
    • 用户级线程:线程切换只在用户空间完成,不需要进行系统调用,因此速度较快。然而,用户级线程的调度效率依赖于线程库的实现,而无法利用多核处理器的并行能力。
  3. 资源分配
    • 内核级线程:资源分配和管理由内核进行,包括内存、CPU 时间等。线程的创建和销毁都涉及内核的直接操作,因此开销相对较高。
    • 用户级线程:资源分配和管理由用户空间的线程库处理,不涉及内核的直接干预。线程的创建和销毁速度较快,但由于缺乏内核支持,线程可能无法充分利用多核处理器的优势。

内核级线程的优缺点

优点

  • 完整的线程调度:内核能够在系统中所有线程之间进行调度,包括不同进程中的线程。这允许系统更高效地利用 CPU 资源,减少线程阻塞的影响。
  • 阻塞处理:内核可以在一个线程阻塞时,选择其他线程继续执行,从而提高系统的响应性和效率。
  • 多核支持:内核可以调度线程到多个处理器核心上运行,从而充分利用多核处理器的性能。

缺点

  • 系统调用开销:线程的创建、调度和撤销涉及系统调用,这可能导致较高的开销。线程切换需要保存和恢复线程状态,影响系统性能。
  • 复杂性:内核级线程的管理和调度涉及到操作系统内核,增加了系统的复杂性和维护难度。

5. 混合实现

人们已经研究了各种试图将用户级线程的优点和内核级线程的优点结合起来的方法。一种方法是使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。

线程多路复用

在混合线程模型中,内核只识别内核级线程,并对其进行调度。每个内核级线程可以同时运行多个用户级线程。这个机制叫做线程多路复用(Thread Multiplexing),具体方式如下:

  1. 线程映射
    • 每个内核级线程可以管理一个或多个用户级线程。用户级线程库负责在这些用户级线程之间进行调度和管理,而内核级线程则负责调度用户级线程库中的多个线程。
  2. 内核级线程调度
    • 内核调度内核级线程,而不是直接调度用户级线程。内核级线程在系统中调度时,用户级线程库负责决定哪个用户级线程在该内核级线程上运行。这样,系统的调度器只需要处理较少的内核级线程,降低了调度的复杂性。
  3. 用户级线程管理
    • 用户级线程库处理用户级线程的创建、撤销、切换等操作。由于用户级线程的调度不涉及内核,因此线程库可以在用户空间高效地管理线程,避免了系统调用的开销。

混合线程实现

优点

  1. 结合两者的优势
    • 用户级线程的优势:快速的线程切换和管理,因为操作系统内核不涉及用户级线程的调度。
    • 内核级线程的优势:内核对线程的直接支持,包括对多核处理器的利用和有效的线程阻塞处理。
  2. 减少内核负担
    • 内核只需要调度较少的内核级线程,而用户级线程的管理和调度完全在用户空间进行。这样减少了内核的负担,提高了系统的响应速度。
  3. 灵活性
    • 用户级线程库可以实现自定义的调度策略,使得应用程序能够根据需要优化线程的运行方式。比如,在高阻塞的应用中,可以通过用户级线程库进行细粒度的线程调度。
  4. 多核处理器支持
    • 内核级线程可以在多核处理器上并行运行,而用户级线程可以在这些内核级线程上调度,充分利用多核处理器的性能。

缺点

  1. 复杂性增加
    • 混合线程模型增加了系统的复杂性。需要在用户空间和内核之间协调线程的调度和管理,同时还需要确保用户级线程库与内核级线程的正确配合。
  2. 用户级线程阻塞问题
    • 如果用户级线程库中的线程阻塞,虽然内核级线程可以继续运行,但其他用户级线程可能仍然会受到影响。如何高效处理用户级线程的阻塞问题仍然是一个挑战。
  3. 调试和测试困难
    • 由于线程调度的复杂性增加,调试和测试混合线程模型的应用程序可能更加困难。开发者需要处理线程之间的交互和资源竞争问题。

实现示例

  1. Java虚拟机(JVM)中的线程实现
    • 在一些 Java 虚拟机实现中,用户级线程(Java 线程)被映射到内核级线程上。Java 线程库(如 java.util.concurrent 包)负责线程的管理和调度,而 JVM 内部可能将这些线程映射到内核线程上进行实际的并行执行。
  2. 线程库和操作系统
    • 一些线程库(如 GNU Portable Threads)实现了用户级线程的多路复用,并将其映射到内核级线程。这种库提供了一组接口,允许应用程序创建和管理用户级线程,同时依赖操作系统内核调度内核级线程。

6. 调度程序激活机制

调度程序激活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间中才能实现的更好的性能和更大的灵活性。

基本思路

  1. 内核与用户空间的协调
    • 当内核检测到某个线程被阻塞(例如,等待 I/O 操作)时,内核通过某种机制通知该线程所在的进程的用户级线程库。
    • 内核并不直接管理用户级线程的调度,而是将控制权交给用户级线程库,用户级线程库负责重新调度其管理的线程。
  2. 上行调用(Upcall)
    • 上行调用是指内核通过调用用户空间的运行时系统(线程库)来通知它线程的状态变化。当线程阻塞时,内核会发出一个上行调用,通知用户级线程库去执行适当的操作(如调度另一个线程)。
    • 这种调用机制使得用户级线程库可以在内核级线程阻塞时调整其线程调度策略,确保系统资源的高效利用。
  3. 重新调度
    • 当用户级线程库收到上行调用后,它会根据当前的线程状态信息重新调度线程。用户级线程库可以选择在同一内核级线程上运行另一个用户级线程,或者根据策略进行其他调整。

机制目标

  1. 提高性能
    • 调度程序激活机制允许用户级线程库在用户空间进行线程调度和管理,避免了频繁的系统调用,从而提高了线程管理的效率和响应速度。
    • 内核级线程调度和用户级线程调度的结合可以更好地利用多核处理器的计算能力。
  2. 灵活性
    • 用户级线程库可以实现自定义的调度策略和管理策略,根据应用需求灵活调整线程的执行顺序和资源分配。
    • 用户级线程库可以在不干扰内核的情况下处理线程的细节,提供更精细的控制。
  3. 维持系统层次结构
    • 调度程序激活机制的设计允许在不破坏系统层次结构的前提下,整合内核级线程和用户级线程的优势。内核负责底层的线程调度和资源管理,而用户级线程库则处理线程的具体调度和管理。

分层次系统的挑战

  • 分层次系统的概念
    • 在传统的分层次系统中,操作系统内核负责底层的资源管理和调度,而用户空间的程序(如线程库)负责应用层的管理。
    • 调度程序激活机制在一定程度上违反了这种分层次系统的结构,因为它要求内核不仅要管理底层资源,还需要通知用户空间的线程库进行线程调度。
  • 系统复杂性
    • 上行调用机制增加了系统的复杂性,因为它涉及内核与用户空间之间的协调和交互。
    • 需要确保用户级线程库能够正确处理上行调用,并且要在多线程环境中保持线程状态的一致性和正确性。
  • 资源竞争
    • 在调度程序激活机制中,内核和用户级线程库之间的竞争可能会影响系统的整体性能和稳定性。需要仔细设计机制以避免资源争用和性能瓶颈。

实现细节

  1. 线程库接口
    • 实现调度程序激活机制需要用户级线程库提供特定的接口,允许内核进行上行调用并通知线程状态变化。这些接口需要与操作系统的调度机制进行协调。
  2. 内核支持
    • 操作系统内核需要支持上行调用机制,并能够将线程状态变化的信息传递到用户级线程库。这通常涉及到内核对线程状态的监测和通知机制。
  3. 调度策略
    • 用户级线程库需要实现调度策略,决定在接收到上行调用后如何调整线程调度。可以根据应用需求实现不同的调度算法和优先级策略。

7. 弹出式线程

弹出式线程概述

定义

  • 弹出式线程是指在接收到一个特定消息时,系统动态创建一个新的线程来处理该消息。这个线程通常是临时的,仅用于处理当前消息。
  • 当消息处理完成后,弹出式线程可能会被终止或者回收,除非有其他任务需要它继续运行。

关键特点

  1. 快速响应
    • 弹出式线程的创建和销毁速度很快,因为它们通常只用于处理即时的消息。这样可以减少消息到达与处理之间的延迟,提高系统的响应速度。
  2. 临时性
    • 由于弹出式线程的生命周期较短,它们通常只在处理特定消息时存在。处理完成后,线程可能会被终止或者被系统回收,从而节省系统资源。
  3. 动态创建
    • 系统根据需要动态创建弹出式线程,而不是预先创建一组线程。这种方式可以有效利用资源,并且避免了线程过多造成的资源浪费。

使用场景

  1. 事件驱动系统
    • 在事件驱动系统中,如图形用户界面(GUI)系统或者网络服务器,弹出式线程可以快速响应用户输入或网络请求。每当有新的事件到达时,系统会创建一个弹出式线程来处理这个事件,从而减少延迟和提高用户体验。
  2. 并发处理
    • 弹出式线程适用于需要处理大量并发任务的场景。例如,Web服务器接收到多个客户端请求时,可以为每个请求创建一个弹出式线程,以并行处理这些请求,提升系统吞吐量。
  3. 实时系统
    • 在实时系统中,弹出式线程可以用于处理需要及时响应的任务,如传感器数据处理或者实时通信。快速创建线程并处理数据能够满足实时性的要求。

优点

  1. 减少响应时间
    • 由于弹出式线程是在消息到达时创建的,它能够迅速开始处理消息,从而减少了消息处理的等待时间。
  2. 资源利用效率
    • 由于弹出式线程通常在需要时才创建,系统能够根据实际需求动态分配资源,避免了资源的浪费和线程的过度创建。
  3. 简化线程管理
    • 弹出式线程的生命周期较短,通常不需要复杂的线程管理。系统可以通过简单的创建和销毁操作来管理这些线程,从而减少管理复杂性。

缺点

  1. 线程创建开销
    • 尽管弹出式线程创建速度较快,但频繁创建和销毁线程仍然会带来一定的开销。如果系统需要处理大量的消息,可能会增加线程创建的开销。
  2. 资源消耗
    • 如果弹出式线程创建过于频繁,可能会导致系统资源消耗增加,尤其是在线程创建和销毁的过程中。需要确保系统能够有效管理这些资源,以避免性能瓶颈。
  3. 上下文切换
    • 每次创建新线程时,系统需要进行上下文切换。虽然弹出式线程的切换开销相对较小,但大量的上下文切换仍然可能影响系统性能。

实现细节

  1. 线程池
    • 为了减少线程创建和销毁的开销,系统可以使用线程池来管理弹出式线程。线程池预先创建一定数量的线程,当有新的任务到达时,可以从线程池中获取空闲线程进行处理,而不是每次都创建新线程。
  2. 消息队列
    • 系统通常使用消息队列来管理消息的到达和处理。消息到达时,系统会从消息队列中取出消息,并为每个消息创建一个弹出式线程进行处理。
  3. 线程调度
    • 弹出式线程的调度需要考虑线程的优先级和处理顺序。系统需要确保线程调度策略能够有效处理高优先级的任务,同时避免过度竞争资源。

三、 进程间通信

进程间通信需要关注的三个问题

进程间通信(Inter-Process Communication, IPC)涉及三个关键问题:

  1. 信息传递:如何将信息从一个进程传递给另一个进程。
  2. 避免交叉:如何确保两个或更多进程在关键活动中不会出现交叉。
  3. 顺序正确:如何保证通信的顺序是正确的,以防止竞争条件。

1. 竞争条件

竞争条件(Race Condition)发生在两个或多个进程同时访问和修改共享数据的情况下,最终结果依赖于进程运行的具体时序。这种情况往往是不可预测和不稳定的,需要进行适当的同步来避免。

2. 临界区

临界区(Critical Section)是指在程序中访问共享资源的代码段。如果多个进程同时进入临界区,则可能导致竞争条件。为了解决这个问题,需要一种机制来保证同一时刻只有一个进程可以进入临关键区。

3. 临界区问题的解决条件

为了确保临界区问题的解决方案是正确和高效的,需要满足以下四个条件:

  1. 互斥条件:任何两个进程不能同时处于其临界区。
  2. 进程独立性:不应对CPU的速度和数量做任何假设。无论进程运行速度如何,解决方案都应保持正确性。
  3. 无饥饿条件:临界区外运行的进程不得阻塞其他进程。任何一个不在临界区的进程都不应该阻止其他进程进入临界区。
  4. 无死锁和无饥饿:不得使进程无限期等待进入临界区。每个请求进入临界区的进程最终都能进入临界区。

详细解释

1. 互斥条件

互斥条件要求在任何时候,最多只有一个进程能处于临界区。这是通过以下方法实现的:

  • :使用锁机制(如互斥锁)确保只有一个进程可以持有锁,从而进入临界区。
  • 信号量:使用信号量(semaphore)来控制对共享资源的访问。
  • 禁用中断:在单处理器系统中,通过禁用中断,确保临界区代码不会被打断。

2. 进程独立性

解决方案不应依赖于特定的硬件属性,如CPU速度或数量。以下方法可以确保独立性:

  • 硬件支持:使用硬件指令(如Test-and-Set、Swap等)来实现同步,不依赖于CPU速度。
  • 软件算法:如Peterson算法,它使用共享变量来实现互斥,不依赖硬件特性。

3. 无饥饿条件

确保临界区外运行的进程不会阻塞其他进程。这可以通过以下方法实现:

  • 队列:使用FIFO队列来管理进程进入临界区的顺序,确保公平性。
  • 优先级继承:当高优先级进程被低优先级进程阻塞时,提升低优先级进程的优先级,以防止优先级反转。

4. 无死锁和无饥饿

每个进程都应该能够在有限时间内进入临界区。这可以通过以下方法实现:

  • 公平调度:确保每个进程都有机会进入临界区。
  • 超时机制:如果进程在一定时间内未能进入临界区,可以触发超时机制,重新尝试。

实现临界区的常用方法

  1. 锁(Mutex)
    • 使用锁机制确保只有一个进程能进入临界区。
    • 锁的典型操作包括:lock()和unlock()。
  2. 信号量(Semaphore)
    • 信号量是一个计数器,用于控制多个进程对共享资源的访问。
    • 操作包括:P(wait)和V(signal)。
  3. 条件变量(Condition Variable)
    • 与锁配合使用,允许进程等待某个条件发生。
    • 操作包括:wait()、signal()和broadcast()。
  4. 自旋锁(Spinlock)
    • 进程在等待锁时不停地检查锁的状态,适用于短时间的临界区。
  5. 消息传递
    • 使用消息队列、管道、套接字等机制进行进程间通信,避免直接共享内存。

例子:Peterson算法

Peterson算法是一个经典的软件解决方案,用于两个进程间的互斥。它使用两个变量(flag和turn)来控制进程进入临界区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int flag[2];
int turn;

void process_0() {
flag[0] = 1;
turn = 1;
while (flag[1] && turn == 1) {
// busy wait
}
// critical section
flag[0] = 0;
}

void process_1() {
flag[1] = 1;
turn = 0;
while (flag[0] && turn == 0) {
// busy wait
}
// critical section
flag[1] = 0;
}

Peterson算法确保满足临界区问题的四个条件,提供了一个简单但有效的解决方案。

3. 忙等待的互斥

忙等待(busy waiting)是一种用于实现互斥的机制,即进程在等待资源时不断检查条件,而不是放弃CPU资源。以下是几种常见的忙等待互斥方法及其详细描述:

(1) 屏蔽中断

在单处理器系统中,最简单的方法是使每个进程在进入临界区时屏蔽所有中断,并在离开临界区时再打开中断。

  • 优点:简单有效,确保进程在临界区内不被中断。
  • 缺点
    1. 如果一个进程屏蔽中断后不再打开中断,系统可能会挂起。
    2. 在多处理器系统中,屏蔽中断只对执行该指令的CPU有效,其他CPU仍可访问共享内存。

结论:屏蔽中断适用于操作系统内核,而不适用于用户进程。

(2) 锁变量

通过一个共享锁变量实现互斥:

  • 机制:进程检查锁变量是否为0,如果是,则将其设置为1并进入临界区;否则等待。
  • 缺点:锁变量的读写不是原子操作,可能被其他进程中断,导致两个进程同时进入临界区。

(3) 严格轮换法

使用一个整型变量 turn 记录轮到哪个进程进入临界区:

  • 机制:进程检查 turn 的值,若符合则进入临界区,否则忙等待。
  • 缺点
    1. 忙等待浪费CPU时间。
    2. 严格轮换会导致一个临界区外的进程阻塞其他进程。

(4) Peterson 解法

Peterson算法使用两个变量 flagturn 来实现互斥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int flag[2] = {0, 0};
int turn = 0;

void enter_region(int process) {
int other = 1 - process;
flag[process] = 1;
turn = other;
while (flag[other] && turn == other) {
// busy wait
}
}

void leave_region(int process) {
flag[process] = 0;
}
  • 优点:满足互斥、进程独立性、无饥饿和无死锁条件。
  • 缺点:仍然存在忙等待的问题。

(5) TSL 指令

TSL(Test-and-Set Lock)指令通过硬件支持实现互斥:

  • 机制:TSL指令将内存字 lock 读到寄存器并将内存字设置为非零值。这是一个原子操作。
  • 例子
1
2
3
4
5
6
7
8
9
void enter_region() {
while (TSL(&lock) != 0) {
// busy wait
}
}

void leave_region() {
lock = 0;
}
  • 替代方法:XCHG指令,它原子性地交换两个位置的内容,与TSL机制类似。

  • 优点:TSL和XCHG都是原子操作,适用于多处理器系统,能有效避免竞争条件。

  • 缺点:忙等待浪费CPU时间。

忙等待的优缺点

优点

  • 简单实现:机制简单,易于实现。
  • 适用于短期锁定:在临界区时间较短时,忙等待的开销较小。

缺点

  • 浪费CPU时间:进程在等待期间不断占用CPU,降低系统效率。
  • 不适合长时间等待:若临界区时间较长,忙等待导致大量CPU时间浪费。

详细示例:Peterson算法

Peterson算法在两个进程间实现互斥的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int flag[2] = {0, 0}; // Flags indicating if a process wants to enter the critical section
int turn; // Indicates whose turn it is to enter the critical section

void enter_region(int process) {
int other = 1 - process;
flag[process] = 1; // Indicate that this process wants to enter the critical section
turn = other; // Give turn to the other process
while (flag[other] && turn == other) {
// Busy wait
}
}

void leave_region(int process) {
flag[process] = 0; // Indicate that this process is out of the critical section
}
  • flag数组flag[0]flag[1] 分别表示进程0和进程1是否希望进入临界区。
  • turn变量:表示当前哪个进程有权进入临界区。
  • enter_region函数:进程调用此函数进入临界区,若条件不满足则忙等待。
  • leave_region函数:进程调用此函数离开临界区。

忙等待是实现互斥的一种简单方法,适用于短期锁定场景。然而,忙等待会浪费CPU时间,在多处理器系统中屏蔽中断也无法保证互斥。更复杂的解决方案如Peterson算法和硬件支持的TSL指令,可以实现互斥但仍存在忙等待的问题。对于长时间锁定,应考虑其他同步机制,如信号量或条件变量。

4. 睡眠与唤醒

睡眠与唤醒机制是一种用于解决忙等待问题的方法,通过使进程在无法进入临界区时进入睡眠状态,并在条件满足时将其唤醒,避免了CPU时间的浪费。以下是该机制的详细描述:

睡眠与唤醒机制的基本原理

  1. sleep:一个系统调用,使得调用进程进入阻塞状态,直到被唤醒。
  2. wakeup:一个系统调用,唤醒指定的进程,使其从阻塞状态转为就绪状态。

这种机制常用于经典的生产者-消费者问题,来协调生产者和消费者进程之间的访问共享缓冲区。

生产者-消费者问题中的睡眠与唤醒

生产者-消费者问题的典型场景是,有一个共享的有限缓冲区,生产者进程将数据放入缓冲区,而消费者进程从缓冲区取数据。

  • 生产者
    1. 如果缓冲区满,则调用 sleep 进入阻塞状态。
    2. 否则,将数据放入缓冲区,并调用 wakeup 唤醒消费者(如果消费者已阻塞)。
  • 消费者
    1. 如果缓冲区空,则调用 sleep 进入阻塞状态。
    2. 否则,从缓冲区取数据,并调用 wakeup 唤醒生产者(如果生产者已阻塞)。

睡眠与唤醒机制的缺点

一个关键的问题是,发给一个(尚未进入睡眠状态的)进程的 wakeup 信号会丢失,从而导致生产者和消费者都进入睡眠状态,系统陷入死锁。这种情形称为丢失唤醒(lost wakeup)。

改进方法:唤醒等待位

一种简单的弥补方法是引入唤醒等待位,当一个 wakeup 信号发送给一个尚未进入睡眠状态的进程时,将该位置1。随后,当该进程要进入睡眠状态时,如果唤醒等待位为1,则将该位清除,而该进程保持清醒状态。

虽然这个方法在某些情况下有效,但并没有从根本上解决问题。为了更彻底地解决丢失唤醒的问题,我们需要更加复杂的同步机制,如信号量(semaphore)和监视器(monitor)。

5. 信号量

信号量的详细描述

信号量是一种用于管理并发进程访问共享资源的同步工具。它的主要功能是记录和协调资源的使用情况,防止竞争条件和其他并发问题。信号量的基本类型包括二进制信号量(互斥信号量)计数信号量。以下是对信号量的详细描述和操作机制。

信号量的定义

信号量是一个整型变量,用于表示可用资源的数量。它可以是以下两种类型之一:

  1. 二进制信号量(互斥信号量):其值只能是0或1,用于实现互斥访问。
  2. 计数信号量:其值可以是任意非负整数,用于管理多个资源。

信号量操作

信号量提供了两种基本操作:down(P操作)和 up(V操作)。这两个操作必须是原子的,即在执行这些操作时不会被中断。

  1. down 操作(P操作)
    • down(semaphore): 检查信号量的值是否大于0。
      • 若大于0,则将其值减1并继续执行。
      • 若等于0,则进程进入阻塞状态,直到信号量的值大于0为止。
  2. up 操作(V操作)
    • up(semaphore): 将信号量的值加1。
      • 如果有进程在该信号量上阻塞,则唤醒一个阻塞进程。

原子操作

原子操作是指一组相关联的操作要么都执行,要么都不执行,不会被中断。信号量操作中的检查数值、修改变量值以及可能发生的睡眠和唤醒操作均作为单一的、不可分割的原子操作完成。这确保了信号量的正确性和一致性。

信号量的实现

以下是使用信号量实现的典型代码示例,展示了信号量的 downup 操作:

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
typedef struct {
int value;
queue<process> queue;
} semaphore;

void down(semaphore *s) {
disable_interrupts();
if (s->value > 0) {
s->value--;
} else {
add_current_process_to_queue(s->queue);
block_current_process();
}
enable_interrupts();
}

void up(semaphore *s) {
disable_interrupts();
if (!is_empty(s->queue)) {
process *p = remove_process_from_queue(s->queue);
wake_up_process(p);
} else {
s->value++;
}
enable_interrupts();
}

信号量的应用

生产者-消费者问题

信号量广泛应用于解决并发问题,如经典的生产者-消费者问题。以下是使用信号量实现的生产者-消费者问题的示例代码:

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
semaphore mutex = {1, empty_queue}; // 互斥信号量,初始值为1
semaphore empty = {N, empty_queue}; // 表示缓冲区中空槽的数量,初始值为N
semaphore full = {0, empty_queue}; // 表示缓冲区中满槽的数量,初始值为0

void producer() {
while (true) {
produce_item(); // 生产一个新项
down(&empty); // 等待缓冲区有空槽
down(&mutex); // 等待对缓冲区的互斥访问
add_item_to_buffer(); // 将新项添加到缓冲区
up(&mutex); // 释放对缓冲区的互斥访问
up(&full); // 通知缓冲区有满槽
}
}

void consumer() {
while (true) {
down(&full); // 等待缓冲区有满槽
down(&mutex); // 等待对缓冲区的互斥访问
remove_item_from_buffer(); // 从缓冲区取出一个项
up(&mutex); // 释放对缓冲区的互斥访问
up(&empty); // 通知缓冲区有空槽
consume_item(); // 消费该项
}
}

信号量是一种强大的同步工具,通过原子操作 downup 管理共享资源的访问,确保并发进程的正确性和效率。它在解决生产者-消费者问题、读者-写者问题以及其他经典并发问题中具有广泛应用。通过信号量,可以有效避免竞争条件和其他并发问题,实现进程间的正确同步与通信。

6. 互斥量

互斥量(Mutex)的详细描述

互斥量(mutex)是用于管理共享资源访问的一种同步原语。它在实现用户空间线程包时特别有用,因为它的实现既容易又高效。互斥量可以处于两种状态之一:解锁(unlocked)和加锁(locked)。当一个线程需要访问临界区时,它调用 mutex_lock。如果互斥量当前是解锁的,调用成功,线程可以进入临界区;如果互斥量已经加锁,调用线程被阻塞,直到另一个线程调用 mutex_unlock

互斥量的操作

  1. mutex_lock
    • 试图获取互斥量。如果互斥量是解锁的,则将其设置为加锁状态并进入临界区。
    • 如果互斥量已经加锁,调用线程将被阻塞,直到互斥量变为解锁状态。
  2. mutex_unlock
    • 释放互斥量,将其设置为解锁状态。
    • 如果有其他线程被阻塞在该互斥量上,则唤醒其中一个线程并允许其获得锁。

互斥量与信号量的区别

互斥量是一种专门用于管理互斥访问的信号量的简化版本。信号量可以有多个资源计数,而互斥量只有两个状态(解锁和加锁),主要用于互斥访问。互斥量在实现时既容易又高效,因此在用户空间线程包中非常有用。

互斥量的实现

以下是互斥量的典型实现,展示了 mutex_lockmutex_unlock 操作:

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
typedef struct {
int locked; // 0 表示解锁,1 表示加锁
queue<thread> queue; // 等待线程的队列
} mutex;

void mutex_lock(mutex *m) {
disable_interrupts();
if (m->locked == 0) {
m->locked = 1; // 获取锁
} else {
add_current_thread_to_queue(m->queue); // 将当前线程添加到等待队列
thread_block(); // 阻塞当前线程
}
enable_interrupts();
}

void mutex_unlock(mutex *m) {
disable_interrupts();
if (!is_empty(m->queue)) {
thread *t = remove_thread_from_queue(m->queue); // 从等待队列中移除一个线程
thread_wakeup(t); // 唤醒该线程
} else {
m->locked = 0; // 释放锁
}
enable_interrupts();
}

互斥量的使用

生产者-消费者问题

互斥量可以用于解决经典的并发问题,如生产者-消费者问题。以下是使用互斥量实现的生产者-消费者问题的示例代码:

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
mutex buffer_mutex = {0, empty_queue}; // 初始化互斥量
semaphore empty = {N, empty_queue}; // 表示缓冲区中空槽的数量,初始值为N
semaphore full = {0, empty_queue}; // 表示缓冲区中满槽的数量,初始值为0

void producer() {
while (true) {
produce_item(); // 生产一个新项
down(&empty); // 等待缓冲区有空槽
mutex_lock(&buffer_mutex); // 获取缓冲区互斥量
add_item_to_buffer(); // 将新项添加到缓冲区
mutex_unlock(&buffer_mutex); // 释放缓冲区互斥量
up(&full); // 通知缓冲区有满槽
}
}

void consumer() {
while (true) {
down(&full); // 等待缓冲区有满槽
mutex_lock(&buffer_mutex); // 获取缓冲区互斥量
remove_item_from_buffer(); // 从缓冲区取出一个项
mutex_unlock(&buffer_mutex); // 释放缓冲区互斥量
up(&empty); // 通知缓冲区有空槽
consume_item(); // 消费该项
}
}

enter_region 和 mutex_lock 的区别

enter_regionmutex_lock 的实现非常相似,但有一个关键区别:忙等待与线程调度。

  1. enter_region
    • 使用忙等待的方式来试图获取锁。如果锁不可用,线程会不断循环测试锁的状态。
    • 这种方法会浪费 CPU 时间,特别是在用户空间线程中,由于没有时钟中断来强制线程切换,导致线程可能永远循环等待。
  2. mutex_lock
    • 当获取锁失败时,不会忙等待,而是调用 thread_yield 放弃 CPU 使用权,允许其他线程运行。
    • 当锁变为可用时,被阻塞的线程会被唤醒,从而避免了忙等待的问题。

线程调度与yield

在用户空间线程实现中,没有内核级的时钟中断来管理线程调度。因此,当一个线程无法获取锁时,通过 thread_yield 主动放弃 CPU 使用权,让其他线程有机会运行并释放锁。这种方法避免了忙等待,提高了系统的整体效率。

总结

互斥量是一种简化的信号量,用于管理共享资源的互斥访问。通过 mutex_lockmutex_unlock 操作,可以有效地实现临界区的互斥访问,而不会浪费 CPU 时间。互斥量在用户空间线程实现中尤其有用,因为它可以避免忙等待,提高系统效率。

7. 条件变量

条件变量详细介绍

条件变量是一种线程同步机制,用于在某些条件满足之前阻塞线程。与互斥量结合使用,条件变量能够有效地解决复杂的同步问题。条件变量主要通过两个操作来管理线程的等待和唤醒:pthread_cond_waitpthread_cond_signal

条件变量的操作

  1. pthread_cond_wait
    • 将调用线程放入等待队列,并解锁互斥量,允许其他线程继续执行。当线程被唤醒时,会重新获取互斥量。
    • 该操作确保线程在等待条件满足时不会占用 CPU 资源。
  2. pthread_cond_signal
    • 唤醒等待队列中的一个线程,使其从 pthread_cond_wait 调用中返回。
    • 如果没有线程在等待,该信号会丢失。
  3. pthread_cond_broadcast
    • 唤醒等待队列中的所有线程,使它们从 pthread_cond_wait 调用中返回。

条件变量的使用

条件变量通常与互斥量结合使用,以确保对共享资源的访问是线程安全的。以下是条件变量的典型使用示例:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int ready = 0;

void* thread_func(void* arg) {
pthread_mutex_lock(&mutex);
while (!ready) {
pthread_cond_wait(&cond, &mutex);
}
// 条件满足后,执行相应的操作
printf("Thread %d: condition met, proceeding...\n", *(int*)arg);
pthread_mutex_unlock(&mutex);
return NULL;
}

void set_ready() {
pthread_mutex_lock(&mutex);
ready = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}

int main() {
pthread_t threads[3];
int thread_ids[3] = {1, 2, 3};

for (int i = 0; i < 3; i++) {
pthread_create(&threads[i], NULL, thread_func, &thread_ids[i]);
}

// 模拟条件变化
sleep(1);
set_ready();

for (int i = 0; i < 3; i++) {
pthread_join(threads[i], NULL);
}

return 0;
}

条件变量与信号量的区别

  1. 内存中的存在性
    • 条件变量在没有等待线程时不保存信号。如果在没有线程等待时发出信号,信号会丢失。
    • 信号量则保存信号,允许稍后使用。
  2. 同步机制
    • 条件变量与互斥量结合使用,主要用于等待某些条件的发生。
    • 信号量用于控制对资源的访问,允许计数多个资源。

生产者-消费者问题的条件变量解决方案

使用条件变量可以有效地解决生产者-消费者问题:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_producer = PTHREAD_COND_INITIALIZER;
pthread_cond_t cond_consumer = PTHREAD_COND_INITIALIZER;
int buffer = 0;
int count = 0;

void* producer(void* arg) {
while (1) {
pthread_mutex_lock(&mutex);
while (count == 1) { // 缓冲区满时等待
pthread_cond_wait(&cond_producer, &mutex);
}
buffer = produce_item();
count = 1;
pthread_cond_signal(&cond_consumer);
pthread_mutex_unlock(&mutex);
}
return NULL;
}

void* consumer(void* arg) {
while (1) {
pthread_mutex_lock(&mutex);
while (count == 0) { // 缓冲区空时等待
pthread_cond_wait(&cond_consumer, &mutex);
}
int item = buffer;
count = 0;
consume_item(item);
pthread_cond_signal(&cond_producer);
pthread_mutex_unlock(&mutex);
}
return NULL;
}

int main() {
pthread_t prod, cons;
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
return 0;
}

8. 管程

管程 (Monitor)

管程是一种高级同步原语,旨在简化并发编程,提供互斥访问共享资源的机制。管程由一个数据结构和一组操作(方法)组成,并且这些操作对外界是不可见的。只有通过管程提供的操作,外部才能访问管程内部的数据。

管程的特性

  1. 互斥访问
    • 在任一时刻,只有一个进程可以在管程内执行。这保证了共享资源的互斥访问,防止数据竞态条件的发生。
  2. 条件变量
    • 管程内部通常包含一个或多个条件变量,用于控制进程在某些条件不满足时的等待和唤醒操作。

管程的工作原理

当一个进程调用管程中的某个操作时,管程首先检查是否有其他进程已经在管程内执行。如果是,则调用进程会被阻塞,直到管程变为空闲状态。一旦进程进入管程,它可以安全地访问和修改管程内的共享数据,直到它完成操作并退出管程。

管程的结构

一个典型的管程结构如下所示:

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
monitor ExampleMonitor {
// 共享变量
int shared_data;

// 条件变量
condition cond_var;

// 管程操作
procedure enter() {
// 进入管程的操作
}

procedure leave() {
// 离开管程的操作
}

procedure wait() {
// 等待某个条件
cond_var.wait();
}

procedure signal() {
// 唤醒等待的进程
cond_var.signal();
}
}

管程的实现

以下是一个使用管程解决生产者-消费者问题的示例:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define BUFFER_SIZE 10

typedef struct {
int buffer[BUFFER_SIZE];
int count;
int in;
int out;
pthread_mutex_t mutex;
pthread_cond_t not_full;
pthread_cond_t not_empty;
} MonitorBuffer;

MonitorBuffer buffer = { .count = 0, .in = 0, .out = 0,
.mutex = PTHREAD_MUTEX_INITIALIZER,
.not_full = PTHREAD_COND_INITIALIZER,
.not_empty = PTHREAD_COND_INITIALIZER };

void produce(int item) {
pthread_mutex_lock(&buffer.mutex);
while (buffer.count == BUFFER_SIZE) {
pthread_cond_wait(&buffer.not_full, &buffer.mutex);
}
buffer.buffer[buffer.in] = item;
buffer.in = (buffer.in + 1) % BUFFER_SIZE;
buffer.count++;
pthread_cond_signal(&buffer.not_empty);
pthread_mutex_unlock(&buffer.mutex);
}

int consume() {
pthread_mutex_lock(&buffer.mutex);
while (buffer.count == 0) {
pthread_cond_wait(&buffer.not_empty, &buffer.mutex);
}
int item = buffer.buffer[buffer.out];
buffer.out = (buffer.out + 1) % BUFFER_SIZE;
buffer.count--;
pthread_cond_signal(&buffer.not_full);
pthread_mutex_unlock(&buffer.mutex);
return item;
}

void* producer(void* arg) {
for (int i = 0; i < 100; i++) {
int item = rand() % 100;
produce(item);
printf("Produced: %d\n", item);
}
return NULL;
}

void* consumer(void* arg) {
for (int i = 0; i < 100; i++) {
int item = consume();
printf("Consumed: %d\n", item);
}
return NULL;
}

int main() {
pthread_t prod, cons;
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
return 0;
}

管程的优点

  1. 简化同步操作
    • 管程通过自动管理互斥访问和条件变量,简化了并发编程中的同步操作,使程序更易于理解和维护。
  2. 提高代码可读性
    • 由于管程封装了共享数据和同步操作,代码的可读性和可维护性得到了提高。
  3. 减少错误
    • 管程自动处理互斥访问,减少了由于手动管理锁和条件变量而导致的编程错误。

管程的缺点

  1. 可扩展性受限
    • 管程在多处理器系统中的性能可能受到限制,因为任一时刻只有一个进程可以在管程内执行。
  2. 实现复杂
    • 尽管使用管程简化了编程,但其底层实现可能比较复杂,特别是在操作系统内核中实现管程时。

9. 消息传递

进程间通信 (IPC) 方法中,使用 sendreceive 原语是一种经典的消息传递机制。这些原语类似于信号量,但它们通常是系统调用而非编程语言的一部分。下面详细解释这些原语的工作方式及其特点。

消息传递原语

1. send 原语

send 原语用于将消息从一个进程发送到另一个进程。该调用会将消息放入发送队列,并可能导致发送进程被阻塞,直到消息被接收。

语法示例

1
int send(int destination_process_id, const void *message, size_t message_size);
  • destination_process_id:目标进程的标识符。
  • message:要发送的消息内容。
  • message_size:消息的大小。

特点

  • 如果目标进程当前没有准备好接收消息,发送进程可能会被阻塞,直到目标进程准备好。
  • 消息可以是任意长度的数据块,但通常要受到系统或实现的限制。

2. receive 原语

receive 原语用于从某个进程或任意进程接收消息。该调用会将消息从接收队列中提取出来,并可能导致接收进程被阻塞,直到有消息可供接收。

语法示例

1
int receive(int source_process_id, void *message, size_t *message_size);
  • source_process_id:消息源的进程标识符。如果设置为特殊值(如ANY),则表示接收任意来源的消息。
  • message:用于存储接收到的消息内容的缓冲区。
  • message_size:消息的实际大小。这个参数在接收之前可以是 NULL,在接收之后指示消息的大小。

特点

  • 如果没有消息可接收,接收进程可能会被阻塞,直到有消息到达。
  • 如果设置了 source_process_id 为特定进程,那么只有来自该进程的消息才能被接收。如果设置为 ANY,则可以接收来自任何进程的消息。

消息传递的工作原理

  • 阻塞行为
    • 在发送时,如果消息队列已满或目标进程未准备好接收,发送进程将被阻塞,直到可以发送消息为止。
    • 在接收时,如果消息队列为空,接收进程将被阻塞,直到有消息到达。
  • 消息队列
    • 系统维护一个消息队列来存储发送的消息。在接收操作完成之前,消息会保留在队列中。
    • 消息队列可以是进程间的私有队列,或者是系统提供的公共队列。

消息传递的优缺点

优点

  1. 高层次的同步
    • 消息传递提供了高层次的同步机制,减少了对底层同步原语(如锁和条件变量)的直接使用。
  2. 解耦合
    • 消息传递机制允许进程间的松耦合。发送者和接收者不需要同时存在或知道对方的具体实现。
  3. 可靠性
    • 消息传递机制通常由操作系统提供,因此具有较高的可靠性和一致性。

缺点

  1. 开销
    • 消息传递可能涉及较高的开销,包括上下文切换和内存拷贝。
  2. 复杂性
    • 在设计时需要仔细处理消息的同步和错误处理,以确保系统的可靠性和性能。
  3. 性能瓶颈
    • 如果消息传递机制不够高效,可能会成为系统性能的瓶颈。

消息传递的应用场景

消息传递机制广泛应用于现代操作系统和分布式系统中,特别是在以下场景中:

  1. 进程间通信
    • 用于实现不同进程间的数据交换和同步。
  2. 分布式系统
    • 用于网络上不同计算节点之间的通信。
  3. 异步任务处理
    • 用于异步任务的调度和结果的传递。

例子

以下是一个简单的消息传递例子,展示如何使用 sendreceive 原语进行进程间通信:

1
2
3
4
5
6
7
8
9
10
11
12
// 进程 A(发送者)
void process_a() {
const char *message = "Hello, Process B!";
send(process_b_id, message, strlen(message) + 1);
}

// 进程 B(接收者)
void process_b() {
char message[256];
receive(process_a_id, message, NULL);
printf("Received message: %s\n", message);
}

在这个例子中,进程 A 向进程 B 发送一条消息,进程 B 接收消息并打印出来。这个简单示例展示了消息传递机制的基本用法。

10. 屏障

屏障同步是一种用于协调多个进程或线程在并行计算中到达某个共同点的机制。在这种机制中,每个进程或线程在完成其阶段的计算后,会到达一个屏障点,直到所有参与的进程或线程都到达这个屏障点,才允许它们继续执行到下一个阶段。

屏障同步的基本概念

1. 屏障定义

屏障是一个同步点,所有进程或线程必须到达这个点才能继续执行下一个阶段。它确保了在同一个阶段的所有并行执行的进程或线程都已经完成了当前阶段的任务后,才能继续进行。

2. 屏障的工作原理

  • 到达屏障: 每个进程或线程在完成当前阶段的计算后,会尝试到达屏障。到达屏障后,它会被阻塞,直到所有其他进程或线程也到达这个屏障。

  • 同步: 一旦所有进程或线程都到达了屏障,屏障就会释放所有被阻塞的进程或线程,允许它们同时进入下一个阶段。

  • 继续执行: 所有进程或线程在屏障处被解除阻塞后,可以继续执行下一阶段的任务。

屏障同步的实现

屏障同步可以通过不同的方式实现,包括使用同步原语、条件变量和其他同步机制。下面是一些常见的屏障同步实现方式:

1. 基于计数器的屏障

最常见的实现方法是使用一个计数器来跟踪到达屏障的进程或线程数量:

  • 初始化: 在创建屏障时,计数器被初始化为进程或线程的总数,所有进程或线程的状态设置为等待屏障。

  • 到达屏障: 每个进程或线程在到达屏障时,都会将计数器减1,并检查计数器的值。

  • 等待: 如果计数器的值为0,说明所有进程或线程都到达了屏障,屏障会释放所有等待的进程或线程。

  • 继续执行: 否则,进程或线程将被阻塞,直到计数器的值变为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
28
// 屏障结构体
typedef struct {
int count; // 剩余未到达屏障的进程数
int total; // 总进程数
semaphore mutex; // 互斥锁
semaphore barrier; // 屏障信号量
} Barrier;

// 初始化屏障
void barrier_init(Barrier *barrier, int total_processes) {
barrier->count = total_processes;
barrier->total = total_processes;
semaphore_init(&barrier->mutex, 1); // 二值信号量
semaphore_init(&barrier->barrier, 0);
}

// 进程到达屏障
void barrier_wait(Barrier *barrier) {
semaphore_wait(&barrier->mutex);
barrier->count--;
if (barrier->count == 0) {
// 所有进程都到达屏障
semaphore_signal(&barrier->barrier, barrier->total);
barrier->count = barrier->total; // 重置计数器
}
semaphore_signal(&barrier->mutex);
semaphore_wait(&barrier->barrier); // 等待屏障释放
}

2. 基于条件变量的屏障

在基于条件变量的实现中,屏障的实现依赖于条件变量来等待所有进程或线程的到达:

  • 条件变量: 每个进程或线程在到达屏障时,会使用条件变量来进行同步。

  • 广播: 当所有进程或线程到达屏障时,条件变量会发出广播通知,允许所有等待的进程或线程继续执行。

示例代码(伪代码):

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
28
29
30
// 屏障结构体
typedef struct {
int count; // 剩余未到达屏障的进程数
int total; // 总进程数
mutex lock; // 互斥锁
condition_variable cv; // 条件变量
} Barrier;

// 初始化屏障
void barrier_init(Barrier *barrier, int total_processes) {
barrier->count = total_processes;
barrier->total = total_processes;
mutex_init(&barrier->lock);
condition_variable_init(&barrier->cv);
}

// 进程到达屏障
void barrier_wait(Barrier *barrier) {
mutex_lock(&barrier->lock);
barrier->count--;
if (barrier->count == 0) {
// 所有进程都到达屏障
condition_variable_broadcast(&barrier->cv);
barrier->count = barrier->total; // 重置计数器
} else {
// 等待所有进程到达屏障
condition_variable_wait(&barrier->cv, &barrier->lock);
}
mutex_unlock(&barrier->lock);
}

屏障同步的优缺点

优点

  1. 确保同步: 屏障同步确保所有进程或线程在进行下一阶段的计算之前都已完成当前阶段,避免了数据不一致的问题。

  2. 简单明了: 屏障同步提供了一种简单且直观的方式来协调并行进程或线程的执行。

  3. 提高效率: 屏障同步可以提高计算效率,特别是在需要对多个进程或线程进行同步的并行计算中。

缺点

  1. 阻塞: 屏障同步可能会导致进程或线程在屏障处被阻塞,特别是在进程或线程数量较多时。

  2. 等待时间: 如果某些进程或线程较慢,可能会导致其他进程或线程在屏障处等待较长时间,从而影响整体性能。

  3. 死锁风险: 在某些情况下,如果屏障的实现存在问题,可能会导致死锁,特别是进程或线程未能正确到达屏障。

应用场景

屏障同步广泛应用于以下场景:

  1. 并行计算: 在并行计算中,屏障同步用于确保所有计算任务在进入下一阶段之前完成当前阶段的任务。

  2. 多线程程序: 在多线程程序中,屏障同步用于协调多个线程在进行下一步操作之前的同步。

  3. 数据处理: 在大规模数据处理任务中,屏障同步用于确保数据处理的每个阶段在进入下一阶段之前都完成。

四、调度

当计算机系统是多道程序设计系统时,通常就会有多个进程或线程同时竞争CPU。只要有两个或更多的进程处于就绪状态,这种情形就会发生。如果只有一个CPU可用,那么就必须选择下一个要运行的进程。在操作系统中,完成选择工作的这一部分称为调度程序,该程序使用的算法称为调度算法。

何时调度

在一个多道程序设计系统中,调度程序负责在多个进程或线程间分配CPU资源。调度决策的时机主要包括以下四种情况:

  1. 新进程创建后:当一个新进程被创建时,调度程序需要决定是继续运行当前的父进程还是切换到新创建的子进程。
  2. 进程退出时:当一个进程完成其工作并退出时,调度程序必须选择下一个进程来运行。
  3. 进程阻塞时:当一个进程因为等待I/O操作、信号量或其他原因而阻塞时,调度程序需要选择一个新的进程来运行。
  4. I/O中断时:当I/O操作完成并产生中断时,调度程序必须决定是否需要切换到等待该I/O操作完成的进程,或者继续当前的进程。

调度算法分类

调度算法根据系统的需求和目标可以分为以下几类:

  1. 批处理调度算法:适用于批处理系统,主要目标是提高系统吞吐量和CPU利用率。
  2. 交互式调度算法:适用于交互式系统,主要目标是缩短响应时间和提高系统的响应能力。
  3. 实时调度算法:适用于实时系统,主要目标是确保关键任务在规定的时间内完成。

调度算法的目标

设计调度算法时,必须考虑其目标,这些目标可能因环境而异,但通常包括以下几个方面:

  1. 公平性:确保每个进程都能获得公平的CPU时间。
  2. 效率:提高CPU利用率,减少空闲时间。
  3. 响应时间:特别是在交互式系统中,尽量缩短响应时间。
  4. 周转时间:在批处理系统中,尽量减少从提交作业到作业完成的总时间。
  5. 实时性:在实时系统中,确保关键任务按时完成。
  6. 吞吐量:提高系统单位时间内完成的作业数量。

批处理系统中的调度算法

先来先服务(FCFS)

描述:FCFS(First-Come, First-Served)是最简单的调度算法,进程按到达顺序依次获得CPU时间。

优点

  • 易于理解和实现。

缺点

  • 平均等待时间长,因为长作业会延迟后续短作业的执行,导致“长尾效应”。

最短作业优先(SJF)

描述:SJF(Shortest Job First)算法选择执行时间最短的作业。

优点

  • 可以最小化平均等待时间。

缺点

  • 需要知道所有作业的执行时间,这是不现实的。
  • 可能导致“饥饿”现象,长作业可能一直得不到执行。

最短剩余时间优先(SRTF)

描述:SRTF(Shortest Remaining Time First)是SJF的抢占式版本,选择剩余执行时间最短的作业。

优点

  • 提高了系统的响应性,因为短作业可以快速完成。

缺点

  • 同样需要知道剩余执行时间。
  • 可能导致长作业一直得不到执行(饥饿现象)。

5. 交互式系统中的调度

交互式系统中的调度

在交互式系统中,调度算法的目标是确保系统能够快速响应用户的操作请求。以下是几种常用的交互式调度算法及其详细解释:

轮转调度(Round Robin)

描述: 轮转调度是最古老、最简单、最公平且使用最广的调度算法之一。每个进程被分配一个固定的时间片,允许该进程在该时间段内运行。如果在时间片结束时进程仍未完成,则剥夺CPU,将其分配给下一个进程。如果进程在时间片结束前阻塞或结束,则立即进行进程切换。

优点

  • 公平性好,所有进程轮流获得CPU时间。
  • 响应时间短,特别适用于交互式系统。

缺点

  • 时间片设得太短会导致过多的进程切换,降低CPU效率。
  • 时间片设得太长会增加响应时间。

合理时间片

  • 通常时间片设定在20ms到50ms之间,是一个较为合理的折中选择。

优先级调度

描述: 每个进程被赋予一个优先级,优先级最高的可运行进程先获得CPU。为了防止高优先级进程无休止地占用CPU,调度程序可以在每个时钟滴答(每个时钟中断)降低当前进程的优先级。如果这个动作导致该进程的优先级低于次高优先级的进程,则进行进程切换。

优点

  • 能够确保高优先级任务迅速得到处理。

缺点

  • 可能导致低优先级进程饥饿(长期得不到执行)。

多级队列调度

描述: 将一组进程按优先级分成若干类,各类之间采用优先级调度,在各类内部采用其他调度方式。每个优先级队列可以有不同的调度算法。例如,较高优先级的队列使用轮转调度,而较低优先级的队列使用先来先服务(FCFS)调度。

优点

  • 兼顾了优先级调度和其他调度策略的优点。
  • 灵活性高,可以根据系统需求调整各队列的调度算法。

缺点

  • 实现复杂,需要维护多个队列和调度策略。

最短进程优先

描述: 最短进程优先(Shortest Process Next, SPN)算法选择预计运行时间最短的进程执行。对于交互式系统,这种算法有助于缩短响应时间。

优点

  • 最小化平均等待时间。

缺点

  • 需要知道进程的预计运行时间,这是不现实的。
  • 可能导致长进程饥饿。

保证调度

描述: 向用户提供明确的性能保证,然后实现这些保证。例如,在一个有n个用户登录的系统中,每个用户保证获得1/n的CPU时间。在一个有n个进程运行的单用户系统中,如果所有进程等价,则每个进程将获得1/n的CPU时间。

优点

  • 公平性好,用户和进程都能得到应有的CPU份额。

缺点

  • 实现复杂,需要实时跟踪和调整各用户和进程的CPU时间。

彩票调度

描述: 彩票调度向进程提供各种系统资源(如CPU时间)的“彩票”。当需要做出调度决策时,随机抽出一张彩票,拥有该彩票的进程获得资源。例如,系统每秒钟抽50次彩票,每个获奖者可以获得20ms的CPU时间。

优点

  • 简单且灵活,可以通过分配更多彩票来增加进程的优先级。

缺点

  • 可能导致不确定性,进程的执行时间无法精确预测。

公平分享调度

描述: 考虑进程的所有者,在调度时保证每个用户分配到CPU时间的一部分。调度程序强制选择进程,确保每个用户都能获得应有的CPU份额。例如,如果两个用户都保证获得50%的CPU时间,无论一个用户有多少进程,每个用户都会得到应有的CPU时间份额。

优点

  • 避免用户间的资源争夺,确保每个用户都能获得公平的CPU时间。

缺点

  • 实现复杂,需要考虑用户身份和进程数。

交互式系统中的调度算法需要平衡公平性、响应时间和系统效率。轮转调度、优先级调度和多级队列调度是常用的交互式调度算法,每种算法都有其优缺点和适用场景。合理选择和调整调度算法可以提高系统的性能和用户体验。

6. 策略和机制

调度机制与调度策略的分离

调度机制

调度机制是指操作系统实现进程切换和分配CPU时间的低级操作。这些操作包括:

  • 进程切换(上下文切换):保存当前进程的状态并恢复另一个进程的状态。
  • 分配CPU时间:将CPU时间片分配给进程。
  • 管理进程队列:维护就绪队列、阻塞队列等。

这些机制是操作系统内核的一部分,确保系统的基本运行。

调度策略

调度策略是指操作系统决定在何时、给哪个进程分配CPU时间的高级决策。这包括:

  • 选择下一个要运行的进程:基于优先级、时间片、进程类型等因素。
  • 调整进程优先级:根据进程行为或用户需求动态调整。
  • 分配资源:根据进程需求和系统资源情况分配内存、I/O等资源。

调度策略可以由用户或系统管理员配置,以满足不同的应用需求和性能目标。

策略与机制分离的好处

  1. 灵活性
    • 通过分离策略和机制,可以根据不同应用的需求调整调度策略,而无需修改底层调度机制。
    • 用户进程可以提供参数来影响调度策略,例如优先级、时间片长度等。
  2. 简化内核设计
    • 内核只需实现通用的调度机制,不必处理各种复杂的调度策略。
    • 调度策略的更改不会影响内核的稳定性和安全性。
  3. 提高系统性能
    • 用户进程可以根据自身的需求提供更优化的调度参数,提高系统资源利用率和响应速度。
    • 通过动态调整调度策略,可以更好地适应系统负载变化。

实现策略与机制分离的方法

  1. 参数化调度算法
    • 调度算法可以接受参数,由用户进程提供这些参数。例如,时间片长度、优先级等。
    • 内核通过提供系统调用或接口,允许用户进程设置调度参数。
  2. 用户级调度策略
    • 在一些系统中,调度策略可以部分由用户级的调度程序决定。用户级调度程序根据自身需求和系统状态,调整进程的调度参数。
    • 例如,用户级调度程序可以动态调整进程的优先级,以保证关键任务的及时执行。
  3. 配置文件和策略脚本
    • 操作系统可以提供配置文件或脚本,允许系统管理员或用户定义调度策略。
    • 通过加载不同的配置文件或执行策略脚本,操作系统可以实现灵活的调度策略调整。

示例:Linux中的CFS调度器

Linux内核中的完全公平调度器(Completely Fair Scheduler, CFS)是一个现代的调度器,实现了策略与机制的分离:

  • 调度机制:CFS调度器在内核中实现了基本的调度操作,如进程切换、时间片分配等。
  • 调度策略:CFS调度器根据进程的虚拟运行时间(vruntime)决定调度策略。用户可以通过调整进程优先级(nice值)影响调度策略。

用户和系统管理员可以使用命令和配置文件调整进程的优先级和调度参数,进而影响调度策略。