[译文]5分钟系列—快速理解Multithreading(多线程)

原文链接:A gentle introduction to multithreading

前言

随着计算机硬件 ( hardware ) 的更新迭代以及操作系统 ( operating systems ) 的智能化,使得现代计算机能够同时处理更多任务;从而让我们的应用程序执行速度更快,响应时间更短

因此编写软件的时候,我们可以利用并发处理越来越多的事情;但凡事都有代价,这要求我们对并发的实现原理要有更深入的了解。让我们用 threads (线程)来开启并发的魔法之旅吧

Processes and threads ( 进程与线程 ) : 字如其意

目前主流的操作系统都能同时运行多个程序,这也是为什么你能用 Chrome 浏览器看这篇文章的同时,还能用网易云音乐听歌。因为每个程序都运行在一个所谓 进程 的环境中;操作系统为每个进程提供不同的硬件支持 ( 包括 CPU,内存,显存,输入输出等)

让操作系统同一时间执行多个任务,并不是只有启动多个进程这一个办法。每个进程可以在其内部同时开启多个 线程 用来并发执行任务。你可以把线程看作是进程的一部分,每个进程启动的时候,至少会开启一个线程,一般称这个线程为:主线程。程序员可以根据实际需求,开启或者停止其他线程用来执行任务;我们经常说的多线程,指的就是在一个进程中,开启多个线程

我们用来听歌的网易云音乐,就是个多线程程序。主线程用来渲染UI界面,子线程1 用来播放音乐,子线程2 用来读取歌词并显示等。你可以通俗的把操作系统看成是进程的容器 ( 其实更像是管理员 ) ,进程是线程的容器,示例图如下:

进程和线程的区别

操作系统为每个进程分配一块独立的内存空间;默认情况下,进程之间不共享内存空间 ( 也就是不能相互访问 ) ,比如:Chrome浏览器无法访问网易云音乐的运行内存,反之亦然。除非使用进程通讯技术 inter-process communication (IPC)

与进程不同,线程能够共享父进程的内存空间。比如: 网易云音乐的UI界面线程可以访问音乐播放线程的内容,反之亦然。同时,线程占用资源更少,创建/切换 速度更快,也有人将线程称为:  lightweight processes (轻量级进程)

因此,相对启动多进程(通讯不方便,更消耗资源),启用多线程才是同时处理多任务的最佳实现,而且程序能运行的更快

多线程用处

为什么需要多线程?因为并发执行任务能大大节省时间提高效率。举个不太恰当的例子:比如你下载一部电影,如果单线程下载,需要1小时;但是多线程下载,就可以根据线程数去下载不同的片段,多任务同时进行,那么就可以很快下完

多线程太完美了,真的就这么简单吗?以下几个问题你要好好考虑:

  • 不是所有程序都要用到多线程。如果你的程序本身就需要顺序执行;或者频繁等待用户输入,那就没有必要多线程
  • 线程不是越多越好。要根据具体任务的类型思考和设计,是否增加线程
  • 多线程并不保证真正做到并发执行。这还要取决硬件是否支持

最后一点请注意,并不是所有硬件设备都支持同一时刻执行多任务,只不过操作系统让它看起来像,这个后面会介绍

我们先把 并发(Concurrency) 看作是多任务看起来是同时执行;而 并行(Parallelism) 是看作是同一时刻处理多任务。举个简单的例子来理解并发和并行

并发:吃饭的时候,来了电话;停止吃饭,接电话

并行:吃饭的时候,来了电话;边吃饭边打电话(并行也不一定高效,吃饭的时候说话对方有可能听不清楚,导致打电话时长增加)

并发(Concurrency),并行(Parallelism)

CPU是程序被执行的地方,它由很多部分组成,其中最核心的部分称之为:Core (核心),每个核心同一时刻只能执行一个操作。硬件设计如此,因此操作系统为了能最大化利用核心,发明了很多先进技术,使得用户能够运行多个进程,其中最重要的技术就是:抢占式多任务处理机制。抢占是指中断当前任务,切换到另一个任务,稍后再切回当前任务继续执行

因此,如果你的硬件是单核CPU,那么操作系统的工作就是将单核的算力分配到不同的进程或线程上,这些进程或者线程会被轮询执行;这样就会给我们造成一个假象:同时多个程序被执行 或者一个程序同时执行多个任务(多线程)。虽然这满足了并发,但实际上并未真正并行

如今CPU早就多核心,每个核心同一时刻都能进行一个操作,这也意味着真正的 并行 是可能的。操作系统会自动检测CPU的核心数量,并为每个核心配置进程/线程。一个进程/线程运行在哪个CPU核心对我们来说是完全透明的,而且一旦所有核心都很忙,那么抢占式多任务处理就会启动。这也是为什么计算机能同时开启的进程数量远超CPU核心数的原因

对于单核CPU多线程有意义吗?

在单核CPU上,真正实现并行是不可能的。不过,多线程依然是有意义的,因为抢占式任务处理的存在,它可以使得应用程序保持响应,即使其中一个线程执行缓慢或阻塞的任务

举个例子:假如你的应用要从磁盘读取某个大文件,此时如果是单线程,那么整个应用会未响应状态。如果用多线程,那么就可以线程A去读取文件,线程B刷新UI界面(实时观测读取该文件的进度)

多线程,多问题

依上文可知,线程会共享其父进程的内存空间;这就使得同个进程内的多线程之间可以很方便的进行数据交互。多个线程从同一块内存地址中读取数据没有任何问题,但是一旦某个线程进行写操作,而其他线程进行读操作时就容易出现如下问题:

  • Data Race(数据竞争):线程A 对一块内存地址的内容形成写操作,而 线程B 又同时读取这块内存地址存储的值,那么就有可能读到的是脏数据
  • Race Condition(竞争条件):线程执行的顺序是不可控的,而我们程序又希望按照指定的顺序执行。比如:我们要求 线程B 只能在 线程A 完成写入操作后再读取数据

如果一段代码,很多线程同时调用它而且不会有 数据竞争 和 竞争条件 ,那么我们称这段代码 thread-safe(线程安全)

数据竞争的根本原因

CPU内核一次只能执行一条机器指令。因其不可分割性,我们称这种指令为 原子指令(它不能被分解成更多步骤的操作)。希腊单词 atom(ἄτομος;atomos)的意思是不可切割的

因不可分割的特性,原子操作天然的线程安全:当 线程A 的写操作具备原子性时,其他线程在 写操作指令 完成之前则无法执行 读操作指令;反之亦然,因此不会发生数据竞争

可问题就是,大部分的操作指令都不是原子指令。即使是像 i++ 这样简单赋值也是由多个原子指令组成 (可以思考以下,为什么它不是原子操作,面试官很喜欢问的一个问题),从而使赋值本身作为一个整体是非原子的。因此,如果一个线程B 读取 i 而线程A 执行赋值,就会触发数据竞争

竞争条件的根本原因

抢占式多任务处理机制使得操作系统能够完全掌控和管理线程:它可以根据调度算法启动、暂停和中止线程;作为程序的开发者,你并不能控制线程的执行时间和顺序。实际上,你并不能保证如下代码会按照指定的顺序执行(一般是指从上到下)

writer_thread.start()
reader_thread.start()

运行多次,你就会发现有时 writer 线程先启动,有时 reader 线程先启动。如果你的程序需要保证先执行写操作再执行读操作,那么你肯定会遇到竞争条件

这种行为被称为不确定性:结果每次都会改变,你无法预测。调试受竞争条件影响的程序非常烦人,因为产生的bug是非必现的,而且有可能在你的计算机运行不会出问题,而在别人的计算机上就会有问题

多线程与并发

多线程编程,数据竞争 和 竞争条件 是经常会出现的问题。容纳多个并发线程的技术称之为: concurrency control (并发控制),操作系统或者一些编程语言为我们提供了一些解决方案,比较通用的是如下几种:

  • synchronization 同步: 确保同一时刻仅有一个线程在使用资源。将代码的特定部分作上标记,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱
  • atomic operations  原子操作: 借助操作系统提供特殊指令,将非原子操作(如:赋值操作)转化为原子操作 ( 比如C#的 Interlocked.Increment )
  • immutable data 不可变数据: 当数据被标记为不可变(ReadOnly),只允许线程从中读取数据,而不允许线程改变数据内容

以上三种只是比较常规的做法,C# 内关于多线程的解决,还有一些其他方案,比如:监视器(Monitor)、互斥锁(lock)、读写锁(ReadWriteLock)、互斥(Mutex)、信号量(Semaphore)、事件(AutoResetEvent/ManualResetEvent)等

发表评论

电子邮件地址不会被公开。 必填项已用*标注