XiaoboTalk

多线程一


原文链接:A gentle introduction to multithreading 副标题:一步一步,走进的并发的世界。
之前的看到的讲解多线程的英文系列博客,感觉写的很好,非常的通俗易懂,并且很详细。我已把相关的 4 篇文章都翻译成了中文,方便进行中文阅读。

相关系列译文


  1. 「译文」带你慢慢的了解多线程 (本篇)
  1. 「译文」线程同步
  1. 「译文」原子操作-无锁多线程编程
  1. 「译文」内存重排对无锁多线程的影响

译文正文部分


现代的计算机可以在同一时间执行多个任务。随着硬件的进步和操作系统的智能化,多任务能够让你的程序越来越快,不管是响应速度还是执行速度。
利用这些强大的多任务特性写软件,确实令人着迷,但令人棘手的是:这需要你了解计算机的硬件处理引擎是如何工作的。这一部分,我将尝试介绍一些“线程”的基本概念,线程是操作系统提供的一种工具。让我们看看它究竟是什么!

进程和线程: 用正确的方式命名


现代操作系统可以同时运行多个程序。这也是为什么,你可以一边用浏览器看文章,一边听着音乐。像浏览器、音乐播放器这些程序,就可以被称作为正在执行的“进程”。操作系统既可以利用硬件,也可以使用软件技巧来实现一个进程与其他进程一起运行的效果,可能这些个进程实际上并不是真的同时运行着,但最终给用户的感觉是它们总在同时运行。
操作系统内部运行多个进程的时候,不仅仅是同时处理这几个进程这么简单。事实上,每个进程都可以在其内部同时执行多个子任务,进程内执行每条子任务的工具被我们称为“线程”。每个进程启动的时候至少会激活了一个线程,通常这个线程,被称为“主线程”。然后,程序执行的过程中,开发者根据程序的需要来开启或者销毁更多的线程。多线程通常指的就是在一个进程内正在运行着的多条线程
举例来说,就好像你的“媒体播放器”软件内部同时运行着多个线程:有绘制用户界面的线程(通常是主线程),还有负责播放音乐的线程,还有其他等等。
你可以简单的认为操作系统是一个大容器,包含了若干进程,每个进程又是多个线程的容器。本文将重点讨论线程,但进程和操作系统同样是有趣的主题,值得以后更加深入的分析。
notion image

进程和线程的区别


每个进程都有它自己的一块内存空间(操作系统分配给它的),默认情况下,每个进程的内存空间是独享的,不能与其他进程共用它们的内存空间:好比你的浏览器软件无法访问音乐播放器的内存空间,反过来也是一样。甚至在同一个软件内部也是这样,例如你的浏览器同时打开了多个窗口,操作系统会给每个窗口分配一个进程,这些进程都有它们各自独享的内存空间。所以,一般情况,两个或两个以上的进程之间没有办法共享各自内存中的数据,除非使用一些高级特性–也就是所谓的 “IPC技术”(进程间通信 inter-process communication)。
与进程不同,多条线程则共享着同一块内存空间(也就是这些线程所处的父进程的内存空间):例如音乐播放主界面(主线程上渲染)的数据可以轻松的被音频处理引擎(一般在子线程运行)访问到。因此,两个线程通信是很容易做到的事情。由此可以看出,线程(threads) 通常比 进程(process) 更加轻量化:线程占用更少的资源,可以快速创建和销毁,所以线程也被叫做轻量级进程(lightweight processes)。
线程是让程序执行多任务的趁手工具,如果没有线程,一个程序一次只能执行一个任务,即一个进程中运行一个程序,要想同步多个进程的最终结果,则需要通过操作系统来处理。也就是上文提到的 IPC 技术,但使用 IPC 是一个棘手的事情,此外由于进程比线程更加繁重,直接操作进程来实现多任务,会让程序运行变慢。

仿生多线程 (原文:Green threads, of fibers) – 没有合适的中文,可自行翻译理解


本文到目前所提到的线程都是操作系统的事情:一个进程要想在其内部启动一个新线程,需要通过请求操作系统来完成。但不是所有的平台都天生支持多线程,所以有了 Green threads 技术 – 一种在不支持多线程的环境中模拟出多线程任务。比如一些虚拟机就实现了 Green threads 技术,防止万一虚拟机所在的底层操作系统不支持多线程(跨平台是虚拟机的一大特性)。
由于 Green threads 绕开了操作系统而创建线程,所以这些线程能够被更快的创建和管理,但也有明细的缺点,具体会在下一部分做详细的分析。
" Green threads " 一词最早来自 Sun Microsystem 的 Green Team 团队,该团队在 90 年代设计了最早期的 JAVA 线程库。现在的 JAVA 语言已经不在使用 Green threads 技术(从 2000 年开始,切换到本地线程),还有一些其他语言,比如 Go, Haskell, Rust 也实现了与 “Green threads” 一样效果的线程技术。

线程的用途


为什么一个进程内需要开启多线程? 前文已经提到,并行(parallel)的处理任务会更加高效。例如使用剪辑软件对一部影片做后期编辑,优秀的编辑软件会把一整部视频的渲染工作分配到多个线程中去,所以每个线程只负责渲染整部影片的一部分内容;假设用一个线程渲染一整部影片需要一个小时,那么同时用两个线程渲染,则只需 30 分钟。用 4 个线程,15 分钟即可完成,以此类推。
但真的如此简单? 这里有 3 个关键因素需要考虑:
1、不是每个应用程序都需要多线程,如果 app 执行的都是顺序任务,或者频繁的需要用户交互,使用多线程反而不会带来好处。
2、不能简单粗暴的在 app 中开启多线程:每个子任务都需要仔细的划分和设计,以确保这些子任务有条件并行执行。
3、不能完全保证多个线程真的在同时运行,是否真的同时运行依赖底层硬件。
第 3 条是关键的一条:如果你的电脑硬件本身不支持多任务同时运行,此时操作系统就会伪造出多任务同时执行的假象。现在我们可以这样理解并发(concurrency)并行(parallelism) 的,并发就是感觉上多任务在同时运行,而真正的并行则是多任务真的在同时运行。
notion image
配图说明:并行是并发的子集。

让并发和并行成为可能的因素


在你的电脑里,CPU负责着运行程序的繁重工作。CPU 的组成有很多部分,最主要的就是所谓的主核心(主运算器): 程序运算真正被执行的地方。[i]一个核心在同一时间只能执行一个操作(译者附加:原子操作“atomic”-即不能再被分割的最小操作单元)。
CPU 的这一设计(上段的 [i] 处) 是一个重大缺陷,由于这个原因操作系统开发了更为先进的技术,让用户在其电脑上有能力同时运行多个进程(或者线程),特别是在图形化的软件环境中,即使电脑是单核心的机器。实现这一机制的关键原因之一是“抢占式多任务”,“抢占(preemptive)”是一种能够能打断当前正在执行的任务,切换到另一个任务去执行,后边再恢复执行第一个被打断的任务的能力。
所以在单核 CPU 上,操作系统的一部分工作就是把单核的运行能力分散到多个进程和线程上,实际上还是一个接一个的循环执行。但由于切换非常快速,会给用户造成有多个程序或者多个线程在同时运行的错觉。这就是并发,但是真正的并行(真的在同时运行多个进程或者线程) 在单核 CPU 上是缺失的。
但现代的 CPU 都是多核心的,多个任务在同一时间的执行都可以是独立的。这意味着,在集成了 2 个或者 2 个以上的 CPU 上,真正的并行( true parallelism )是可能的。比如,我的电脑上的 Intel Core i7 是 4 核 CPU:这让我的电脑可以在同一时刻,真正的并行 4 个不同的进程或者线程。
操作系统可以检测到 CPU 核心的数量,并且给每个核心分配不同的进程或者线程。一个线程可能被操作系统分配到任意的 CPU 核心上,这种排程对正在运行的程序完全透明。操作系统的“抢占式任务”机制在 CPU 所有的核心都处于忙碌状态时依然生效。这让计算机有了“并发执行的任务数量远大于 CPU 的核心数量”的能力。

多线程的 app 运行在单核 CPU 上,合理吗?


综上,我们知道真正的并行在单核 CPU 上是不可能完成的。不过只要应用程序能从中受益,就依然有必要使用多线程编程。在单核 CPU 上,假设一个进程内启动了多个线程,虽然多个线程不能真的并行,但当其中某个线程被阻塞(例如等待用户输入)或者执行低效的时候,“抢占式多任务”机制会让比较空闲的 CPU 去执行其他线程的任务,从而让整体的程序执行更加高效。
比如,你在使用某个电脑的桌面程序从一个传输速度很慢的硬盘里读取数据。如果这个程序只有一个线程,那么整个 app 运行都将暂停直到数据从磁盘读取完成:这段时间 CPU 的运算能力将被浪费。当然操作系统还有其他 app 在运行,但当前这个和用户交互的 app 将没有任何执行的进展。
让我们重新在多线程环境下,考虑上述场景。线程 A 负责从磁盘读取数据,与此同时线程 B 负责 UI 界面。即使线程 A 被读取速度慢的磁盘等待卡住,线程 B 依然能完成 UI 界面的交互。操作系统会调度 CPU 在线程 A 和线程 B 之间来回切换执行,而不被线程 A 卡住。

更多的线程,面临更多的问题


我们都知道,多个线程共享着当前进程的同一块内存空间。这极大的方便了线程间的数据交换。比如视频编辑软件,可能需要共享一大块内存空间,让多个线程都能读取视频源文件,同时写出渲染后的数据。
如果只发生多个线程从同一个内存地址“读取”数据,事情就可以平滑的进行。但麻烦的事情发生在,当有一个线程在往共享区域写入时,与此同时还有其他线程从这里读数据。这种情况将有两个问题发生:
1、数据竞争:当写入线程修改了共享内存中的数据,而此时读取线程可能正在从中读取数据。如果写入线程还没有执行完数据写入,那么读取线程读到的就是“脏数据”。
2、竞争条件:读线程应该只在写入线程完成后才读取。如果正好相反呢?与数据争用相比,争用条件更微妙,它是指两个或多个线程以不可预知的顺序执行其工作,而事实上,操作应该以正确的顺序执行。对此,程序可以实现一个竞争条件触发器,即使被竞争的数据已经被保护。

线程安全的概念


一段代码被称为“线程安全”是指:在多线程同时执行的环境下,这段代码能够符合预期的被正确执行,没有数据竞争竞争条件(data races or race conditions)发生。你可能已经注意到一些代码程序库会申明它们是线程安全的:这意味着你在多线程中可以任意使用这些三方函数的调用,而不需要考虑并发问题。

数据竞争的根本原因


我们都知道一个 CPU 核心同一时间只能执行一条机器指令,因为一条机器指令不能再被分割为更小的操作单元,所以这条的指令被称为原子(atomic)操作。在希腊语中:“atom” 一词表示“不可切割的东西”。
由于“原子操作”的不可切割性,所以它自然是线程安全的。试想,如果一个线程往共享区域写入数据具备“原子性”,这就意味着其他的线程不能从半路打断它。反过来讲,一个读取操作如果是原子性的,那么它将不被打断的读取到整个数据。如此也就不存在数据竞争(data race)。
但坏消息是大多数的操作(计算机程序)都是“非原子性的”。即使是一条简单的赋值语句:
x = 1
在一些硬件设备也会被分割为多条原子操作,从而让整个赋值操作变成“非原子性的”。所以当一个线程读取x 值,而另一个线程在给它赋值的时候,数据竞争就会发生。

竞争条件发生的根本原因


“抢占式多任务”让操作系统具备了完全管控线程的能力:线程启动、暂停、终止。所以开发者不能精确控制代码的执行时机。事实上,像下边这样简单的代码也无法确保它们的执行时机:
writer_thread.start() reader_thread.start()
这段代码,开启了两个线程。但当你多次运行程序就会注意到,每次运行的结果可能都不一样,有时候 writer 线程先被开启,有时候则 reader 线程先被开启。这就是“竞争条件(race condition)”问题。
这种现象被称为 “(不可预测执行)non-deterministic”:每次的执行结果都有可能发生改变,开发者无法预测。调试这样的程序是非常令人厌烦的,因为问题的复现完全不可控。

让多线程和平共处:并发控制


数据竞争和竞争条件总是会发生,容纳两个或多个并发线程的技术称为并发控制:为此操作系统和编程语言提供了一些解决方案:
1、同步: 确保资源同一时间只被一个线程使用。同步就是保护一部分代码不同时被 2个或者 2个以上的线程同时执行,从而搞砸了共享数据。
2、原子操作:操作系统提供了一下特殊的指令,可以让非原子操作(例如赋值操作)变成原子操作。
3、不可变数据:一旦一个数据被标识为不可变数据,任何线程都不能改写这个数据,只能读取数据。从根本上消除了多线程数据安全问题。我们知道,多线程可以任意的读取同一块内存中的数据,只要不修改它。这也是函数式编程背后的主要哲学。
I will cover all this fascinating topics in the next episodes of this mini-series about concurrency. Stay tuned!

该系列的其他文章英文原文: