5. 多处理器编程:从入门到放弃

背景回顾:“操作系统玩具” 给出了理解操作系统的新视角:操作系统是状态机的管理者。因为在 sys_sched() 之后操作系统拥有随机选择状态机执行的权力,因此也带来了并发性。操作系统是世界上最早的并发程序。

本讲内容:多线程编程模型、线程库,以及并发带来的巨大麻烦。

5.1 多处理器编程入门

在我们的 thread.h 背后是 POSIX Threads 的 API 支持。相比比最小的线程库,我们可以使用 pthread 设置更大的线程栈、将线程从 main 函数中 “分离” 出去,不在 main 返回后被杀死。阅读手册可以查看它完整的功能。

5.2 放弃 (1):状态迁移原子性的假设

当我们谈 “放弃” 时,放弃的并非并发编程,而是要舍弃一些我们之前对单线程顺序程序的理解。这些理解在我们长期的编程中,多多少少成为了肌肉记忆,这也是并发编程打破这些理解带来麻烦的原因。我们努力试图理解并发程序和顺序程序的本质区别,从而更好地应对并发带来的编程挑战。

“世界上只有一个状态机” 的假设在共享内存并发模型下被打破了。进而,每一次内存 load 都可能会读到其他线程覆盖写入的值——这给并发编程带来了很大的麻烦。

5.3 放弃 (2):程序顺序执行的假设

即便我们能小心处理其他进程写入的数据,编译器是对并发无感知的——如果编译器假设每个变量的值都可能来自其他线程,那么可做的优化就会变得十分局限。这给理解并发程序行为带来了更大的挑战。

5.4 放弃 (3):存在全局指令执行顺序的假设 🌶️

即便我们能控制编译器生成的指令,处理器内部还隐藏了一个动态编译器——它和缓存共同作用,最终使并发程序的行为变得更难理解:并发程序执行的结果,甚至可以不是所有执行过指令的某个排列顺序运行的结果!

Take-away Messages

在简化多线程的模型中,并发程序就是 “状态机的集合”,每一步选一个状态机执行一步。然而,真实的系统却因为 “编译器” 的无处不在,使共享内存并发的行为十分复杂。

不幸的是,人类本质上是物理世界 (宏观时间) 中的 “sequential creature”,因此我们在编程时,我们的直觉也只习惯于单线程的顺序/选择/循环结构,真实多处理器上的并发编程是非常具有挑战性的 “底层技术”。在后续的课程中,我们将会提出若干并发控制技术,使得我们可以在需要的时候避免并发的发生,使并发程序退回到顺序程序,从而使我们能够理解和控制并发。

课后习题/编程作业

📚阅读材料

教科书 Operating Systems: Three Easy Pieces:

  • 第 25 章 - Dialogue on Concurrency
  • 第 26 章 - Concurrency and Threads
  • 第 27 章 - Thread API

注意:我们的课程和教科书有较大的重叠,但教科书提供了许多授课时间比较难以花时间讲清楚的细节,因此仔细阅读教科书同样重要。

🖥️编程

在你的 Linux 中运行课堂上的代码示例、复现课堂上奇奇怪怪的并发程序结果。我们鼓励同学们去理解 thread.h 的实现——我们在代码中提供了足够的注释。