分身之術(?) Thread

Thread 幼幼班

前言

這篇文章主要是介紹 thread 的基本概念,希望能夠讓非科系或是和我一樣半路才出家寫程式的人有幫助。

雖然寫網頁用到的機率不算高,但如果把概念套到我們日常生活上的行為,其實是蠻有趣的概念。

以前一直認為程式如果有多個 thread 的話,我的執行速度應該也可以變成 n 倍快。一直到今年我才發現不是這樣,至少在現在的 ruby 大部分情況下還是如此。

Parallel

台灣翻譯成「平行」,顧名思義就是所有 thread 會一起執行。這也是當初我對多執行緒程式的想像,因為會一起跑,所以執行速度應該也會是 n 倍快,不然我用多個 thread 有什麼意義呢?

Concurrent

台灣翻譯成「並行」。因為和中國的翻譯正好相反,想當初我個人自己被搞混了很久,這個其實是 ruby 現行版本中多執行緒的執行方式。

如果用現實的行為來呈現的話,一個廚師在做菜的行為就屬於 concurrent。比方說要完成一道義大利麵的話可以把步驟分成下面幾個:

  • 洗菜
  • 切菜
  • 材料丟平底鍋
  • 燒水煮義大利麵
  • 煮好的義大利麵丟平底鍋和料一起拌炒一下
  • 裝盤

但一個熟練的廚師絕對不會做完一個步驟之後才繼續下一個步驟,通常會在切菜之前就開始煮水,然後在炒其他材料的時候也順便算時間把義大麵丟下去。這種並非完全照順序來執行,但也不是真的同時做兩件事情,而是不停的切換現在正在執行的動作的行為就算是 concurrent。

而因為電腦的執行速度夠快,所以會讓你有他同時做了好多事情的錯覺。

ruby 對多執行緒的實際狀況

如同上面講的,ruby 現在面對多執行緒是 concurrent 而不是 parallel。這讓我對我程式裡面的 thread 產生了疑問,如果他不是同時在執行的話,是不是對速度上根本沒有任何影響?

於是這邊我們用計算質數的程式來比一下速度:

require 'benchmark'
require 'prime'

primes = 50_000_000

Prime.each(primes).to_a

Benchmark.bm(15) do |x|
  x.report('single-threaded') do
    8.times do
      Prime.each(primes).to_a
    end
  end

  x.report('multi-threaded') do
    2.times.map do
      Thread.new do
        4.times do
          Prime.each(primes).to_a
        end
      end
    end.each(&:join)
  end
end

單執行緒會去計算 5 千萬中的質數 8 次。

多執行緒則是會去分成 2 個執行緒,然後各做 4 次。

下面是執行結果:

單執行緒

6.558085秒

6.622057秒

6.708759秒

多執行緒

6.375448秒

6.461049秒

8.008214秒

這樣子果然多執行緒是寫心酸的嗎?!

GIL

Global Interpreter Lock

  • 有他在一次就只會跑一個 thread,所以你只能 concurrent 不能 parallel,於是有了上面的結果。
  • 簡略來說這樣做是為了確保 ruby 內部是 thread safe 的,不過即使這樣也還是有網開一面的時候。

Blocking IO

GIL 會防止 ruby code 平行執行,但是 blocking IO 並不是 ruby code 的一部份,這種時候 GIL 是會放行的,比方用下面這個簡單的程式來說明:

require  'open-uri'

2.times.map do
  Thread.new do
      open('http://google.com')
  end
end.each(&:join)

上面那段程式會送 request 給 google,執行速度會取決於 google 的狀態 + 你的網路速度。慢的話可以很慢,還好 GIL 在這種時候不會被單一 thread 卡著,實際上在跑的時候會像是下面這樣:

  • ThreadA: ya~~ 我要來連去 google 啦,在等回應的時候我先把 GIL 釋放吧
  • ThreadB: GIL 現在是我的啦,我也去連 google,然後等 response…..
  • 於是就有可能兩個 thread 現在都在等 response,而且都沒有佔用 GIL 了
  • 於是在得到回應前,可能有個時間點同時有兩個 thread 在執行

上面那一段程式如果也去比較單和多執行緒的執行時間的話,會得到下面這樣的結果:

單執行緒

14.179605秒

11.918178秒

13.309941秒

多執行緒

5.949117秒

6.091986秒

8.468648秒

可以明顯發現這一次多執行快了很多。

ruby3 - GUILD

為了讓 ruby 可以真正 的 parallel,ruby commiter 的笹田耕一先生發表了 Guild Prototype,大略上的樣子如下:

  • GUILD 裡面會至少有一個 thread
  • 一個 thread 裡面會至少有一個 fiber
  • 基於 GIL 所以同個 guild 裡面的多個 thread 還是不會同時跑
  • 但可以同時跑多個 guild 來達成平行的效果

在今年的 Ruby Kaigi / RubyElixirConfTaiwan 2018 也都發表了演講,有興趣的話可以點進去觀看他的詳細介紹。