RISC-V:跟着清华训练营从零打造OS【第五课】

上一节回顾

RISC-V:跟着清华训练营从零打造OS【第四课】中,我们主要讲解了文件系统在操作系统中的重要性,并讲述了UNIX操作系统的创始人 Ken Thompson 在编写一个测试程序时,意识到可以将其扩展成一个文件系统,从而创造了UNIX操作系统的故事。还介绍了EasyFS文件系统的设计和实现,包括常规文件和目录文件的新增,以及文件系统的核心数据结构和块缓存管理器的实现。最后,还介绍了EasyFS的五个不同层次的设计和相关接口代码定义,本节课是第二阶段的最后一节课,主要讲解进程间通信、I/O重定向和并发的设计。

进程间通信与I/O重定向

截至目前,进程在输入和输出方面仍存在不少限制。尤其是进程能够进行交互的 I/O 资源相当有限,仅能接收用户在键盘上的输入,并将字符输出到屏幕上。我们通常将这些 I/O 通道分别称为标准输入和标准输出。更令应用开发者感到受限的是,进程被操作系统完全隔离。由于进程间无法方便地“沟通”,它们很难共同协作完成“大事”。

如果能实现不同进程之间的数据共享与交互,就可以将各种程序的功能组合在一起,形成更强大且灵活的功能。为了让简单的应用程序能够通过组合形成各种强大和复杂的功能,本章将介绍操作系统的核心目标:实现不同应用通过进程间通信的方式组合在一起运行。

首先,我们建立一个基于文件的统一 I/O 抽象,将标准输入/标准输出的访问转换为文件描述符。接着,我们基于文件描述符实现了一种父子进程之间的通信机制 —— 管道,从而实现灵活的进程间通信。通过文件抽象和管道,我们支持不同的独立进程之间动态组合,以实现复杂功能。

此外,通过实现信号机制,进程和操作系统可以主动发出信号,异步地通知其他进程相关事件。这样,我们就构建了一个具有团队协作能力的白垩纪 “迅猛龙” 操作系统。在这个操作系统中,进程间可以高效地协作,实现更强大的功能,犹如迅猛龙一般迅猛且灵活。

IPCOS

从上图可以看出,迅猛龙操作系统 —— IPCOS 增加了两种通信机制:

  1. 用于交换数据的管道(Pipe)机制
  2. 用于发送异步通知事件的信号(signal)机制。

应用程序可以通过新增的管道和信号相关的系统调用实现进程间通信。这两种机制所对应的资源均由进程管理。如下图所示:

PCB-PIPE

这里将管道视为一种特殊的内存文件,并在进程的打开文件表 fd_table 中进行管理。进程可以通过文件读写系统调用方便地实现基于管道的中间数据交换。而信号作为一种进程管理的资源,发送信号的进程可以通过系统调用向接收信号的目标进程的控制块中的 signal 结构更新所发信号信息。

操作系统通过扩展 trap_handler 中从内核态返回到用户态的处理流程,改变了接收信号的目标进程的执行上下文。这样,接收信号的目标进程可以优先执行处理信号事件的预设函数 signal_handler。在处理完信号后,进程将继续执行之前暂停的工作。这种设计使得进程能够更加高效地响应和处理各种信号事件,进一步提高系统的稳定性和可靠性。

实现迅猛龙操作系统的过程主要涉及对各种内核数据结构和相关操作的不断扩展,以支持以下功能:

  1. 标准输入/输出文件:确保操作系统支持标准输入(如键盘)和标准输出(如屏幕)文件,以便进程能够读取和输出数据。
  2. 管道文件:实现管道文件,支持进程间数据传输和通信。这是一种特殊的文件,允许一个进程将输出传递给另一个进程作为输入。
  3. 应用程序命令行参数解析和传递:完善操作系统功能,使应用程序能够方便地解析和传递命令行参数,以便更好地控制程序行为。
  4. 标准 I/O 重定向功能:支持将进程的输入和输出重定向到其他文件或设备,以便在程序运行过程中灵活地调整数据传输路径。
  5. 信号支持:实现信号机制,使进程和操作系统能够主动发出信号,异步地通知其他进程相关事件。这有助于提高系统的响应速度和稳定性。

最后,一起看看信号的处理流程:

sigaction

信号主要有两种来源:

  1. 异步信号:在进程正常执行过程中,内核或其他进程向其发送信号。这些信号称为异步信号,是信号的第一种来源。
  2. 同步信号:进程自身执行触发信号。当处理 Trap 时,内核将相应信号直接附加到进程控制块中。这种信号称为同步信号。

内核在处理 Trap 并即将返回用户态时,会检查进程是否有待处理的信号。如果有,根据进程是否提供相应信号的处理函数,存在以下两种处理方法:

  1. 如果进程通过 sigaction 系统调用提供了相应信号的处理函数,内核会将 Trap 进入时留下的 Trap 上下文保存在其他地方,并执行进程提供的处理函数。处理函数编写者需要在函数末尾手动调用 sigreturn 系统调用,以表示处理结束并请求恢复进程原来的执行。内核将处理该系统调用并恢复之前保存的 Trap 上下文。当进程再次回到用户态时,它会继续处理信号之前的执行。
  2. 如果进程未提供处理函数,内核将采用默认方式处理信号。处理完毕后,进程将返回用户态并继续执行原始任务。

以上两种情况保证了进程在接收到信号时能够采取适当的措施,同时确保了系统资源的合理利用和进程执行的连续性。

并发

为了使 CPU 保持高效运转并充分利用各种资源,操作系统需要多种不同的应用程序同时执行。这些应用程序分时执行,并由操作系统在运行时完成任务切换。虽然并发性有助于提高系统资源利用率,但也引发了共享资源的争抢问题,即同步互斥问题。此外,并发性还会导致执行时间的不确定性,即并发程序在执行过程中走走停停,断续推进,使得应用程序的完成时间无法确定。并发性对操作系统设计提出了诸多挑战,一旦处理不当,可能导致程序执行结果不确定、程序死锁等难以调试和重现的问题。

  • 并行(Parallel)是指两个或多个事件在同一时刻发生;
  • 并发(Concurrent)是指两个或多个事件在同一时间间隔内发生。

对于基于单 CPU 的计算机而言,所谓“同时”运行的程序实际上是通过串行分时复用一个 CPU 实现的。在任何时刻,只有一个程序在 CPU 上运行。这种虚拟性为应用程序的开发和执行提供了极为便利的环境,但同时也给操作系统的设计和实现带来了很多挑战。

贝尔实验室 Victor A. Vyssotsky 提出线程(thread)概念

1964年开始设计的 Multics 操作系统已经有进程的概念,也有多处理器并行处理的 GE 645 硬件设计,甚至提出了线程( thread )的概念。1966年,参与 Multics 开发的 MIT 博士生 Jerome Howard Saltzer 在其博士毕业论文的一个注脚提到贝尔实验室的 Victor A. Vyssotsky 用 thread 这个名称来表示处理器(processor)执行程序(program)代码序列这个过程的抽象概念,Saltzer 进一步把”进程(process)”描述为处理器执行程序代码的当前状态(即线程)和可访问的地址空间。但他们并没有建立类似信号量这样的有效机制来避免并发带来的同步互斥问题。

引入线程(Thread)的主要原因是为了提高整个系统的并行/并发执行效率。考虑以下情况:许多应用程序(以单一进程的形式运行)在逻辑上由多个可并行执行的任务组成。如果其中一个任务受到阻塞,整个进程也将被阻塞。这意味着与阻塞任务无关的其他任务也会受到影响,尽管它们实际上本不应受到干扰。这种情况降低了系统的并发执行效率。

为了解决这个问题,引入了线程这一概念。线程是进程内部的一个轻量级执行单元,它们可以独立地执行任务并进行任务切换。当一个任务被阻塞时,线程可以根据需要切换到其他可执行的任务,从而避免整个进程受到阻塞的影响。这样,系统的并发执行效率得到提高,资源利用率也得到优化。同时,线程还可以充分利用多核 CPU 的性能,进一步提高了整个系统的执行效率。

线程与进程的区别:

  • 进程之间相互独立(即资源隔离),而同一进程内的各线程共享进程资源(即资源共享)。
  • 子进程与父进程具有不同的地址空间和资源,多个线程(无父子关系)共享同一所属进程的地址空间和资源。
  • 每个线程具有自己的执行上下文(包括线程 ID、程序计数器、寄存器集合和执行栈),进程的执行上下文涵盖其管理的所有线程的执行上下文和地址空间。因此,同一进程内的线程间上下文切换速度较快。
  • 线程是一个可调度、分派和执行的实体,具有就绪、阻塞和运行三种基本执行状态。进程并非可调度、分派和执行的实体,而是线程的资源容器。
  • 进程间通信需要通过 IPC 机制(如管道等),而同一进程的线程间可以共享“即直接读写”进程的数据。但需要同步互斥机制的辅助,以避免数据不一致性和不确定计算结果的问题。

总之,线程和进程在资源管理、通信机制和执行上下文方面存在显著差异。这些差异使得线程在多任务处理、资源利用和通信效率方面具有优势,从而提高了整个系统的性能。

并发相关术语:

  • 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。
  • 临界区(critical section):访问共享资源的一段代码。
  • 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。
  • 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,即执行结果不确定,而开发者期望得到的是确定的结果。
  • 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域,具有原子性的一系列操作称为事务(transaction)。
  • 互斥(mutual exclusion):一种原子性操作,能保证同一时间只有一个线程进入临界区,从而避免出现竞态条件,并产生确定的预期执行结果。
  • 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
  • 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程(包括他自身)才能引发的事件,这种情况就是死锁。
  • 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。

ThreadCoroutineOS

从上图中可以看到,Thread&Coroutine OS 引入了用户态管理的用户态线程/用户态协程以及内核态管理的用户态线程。针对用户态管理的用户态线程和协程,新增了一个运行在用户态的 Thread/Coroutine Manager 运行时库(Runtime Lib),无需修改操作系统内核。

对于内核态管理的用户态线程,需要新增线程控制块(Thread Control Block, TCB)结构,将之前进程控制块(Process Control Block, PCB)中与执行相关的内容剥离给线程控制块。同时,对进程控制块进行进一步重构,将线程控制块列表作为进程控制块中的一部分资源,使一个进程控制块能够管理多个线程。最后,还提供了与线程相关的系统调用,如创建线程、等待线程结束等,以支持多线程应用的执行。

通过这些改进,Thread&Coroutine OS 实现了对用户态线程和协程的有效管理,提高了系统的并发性能和资源利用率。同时,内核态线程管理的改进使得多线程应用能够更加高效地运行,降低了操作系统复杂性,并为开发者提供了更加便捷的多线程编程接口。

在这里可以将进程、线程和协程中的控制流执行视为任务(Task)的执行过程,如下图所示:

TaskProcess

在上图所示的层次结构中,进程包含线程(即有栈协程),线程包含无栈协程。它们的层次包含关系体现了任务在不同抽象层次上的组织和管理。

任务切换是进程、线程和协程执行过程中的关键环节,涉及到控制流的切换。任务切换的核心是上下文保存与恢复,而任务上下文的核心部分是各个任务分时共享的硬件寄存器内容。
对于无栈协程,只需切换这些寄存器;对于拥有独立栈的线程,还需要切换线程栈;而对于拥有独立地址空间的进程,还需进一步切换地址空间(即切换页表)。

这种层次化的任务切换策略使得操作系统能够高效地管理不同层次的任务,实现任务之间的协同执行。理解这些层次关系和切换机制有助于我们更好地利用多核处理器资源,提高系统的并发性能和资源利用率。同时,为开发者提供了更加便捷的多线程、多进程和协程编程接口,降低了编写高性能并发程序的难度。

进一步增加了同步互斥机制的慈母龙操作系统 – SyncMutexOS的总体结构如下图所示:

SyncMutexOS

在上图所示的进程控制块中,增加了互斥锁(Mutex)、信号量(Semaphore)和条件变量(Condvar)这三种资源,并提供了与这些同步互斥资源相关的系统调用。这使得多线程应用能够利用这些同步互斥机制来解决各种同步互斥问题,例如生产者消费者问题、哲学家问题、读者写者问题等。

互斥锁、信号量和条件变量是多线程编程中常见的同步互斥工具,它们可以帮助线程在共享资源访问过程中避免竞争条件和死锁。

互斥锁用于保护共享资源,确保同一时间只有一个线程可以访问资源。当一个线程需要访问受保护的资源时,它必须先获得互斥锁,访问完成后释放互斥锁,以便其他线程访问该资源。

信号量用于实现线程之间的有序执行。它通常用于解决生产者消费者问题,其中一个线程负责生产商品,另一个线程负责消费商品。通过信号量,生产者线程可以在商品数量大于等于消费者线程所需数量时继续生产,而消费者线程在发现商品数量小于所需数量时开始消费。
条件变量用于在线程之间传递信号,实现异步通信。当一个线程等待某个条件成立时,它可以阻塞在条件变量上。当条件成立时,另一个线程通过信号或广播唤醒阻塞的线程,使其继续执行。

通过提供这些同步互斥机制,操作系统为多线程应用提供了强大的工具来解决各种同步互斥问题。这有助于确保多线程应用在共享资源访问方面的正确性和稳定性,同时提高了系统的并发性能。理解和使用这些同步互斥机制是多线程编程的关键,有助于避免潜在的并发问题,确保程序正确运行。

未完待续

以上就是关于 [清华开源操作系统训练营] 第五课学到的知识,同时也是第二阶段的最后一节课。接下来,我们将迈入富有挑战的第三阶段。第三阶段将持续一个月,涵盖众多课程,如 ArceOS、Rust for Linux、协程异步 OS 等。我已选修《Rust for Linux》这门课程,初衷是为了为未来的 Linux 设备驱动开发贡献力量。实际上,我对这些课程都充满兴趣,待后续开课时再报名继续学习。最后,希望这节课能对您有所帮助。祝大家玩得开心 ^_^

如果您喜欢这篇文章,欢迎关注微信公众号《猿禹宙》、点赞、转发和赞赏。每一位读者的认可都是我持续创作的动力。

公众号