多线程简介:走进并发世界
现代计算机有能力在同一时间进行多种操作。在硬件进步和愈发智能的操作系统的支持下,这一特性能够使我们的程序运行得更快,无论是在执行速度还是响应速度方面。
利用多线程的优势编写软件是非常有趣的,但是也非常困难:它需要你了解你的计算机的内部。多线程是操作系统提供的一种充满魔力的工具,在这一篇我们将尝试掀开多线程的面纱,让我们开始吧!
进程和线程:命名
现代操作系统可以同时运行多个程序。这就是为什么你可以在你的浏览器(一个程序)中阅读这篇文章,同时在你的媒体播放器(另一个程序)中听音乐。每个程序被称为一个正在执行的进程。操作系统可以利用许多软件技巧,使一个进程与其他进程一起运行,或者充分利用底层硬件的优势。无论哪种方式,最后的结果都是你感觉到所有程序都在同时运行。
在操作系统中运行进程并不是同时执行几个操作的唯一方法。每个进程能够在其内部同时运行子任务,称为线程。你可以把线程看作是进程本身的一个片段。每个进程在启动时至少会触发一个线程,这被称为主线程。然后,根据程序或程序员的需要,可以启动或终止其他线程。多线程是指在一个进程中运行多个线程。
例如,你的媒体播放器很可能运行多个线程:一个用于渲染界面–这通常是主线程,另一个用于播放音乐,等等。
进程与线程的不同
每个进程都有操作系统分配的自己的一大块内存。默认情况下,该内存不能与其他进程共享:你的浏览器不能访问分配给媒体播放器的内存,反之亦然。如果你运行同一个进程的两个实例,也就是说,如果你启动浏览器两次,也会发生同样的事情。操作系统将每个实例视为一个新的进程,分配给它自己独立的内存部分。因此,默认情况下,两个或更多的进程没有办法共享数据,除非他们执行高级技巧 – 进程间通信(IPC)。
与进程不同,线程共享操作系统分配给其父进程的同一块内存:媒体播放器主界面的数据可以很容易地被音频引擎访问,反之亦然。因此,两个线程之间的对话更容易。除此之外,线程通常比进程更轻量:它们占用资源更少,创建速度更快,这就是为什么它们也被称为轻量级进程。
线程是一种使你的程序同时执行多个操作的方便方法。如果没有线程,你将不得不为每个任务编写一个程序,将它们作为进程运行,并通过操作系统使它们同步。这将更加困难( IPC 很麻烦)和缓慢(进程比线程更重)。
绿色线程:纤维
到目前为止,所提到的线程是操作系统的事情:一个想要启动一个新线程的进程必须与操作系统对话。不过,并不是每一个平台都原生支持线程。绿色线程,也被称为纤维,是一种模拟,使多线程程序能够在不原生支持线程的环境中工作。例如,在底层操作系统没有原生线程支持的情况下,虚拟机可以实现绿色线程。
绿色线程的创建和管理速度较快,因为它们完全绕过了操作系统,但也有缺点。关于这点我们之后再讲。
“绿色线程”这个名字是指 Sun Microsystem 的 Green Team,他们在 90 年代设计了最初的 Java 线程库。如今,Java 不再使用绿色线程:他们早在 2000 年就转向了本地线程。其他一些编程语言 – Go、Haskell 或 Rust 等等–都实现了绿色线程的类似产物,而不是本地线程。
线程的用途
为什么一个进程要采用多线程?正如我之前提到的,并行做事会大大加快事情的进展。假设你要在你的电影编辑器中渲染一部电影。编辑器可以很聪明地将渲染操作分散到多个线程中,每个线程处理电影的一个部分。因此,如果用一个线程处理,比如说,需要一个小时,用两个线程则需要 30 分钟;用四个线程则需要 15 分钟,以此类推。
真的就这么简单吗? 有三个要点需要考虑:
- 并非每个程序都需要多线程。因为如果你的应用程序执行顺序操作或经常等待用户进行 IO,多线程可能不是那么有益;
- 你不能把更多的线程扔给一个应用程序来使它运行得更快:每个子任务都必须经过深思熟虑和精心设计,以执行并行操作。
- 并不能 100% 保证线程会真正并行地执行它们的操作,也就是在同一时间会发生什么:这很大程度上取决于底层硬件。 最后一个是至关重要的:如果你的电脑不支持同时进行多项操作,操作系统就必须伪造这些操作。我们将在一分钟内看到如何做。现在,让我们把并发看作是有任务同时运行的感觉,而真正的并行则是任务在同一时间运行。
是什么让并发和并行成为可能
计算机中的中央处理单元(CPU)负责运行程序的工作。它由几个部分组成,其中最主要的是所谓的核心:这是实际进行计算的地方。一个核心一次只能运行一个操作。
这当然是一个主要的缺点。由于这个原因,操作系统已经开发了先进的技术,使用户能够同时运行多个进程(或线程),特别是在图形环境中,甚至在单核机器上。最重要的一项技术叫做抢占式多任务,抢占式多任务是指中断一个任务,切换到另一个任务,然后在稍后的时间恢复第一个任务的能力。
因此,如果你的 CPU 只有一个核心,操作系统的部分工作就是将这个单一核心的计算能力分散到多个进程或线程中,这些线程在一个循环中一个接一个地执行。这种操作给你一种错觉,即有一个以上的程序在并行运行,或者一个程序同时做多件事情(如果是多线程)。并发性得到了满足,但真正的并行性–同时运行进程的能力–仍然缺失。
今天,现代的CPU通常有一个以上的内核,其中每个内核一次执行一个独立的操作。这意味着,有了两个或更多的核心,真正的并行是可能的。例如,我的 Intel Core i7 有 8 个核心:它可以同时运行 8 个不同的进程或线程。
单核上的运行多线程应用:有意义吗?
在单核机器上,真正的并行化是不可能实现的。尽管如此,如果你的应用程序能够从中受益,那么编写一个多线程程序仍然是有意义的。当一个进程采用多个线程时,即使其中一个线程执行缓慢或阻塞的任务,抢占式多任务也能保持应用程序的运行。
例如,你正在开发一个桌面应用程序,从一个非常慢的磁盘上读取一些数据。如果你编写的程序只有一个线程,那么整个应用程序就会冻结,直到磁盘操作完成:分配给唯一线程的 CPU 功率在等待磁盘唤醒时被浪费了。当然,除了这个进程之外,操作系统还在运行许多其他进程,但你的具体应用程序不会有任何进展。
让我们以多线程的方式来重新思考你的应用程序。线程 A 负责磁盘访问,而线程 B 负责主界面。如果线程 A 因为设备速度慢而被卡住等待,线程 B 仍然可以运行主界面,保持你的程序的响应速度。这是可能的,因为有两个线程,操作系统可以在它们之间切换 CPU 资源,而不会卡在较慢的那一个。
越多线程,越多问题
正如我们所知,线程共享其父进程的同一块内存。这使得两个或多个线程在同一个应用程序中交换数据变得非常容易。例如:一个电影编辑器可能持有很大一部分共享内存,其中包含视频时间线。这样的共享内存被几个工作线程读取,这些线程被指定用于将电影渲染到文件中。他们都只需要一个到该内存区域的句柄(例如一个指针),以便从其中读取并将渲染的帧输出到磁盘。
只要有两个或更多的线程从同一内存位置读取,事情就能顺利进行。当至少有一个线程向共享内存写东西,而其他线程则从该内存中读取时,麻烦就来了。这时会出现两个问题:
- 数据竞争:当一个写者线程修改内存时,一个读者线程可能正在从内存中读取。如果写者还没有完成它的工作,读者就会得到损坏的数据。
- 竞态条件:读者线程应该在写者写完之后才读。如果相反的情况发生呢?比数据竞争更微妙的是,竞争条件是指两个或多个线程以不可预测的顺序做他们的工作,而事实上,这些操作应该以适当的顺序进行才能正确完成。即使你的程序已被保护为防止数据竞争,也可能触发竞争条件。
线程安全的概念
如果一段代码能够正常工作,即没有数据竞争或竞态条件,即使许多线程同时执行它,也可以说它是线程安全的。你可能已经注意到,一些编程库声明自己是线程安全的:如果你正在编写一个多线程程序,你要确保任何其他的第三方函数可以在不同的线程中使用而不会引发并发问题。
数据竞争的根源
我们知道,一个 CPU 核心一次只能执行一条机器指令。这样的指令被说成是原子性的,因为它是不可分割的:它不能被分解成更小的操作。希腊语中的「atom」(ἄτομος;atomos)的意思是不可切割。
不可分割的属性使得原子操作在本质上是线程安全的。当一个线程对共享数据执行原子写操作时,没有其他线程可以读取半成品的修改。相反,当一个线程对共享数据进行原子读取时,它读取的是整个数值,因为它出现在一个时间点上。线程没有办法从原子操作中溜走,因此不可能发生数据竞争。
坏消息是,绝大多数的操作都是非原子性的。即使是像 x = 1
这样的琐碎赋值,在某些硬件上也可能是由多个原子机器指令组成的,使得赋值本身作为一个整体是非原子的。因此,如果一个线程读取 x
,而另一个线程执行赋值,就会触发数据竞争。
竞态条件
抢占式多任务让操作系统完全控制线程管理:它可以根据高级调度算法启动、停止和暂停线程。作为一个程序员,你无法控制执行的时间或顺序。事实上,像这样的简单代码是无法保证的:
writer_thread.start()
reader_thread.start()
将以这个特定的顺序启动两个线程。多次运行这个程序,你会注意到它在每次运行中的表现是不同的:有时写程序的线程先启动,有时则是读程序的。如果你的程序需要写程序总是在读程序之前运行,你肯定会遇到一个竞态条件。
这种行为被称为非决定性的:每次的结果都会改变,你无法预测它。调试受竞态条件影响的程序是非常烦人的,因为你不可能总是以可控的方式重现这个问题。
教会线程们如何相处:并发控制
数据竞争和竞态条件都是现实世界的问题:有些人甚至因为它们而死亡。容纳两个或多个并发线程的艺术被称为并发控制:操作系统和编程语言提供了几种解决方案来处理它。其中最重要的是:
- 同步:一种确保资源一次只被一个线程使用的方法。同步是指将你的代码的特定部分标记为「保护」,这样两个或更多的并发线程就不会同时执行它,从而破坏你的共享数据。
- 原子操作:由于操作系统提供的特殊指令,一堆非原子操作(如前面提到的赋值)可以变成原子操作。这样一来,无论其他线程如何访问,共享数据总是保持在一个有效的状态。
- 不可变的数据:共享数据被标记为不可变的,没有什么可以改变它:线程只允许从它那里读取,消除了根本原因。我们知道线程可以安全地从同一内存位置读取,只要他们不修改它。这就是函数式编程背后的主要理念。
This is a version from Internal Pointers.