操作系统(六)操作系统的进程管理和线程管理

课堂笔记转换

🔄 并发和并行

  • 并发:在一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。
    • 单核处理器做到的并发,其实是利用时间片的轮转,例如有两个进程 A 和 B,A 运行一个时间片之后,切换到 B,B 运行一个时间片之后又切换到 A。因为切换速度足够快,所以宏观上表现为在一段时间内能同时运行多个程序。
  • 并行:在同一时刻,有多个任务在执行。这个需要多核处理器才能完成,在微观上就能同时执行多条指令,不同的程序被放到不同的处理器上运行,这个是物理上的多个进程同时进行。
并发与并行示意图

进程之间是独立和隔离的,一个进程崩溃不会导致所有进程崩溃

  • 进程隔离性:每个进程都有自己独立的内存空间,当一个进程崩溃时,其内存空间会被操作系统回收,不会影响其他进程的内存空间。这种进程间的隔离性保证了一个进程崩溃不会直接影响其他进程的执行。
  • 进程独立性:每个进程都是独立运行的,它们之间不会共享资源,如文件、网络连接等。因此,一个进程的崩溃通常不会对其他进程的资源产生影响。

🧠 进程

在系统中正在运行的一个应用程序;程序一旦运行会被载入内存。进程是资源分配的最小单位。在操作系统中能同时运行多个进程;

进程可以分成以下两类

  • 用户态进程 :通常是应用程序的副本
  • 内核态进程 :内核本身的进程。

如果用户态进程需要申请资源,比如内存,可以通过系统调用向内核申请。每个进程都有独立的内存空间,存放代码和数据段等,程序之间的切换会有较大的开销

💾 进程占用的资源

每个进程都有自己独立的内存空间(代码段、数据段、堆栈等),可以看作是一个正在运行的程序实例。进程之间是相互独立的。

  • 内存 :进程需要内存来存储其代码、数据和执行时的状态信息。负责为进程分配和管理内存。
  • 文件描述符 :进程可能需要访问文件系统中的文件来读取或写入数据。
  • 输入/输出设备 :进程可能需要与外部设备进行交互,如键盘、鼠标、显示器、打印机等。
  • CPU:进程被分配CPU时间片,允许它在CPU上执行指令。

其中部分资源是共享的,而其他资源是独立的。

  • 不共享的资源
    • 虚拟地址空间:每个进程有独立的内存地址空间,互不可见
    • 代码段、数据段、堆、栈:各自维护自己的函数执行栈和内存分配区。
    • 进程控制块:每个进程都有独立的 PCB,包含 PID、状态、优先级、调度信息等。
  • 共享的资源
    • 程序代码:如果多个进程运行同一个程序,内核会优化共享只读代码段 • 打开的文件:父子进程可以共享打开的文件(如 fork 后)。

📊 进程的状态

📌 进程状态

  • 创建状态(new):进程正在被创建时的状态

  • 就绪状态(Ready):进程处于可运行,进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。由于其他进程处于运行状态而暂时停止运行

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

  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行

  • 结束状态(Exit):进程正在从系统中消失时的状态

    进程状态变迁图

⏸️ 进程挂起

如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,出现浪费物理内存的行为。所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。

挂起状态用来描述 进程没有占用实际的物理内存空间的情况

🧟 僵尸进程

僵尸进程是已完成且处于终止状态,但在进程表中却仍然存在的进程。(进程已经终止,但其父进程未对其进行回收)

僵尸进程占用系统的进程表项,但不再消耗其他资源。操作系统会等待其父进程来获取它的终止状态信息,清除僵尸进程。

僵尸进程一般发生有父子关系的进程中,一个子进程的进程描述符在子进程退出时不会释放,只有当父进程通过 wait()waitpid() 获取了子进程信息后才会释放。如果子进程退出,而父进程并没有调用 wait()waitpid() ,那么子进程的进程描述符仍然保存在系统中。

👶 孤儿进程

父进程提前终止,子进程继续运行,这些子进程就成为孤儿进程。操作系统会将孤儿进程托管给 init 进程(Linux系统中的PID为1的进程),由 init 进程来收养并清理这些孤儿进程。

🗂️ 进程控制块PCB

PCB 是进程存在的唯一标识 。一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

🏗️ PCB的结构

🔗 PCB之间的组织

通常是通过 链表 的方式进行组织。相同状态的进程会被链接在一起,组成各种队列

⚙️ 进程的控制

➕ 创建进程

操作系统提供了 fork 指令,允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。

fork 进程示意图

fork 函数用于创建一个与当前进程一样的子进程,所创建的子进程将复制父进程的代码段、数据段、堆、栈等所有用户空间信息,在内核中操作系统会重新为其申请一个子进程执行的位置。但是会有自己的进程空间

fork 函数被调用一次但返回两次,两次返回的唯一区别是子进程中返回0而父进程中返回子进程ID。

提示

fork函数为什么被调用一次但是返回两次

因为复制时会复制父进程的堆栈段,所以两个进程都停留在 fork 函数中等待返回,因此会返回两次,一个是在父进程中返回,一次是在子进程中返回,两次返回值是不一样的。

fork 的具体流程

➖ 终止进程

进程有以下三种结束方式

当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。

终止线程的过程

⏸️ 阻塞线程

当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。

阻塞进程的过程如下:

▶️ 唤醒进程

进程只能被另一个进程唤醒,唤醒进程的过程如下

🔀 进程的上下文切换

💭 上下文切换的概念

上下文切换是一种将CPU资源从一个进程分配给另一个进程的机制。

操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。CPU 寄存器和程序计数是 CPU 在运行任何任务前所必须依赖的环境,这就是CPU上下文

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

📂 进程上下文的内容

进程上下文包括以下内容:

🔬 进程上下文切换过程

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行

进程上下文切换示意图

🎬 进程上下文切换的场景

🧪 fork 进程创建

fork命令执行后,系统会创建一个与父进程几乎完全相同的子进程,子进程拥有独立的进程ID,但其代码段、数据段、堆栈等内存空间初始时与父进程相同。父子进程从fork之后的代码位置开始分别独立执行。

fork系统调用时,子进程会复制父进程的进程上下文,包括内存、文件描述符、信号处理函数等。有些是共享、有些是独立拷贝,还有些是不会拷贝的。

子进程会复制父进程的整个虚拟地址空间(代码段、堆、栈、全局变量、文件描述符等)。但并不会立即复制物理内存,而是采用 写时复制(Copy-On-Write, COW) 机制。 父进程在 fork() 时,操作系统会:

当父或子进程对某块内存进行写操作时:

📡 进程间通信

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走。

🧪 管道

本质上管道是内核里面的一串缓存

对于匿名管道来说,匿名管道没有实体,不存在管道文件,只能通过父进程和子进程的文件描述符来进行通信。当一个进程创建匿名管道时,操作系统会分配两个文件描述符,一个用于读取,一个用于写入。这两个描述符指向同一个内核缓冲区。内核为该管道分配一个 page-sized 的缓冲区(通常是 4KB 或多个页),采用环形队列结构管理。数据写入写端时,由 write() 系统调用将用户空间数据拷贝到内核缓冲区;读取时,read() 从缓冲区取出数据并拷贝至用户空间。当数据写入到写端时,数据被存储在这个缓冲区中,读端可以从缓冲区中读取数据。

对于命名管道来说,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

命名管道示意图

💾 共享内存

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中 。这样这个进程写入的东西对于另外一个进程是可见的,不需要经过数据的拷贝和传输,大大提高了进程间通信的速度。

共享内存示意图

进程和线程在共享内存通信方式上的主要区别在于默认的内存访问权限和隔离机制。

共享内存是由操作系统内核管理的资源,它的生命周期独立于创建它的进程。如果一个进程死了,它不会自动释放共享内存,共享内存段会继续存在,直到显式地被删除或系统重启

📬 消息队列

消息队列的通信模式适用于频繁地进行信息传输。消息队列允许一个进程向另一个进程发送消息,消息在队列中按顺序存储,并且接收方可以按需接收。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

消息队列是保存在内核中的消息链表 ,消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在

消息队列具有以下缺点

📢 信号

信号是一种 异步 的通信方式,用于通知目标进程发生了某个事件。信号常用于进程之间发送中断或终止命令。信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件

🌐 套接字

套接字提供网络通信的端点,可以让不同机器上运行的进程之间进行双向通信。但是套接字实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信。

🚦 信号量

进程间通信处理同步互斥的机制。是在多线程环境下使用的一种设施,它负责协调各个线程,以保证它们能够正确,合理的使用公共资源。

对于信号量有两种操作

信号量有两种类型

📋 进程调度

⏰ 调度时机

以下状态的变化都会触发操作系统的调度

📊 调度算法

调度算法分为以下两类

对于单核CPU有以下常见的调度算法


🧩 线程

📖 线程的定义

线程(Thread) 可以被视为轻量级进程,是进程当中的一条执行流程,任务调度和执行的基本单位。线程的生命周期由进程控制,进程终止时,其所有线程也会终止。多个线程可以在同一个进程中同时执行,并且共享进程的资源比如内存空间、文件句柄、网络连接等。但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

💾 线程占用的资源

线程占用的资源包括以下几种

其中部分资源是共享的,而其他资源是独立的。

⚖️ 线程的优缺点和存在意义

线程具有以下优缺点

线程的存在意义

🆚 进程和线程的区别

💱 线程的上下文切换

线程上下文 = CPU 寄存器 + 栈 + 程序计数器等线程特有状态,描述整个进程状态的集合,包含 CPU 状态、内存映像、资源使用情况等。用于 进程切换 时恢复进程执行。 线程切换是指将 CPU 的控制权从一个线程转移到另一个线程的过程。与进程切换相比,线程切换的开销较小,因为同一进程内的线程共享相同的地址空间和资源。

提示

线程控制块 TCB

类似于进程控制块PCB,TCB是操作系统用来管理线程的一个数据结构,它包含了与线程执行相关的所有必要信息,确保线程能够在系统中被正确调度和执行。每个线程都有一个对应的 TCB,操作系统通过 TCB 来管理和调度线程的执行。

TCB的主要内容

🛠️ 线程的类型

👤 用户线程

用户线程是在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理。操作系统内核并不知道它的存在,它完全是在用户空间中创建。所以 用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。

用户线程的优缺点

⚙️ 内核线程

内核线程是由操作系统管理的,线程对应的 TCB 是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。

优缺点


🔒 线程的同步机制

竞态条件:指当多个线程同时访问和操作同一块数据时,最终结果依赖于线程的执行顺序,这可能导致数据的不一致性。

临界区:对共享资源访问的程序片段,我们希望这段代码是互斥的,可以保证在某个时刻只能被一个线程执行,也就是说一个线程在临界区执行时,其它线程应该被阻止进入临界区。

⚡ CAS 操作

CAS(Compare-And-Swap) 是一种 原子性操作(Atomic Operation),它的作用是:比较某个内存地址中的值是否为预期值,如果是,则将其更新为新值,否则不做任何操作。整个过程是不可分割的。

CAS 的原子性不是由操作系统保证的,而是由 CPU 指令集 直接提供的。

🔐 锁机制

🔒 悲观锁(互斥锁)

使⽤加锁操作和解锁操作可以解决并发线程/进程的互斥问题。

任何想进⼊临界区的线程,必须先执⾏加锁操作。若加锁操作顺利通过,则线程可进⼊临界区;在完成对临界资源的访问后再执⾏解锁操作,以释放该临界资源。

锁有以下两类实现

🔒 乐观锁

乐观锁是一种并发控制策略,它假设在大多数情况下多个线程不会同时修改同一个数据,因此在读取数据时不加锁,而是在更新数据时检查在此期间是否有其他线程修改过该数据。如果发现冲突,则采取补偿措施(如重试或抛出异常),而不是预先阻止并发访问。 通常有以下几种乐观锁的实现

🚨 死锁

⚠️ 死锁的发生条件

当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成 两个线程都在等待对方释放锁 ,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁

死锁的发生条件如下

✅ 避免死锁的方法


🧵 协程

📚 协程的定义

协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。 协程是为了解决“高并发场景下线程过重、上下文切换昂贵、编写异步代码困难”这三大痛点而提出的。

🎯 协程的优势

🧵 协程和线程的关系

协程本质上是轻量级的执行单元,通常运行在操作系统线程之上。一个操作系统线程可以包含多个协程。这些协程由应用程序或编程语言的运行时环境调度和管理,而操作系统则负责管理线程的调度和资源分配。一个操作系统线程可以运行多个协程。协程在某些情况下会挂起当前任务,操作系统线程则继续执行其他任务。当协程恢复执行时,它在同一个线程中继续执行,而操作系统的线程调度并不参与其中。

对比线程的,因为协程本质是“用户态线程”,没有 OS 开销。

🔧 操作系统如何控制协程

操作系统(OS)本身并不直接控制协程。协程的管理和调度通常是由程序的运行时环境(例如编程语言的虚拟机或库)来完成的,而不是操作系统内核。操作系统主要负责管理 进程 和 线程,而协程通常是在用户空间(用户代码中)进行调度的轻量级执行单元。不过,操作系统可以间接影响协程的执行,特别是在与其他线程和进程的调度、资源管理等方面的交互

📌 总结

进程、线程、协程这三者之间的区别和联系用白话总结一下。