更多資訊請參考 rubyonrails.org:

Rails 中的執行緒與程式碼執行

閱讀本指南後,您將了解

  • Rails 會自動並行執行哪些程式碼
  • 如何將手動並行性與 Rails 內部整合
  • 如何包裝所有應用程式程式碼
  • 如何影響應用程式重新載入

1 自動並行性

Rails 會自動允許同時執行各種操作。

當使用執行緒化的 Web 伺服器(例如預設的 Puma)時,多個 HTTP 請求將會同時處理,每個請求都有自己的控制器實例。

執行緒化的 Active Job 轉接器(包括內建的 Async)也會同時執行多個作業。Action Cable 通道也是以這種方式管理。

這些機制都涉及多個執行緒,每個執行緒都為某些物件(控制器、作業、通道)的唯一實例管理工作,同時共享全域程序空間(例如類別及其組態和全域變數)。只要您的程式碼不修改任何這些共享項目,它基本上可以忽略其他執行緒的存在。

本指南的其餘部分將描述 Rails 用於使其「基本上可忽略」的機制,以及具有特殊需求的擴展和應用程式如何使用它們。

2 執行器

Rails 執行器將應用程式程式碼與框架程式碼分開:每當框架呼叫您在應用程式中編寫的程式碼時,它都會由執行器包裝。

執行器由兩個回呼組成:to_runto_complete。執行回呼會在應用程式程式碼之前呼叫,而完成回呼會在之後呼叫。

2.1 預設回呼

在預設的 Rails 應用程式中,執行器回呼用於

  • 追蹤哪些執行緒處於自動載入和重新載入的安全位置
  • 啟用和停用 Active Record 查詢快取
  • 將取得的 Active Record 連線返回到集區
  • 限制內部快取生命週期

在 Rails 5.0 之前,其中一些是由單獨的 Rack 中介軟體類別處理(例如 ActiveRecord::ConnectionAdapters::ConnectionManagement),或直接使用像 ActiveRecord::Base.connection_pool.with_connection 的方法包裝程式碼。執行器使用單一更抽象的介面取代這些。

2.2 包裝應用程式程式碼

如果您正在編寫將會呼叫應用程式程式碼的程式庫或組件,您應該使用呼叫執行器來包裝它

Rails.application.executor.wrap do
  # call application code here
end

如果您從長時間執行的程序重複呼叫應用程式程式碼,您可能會想要改用 重新載入器 包裝。

每個執行緒在執行應用程式程式碼之前都應該被包裝,因此如果您的應用程式手動將工作委派給其他執行緒(例如透過 Thread.new 或使用執行緒集區的 Concurrent Ruby 功能),您應該立即包裝區塊

Thread.new do
  Rails.application.executor.wrap do
    # your code here
  end
end

Concurrent Ruby 使用 ThreadPoolExecutor,有時會使用 executor 選項進行配置。儘管名稱相同,但它與此無關。

執行器是安全可重入的;如果它已在目前執行緒上啟用,則 wrap 不會執行任何動作。

如果將應用程式程式碼包裝在區塊中是不切實際的(例如,Rack API 使此問題複雜化),您也可以使用 run! / complete! 配對

Thread.new do
  execution_context = Rails.application.executor.run!
  # your code here
ensure
  execution_context.complete! if execution_context
end

2.3 並行性

執行器將使目前執行緒在 載入互鎖 中進入 running 模式。如果另一個執行緒目前正在自動載入常數或卸載/重新載入應用程式,此操作將暫時被封鎖。

3 重新載入器

與執行器一樣,重新載入器也會包裝應用程式程式碼。如果執行器尚未在目前執行緒上啟用,重新載入器將會為您呼叫它,因此您只需要呼叫一個。這也保證重新載入器所做的一切(包括其所有回呼呼叫)都發生在執行器內部。

Rails.application.reloader.wrap do
  # call application code here
end

重新載入器僅適用於長時間執行的框架層級程序重複呼叫應用程式程式碼的情況,例如 Web 伺服器或作業佇列。Rails 會自動包裝 Web 請求和 Active Job 工作者,因此您很少需要自己呼叫重新載入器。請務必考慮執行器是否更適合您的使用案例。

3.1 回呼

在進入包裝的區塊之前,重新載入器會檢查正在執行的應用程式是否需要重新載入 - 例如,因為模型的原始檔已被修改。如果它判斷需要重新載入,它將會等待直到安全,然後再繼續進行重新載入。當應用程式配置為始終重新載入,無論是否偵測到任何變更時,重新載入會在區塊結尾執行。

重新載入器也提供 to_runto_complete 回呼;它們與執行器的回呼在相同的點被呼叫,但僅在目前執行已啟動應用程式重新載入時。當不需要重新載入時,重新載入器將會呼叫包裝的區塊,而沒有其他回呼。

3.2 類別卸載

重新載入過程中最重要的部分是類別卸載,其中所有自動載入的類別都會被移除,準備好再次載入。這將在執行或完成回呼之前立即發生,具體取決於 reload_classes_only_on_change 設定。

通常,需要在類別卸載之前或之後立即執行其他重新載入動作,因此重新載入器也提供 before_class_unloadafter_class_unload 回呼。

3.3 並行性

只有長時間運作的「頂層」程序應該呼叫重新載入器,因為如果它判斷需要重新載入,它將會被封鎖,直到所有其他執行緒都完成任何執行器呼叫。

如果這發生在「子」執行緒中,而父執行緒在執行器內部等待,則會導致無法避免的死鎖:重新載入必須在子執行緒執行之前發生,但當父執行緒正在執行時,它無法安全地執行。子執行緒應該改用執行器。

4 框架行為

Rails 框架組件也使用這些工具來管理其自身的並行需求。

ActionDispatch::ExecutorActionDispatch::Reloader 是 Rack 中介軟體,分別使用提供的執行器或重新載入器包裝請求。它們會自動包含在預設的應用程式堆疊中。如果發生任何程式碼變更,重新載入器將確保任何傳入的 HTTP 請求都使用新載入的應用程式副本進行處理。

Active Job 也使用重新載入器包裝其作業執行,載入最新的程式碼來執行佇列中的每個作業。

Action Cable 改用執行器:由於纜線連線連結到類別的特定實例,因此無法針對每個傳入的 WebSocket 訊息重新載入。但是,只有訊息處理常式被包裝;長時間執行的纜線連線不會阻止由新的傳入請求或作業觸發的重新載入。相反,Action Cable 使用重新載入器的 before_class_unload 回呼來斷開其所有連線。當用戶端自動重新連線時,它將會與新版本的程式碼進行通訊。

以上是框架的進入點,因此它們負責確保其各自的執行緒受到保護,並決定是否需要重新載入。其他組件只需要在產生額外執行緒時使用執行器。

4.1 配置

只有當 config.enable_reloadingtrueconfig.reload_classes_only_on_change 也為 true 時,Reloader 才會檢查檔案變更。這些是 development 環境中的預設值。

config.enable_reloadingfalse 時(預設在 production 環境中),Reloader 僅作為 Executor 的傳遞機制。

Executor 總是有重要的工作要做,例如資料庫連線管理。當 config.enable_reloadingfalseconfig.eager_loadtrue 時(production 環境的預設值),不會發生重新載入,因此不需要載入互鎖機制 (Load Interlock)。在 development 環境的預設設定下,Executor 將使用載入互鎖機制來確保常數僅在安全的情況下載入。

5 載入互鎖機制 (Load Interlock)

載入互鎖機制允許多執行緒運行環境中啟用自動載入和重新載入。

當一個執行緒正在透過評估適當檔案中的類別定義來執行自動載入時,重要的是沒有其他執行緒遇到對部分定義的常數的引用。

同樣地,只有在沒有應用程式程式碼正在執行時,執行卸載/重新載入才是安全的:重新載入後,例如 User 常數可能會指向不同的類別。如果沒有這個規則,時間不佳的重新載入會導致 User.new.class == User,甚至 User == User 的結果為 false。

載入互鎖機制解決了這兩個限制。它追蹤目前哪些執行緒正在執行應用程式程式碼、載入類別或卸載自動載入的常數。

一次只能有一個執行緒載入或卸載,並且要執行這兩者中的任何一個,它必須等待直到沒有其他執行緒正在執行應用程式程式碼。如果一個執行緒正在等待執行載入,它不會阻止其他執行緒載入(事實上,它們會合作,並且每個執行緒會依次執行它們排隊的載入,然後全部一起恢復執行)。

5.1 permit_concurrent_loads

Executor 會在其區塊的持續時間內自動取得一個 running 鎖,並且自動載入知道何時升級為 load 鎖,然後在之後切換回 running

然而,在 Executor 區塊內執行的其他阻塞操作(包括所有應用程式程式碼)可能會不必要地保留 running 鎖。如果另一個執行緒遇到必須自動載入的常數,這可能會導致死鎖。

例如,假設 User 尚未載入,以下程式碼會導致死鎖

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread waits here; it cannot load
           # User while another thread is running
    end
  end

  th.join # outer thread waits here, holding 'running' lock
end

為了防止此死鎖,外部執行緒可以 permit_concurrent_loads。透過呼叫此方法,執行緒保證它不會在提供的區塊內取消引用任何可能自動載入的常數。滿足該承諾最安全的方法是將其盡可能靠近阻塞呼叫放置

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # inner thread can acquire the 'load' lock,
           # load User, and continue
    end
  end

  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    th.join # outer thread waits here, but has no lock
  end
end

另一個使用 Concurrent Ruby 的範例

Rails.application.executor.wrap do
  futures = 3.times.collect do |i|
    Concurrent::Promises.future do
      Rails.application.executor.wrap do
        # do work here
      end
    end
  end

  values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    futures.collect(&:value)
  end
end

5.2 ActionDispatch::DebugLocks

如果您的應用程式發生死鎖,並且您認為可能涉及載入互鎖機制,您可以暫時將 ActionDispatch::DebugLocks 中介軟體新增至 config/application.rb

config.middleware.insert_before Rack::Sendfile,
                                  ActionDispatch::DebugLocks

如果您接著重新啟動應用程式並重新觸發死鎖條件,/rails/locks 將顯示互鎖機制目前已知的所有執行緒的摘要、它們正在持有或等待的鎖定層級以及它們目前的堆疊追蹤。

通常,死鎖是由互鎖機制與其他一些外部鎖定或阻塞 I/O 呼叫衝突所引起的。一旦找到它,您就可以使用 permit_concurrent_loads 將其包裝起來。



返回頁首