近期跑步的时间少了,写作灵感尽失,已经好久没有更新文章了。所幸前段时间看了一些关于多线程的资料,故而有了这篇文章,也算是一个阶段性的总结吧。
一. 先普及一下知识
开始扯淡之前先来贴一些关于线程的文字。
1)进程与线程有何区别
有人在StackOverflow概括得比较全面,线程其实就是轻量级的进程。一般进程都有自己的一部分独立的系统资源,彼此是隔离的。为了能使不同的进程之间能够互相访问资源并进行协调工作,则需要通过进程间的通信。而线程则采用共享内存空间的形式,多个线程可以共享同一份内存空间。相比起进程,虽然线程看起来占用内存空间少了,但是却会出现资源竞争的情况。
2)并行与并发
采用多线程或者多进程的方式进行开发的方式称为并发,有些语言甚至可以充分利用电脑CPU的多核特征实现并行。那么并行与并发又有何区别?这里有比较有意思的解答Github。
简单来讲,并行就是多个任务同时执行,而并发则是同一时间段多个任务交替执行。并行强调的是同一时间点两个任务同时执行,而并发强调的是同一时间段两个任务同时执行。
二. Ruby中的高并发
传统意义上的Ruby--MRI,有Thread这个类,这样看来它是支持多线程的,我们可以用这个类来创建多个线程实例。然而在MRI里面我们却只能实现并发,它并不能活用电脑的CPU多核特征实现并行操作。下面我分场景来讲解一下相关的概念。
1) GIL的约束
得益于GIL(全局解析器锁)的存在,MRI只能够实现并发,并无法充分利用CPU的多核特征,实现并行任务,进而减少程序运行时间。
在MRI里面线程只有在拿到GIL锁的时候才能够运行,即便我们创建了多个线程,本质上也就只有一个线程实例能够拿到GIL,如此看来某一时刻便只能有一个线程在运行。
可以考虑下面这样的场景:
老师给小蓝安排了除草任务,小蓝为了加快速度呼唤了好友小张,然而除草任务需要有锄头才能进行。为此,即便有好友相助但锄头却只有一把所以两个人无法同时完成除草的任务,只有拿到锄头使用权的一方才能够进行除草。这把锄头就像是解析器中的GIL,把小张跟小蓝想象成被创建的两个线程,当两个人的工作效率一样的时候,受限于锄头这个约束并无法同时进行除草任务,只能够交替使用锄头,本质上并不会减少工作时间,反而会在换人的时候(上下文切换)耗费掉一定的时间。
在一些场景下创建更多的线程并不能真正地减少程序的运行时间,反而有可能会随着进程数量的增加而增加切换上下文的开销,从而导致程序变得更慢。
2) 多线程的作用
从前面的故事可以看出,由于GIL的存在,多线程并不能减少程序运行的时间,反而会因为开的线程太多,导致上下文切换开销变大,从而增加程序的运行时间。那么多线程是否就没有作用了?是不是Ruby的Thread类在实际场景下它就是个摆设?
当然不是,请大家看下面两个场景:
场景1: 单线程坦克大战
在不能运用计算机多核的程序设计语言里面,我们每个时间点只有一个线程在工作。回想我们小时后玩的坦克大战的游戏。假设我们有坦克A, 坦克B。坦克A需要向上移动100px,坦克B需要向右移动300px。如果我们是单线程的话,那么只能够等坦克A向上移动100px之后,坦克B才能向右移动300px,这样的游戏体验肯定是很糟糕的,这就是我们的串行开发所不能适用的场景,它的特点是必须要先完成一个任务然后再开始其他任务。
运行起来大概就像下面这样:
场景2: 多线程坦克大战
如果我们采用多线程实现方式便能够很好地解决这种问题了。线程默认由操作系统进行调度,在某个时间段内这些线程几乎是交替使用CPU。这样就可能实现,在坦克A向右移动一小段位置(可能是1~2px)后坦克B的线程占用了CPU,坦克B得以向上移动一小段位置,如此循环反复直到任务完成。另外,由于两个线程之间上下文切换是很快的用户几乎察觉不到中间的停顿,因此可以给用户造成一种“两辆坦克同时移动”的假象。
感觉就像下面这种行为:
3) 多线程需要额外保障
我们前面也说了线程是轻量级的进程,并且他们共享内存空间,这会造成什么问题呢?
考虑下面这种场景:
假设如果我们为某个操作创建了10个线程,而每个线程都会依赖于前一个线程的值。如果在一个线程任务处理过程中,系统把CPU让给了另外线程,然而前面的线程任务只是处理了一半,其他线程无法享受这个线程的任务成果,这便可能导致最终结果与我们期望不符。
这样说可能有点迷糊,我举个Ruby例子来说明一下这一点。
a = 0
threads = (1..10).map do |i|
Thread.new(i) do |i|
c = a
sleep(rand(0..1))
c += 10
sleep(rand(0..1))
a = c
end
end
threads.each { |t| t.join }
puts a
这段代码要实现的功能很简单,只是把变量a累加10次,每次加10,并且开了10个线程去完成这个任务。正常情况下我们期望的值是a == 100
,然而事实却是
> ruby a.rb
10
> ruby a.rb
10
> ruby a.rb
30
> ruby a.rb
20
怎么可能有这种随机的值?如果不信的话你可以把程序拷贝到自己的电脑去运行一下。出现这种情况的原因是,当我们的操作执行到一半的时候其他线程介入了,导致了数据混乱。这里为了突出问题,我们采用了sleep方法来把控制权让给其他线程,而在现实中,线程间的上下文切换是由操作系统来调度,我们很难分析出它的具体行为。
我们减少线程数量来分析一下:
线程1执行了c = a
之后让出了系统控制权,然后线程二执行了c = a; c += 10; a = c
,我们得到a == 10 && a == c
。 然而这个时候控制权让回给线程1,它继续往下执行c += 10, a = c
。因为在线程1的上下文中c
还是原来的数值0,所以执行这个操作之后我们会得到a == 10 && a == c
,并且覆盖了线程二的操作结果。因此最终我们无法得到我们心目中的值20
。而且线程越多这个过程可能会越乱,更加难以分析。
为了解决这种问题,我们常用的方法是给某个代码块或者变量加锁。它使得加锁部分被一个线程访问的时候不允许其他线程介入。修改后的代码如下
a = 0
mutex = Mutex.new
threads = (1..10).map do |i|
Thread.new(i) do |i|
# 加锁
mutex.synchronize do
c = a
sleep(rand(0..1))
c += 10
sleep(rand(0..1))
a = c
end
end
end
threads.each { |t| t.join }
puts a
加上锁之后我们的运行结果就能得到保证了
> ruby a.rb
100
> ruby a.rb
100
PS: 从前面的知识可以知道,如果是只能运用计算机单核所形成的并发操作其实并不能真正提高程序的运行效率,反而会因为上下文的切换而拖慢程序运行速度,如果再加上锁机制的话就更会增加程序运行的开销。因此当我们想使用多线程的时候要考虑到使用场景中并发的必要性。这里是否真的需要多线程?我们需要多少线程?是否需要加锁?会不会有更好的解决方案?
4) 协程
上面说了线程的种种问题,它是由系统调度的,我们很难控制并预测它的行为。在不能够充分利用计算机多核的情况下它们运行起来可能比串行程序还要慢。那现在说一个高端点的线程-协程。
协程也是线程的一种,但是它与传统的线程还是有点区别,Ruby在1.9之后开始支持Fiber
,使用它就可以很容易地写出协程的程序。在某些场景下(如Web领域)协程会比线程更加适用。
我举个比较简单的场景的去聊聊这个事情
小蓝被布置了需要完成语文,数学,英语三门功课,预计每门功课需要一个小时的时间去完成。那完成3门功课则大约需要3个小时。小蓝可以采用下面两种工作方式
系统调度线程的方式
如果使用系统默认线程调度的方式来工作,我们以5分钟作为一个时间片段,每5分钟小蓝就需要换一门功课去做,比如正在做着语文,然后5分钟之后会切换到数学,或者英语,也有一定几率继续做语文。做5分钟后,又再次进行切换。如此反复,直到3门功课都完成之后小蓝就可以休息了。
这看起来有点疯狂,如果你让小蓝按这样的方式去写作业的话,估计不到3个小时他就已经疯了。另外,如此频繁的上下文切换,最终所耗费的时间肯定会大于3个小时。它的任务流程大概就像这样子
协程的工作方式
从上面的工作方式可以看出,系统默认的线程调度方式在有些场景下会过度消耗计算资源,无故增加运行时间,并且有点反人类。从人类的角度去考虑如何完成这3门功课,更人性化的做法会是先完成一门然后再去完成下一门。我们或许可以把这个过程想象成一队列。
这个过程就有点像线程之间可以相互协作来完成任务,避免不必要的上下文切换,具体是否把控制权让给其他线程,交给哪个线程,将由当前这个线程来决定,在某种程度上可以减少了不必要的线程切换。工作流程大概像这样
Ruby1.9引入了Fiber后使得我们可以在代码中使用协程。下面是一个简单的例子
require 'fiber'
fiber1 = Fiber.new do
puts "In Fiber 1"
Fiber.yield
end
fiber2 = Fiber.new do
puts "In Fiber 2"
fiber1.transfer
puts "Never see this message"
end
fiber3 = Fiber.new do
puts "In Fiber 3"
end
fiber2.resume
fiber3.resume
Fiber是Ruby用于创建协程的类,在正式调度之前我们先创建fiber1~3
三个协程用例,然后在后面的代码中对实例进行调度。首先是fiber2
被调度,然后从fiber2
内部去把控制权转移给fiber1
, 故而"Never see this message"这条信息不会被打印。最后fiber1
在内部把控制权转交给主线程,这个时候主线程继续往下执行,开始调度fiber3
。最后的输出结果是
In Fiber 2
In Fiber 1
In Fiber 3
咋一看这个例子似乎没有什么特别,虽然它可以让我们很方便地调度三个协程实例,但是实际上这种控制流我们即便使用平时的串行程序也能够实现,只需要定义三个方法,然后按照上面的顺序去调用就行了。那我再举一个例子来突出一下协程
fiber1 = Fiber.new do
puts "fiber1 first resume"
Fiber.yield
puts "fiber1 second resume"
Fiber.yield
puts "fiber1 third resume"
end
fiber2 = Fiber.new do
puts "fiber2 first resume"
Fiber.yield
puts "fiber2 second resume"
end
fiber1.resume #1
fiber2.resume #2
fiber2.resume #3
fiber1.resume #4
fiber1.resume #5
fiber1.resume #6
以上这段代码会输出什么?答案是
fiber1 first resume
fiber2 first resume
fiber2 second resume
fiber1 second resume
fiber1 third resume
tread.rb:22:in `resume': dead fiber called (FiberError)
from tread.rb:22:in `<main>'
代码分析: 我们首先创建fiber1
, fiber2
这两个实例,先在主线程调度fiber1
(代码#1),fiber1
输出了第一次被调度的信息,然后通过Fiber#yield
让出控制权给主线程,主线程便继续执行代码,调度fiber2
(代码#2)。fiber2
输出了自己第一次被调度的信息之后就通过Fiber#yield
让出控制权给主线程,主线程便继续往下执行代码#3。fiber2
再次被调度后输出了自己第二次被调度的信息,同时它也运行到代码块的末尾,让出了控制权。主线程接着执行代码#4, #5, #6。而#6这段代码我特地空了一行,因为在#5的时候fiber1
已经完成所有任务了,如果再次调度的话,我们会收到错误信息,警告我们当前被调度的协程已经dead了。
如果用串行代码来实现上面这么诡异的控制流会有点困难吧,这也是协程的可贵之处,线程之间似乎是在相互协作着一起工作。得益于这种特性我们可以写出非租塞的,吞吐量更高的Web服务。在Python领域就有一款叫Tornado的框架非常火热,便是以这种机制来实现的。
5) 真并行
既然官方版本的Ruby(MRI)会加上GIL导致我们没有办法活用CPU的多核,使得我们只能够实现并发操作,没有办法实现并行。既然GIL这么碍事,拿掉不就好了?
回到最初的故事,如果小蓝跟小张的除草任务不需要用锄头就能够完成,或者人手都有一把锄头的话,那么两个人便能够同时进行除草任务了,而不需要交替执行。只要安排合理,势必会比一个人完成除草任务更节省时间,这便是并行的工作方式。
在Ruby的世界中确实存在一些已经去除了GIL的实现,其中包括Rubinius 以及 jruby他们的底层分别用的是C++以及Java实现的,除了去除GIL锁之外,他们还做了其他方面的优化。某些场景下他们都有着比MRI更好的性能,更详细比较可以猛戳这里。目前比较火的Rails服务器Puma也说到
Today, Puma runs on all Ruby implementations, but will always run best on any implementation that provides true parallelism.
或许得益于这些Ruby的实现,像Rails这些Web框架将会有更好的性能吧。
三. 尾声
以上是我对线程高并发这些概念的一些简单总结,如果有误解的还望指正。
PS: 所有的动画例子为了方便起见我都用JavaScript实现,代码托管到这里,前端初学者可以上去看看具体实现,前端高手请忽略。
很感谢你能读到这里。