多线程简介

多线程的介绍

进程与线程:用正确方式命名事物

现代操作系统能够同时运行多个程序。这就是为什么你能够在浏览器(一个程序)中阅读文章的同时也可以通过媒体播放器(另一个程序)来听音乐。每个程序都被称为一个正在被执行的进程。操作系统利用底层的硬件优势和软件技巧使得一个进程能与其他进程一起运行。无论怎样,最终的结果就是你感觉到你所有的程序在同时运行。
在操作系统中运行程序不是同时执行多个操作的唯一方式。每个进程都能够在其内部同时运行多个被称为线程的子任务。你可以把线程看着是进程的一部分。每个进程在启动时都会触发至少一个线程,这个线程被称为主线程。然后根据程序的需求,额外的线程会被启动或是终止。多线程就是在单个进程中运行多个线程。
例如,你的多媒体播放器就可能运行了多个线程:一个渲染出操作界面--这通常被称为主线程,另一个执行播放音乐等操作。
你可以把你的操作系统看做一个持有多个进程的容器,每个进程又是一个持有多个线程的容器。在这篇文章中,我将只关注线程,但这整个主题却是非常有趣并值得在未来更加深入分析的。
进程与线程

进程与线程的区别

每个进程都有一块由操作系统分配的内存。通常情况下这块内存不会与其他的进程分享:你的浏览器无法访问被分配给你播放器的内存,反之亦然。如果你同时运行一个程序的两个实例,即启动你的浏览器两次,这样的情况也会发生。操作系统将每个实例都视为一个新的进程并为它分配一块独立的内存。所以,通常情况下,两个或者更多的进程间是无法分享数据的,除非它们使用高级技术--IPC(进程间通信)
不像进程那样,线程共享由操作系统分配给它们父进程的内存:播放器主线程内的数据能够轻易的被播放器中的其他线程访问,反之亦然。
因此,两个线程间更容易通信。最重要的是,线程通常比进程更轻:它们消耗更小的资源并且能够更快的被创建,这就是为什么我们又称线程为轻量级进程的原因。
线程是使程序同时执行多个操作的便利方式。如果没有线程,你将必须要为每一个任务编写一个程序,将它们作为进程运行并通过操作系统同步。这使得开发更加困难和缓慢。

绿色线程(Green Threads)

目前为止所提到的线程都是一个操作系统的东西:进程如果想要触发一个线程必须通知操作系统。然而,不是每个操作系统本身都支持线程。绿色线程(又被称为fibers)是一个仿真线程,它能使多线程程序运行在不提供这样功能的环境中。例如,如果底层操作系统没有提供本地线程支持,则虚拟机可能会实现绿色线程。
绿色线程相比本地线程更容易被创建和管理,因为它们完全绕开了操作系统。但也有缺点,我会在下一篇文章中说明这个问题(绿色在I/O和上下文操作方面性能要低于本地线程)。
“绿色线程”这个名字来源于Sun Microsystem的Green Team,他们在90年代设计了原始的java线程库。今天JAV不在使用绿色线程,他们在2000年改用本地线程。一些其他的语言--GO,Haskell,Rust等等--实现了绿色线程的等价物来替代本地线程。

使用什么线程

为什么进程应该使用多线程?正如我之前提到的,并行处理可以大大加快速度。假设你要在电影编辑器中渲染电影,编辑器足够智能,可以跨多个线程传播渲染操作,每个线程处理电影的一部分。所以如果说一个线程完成任务需要一个小时,那么2个线程就只需要30分钟,4个线程只需要15分钟.....
真的就是这么简单么?这里有3个需要着重考虑的点:
1、不是每个程序都需要多线程。如果你的app执行的是顺序操作或者经常等待用户做某些事。多线程可能并不是那么有用。
2、你不能仅仅为了使应用程序更快就抛出更多的线程:每个子任务都必须小心的思考和设计,以便执行并行操作。
3、线程不能100%的保证能真实的并行执行它们的操作:这取决于底层硬件。
最后一点是最重要的:如果你的计算机不支持同时执行多个操作,操作系统必须伪造它们。一会我们将看到该怎么做。现在我们先将并发视为感觉上多个任务在同时执行,而真正的并行就是字面意义上的同时执行多个任务(感觉这段翻译的不太好,原句是:For now let's think of concurrency as the perception of having tasks that run at the same time, while true parallelism as tasks that literally run at the same time。个人对这段话的理解就是:并发:CPU在一段时间内执行多个任务,并行:CPU在同一时间点执行多个任务。所以单核的CPU是没有办法执行并行操作的)。可以把并行看作并发的子集
并行与并发

什么使并行与并发成为可能

CPU承担了程序运行的繁重工作。它由几个部分组成,主要的部分被称作核心(Core):这是实际执行计算的地方。一个核心一次只能运行一个操作。
这是一个主要的缺陷。由于这个原因,操作系统发展出先进的技术使用户能够运行同时运行多进程(或者线程),尤其是在图形环境中,甚至是单核处理器上。最重要的一个被称作抢占式多任务处理,抢占指的是一种打断任务运行,选择其他任务执行然后再切回到第一个任务的能力。
假如你的CPU是只有一个核心,那么操作系统的一部分工作就是将单核心的计算能力分散到多个进程或线程中,这些进程或线程在循环中被一个接一个的执行。这个操作让你感觉有多个程序在并行运行,或是一个程序在同时做多个事情。并发性得到满足,但真正的并行--同时运行多个进程的能力--仍然缺失。
今天,现代CPU拥有不止一个核心,每个核心一次都能执行一个操作。这意味着如果有两个或者更多的核心,并行将成为可能。例如,我的Inter Core i7 有4个核心:它能同时运行4个不同的进程或线程。
操作系统可以检测到CPU的核数,并为它们每一个都分配进程或线程。一个线程可能被分配给任何一个操作系统喜欢的核心,这些操作对正在执行的程序来说是完全透明的。另外,如果所有核心都忙,抢占式多任务处理可能会启动。这使你具有在机器中运行比实际核数更多的进程的能力。

在单核处理器上运行多线程:是否有意义

真正的并行在单核机器上是不可能实现的。然而,如果你的程序能从中获益的话,编写一个多线程程序仍是有意义的。当进程采用多线程时,即使其中一个线程在执行缓慢或者是堵塞任务,抢占式任务处理机制依然能保持app运行。
举个例子,你正在使用一款桌面应用,这个应用从非常慢的磁盘中读取数据。如果程序只有一个线程,那么整个app将会被冻结直到磁盘操作被完成:当等待磁盘被唤醒时,CPU分配给这个线程的能力是被浪费的。当然,操作系统同时也在运行其他的程序,但你这个特定的程序将不会取得任何进展。
让我们用多线程的思维去思考这个应用。线程A负责磁盘访问,线程B负责主界面。当线程A因为设备慢而被卡在等待操作时,线程B依然能够运行主界面,保持你的程序响应。这是可行的,因为如果有2个线程,操作系统会在他们之间切换CPU资源而不会卡在较慢的线程上。

更多的线程,更多的问题

正如我们知道的,线程共享父进程中的同一块内存。这使得在同一应用中的两个或者多个线程交换数据变得非常容易。例如:电影编辑器持有包含电影时间线的一大段共享内存。这段共享内存被一些指定用于将电影渲染到文件的工作线程所读取。这些线程都需要一个指向内存的handle(如一个指针)以便从中读取并将渲染好的帧输出到磁盘。
只要2个或者多个线程从同一个内存位置读取,事情就会进行得很顺利。麻烦出现在:当有一个线程向共享内存中写入,而其他线程从中读取时。在这种情况下,2个问题将有可能发生。

  • 数据竞争(data race):当一个写入线程修改内存时,一个读取线程可能正从中读取。如果写入线程没有完成写入,这个读取线程将会获取到损坏的数据。

  • 竞争条件(race condation):一个读取线程应该在写入线程完成工作后再进行读取操作。但如果出现相反的情况,会发生什么?这比data race更加微妙。一种情况是2个或者更多的线程以不可预料的顺序完成他们的工作,但事实上,操作应该以正确的顺序被执行完成。你的程序即使受到数据竞争的保护也能触发竞争条件(ps:查了一下数据竞争与竞争条件的概念,觉得文章中的这段描述莫名其妙的)

线程安全的概念

我们将一段工作正常的代码称作线程安全,即使在许多线程被同时执行的情况下,这段代码也不会发生数据竞争或者是竞争条件。你可能已经注意到了一些编程库申明它们是线程安全的:如果你在编写一个多线程应用,你会想确认任何一个第三方的方法都可以跨线程调用而不会触发并发问题。

数据竞争的根本原因

我们知道一个CPU核心只能同时执行一个机器指令。这种指令具有原子性,因为它无法被分割:它不能再被拆分为更小的操作。古希腊人用“原子”来表示无法再分割。
不可再分割的属性使得原子性操作本质上是线程安全的。当一个线程在共享内存上执行一个原子性地写操作时,没有其他线程能够读取到只完成一半的修改。反过来,当一个线程在共享内存上执行一个原子性的读操作时,它会读取到操作那一瞬间的整个的值。There is no way for a thread to slip through an atomic operation, thus no data race can happen.(这句翻译不出来)
坏消息是大部分的操作并不是原子性的。即使是像x=1这样的简单分配,在一些硬件上可能都由几个原子性的机器指令组成,使得赋值本身不是原子性的。所以当一个线程在读取x而另一个线程在给他赋值时就会触发数据竞争。

竞争条件的根本原因

抢占式多任务处理使操作系统可以完全控制线程管理:它可以通过高级调度算法生成,停止和暂停线程。你作为程序员无法控制执行的时间以及顺序。事实上,像这样的简单代码也无法保证能按特定的顺序启动2个线程:

writer_thread.start()
reader_thread.start()

运行这段程序一段时间后你会注意到,每次运行他的行为都不同:有时写入线程先启动,有时读取线程先启动。如果你的程序需要写入始终在读取前面运行,你就可以确定发生了竞争条件。
这个行为被称为不确定性(non-deterministic):结果每次都会改变,并且你无法预料它。调试程序被竞争条件所影响是非常让人烦恼的,因为你无法通过一个可控的方式复现问题。

教导线程相处:并发控制

数据竞争与竞争条件都是现实问题:有些人甚至因为他们死亡。包容2个或者更多并发线程的技术被称为并发控制:操作系统与编程语言提供了一些解决方案。其中最重要的是:

  • 同步 —— 确保资源一次只被一个线程使用。同步就是将你代码的某个特定部分标记为受保护的,保证2个以上的并发线程不能同时执行它,从而搞砸共享内存中的数据。

  • 原子性操作 —— 一堆非原子性操作可以通过操作系统提供的特殊指令转换为原子性操作。这种方式使共享内存始终保持在有效状态,不论有多少线程同时访问它。

  • 不可变数据 —— 共享内存被标记为不可变的,这样没有任何东西可以改变它。线程只被允许从中读取数据。正如我们所知的,线程能够安全的从同一块内存比中读取数据,只要不去改变这块内存。这就是函数式编程背后的哲学。

原文地址

A gentle introduction to multithreading


书山有路勤为径 学海无涯苦作舟