多线程讲解

2019-09-13 22:04发布

0. 引言

本文是多线程技术入门篇,对进程、线程、纤程、并发、并行、线程安全、竞态条件等概念进行了介绍,讨论了多线程技术的实现原理、使用中可能遇到的问题以及如何正确处理。

伴随着硬件和操作系统的进步,现在的计算机能够同时执行多个操作,程序执行更快响应时间缩短。

在软件开发中使用并发既诱人又困难,需要了解计算机底层工作原理。本文是系列的第一篇,从操作系统中线程的基本概念入手,介绍线程背后的魔法。让我们开始吧。

1. 正确认识进程与线程

现在的主流操作系统都支持同时运行多个程序,比如可以一边在浏览器上看这篇文章一边用播放器听歌。运行中的浏览器和播放器程序都是**进程**,操作系统提供了一套机制利用底层硬件保证多个进程同时运行。无论具体采用的是哪种技术,最终让你感觉这些程序是在同时运行。

在操作系统中同时执行多任务,进程不是唯一选项。每个进程都能在内部并发执行子任务,子任务也被称为线程。你可以把线程看成一个进程切片。进程启动时会启动至少一个线程,称为主线程。接下来,根据程序或开发者的需要可以启动新线程或终止线程。多线程即在一个进程中运行多个线程。

例如,播放器会运行多个线程。一个线程显示界面,通常是主线程,另一个线程播放音乐。

可以把操作系统看成进程的容器,而每个进程又是线程的容器。虽然本文只关注线程,但这个主题非常吸引人,值得在未来深入分析。

"1. 操作系统这个盒子里装的是进程,进程又包含了一个或多个线程"

1.1 进程与线程的区别

操作系统会为每个进程分配一块独立的内存。默认情况下,一个进程的内存不能与其他进程共享。例如浏览器不能访问播放器的内存,反之亦然。一个进程启动的多个实例也是如此。启动两次浏览器,操作系统会把每个实例看作一个新进程,并为其分配一块独立的内存。因此,多个进程之间默认是无法共享数据的,除非使用进程间通信(IPC)技术。

与进程不同,线程能够共享父进程的内存。例如播放器中,音乐播放线程可以访问界面线程的数据,反之亦然。因此,线程之间沟通起来更容易。此外,线程占用资源更少,创建速度更快,这就是为什么线程也被称为轻量级进程。

如果没有线程,需要将这些任务作为进程运行并通过操作系统同步。使用 IPC 通信相对困难,而且由于进程比线程更“重”,执行速度也更慢。

1.2 Green Thread 纤程

到目前为止,线程都由操作系统管理,即必须经由操作系统才能启动一个新线程,不过并非所有平台都支持线程。Green Thread 也称为纤程,它模拟线程的工作,可以在不支持本地线程的平台上开发多线程应用。例如,如果操作系统不支持本地线程,虚拟机会实现纤程。

纤程的优点是创建速度快、管理效率高,因为完全绕过了操作系统。但纤程也有缺点,会在下一篇中讨论。

“Green Thread”代指 Sun 公司的 Green Team,他们在20世纪90年代设计了最初的 Java 线程库。今天的 Java 不再使用纤程,早在2000年就已经开始使用本地线程了。一些其他编程语言,比如 Go、Haskell、Rust 等实现了类似纤程的机制代替本地线程。

2. 线程的作用

为什么进程中需要使用多线程?正如之前提到的,并行可以加快处理速度。举个例子,在视频编辑器里渲染一部电影,编辑器会把渲染工作分配给多个线程,每个线程只处理其中一部分工作。假设一个线程需要1小时,那么两个线程只需要30分钟,4个线程缩短到15分钟,以此类推。

真的这么简单?还需要考虑以下三点:

  1. 不是所有程序都要使用多线程。如果程序本身顺序执行或者频繁等待用户输入,这种情况多线程无法发挥作用;
  2. 仅仅增加线程并不会让程序跑得更快,每个子任务都需要经过仔细设计和考虑;
  3. 同样,并不能100%保证并行,一切都依赖于底层硬件。


最后很关键的一点,如果计算机不支持同时执行多个操作,操作系统会让它们看起来像同时执行,稍后会对此进行介绍。现在,让我们把并发(concurrency)理解为多个任务看起来是并行执行的样子,而并行(parallelism)是真正的同时执行。

"2. 并行是并发的子集"

3. 并发与并行的工作原理

CPU 是程序真正执行的地方,它由几个部分组成,其中最主要的部分被称为核心(core)。CPU 核心同时只能运行一个操作。

这是一个明显的缺点。由于这个原因,操作系统设计了各种机制使图形化环境即使在单核设备上也可以支持多进程(多线程)。其中最重要的技术称为抢占式多任务处理,这里的“抢占”指中断当前任务切换到另一个任务,稍后再恢复前一个任务执行的能力。

如果 CPU 只有一个核心,那么操作系统会将这个核心的计算能力分配给多个进程或线程,它们会在循环中一个接一个地执行。这种设计会造成任务在并行执行或者说程序同时在执行多个任务(多线程)的错觉。**满足了并发,但不是真正的并行**,并没有同时运行多个进程。

如今的 CPU 已经不止一个核心,同一时刻每个核心都能独立执行一次操作,这意味着对于多个核心的核心能够真正做到并行。例如,我的 Intel Core i7 有4个核心,可以同时运行4个不同的进程或线程。

操作系统能够检测 CPU 核心的数量,并为每个核心分配进程或线程。只要操作系统喜欢,线程可以分配到任何核心上执行,这个过程对正在运行的程序而言完全透明。不仅如此,如果所有核心都在忙碌中,仍然可以启用抢占式多任务机制。这样可以运行更多的进程和线程,超过实际设备的核心数量。

在单核 CPU 上使用多线程有意义吗?

"单核无法做到真正的并行,但多线程编程是有意义的"。当进程包含了多个线程时,由于抢占式多任务机制,即使其中某个线程执行缓慢或任务阻塞,也可以保持程序运行。

举个例子,运行的桌面程序正从磁盘读数据,读磁盘的过程非常缓慢。如果程序只有一个线程,那么整个程序会出现“无法响应”直到读取结束。CPU 所有计算资源都分配给了唯一的线程,浪费在等待磁盘IO完成上。当然,操作系统还会运行许多其他进程,但这个应用无法继续响应。

让我们用多线程方式设计这个应用。线程 A 访问磁盘,与此同时线程 B 负责主界面。当 A 由于读磁盘操作进入等待,线程 B 仍然可以保持界面能够作出响应。由于这里有两个线程,操作系统可以在它们之间切换,不会在较慢的线程上卡住。

4. 线程越多,问题多多

正如我们知道的那样,线程会共享父进程的内存,这使得应用中的多个线程可以更好地交换数据。例如,视频编辑器进程的内存中包含了时间轴数据,若干工作线程从内存读取、渲染并把结果存储到影片文件中。它们需要一个内存句柄(例如指针)指向对应的内存区域,从而实现读取并把渲染好的帧存入磁盘。

多个线程只从同一块内存读取没有任何问题,但是当其中某个线程执行写入而其它线程正在读取时,问题来了。这时可能出现两个问题:

  • 数据竞争:读线程正在读内存,写线程还没有写入完成,这时会读到损坏的数据;
  • 竞态条件:读线程应该只在写线程完成操作后读取,如果发生相反的情况会怎么样呢?比数据竞争更微妙的,竞态条件可能是多个线程按照不可预知的顺序执行操作,而实际的要求应该按照指定顺序执行。因此,即使做到了数据保护,还是有可能触发竞态条件。


线程安全指什么

一段代码如果声称自己线程安全,那么应该做到在多线程调用时没有数据竞争并且不会触发竞态条件。你可能会注意到一些开发库声称自己是线程安全的,如果正在编写多线程代码,那么就需要确保所有第三方库都能够跨线程使用而且不会引起并发问题。

5. 数据竞争的根源

我们知道一个 CPU 核心一次只能执行一条机器指令,这种指令因为不可分割所以被称为原子操作,即不能拆分成更小的操作。希腊单词“atom”(ἄτομος; atomos)表示不可切分。

不可分割的特性让原子操作天然线程安全。当一个线程写入共享数据时,其他线程在修改完成前无法读取。反过来,当一个线程读共享数据操作具有原子性时,一定能够读到某个时刻的完整数据,不会出现某个线程进入原子操作中的情况,因此不会发生数据竞争。

坏消息是绝大多数的操作都不是原子操作,即使 `x = 1` 这样简单的赋值语句在硬件上也可能由多个机器指令组成,所以说赋值语句本身不是线程安全的。因此如果一个线程读取 `x`,而另一个线程执行赋值操作,就会产生数据竞争。

6. 竞态条件的根源

抢占式多任务机制让操作系统能完全控掌控线程管理:按照调度算法启动、停止和暂停线程执行,程序员无法控制线程执行时间或执行顺序。事实上,并不能保证下面这段简单的代码按照指定顺序启动:

```java
writer_thread.start()
reader_thread.start()
```

多运行几次就能注意到,每次的运行的结果可能不一样,有时写线程先启动,有时读线程先启动。如果要求程序确保在读取之前进行写入,那么肯定会触发竞态条件。

这种行为被称不非确定性:每次结果都会改变,无法预测。调试带有竞态条件的程序非常麻烦,因为不能以可控的方式复现问题。

7. 教线程和谐相处:并发控制

数据竞争和竞态条件都是现实中会出现的问题,有人甚至因此丧生。并发控制是一种多线程并发的艺术,操作系统和编程语言为此提供了一些解决方案,其中最重要的几项有:

  • 同步:确保一次仅有一个线程使用资源。同步是指将代码的特定部分标记为“protected”,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱;
  • 原子操作:由操作系统提供特殊指令,一些非原子操作,比如前面提到的赋值操作,可以转化为原子操作。这样,无论其他线程如何访问,共享数据始终保持有效;
  • 不可变数据:当共享数据被标记为不可变时,只允许线程从中读取数据,没有任何方式可以改变它的内容,从而解决了竞态条件的根本原因。众所周知,只要不修改内存地址,线程就可以安全地从相同的位置读取数据。这也是函数式编程背后的哲学。


在系列的下一篇中,我们将讨论这些有趣的并发话题,敬请期待!

举两个例子,怎么样写好代码

最经典的算法,献给正在面试道路上的你

文章来源: https://www.toutiao.com/group/6736148099581870595/