探索并发的世界,一次一步。
得利于硬件的进步和更智能的操作系统,现代计算机都有能力同时执行多个操作。就执行和响应速度而言,计算机的这个特性使你的程序运行的更快。
编写利用这种能力的软件,是如此的吸引人。但是棘手的是,它需要你了解你的计算机在执行的时候,到底发生了什么。在这篇文章中,我将试着揭开线程的面纱,线程是操作系统提供的工具之一,用来执行这种有”魔力“的操作,让我们开始吧!
进程和线程:用正确的方法命名
现代操作系统可以同时运行多个程序。这就是为什么你能在浏览器里看这篇文章的同时,又能用音乐播放器听音乐。我们知道每个被执行的程序被称为一个进程。操作系统可以利用底层硬件,和一些软件的技巧来使每个进程独立于其他进程运行。无论是哪种方式,最终的结果就是你感觉你的程序都在同时运行。
运行进程不是操作系统同时执行数个操作的唯一方式,每个进程的内部,可以同时运行多个子任务,这些子任务称为线程。你可以想象一下,一个线程就是一个进程的切片。在进程开始运行的时候,内部至少触发一个线程,这个线程称为主线程。然后根据程序或者程序员的需要,可以额外增加或结束其他线程。多线程就是在一个进程内部运行多个线程。
举个栗子,就像你的音乐播放器就是多线程,一个主线程用来渲染界面,另一个线程用来播放音乐或其他等等操作。。
你可以把操作系统当成一个容器,这个容器里面盛满了进程,每个进程里面又含有很多线程。这篇文章仅仅关注线程,但是这整个话题是如此的吸引人,以后值得更深入的分析。
进程和线程的区别
每个进程都有自己的内存块,这个是操作系统分配的。默认这个内存不能和其他进程共享,你的浏览器没有权限访问你音乐播放器的内存,反之亦然。同样的,如果你打开浏览器两次,那是两个独立的进程,有不同的内存区域。所以默认的,两个进程没办法分享数据,除非他们执行一个高级的技巧–IPC(inter-process communication)
跟进程不一样, 线程共享操作系统分配给他们所属进程的内存块。你音乐播放器的界面数据是可以容易的被音频引擎访问的,反之亦然。最重要的是,比起进程,线程是很轻量级的,他们消耗很少的资源,创建起来很快,这就是为什么它们又被称为轻量级的进程
Green threads, of fibers
当前提到的线程,都是操作系统层面的事情:一个进程需要触发线程,只能与操作系统通信。但是不是每一个平台都原生的支持线程。所以绿色线程也被称为纤维,就是这种情况下,多线程的一个替代品。比如一个虚拟机就可能实行绿色线程,因为底层操作系统不支持原生的线程。
在运行层面上,创建和管理绿色线程是非常快的,因为它的实现绕过了操作系统。但是也有缺点,我们将在下一篇文章中讨论。
绿色线程名字来源与Sun公司的绿色团队(Green Team),他们是90年代原生Java线程库的设计师。现在,Java不再使用绿色线程了,他们在2000年又转到原生线程上了。其他一些编程语言– Go, Haskell, Rust等都实现了自己的绿色线程。
线程能用来做什么
为什么进程需要雇佣多个线程?就像我前面提到的,同时做很多事情,能够极大的提高做事情的效率。就说你在影片编辑器里渲染一部电影,这个编辑器应该足够智能的在多个线程间传递渲染操作,每个线程都处理整个影片中的一段。所以当只有一个线程可以用的时候,可能要花一个小时,2个线程的话30分钟,4个线程的话,15分钟。
真的这么简单?还有3个重要的点需要考虑:
- 不是每个程序都需要使用多线程,如果你的应用是处理有序(串联)的操作,或者经常需要等待用户的响应,多线程可能不是那么适合。
- 别只是为了使你的程序运行的更快,而抛出更多的线程。每个子任务都要小心的考虑和设计它们的并行操作。
- 线程在真正的并发下并不不能100%保证正确的处理它们的操作。这取决于底层的硬件。
最后一条是很关键的。如果你的计算机不支持多线程的同时操作,操作系统必须模拟它。让我们用1分钟的时间看看是如何做的,假设并发(concurrency)是我们的一种感知,就是有多个任务在同时处理,而真正的并行(parallelism)是同时处理的任务。
是什么使并发和并行成为可能
电脑里的CPU做了运行程序中最艰苦的工作,它包含以下几个部分,最主要的称为核心,这是计算真正执行的地方。一个核心同一时间只能做一个操作。
这当然是一个主要的缺点。基于这个原因,操作系统又开发了高级的技术,使用户有能力同时运行多进程(或线程),尤其是在图形环境中。
即使是单核的机器。其中最重要的一个技术称为抢先式多任务处理(preemptive multitasking),就是打断正在执行的任务,转换到另一个任务上,过一会再转回来,
所以,当你的计算机只有单核的时候,操作系统的部分任务就是传播这个单核的算力,跨越多个进程或线程。循环执行他们中的一个到另一个。这个操作会给你一种错觉,让你感觉好像多个程序在同时执行,或者一个程序同时做了多件事情。我们已经讲到了并发,但真正的并行– 能够同时运行多个进程,还没讲到。
如今现代的CPU都有多个核心,每一个都可以同时执行一个独立的操作,这就意味着真正的并发成为可能。比如我的inter core i7 有4个核心,它能同时处理4个进程或线程。
操作系统能够探测到CPU的数量,然后给它们分配进程或者线程。线程可能分配给操作系统喜欢的任何核心,这种类型的调度对你的程序来说是完全透明的,另外,如果所有核心都在忙的话,抢先式多任务处理就应该起作用了。这就给你了机会,来运行比你计算机真正核心数多的进程和线程。
单核心运行多线程程序,真的有意义吗?
真正的并行在单核机器上是不可能实现的。如果你的程序能够受益于多线程,那写一个多线程的程序还是有意义的。当程序使用多线程的情况下,抢先式多任务处理能够保证你的应用正常运行,即使其中有个线程在执行很慢,或是闭塞的任务。
举个栗子,你在开发一个桌面应用,从一个很慢的硬盘里面读数据,如果你写的程序是单线程的,整个app会冻结,直到硬盘的操作完成,在等待磁盘唤醒时浪费了分配给唯一线程的CPU功率,当然,操作系统除此之外还运行许多其他进程,但你的应用程序不会有任何进展,只会停在那里。
让我们重新考虑一下你的应用程序,如果改成多线程呢? 线程A用来访问硬盘,同时线程B负责主要的界面。当线程A因为硬盘原因被卡住的时候,线程B仍然能够运行主要的用户界面,保持你的程序正常响应。当你有两个线程的时候,操作系统可以让CPU资源在它们之前转换,而不是卡在慢的那个上面。
线程更多,问题更多
我们知道,线程共享它们父进程的内存块,这就使得多个线程可以很容易的互相交换数据,在同一个应用内。举个例子,一个电影编辑器可能占用含有视频时间轴的一大部分共享内存,而这里共享的内存又可以被他的多个工作线程读取,这里的线程被设计用来渲染影片,并导出到硬盘文件。它们只需要一个到内存区块的指针,用来读取然后输出渲染的帧到硬盘。
只要两个或多个线程都从相同的内存位置读取数据,事情就能顺利的进行。But,注意这个but,让至少一个线程往内存里面写数据,而同时多个其他线程在读数据,问题就来了,这里有两个问题:
- 数据竞赛 – 让一个线程在执行写操作,而一个现在在执行读操作,如果这时候,写还没完成,那读肯定会得到一个坏的数据。
- 竞赛条件 – 读操作应该在写操作完成之后。但是如果相反的情况会发生什么?比数据竞赛更微妙的是,当两个或多个线程以不可预期的顺序做他们的工作,而实际上这些操作应该以特定的顺序执行,你的程序就会触发一个竞赛条件。
线程安全的概念
一段代码如果能正常工作,被称之为线程安全,也就是没有数据竞赛或者竞赛条件,尽管许多线程在同步执行。你可能已经注意到一些编程库声称自己是线程安全的:如果你在写多线程的程序,你希望确保任何其他的第三方函数能够跨线程正确的调用,而不触发并发问题。
数据竞赛的根本原因
我们知道CPU核心同一时间只能执行一条机器指令,这种指令被称为原子操作,因为它不能再分解:不能再分解成更小的操作。
这种不能分割的特性使得原子操作自带线程安全。当一个原子在执行写操作的时候,其他线程是不可能读完成一半的数据的。反过来,当一个线程在执行一个原子读操作的时候,肯定是读的当时完整的数据。对一个线程来说,是不可能遗漏原子操作的,所以不可能发生数据竞赛。
坏消息是,绝大部分的操作都不是原子操作。就是一个小小的赋值语句 x = 1
,在一些硬件上都可能是多个原子机器指令的组合。使得赋值本身变成非原子操作。所以在赋值的时候,如果一个线程在读x
的值,那就会触发数据竞赛。
竞赛条件的根本原因
抢先式多任务处理使操作系统能够完成的控制进程管理。通过高级的调度算法,它能开始,停止或者暂停线程。你作为一个程序员,是不能控制执行的时间和顺序的。实际上,像下面这样的代码是没有保护的
writer_thread.start()
reader_thread.start()
这段代码将以特定的顺序,开始两个进程。运行几次你将会注意到,每次运行的行为都不一样:有时writer线程先开始,有时候reader线程先开始,如果你的程序需要writer线程总是先执行,那你肯定会触发竞赛条件。
这种行为称为 非确定性 :你不能预测到每次执行的顺序。在debug由竞赛条件引起的错误的时候,会让人很恼火,因为你不能每次都重现这个错误。
教会线程相处: 并发控制
数据竞赛和竞赛条件都是现实中会遇到的问题。容纳两个或多个并发线程的技术称为并发控制,操作系统和编程语言提供了好几种方案来处理它。
同步 – 确保资源同一时间只能被一个线程使用。同步是将代码的特定部分标记为“受保护”,以便两个或多个并发线程不会同时执行它,从而搞砸了你的共享数据;
原子操作 – 一堆的非原子操作(像前面提到的赋值)通过操作系统提供的特殊指令,可以被转化为原子操作。用这种方法,共享的数据总会保持有效的状态,不管其他的线程以何种方式访问它。
不可变数据 – 共享的数据标记为不可变状态。线程只允许读,从根本上解决问题。我们知道,如果是只读的话,访问相同的内存地址里面的数据是安全的。这也是函数编程里面的主要哲学。
翻译自A gentle introduction to multithreading
单词
fiber: In computer science, a fiber is a particularly lightweight thread of execution.
https://en.wikipedia.org/wiki/Fiber_(computer_science)