进程和线程
进程和线程
在多道程序环境下,允许多个程序并发执行,为此操作系统引入了进程 (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)是指父进程在子进程终止前退出,导致子进程失去父进程的进程。孤儿进程与僵尸进程不同,它仍在运行,只是失去了原来的父进程。
线程
线程是 系统调度的基本单位。多核处理器可以将系统中的不同线程调度到不同的 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)的设计上各有不同。
系统中的线程模型可以分为如下三种:
- 纯用户态:所有的线程操作都在用户空间中进行,内核对线程的存在一无所知。
- 纯系统态:线程由操作系统内核直接支持和管理,内核负责线程的创建、调度和管理。
- 混合方案:应用程序可以在用户空间管理多个用户级线程,这些线程映射到较少数目的内核级线程。
混合方案中的映射关系
如果使用混合方案的线程模型的话,用户级线程和内核级线程的映射方式也可以分为如下几种:
- 一对一:每一个用户级线程都对应一个内核级线程。
- 多对一:多个用户级线程映射到同一个内核级线程上。
- 多对多:多个用户级线程映射到多个内核级线程上。