进程和线程
进程和线程
在多道程序环境下,允许多个程序并发执行,为此操作系统引入了 进程 (Process) 的概念,以便更好地描述和控制程序的并发执行,实现操作系统的并发性。
进程
进程是系统资源分配的基本单位。进程拥有独立的系统资源,包括内存空间、文件描述符、CPU 时间片等,这些资源的分配由操作系统负责。
进程和程序
程序是 静态的,进程是 动态的,进程可以理解成运行的程序,两者的具体区别如下:
- 程序
- 程序是一组指令的静态集合,它存储在磁盘等持久性存储介质中。
- 程序是被动的,它本身并不执行任何操作。
- 一个程序可以多次运行,每次运行都会创建一个新的进程。
- 进程
- 进程是程序在内存中执行的实例。
- 当一个程序被加载到内存中并开始执行时,操作系统会创建一个进程。
- 进程是动态的,它代表了程序的执行过程。
进程控制块
操作系统通过 进程控制块(PCB,Process Control Block)对进程进行管理,进程控制块中包含一系列 进程的元信息,这些元信息帮助操作系统对进程进行管理:
进程描述信息 | 进程控制和管理信息 | 资源分配清单 | 处理机相关信息 |
---|---|---|---|
进程标识符(PID) | 进程状态 | 代码段指针 | 通用寄存器值 |
用户标识符(UID) | 进程优先级 | 数据段指针 | 地址寄存器值 |
代码运行入口地址 | 堆栈段指针 | 控制寄存器值 | |
程序的外存地址 | 文件描述符 | 标志寄存器值 | |
等待时间 | |||
CPU 占用时间 |
上表给出了 PCB 的关键内容,其中进程描述信息用于唯一标识和管理进程,以及实现用户级别的权限控制;进程控制和管理信息用于管理进程的生命周期、调度和执行;资源控制清单记录进程使用的资源,以便进行有效的资源管理和分配;处理机相关信息保存与处理器相关的状态信息,以实现上下文切换。
进程控制块 在操作系统 进程 管理的多个阶段都发挥重要作用:
- 进程创建和终止:在进程创建时,操作系统为它新建一个PCB(进程控制块)。该结构在进程存在期间常驻内存,可以随时存取,并在进程结束时删除。
- 进程调度:当操作系统进行进程调度时,会根据PCB中存储的信息(如优先级、进程状态)来决定哪个进程应该获得CPU的控制权。
父子进程
在操作系统中,进程 是程序执行的基本单位。父子进程 是指通过 进程创建机制 生成的一种层级关系:
- 父进程:创建新 进程 的 进程。
- 子进程 :由 父进程 创建的 进程。
子进程 通常由 父进程 通过 fork 系统调用 建立。子进程是父进程的副本,继承父进程的代码段、数据段、堆栈等资源,但拥有自己的独立地址空间和执行状态。两者可以通过进程间通信(IPC)机制(如管道、信号、共享内存)进行数据交换或同步。
fork 系统调用的声明为 pid_t fork(void)
,其返回值为一个整数,可以用于判定该程序是 父进程 还是 子进程:
- 在 父进程 中,返回值为 子进程 的 pid(pid > 0)。
- 在 子进程 中,返回值为 0。
fork 调用后,父进程 和 子进程 从 fork 调用后的下一条指令开始继续执行。子进程 获得 父进程 的 代码、数据 和 堆栈 的副本,但两者地址空间独立。
除了 fork 系统调用外,还有一个 exec 系统调用,两种具有不同的功能,需要能够区分:
- fork:创建 子进程,复制 资源,不改变程序,父子进程 继续执行原代码。
- exec:在当前 进程 中加载并执行一个新的程序。替换当前 进程 的 代码段、数据段 和 堆栈,保留部分 资源 如 进程 PID,不涉及到并发。
此外,父进程 可以通过 wait 系统调用等待 子进程 执行结束,并获取 子进程 的退出状态,防止 进程 成为 僵尸进程。
僵尸进程 (Zombie Process)是指一个已经终止(完成了其执行)的 子进程,但其 父进程 尚未通过 wait 或 waitpid 系统调用回收其退出状态。僵尸进程 虽然不再运行,但仍在 进程表 中保留一个条目,占用系统 资源,直到 父进程 回收或 父进程 终止。
孤儿进程 (Orphan Process)是指 父进程 在 子进程 终止前退出,导致 子进程 失去 父进程 的 进程。孤儿进程 与 僵尸进程 不同,它仍在运行,只是失去了原来的 父进程。
下表给出了两者的对比:
项目 | 僵尸进程(Zombie Process) | 孤儿进程(Orphan Process) |
---|---|---|
状态 | 已经终止(执行完毕) | 仍然在运行 |
父进程是否存在 | 存在,但未调用 wait() /waitpid() 回收子进程状态 | 父进程已终止 |
占用资源 | 占用进程表项(PID 仍存在),不占用内存等资源 | 占用资源,正常运行中 |
线程
线程是系统调度的基本单位。多核处理器可以将系统中的不同 线程 调度到不同的 CPU 逻辑核心上,所以在同一个时刻,系统中的 线程 可能会在不同的逻辑核心上并行运行。
每个 进程 启动的时候,都包含一个 线程(主线程),可以在主线程外创建更多的 线程。
如上图所示,一个 进程 中的所有 线程 都共享 虚拟地址空间 和系统 资源,但是每个 线程 都维护自己的 堆栈、寄存器和一些额外信息。
在一个 进程 中没有创建 线程 时,则该 进程 为 单线程进程,否则该 进程 中包含多个 线程。
单线程进程 只有一个线程,独占进程的虚拟地址空间和系统资源,如文件描述符和内存。线程维护自己的堆栈和寄存器,无需同步,但无法利用多核CPU,且阻塞会影响整个进程,适合简单任务,但并发性和响应性较差。
多线程进程 包含多个线程,共享虚拟地址空间和系统资源,如代码段和堆,便于高效通信。每个线程有独立的堆栈、寄存器和线程ID,需通过锁等机制同步以避免资源竞争。
进程的状态
进程状态 是指在操作系统中,一个 进程 在执行过程中的不同阶段。它反映了 进程 当前正在做什么,以及是否可以被 CPU 执行。
状态种类
不同的操作系统对 进程状态 的划分可能不同,但常见的包括以下几种:
- 创建状态(New):当 进程 被创建但还未分配资源或执行时,它处于 创建状态。
- 就绪状态(Ready):在 就绪状态 中,进程 已准备好执行,但由于操作系统调度算法或其他原因,尚未获得 CPU 时间片。
- 运行状态(Running):在 运行状态 中,进程 正在执行指令并占用 CPU。
- 阻塞状态(Blocked):当 进程 在等待某些事件发生时,如等待 I/O 操作完成或等待其他资源时,它会进入 阻塞状态。在 阻塞状态 下,进程 暂停执行,直到等待的事件发生。
- 终止状态(Terminated):当 进程 执行完毕或被操作系统终止时,它进入 终止状态。
状态转化
- 就绪态 → 运行态
- 调度:当操作系统的调度器选择一个就绪状态的进程分配给处理器时,该进程就会从就绪状态转换到运行状态。
- 运行态 → 就绪态
- 时间片用完:如果系统使用时间共享调度,当进程的时间片用完,它会被中断并放回就绪队列。
- 优先级更高的进程就绪:在优先级调度算法中,如果一个优先级更高的进程变为就绪状态,当前运行的进程可能会被挂起。
- 自愿放弃 CPU:进程可能主动放弃CPU,比如它发出了一个系统调用请求其他资源。
- 运行态 → 阻塞态
- I/O 请求:进程进行I/O操作,由于I/O设备比CPU慢得多,进程会被挂起直到I/O完成。
- 等待资源:进程等待不可用的资源,如信号量、互斥锁等。
- 等待事件:如等待其他进程的信号、消息或者某个条件的发生。
- 阻塞态 → 就绪态
- I/O 完成:当 I/O 操作完成,相应的进程会被移至就绪队列。
- 资源获得:进程所等待的资源变得可用,如获得了互斥锁。
- 事件发生:进程所等待的事件发生了,如接收到了另一个进程发出的信号。
进程内存空间
- 用户空间(User Space):包含 进程 执行的用户程序代码和数据。在 用户空间 中,进程 可以执行各种任务,如运行应用程序、访问文件系统等。用户空间 对于应用程序是可见的,但对于操作系统中的核心功能是不可见的。
- 代码区(Text Segment):也称为“可执行代码区”,存储了 进程 的可执行代码,包括程序的指令和只读数据。这个区域通常是只读的,因为程序的指令在运行时不应被修改。
- 数据区(Data Segment),数据区分为两个子区域:
- 初始化数据区(Initialized Data Segment):存储全局和静态变量以及初始化的数据。这些变量在程序运行前就已经分配了内存并初始化。
- 未初始化数据区(Uninitialized Data Segment),也称为 BSS(Block Started by Symbol)段,存储全局和静态变量,但这些变量没有显式的初始化值。操作系统会在程序启动时自动将这个区域初始化为零。
- 堆区(Heap):堆区 是动态分配内存的地方,用于存储程序运行时需要的变量和数据结构。在 堆 中分配的内存需要手动释放,以避免内存泄漏。
- 栈区(Stack):栈区 用于存储函数调用和局部变量。每个函数调用都会在栈上创建一个栈帧,栈帧包含了函数的参数、局部变量以及函数返回地址。栈 是一种后进先出(LIFO)的数据结构,它的大小通常有限,由操作系统或编程语言定义。
- 内存映射区域(Memory Mapped Region):这是一些操作系统或运行时库的扩展,用于存储动态链接库(DLL)和共享库的信息以及其他系统数据结构。
- 内核空间(Kernel Space):内核空间 包含了操作系统的核心代码和数据结构,如页表、调度程序和系统调用接口等。内核空间 具有更高的特权级别,可以执行特权指令并且访问系统的各种资源,内核空间 对于用户程序是不可见的。
函数调用时内存结构
EBP (base pointer) 指向函数栈(栈帧)的底部(高地址),函数执行过程中在栈帧中分配局部变量,栈帧由高地址向低地址增长,ESP(stack pointer)一直指向栈顶。
每个函数的栈帧中包含如下内容:
- 上一个函数的 EBP
- 该函数的局部变量
- 如果函数内有
call
指令的话,还需要保存额外信息:- 下一个函数的参数:依次存储从第 n 个到第 1 个,从高地址到低地址
- 返回地址:当前 PC 指向的位置,即
call
指令的下一条指令的地址
在函数的调用过程中,调用函数叫做 caller,被调用函数叫做 callee。当我们从 caller 中调用 callee 时,callee 的 EBP 指向的物理地址的上下存储单元分别包含 caller 的返回地址以及 caller 所在栈帧的 EBP,当我们在 callee 中执行 ret
指令时,计算机可以跳转到 caller 中 call
指令的下一条并开始执行,同时 caller 的栈帧也会被恢复。
进程间通信
进程间通信(Inter-Process Communication,IPC)是指在同一计算机系统中,两个或多个 进程 之间交换数据或信号的机制。常见的 进程间通信 方式主要包含 共享内存、管道、消息队列、信号、套接字 和 信号量。
管道 是一种最基本的 进程间通信 方式,主要用于具有亲缘关系的 进程 之间的数据传递。它分为 无名管道 和 有名管道,其中 无名管道 只能在 父子进程 或 兄弟进程 之间使用,而 有名管道(FIFO)通过文件系统中的特殊文件实现,不要求通信双方存在亲缘关系。
管道 以 字节流 的形式传输数据,具备先进先出的特性,但 只支持单向通信,并且效率相对较低,适用于简单的任务传递。
# 在 linux shell 中使用 | 操作符创建一个管道通信
# cat 进程的标准输出(stdout)会从管道输入到 grep 进程的标准输入(stdin)
cat file.txt | grep word
共享内存 是效率最高的一种 进程间通信 方式,允许多个进程将同一段 物理内存 映射到各自的 虚拟地址空间 中,从而直接读写该区域的数据。
由于不需要内核频繁介入,通信效率极高,非常适合大规模数据的快速传输。然而,由于多个进程可同时访问内存区域,必须通过额外的同步机制如 信号量 或 互斥锁 来确保数据一致性,否则容易出现竞争条件和数据错误。
消息队列消息队列 是一种基于内核的数据结构,允许多个进程以消息为单位进行通信,具有良好的结构化和独立性。进程通过系统调用将消息发送到队列中或从中接收消息,可以实现同步与异步的通信模式。
相比 管道,消息队列 的通信更加灵活,支持优先级管理,适用于需要有序、分类传输数据的场景。但由于涉及内核操作,性能开销相对 共享内存 较大。
用户级线程和内核级线程
- 用户级线程
(ULT,User Level Thread)
- 用户级线程 是由应用程序通过线程库来实现的,操作系统内核并不直接感知到这些 线程 的存在。
- 线程的创建、调度和管理都由应用程序在 用户空间 完成。
- 内核级线程
(KLT,Kernel Level Thread)
- 内核级线程 是由操作系统内核直接支持的线程。
- 线程的创建、调度和管理都由 操作系统内核 完成。
线程模型
操作系统中的 线程实现方式 称为 线程模型,不同的 线程模型 在 用户级线程(ULT)和 内核级线程(KLT)的设计上各有不同。
系统中的 线程模型 可以分为如下三种:
- 纯用户态:所有的线程操作都在用户空间中进行,内核对线程的存在一无所知。
- 纯系统态:线程由操作系统内核直接支持和管理,内核负责线程的创建、调度和管理。
- 混合方案:应用程序可以在用户空间管理多个用户级线程,这些线程映射到较少数目的内核级线程。
混合方案中的映射关系
如果使用 混合方案 的 线程模型 的话,用户级线程 和 内核级线程 的映射方式也可以分为如下几种:
- 一对一:每一个用户级线程都对应一个内核级线程。
- 多对一:多个用户级线程映射到同一个内核级线程上。
- 多对多:多个用户级线程映射到多个内核级线程上。