一步一步走进并发的世界

现代计算机可以同时进行多个操作。得益于强大的硬件以及智能的操作系统的支持,这个特性可以让你的程序在执行速度和响应速度两个方面都有更好的表现。

编写能够利用计算机这一特性的程序是非常吸引人的,但同时也很棘手:这需要你明白,计算机内部到底在发生什么。在这第一章中,我会试着去揭开线程(thread)的神秘面纱,它是由操作系统提供的一种能够让你施展这种魔法的工具。让我们开始吧!

进程和线程——别叫错名字啊亲

TL;DR

  • 进程是操作系统层级的东西,由操作系统创建和管理
  • 线程是进程的子任务,由所在进程创建和管理
  • 一个进程至少包含一个线程,这个线程被称为主线程

现代操作系统可以同时运行多个程序,因此你可以一边开着浏览器(一个程序)看这个文章,一边用QQ音乐(另一个程序)放着歌。每个程序被称为一个正在被执行的进程(process)。操作系统知道如何用软件上的技巧去让一个程序与其他程序同时运行,也知道如何去利用底层的硬件做到这一点。两种方式都产生了一个结果——你感觉你的程序们在同步地执行。

在操作系统中运行进程并不是唯一的同时执行多个操作的方法。每个进程都可以在它内部同时运行多个子任务,这些子任务被称为线程(process)。你可以把线程理解为进程本身的一部分。每个进程在启动时都会生成至少一个线程,这个线程被称为主线程(main thread)。在这之后根据程序/程序员的需要,可能会不断的有额外的线程被创建或销毁。多线程(multithreading)就是指在一个进程中运行多个线程。

举个例子吧,QQ音乐就可能在运行着多个线程:一个用于渲染界面——这个线程通常是主线程,还有其他的线程用于播放音乐等。

你可以把操作系统想像成一个包含多个进程的容器,同时每个进程又是一个包含多个线程的容器。在这篇文章中,我主要讲的只是线程,但整个操作系统、线程和进程的主题都是十分吸引人的,值得以后进行深入分析。
image.png
1.操作系统可以被看作是一个包含进程的容器,进程也可以看作一个包含线程的容器
_

进程和线程的区别

TL;DR

  • 进程之间的内存空间独立,除非使用IPC否则难以共享数据;而线程共享所在进程的内存空间,共享数据容易。
  • 线程比进程更加轻量级,创建也更快。

操作系统会为每个进程分配独立的内存空间。默认情况下,这些内存空间不能被进程之间共享:你的浏览器不能访问分配给QQ音乐的内存,反之亦然。即使你运行了同一个进程的两个实例(instance),比如把浏览器开遍,上面的规则也是成立的。操作系统会把每个实例看作一个新的进程,并为它分配独立的内存空间。所以,在默认情况下,多个进程之间是没法共享数据的,除非它们使用了更高级的技巧——我们称之为进程间通信(Inter-Process Communication, IPC)

与进程不同,同一个进程的线程之间共享分配给它们所在进程的内存空间:QQ音乐主界面中的数据可以被播放引擎轻松的拿到,反之亦然。所以,两个线程之间可以轻松的进行交流。除此之外,线程通常比进程更加轻量级:线程会消耗更少的资源,而且线程的创建比进程快得多。这也是为什么线程被人们称为
轻量级进程(lightweight process)**。

线程是一种让你的程序同时执行多个操作的好方法。如果没有线程,你就得为你的每一个任务写一个单独的程序,把它们分别作为进程跑起来,再去通过操作系统去同步它们。这是非常困难(因为IPC是很棘手的)而且非常低效的(进程比线程笨重的多)。

包含纤维的绿色线程😄

TL;DR

  • 绿色线程是一种在不支持线程的环境中模拟线程的手段
  • 纤维和绿色进程是一个东西
  • 绿色线程起源于Java,但现在Java不同了,其它一些语言在用类似的东西。

到目前为止,我们提到的线程都是操作系统的东西:一个进程如果想创建一个新线程,它必须和操作系统说明。但是,并不是所有操作系统都原生支持线程。绿色线程(green threads),也被称为纤维(fibers),是一种模拟手段,它可以让多线程程序在不支持线程的环境中运行。举个例子,虚拟机就经常会实现绿色线程以防止其依附的操作系统不提供对线程的原生支持。

绿色线程的创建和管理都非常的迅速,因为它们完全的绕过了操作系统;但也有很多的缺点——我会在后面的几章中讨论这些。

“绿色线程”这个名字代表着90年代Sun公司里设计最初的Java线程库的Green团队。当今的Java已经不再使用绿色线程,他们在2000年改用原生线程了。其他的一些编程语言——比如Go、Haskell或者Rust等——实现了与绿色线程类似的东西来代替原生线程。

啥玩意啊,线程是用来干啥的啊?

为啥一个进程要用多线程啊?我之前提到了,并行的处理东西能够加快速度。假设你要用PR渲染一个视频了,PR就很聪明,它能把这个渲染的工作分配给多个线程,大家一起做,每个线程处理这个视频的一部分。所以,如果一个线程要1小时完工,那么两个线程就只需要30分钟,四个线程就只需要15分钟,以此类推。(译者注:实际上并没有这么理想化)

事情真的那么简单吗(显然不是)?有三个需要考虑的点:

  1. 并不是所有的程序都需要多线程。如果你的程序只是顺序的执行一些操作,或者经常等待用户进行一些操作,多线程并不会给你带来很多好处。
  2. 你不能简单的直接往进程里面扔线程,因为每个线程都需要被仔细考虑和设计去完成并行的任务。
  3. 不能100%保证线程真的是在并行的处理任务,它们可能只是“同时”处理任务:这是由底层的操作系统决定的。(后面会解释)

最后一点十分重要:如果你的计算机不支持同时处理多个任务,操作系统必须去伪造这一特性。我们之后会看到这是如何实现的。现在我们可以把“并发”(concurrency)理解为那种同时处理多个任务的感觉,而真正的“并行”(parallelism)是指任务真真正正的在被并行执行。

image.png

并行和并发是咋实现的呢?

计算机中的中央处理器(CPU)是负责运行程序的。它是由多个部分组成,其中最主要的部分称为核心(core):这是真正进行计算的地方。一个核心在同一时间只能进行一个操作。

这显然是一个很大的不足。因此操作系统衍生出了更加先进的技术,可以让用户在单核心的计算机上运行多个进程(或者线程),尤其是在图形环境中(因为一旦单进程/线程阻塞,界面就卡死了)。这些技术中,最重要的一个被称为抢占式多任务处理(preemptive multitasking)抢占(preemption)是指打断一个任务,切换到另一个任务,然后在稍后再恢复到原来的第一个任务的一种能力。

所以,如果你的CPU只有一个核心,操作系统的一部分任务就是去把这一个核心的计算能力分配给各个进程/线程,这些进程/线程会一个接一个的循环执行。这一操作会给你一种多个程序在同时运行,或者同个程序在运行多个线程(在多线程编程时)的假象。此时是并发,但是并不是真正的并行。

如今,现代CPU在内部都有一个以上的核心,每一个核心都可以独立的处理一个任务。这意味着如果我们有两个或者两个以上的核心,真正的并行就可以实现了。举个例子,我的电脑中的英特尔酷睿i7处理器有4个核心:它可以真正的同时运行4个不同的进程或者线程。

操作系统可以监测到CPU的核心数,然后给它们每一个分配进程或者线程。一个线程可能会被分配到任何一个核心上,这取决于操作系统的调度算法,这个过程对于在运行的程序是完全透明的。此外,在所有核心都忙起来时,操作系统也会使用抢占式多任务处理的方法进一步分配处理资源。这样,你运行的进程和线程的数量就可以多于CPU的核心数了。

在单核心上跑多线程应用有意义吗?

在但核心机器上实现正真的并行是不可能的。尽管如此,如果多线程能够为你的应用程序带来好处,写多线程仍然是有意义的。当一个程序应用了多线程,抢占式多任务处理机制能够保证即使一个线程运行缓慢或阻塞时,程序也能继续运行。

举个例子吧,假设你在用一个桌面应用从一个非常慢速的硬盘里读取数据。如果你写的程序只有一个线程,那么你的整个程序都会呆住不动,直到硬盘操作完成:分配给这唯一一个线程的CPU算力会在等待磁盘IO的过程中被白白浪费。当然,除了这个程序,操作系统还运行着许多其它程序,但是这一个特定的程序不会有任何进展。

让我们用多线程的思维方式重新考虑一下你的程序。线程A负责硬盘操作,同时线程B负责操作主界面。如果线程A因为硬盘速度缓慢而卡住了,线程B仍然可以让主界面继续运行,让你的程序保持响应。这是因为,一旦你有了两个线程,操作系统可以在它们之间切换CPU资源,不会一只卡在比较慢的那一个上。

多线程带来的多问题

我们都已经知道了,线程会共享分配给它们父进程的内存空间。这使得同一进程下的线程之间的数据共享变的及其简单。举个例子,一个视频编辑器可能在共享的内存空间中取一大块用于存放视频的时间线。这种共享的内存空间在被多个负责渲染视频的工作线程同时读取。它们只需要一个获取那片内存的方式(如指针)来从中读取数据,并把渲染好的帧写到硬盘上。

只要多个线程仅仅是读**取**共享内存空间的数据,一切都会顺利的进行。但是,一旦有一个线程要往这块区域数据,那么麻烦就出现了。这时可能会有两个问题:

  • 数据竞争(data race):当一个线程在内存中写数据时,另一个线程从中读取数据。此时如果写数据的线程还没完成工作,那么读数据的线程就会拿到错乱的数据。
  • 竞态条件(race condition):一个读数据的线程应该在一个写数据的线程写完之后再去读取数据。如果顺序反了,将会怎么样?与数据竞争有微妙的不同,竞态条件是说当多个线程的对于共享内存空间的操作应当按某个特定顺序进行,但是它们的操作顺序确实不可预知的。即使你的程序采用某些技巧避免了数据竞争,但是还是可能会受竞态条件的影响。

线程安全的概念

如果一段代码可以保证在多线程环境下不会出现数据竞争和竞态条件,那么它被称为线程安全(thread-safe)的。你可能已经注意到很多第三方库声称他们是线程安全的:如果你在写多线程的程序,你会希望所有第三方库的函数可以在多个线程中使用,而不会产生同步问题。

数据竞争的根本原因

我们知道,一个CPU核心一次只能执行一条机器指令。这种指令被称为“原子的(atomic)”,因为它不可再分:它不能再被分解成更小的操作。希腊单词“atom”(ἄτομος; atomos)是“不可分割的”的意思。

这种不可分的特性,使得原子操作本质上就是线程安全的。当一个线程在一块共享内存空间进行原子的写操作时,任何其它的线程都不能在写到一半时进行读取。相反的,如果一个线程在一块共享内存空间进行原子的读操作时,它就好像一瞬间完成读取一样。没有线程能在原子操作上蒙混过关,因此不可能出现数据竞争。

坏消息是,大多数操作都不是原子操作。即使一个简单的赋值操作 x=1 在一些硬件上也可能包含多个原子的机器指令,所以整个赋值操作并不是原子的。所以,当一个线程在另一个线程为 x 赋值时去读取它,就会发生数据竞争。

竞态条件的根本原因

抢占式多任务处理机制让操作系统能完全掌控线程的管理工作:它可以根据相应的调度算法开始、停止、暂停线程。你作为一个程序员,无法控制线程执行的时间或者顺序。事实上,你甚至都无法保证像这样简单的代码按确定的顺序执行:

  1. writer_thread.start()
  2. reader_thread.start()

多次运行这个程序,你会发现每次运行都可能有不同的结果:有时 writer_thread 先执行,有时 reader_thread 先执行。如果你的程序要求 writer_thread 必须在 reader_thread 之前执行,那么你的程序肯定会遇到竞态条件的问题。

这样的行为被称为非确定的(none-deterministic):每次输出都会变化,而且你不能预测到结果。debug被竞态条件影响的程序是非常烦人的,因为你不能用可控的方式去复现这个问题。

教会线程们和平相处:并发控制

数据竞争和竞态条件都是现实世界的问题:一些人甚至因为它们死掉了。和谐处理多个线程之间的关系的技术被称为并发控制(concurrency control):操作系统和编程语言提供了一些解决方案。这里是最重要的一些:

  • 同步(synchronization)——一种确保资源一次只能由一个线程使用的方法。同步是将代码的特定部分标记为“受保护的(protected)”,以便两个或多个并发线程不会同时执行它,从而搞乱了共享数据。
  • 原子操作(atomic operations)——得益于操作系统提供的特殊指令,一系列的非原子操作(例如之前提到的赋值操作)可以被转换为原子操作。通过这种方法,可以保证不管多少个线程操作共享数据,都让共享的数据一直保持有效的状态。
  • 不可变数据(immutable data)——共享数据被标记为不可变的,那么任何东西都不能改变它:线程只被允许从中读取数据,从而消除了之前提到的根本原因。我们都知道,只要线程不修改共享内存空间的数据,那么它们就可以安全的从中读取数据。这就是函数式编程背后的哲学。

在这个小册子的后几章中,我会讨论所有这些关于并发的主题。敬请关注哦!