在程序员的职业生涯中,有时会意识到他们的代码只需要运行得更快。 无论是创建低延迟API还是解析数十亿个数据点的程序,速度都是一个巨大的因素。
如果代码运行太慢,该怎么办?
幸运的是,编程工具带中有很多工具可以使代码运行更快。 您可能想到的第一个想法是缓存。 但是,当无法选择缓存时该怎么办?
另一个可行的选择是多线程。 为了讨论多线程,重要的是讨论什么是进程和线程。 用最简单的术语来说,您可以将流程视为执行程序。
例如,在终端中运行ps aux以查看计算机上当前正在运行的所有进程。 所有这些过程都对应于程序或应用程序。 您正在阅读的Web浏览器正在使用一个或多个进程。
您可以将线程视为该进程的工作程序。 如果流程是老板,那么线程就是忠实的员工。 每个进程都会启动一个线程,但可以根据需要创建更多线程。
一个进程内的所有线程共享相同的堆内存,但包含它们自己的执行堆栈。 这意味着线程可以共享数据,但不能共享函数调用。
多线程之所以有用,是因为执行程序可以将任务委托给许多不同的线程。 这相当于雇主雇用五十名程序员来构建整个SaaS产品,而不仅仅是一个。 证明这一点的最佳方法是举一个例子。
需要注意的是,本文中的所有代码示例都将用Ruby编写,并将使用 Thread 类启动新线程。
假设您有一个程序,需要遍历任务列表并完成它们。 在此示例中,假设每个任务执行大约需要1秒钟。 如果您依靠单个线程来执行此循环,则可能需要10秒才能完成。
$ ruby 01_serial_loop.rb
1
2
3
4
5
6
7
8
9
10
10.02504587173462 seconds to complete.
但是,如果我们将每个任务分配给不同的工人,则只需1秒钟,因为所有工人将同时工作。 在Ruby中,这就像创建一个新的Thread.new实例并将其传递给一个块执行一样简单。 每个Thread.new返回一个线程的新实例。
但是,仅仅增加新线程是不够的。 程序必须确保这些线程在退出之前完成。 如果程序没有明确等待线程完成,则程序将退出得太早。
在Ruby中,必须在每个线程实例上调用join方法,以便每个线程与主线程重新连接。
$ ruby 02_multithread_loop.rb
5
4
8
6
10
2
1
9
3
7
1.0051839351654053 seconds to complete.
并发与并行
您可能从并发示例中注意到的一件事是,线程以随机顺序返回。 线程的完成顺序与启动时不同。 它们是异步的。 这是并发和并行性背后的主要概念。 每个线程以其自己的步调启动和完成,而不考虑其他线程。
虽然并发性和并行性并不相同,但是它们是相似的。 每个概念之间的区别不在本文的讨论范围之内,但可以在此处阅读 。
需要注意的重要一件事是,由于Ruby语言的限制-特别是Matz Ruby解释器(MRI)和全局解释器锁定(GIL)-Ruby的标准版本无法实现真正的并行性。 但是,它可以进行并发。
注意:直到1.8版,MRI才在Ruby中使用。 从1.9版开始,默认解释器为 YARV (又一个Ruby VM)。
比赛条件
了解并发性很重要,因为如果您不小心,可能会导致难以修复的错误。 一种可能由多线程引起的错误被称为竞争条件 。
竞争条件的最常见形式是当多个线程尝试同时访问同一块内存时。 这种情况下的问题是许多操作可以忽略,或者更糟的是,内存可能损坏。
让我们看一个例子,其中许多线程试图增加全局计数器。 我们从0开始启动计数器,并创建1000个线程,所有任务都是将计数器增加1。
如果一切顺利,计数器应以1000结尾。但是请记住,每个线程都在单个变量或内存上运行。 在单个状态下发生如此多的不同操作时,可能会发生数据损坏。 让我们看看在这种情况下会发生什么。
$ ruby 03_global_counter.rb
count = 1000
瞧! 有效! 但为什么? 在单个状态上发生的如此多的操作肯定会破坏它,对吗? 不总是。
在这种情况下,每个线程必须执行的操作是如此之快,以至于在创建下一个线程时,第一个线程已经完成。 根本没有足够的时间让多个线程互相覆盖。
但是,如果不是这种情况怎么办? 如果每个线程在进行某种状态操作之前必须做一些工作,例如API调用,该怎么办? 我们可以通过在递增计数器之前让每个线程休眠一段随机的时间来模拟额外的工作。 这将阻止线程立即完成。
$ ruby 04_sleeping_threads.rb
count = 1000
“嗯,”您可能在想。 “看起来它仍然有效。 我不认为这个人知道他在说什么。”
别太自在 该示例起作用的原因是标准Ruby运行时无法并行处理。 默认情况下,线程一次只能在全局变量上执行一个。 使用标准的Ruby运行时,您不必太担心竞争条件。
但是,如果您不使用标准的Ruby运行时该怎么办? 如果您使用的是具有并行性的其他语言或运行时(如JRuby),该怎么办? 如果我们将Ruby版本切换到JRuby,我们可以看到竞争条件的结果。
# Change ruby version to JRuby
$ rbenv local jruby-9.1.10.0
$ ruby 04_sleeping_threads.rb
count = 864
kes 看来我们的柜台缺少136计数。 这意味着在一个或多个点上,多个线程相互覆盖。
由于JRuby在Java虚拟机上运行并具有并行性,因此我们的程序容易受到竞争条件的影响。 在线程安全方面 ,这是非常不安全的。
多个全局变量
如果我们在线程中访问多个共享数据,则此漏洞甚至更加明显。 在下面的程序中,我有两个递增的计数器,并且计算了两个计数器之间的差。
如果一切顺利,我们应该看到两个最终计数都应为1000,而差异应等于0,这意味着计数值在任何时候都不会变得不同步。
$ ruby 05_multiple_counters.rb
count1 : 1000
count2 : 1000
diff : 0
执行两个增量和计算差值所花费的时间对于多个线程彼此重叠来说太快了。 如您所料,如果我们添加其他计算随机性并启用并行性,情况并非如此。
$ rbenv local jruby-9.1.10.0
$ ruby 06_mulitple_values_sleep.rb
count1 : 890
count2 : 869
diff : 253904
然而,有趣的是,在这种情况下,即使是普通的Ruby运行时也可能被触发。
$ rbenv local 2.4.1
$ ruby 06_mulitple_values_sleep.rb
count1 : 1000
count2 : 1000
diff : 339498
尽管一次只能有一个线程访问一个计数器,并且两个计数最终都达到预期的1000个计数值,但它们并不总是同步的。 当一个线程递增count2 ,另一个线程递增count1导致值不同。 这种差异在diff 。 虽然标准的Ruby MRI可以防止很多典型的比赛条件,但不能不受这些条件的影响。
防止比赛条件
对于那些不熟悉比赛条件的人,您可能想知道如何预防它们。 “必须有一种使我们的程序具有确定性和线程安全性的方法,对吗?” 你可能会问。 幸运的是,有。 我们可以使用一种称为互斥对象的东西。 Mutex ,简称。
互斥锁将确保一次只能有一个线程访问一块内存。 在Ruby中使用互斥锁非常容易。 所有你需要做的就是创建Mutex类的新实例,并在包裹漏洞代码synchronize块。
$ ruby 07_thread_safe.rb
count1 : 1000
count2 : 1000
diff : 0
多线程并不总是最好的解决方案
使用互斥锁的权衡是,由于线程必须等待,因此程序的运行速度将比不使用互斥锁慢。 虽然运行时间较长的程序要比不准确的程序要好,但是使用互斥锁可能会破坏多线程的目的。 该程序最好以串行方式运行。
例如,如果我们以最后一个示例为例,将线程数减少到100,并花了多长时间来运行,则可以看到多线程和非多线程之间的差异很小。 实际上,由于创建线程的开销以及它们之间的上下文切换,多线程可能会变慢。
# Non-Multithreading Result
$ ruby 08_single_thread_multiple_counter.rb
count1 : 100
count2 : 100
diff : 0
96 seconds to finish
# Multithreading Result
$ ruby 09_multi_thread_mutex.rb
count1 : 100
count2 : 100
diff : 0
99 seconds to finish
由于创建100个线程并迫使它们全部等待mutex的开销,多线程程序的性能较差。 严重依赖访问全局变量的程序可能无法从多线程中受益。
同样,必须以特定顺序执行的任务也不适合多线程。 正如我们在本文前面所看到的,线程并非以确定性顺序完成。
理想的用例
尽管多线程可能并不适合每种情况,但在很多情况下它都是完美的。 一个示例是您的程序必须发出多个请求以从内部服务或第三方获取数据。
假设您正在创建一个API终结点,该终结点返回有关用户帐户的数据。 响应中是贵公司既不拥有也不管理的数据,例如GitHub Repos。 要获取用户的GitHub信息,该程序将需要向GitHub发出许多请求。
此API的要求是数据必须准确,并且延迟必须小于一秒。 越快越好。
在以下示例API中,程序从文件中提取所有回购名称(以模拟对数据库的请求)。 然后,该程序循环遍历每个存储库名称,并向GitHub API发出请求以检索有关该存储库的更多数据。
您可能会想到,这可能导致用户的响应时间较长。
$ curl localhost:4567/slow_response
{
“response”: [
…
],
“time”: 7.479549884796143
}在此示例中,检索用户数据需要花费7秒钟以上的时间。 这是不可接受的。 我们需要花一秒钟的时间,以便可以扩展并提供更好的用户体验。
缩短此响应时间的一种可能方法是将所有回购数据缓存在数据库中。 该解决方案的问题在于回购数据经常更改。 标题,观察者数量,星星数量和叉子。 这些值都可能发生变化,这将导致结果不准确。 错误的结果是不允许的。
要考虑的另一个工具是多线程。 不必一次向GitHub发出API请求,而是可以为每个请求创建一个单独的线程。
$ curl localhost:4567/fast_response
{
“response”: [
…
],
“time”:0.5799121856689453
}通过更改3行,我们将响应时间从7秒以上缩短到了不到1秒。 通过启用并行性,可以使此代码的性能更高。
$ rbenv local jruby-9.1.10.0
$ ruby sinatra_demo_fast_response.rb
[2018–09–29 11:47:21] INFO WEBrick 1.3.1
[2018–09–29 11:47:21] INFO ruby 2.3.3 (2017–05–25) [java]
== Sinatra (v1.4.8) has taken the stage on 4567 for development with backup from WEBrick
# In a new terminal tab
$ curl localhost:4567/fast_response
{
“response”: [
…
],
“time”:0.3163459300994873
}有了并行机制,响应时间可以减少到三分之一秒。 我们的用户将对该性能感到非常满意。
希望您对多线程有新的见解。 并发是一种语言不可知的工具,非常适合优化代码。 大多数语言都以某种方式支持多线程或多处理。
如果您发现这篇文章对您有帮助或有见地,请留下一些????。 下次您发现自己需要创建快速程序时,希望您将多线程视为一种可行的解决方案。
From: https://hackernoon.com/need-faster-code-try-multithreading-5dc30c83837c