v7.1.3.2
更多資訊請至 rubyonrails.org: 更多 Ruby on Rails

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

閱讀本指南後,您將知道

1 自動並行

Rails 自動允許在同一時間執行各種作業。

當使用執行緒網路伺服器(例如預設的 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 並行性

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

3 重新載入器

與 Executor 類似,Reloader 也會包裝應用程式碼。如果 Executor 尚未在目前執行緒中啟用,Reloader 會為您呼叫它,因此您只需要呼叫其中一個即可。這也能確保 Reloader 執行的所有動作,包括其所有回呼呼叫,都會在 Executor 內部包裝執行。

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

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

3.1 回呼

在進入包裝區塊之前,Reloader 會檢查執行中的應用程式是否需要重新載入,例如因為模型的原始檔已變更。如果它判定需要重新載入,它會等到安全後再繼續進行。當應用程式設定為不論是否偵測到任何變更都重新載入時,重新載入會在區塊結束時執行。

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

3.2 類別卸載

重新載入程序最重要的部分是類別卸載,其中會移除所有自動載入的類別,準備再次載入。這會在執行或完成回呼之前立即發生,視 reload_classes_only_on_change 設定而定。

通常,需要在類別卸載之前或之後執行額外的重新載入動作,因此 Reloader 也提供 before_class_unloadafter_class_unload 回呼。

3.3 同時執行

只有長執行時間的「頂層」程序應該呼叫 Reloader,因為如果它判定需要重新載入,它會封鎖,直到所有其他執行緒完成所有 Executor 呼叫。

如果這發生在「子執行緒」中,而 Executor 內有等待的父執行緒,這會造成無法避免的死結:重新載入必須在子執行緒執行之前發生,但無法在父執行緒執行中安全執行。子執行緒應該改用 Executor。

4 框架行為

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

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

Active Job 也會以重新載入器包裝其工作執行,在每個工作從佇列中移出時載入最新程式碼來執行。

Action Cable 則使用執行器:因為 Cable 連線連結到類別的特定執行個體,因此無法為每個到達的 WebSocket 訊息重新載入。不過,只有訊息處理常式會被包裝;長執行時間的 Cable 連線並不會妨礙由新進來的請求或工作觸發的重新載入。取而代之的是,Action Cable 使用重新載入器的 before_class_unload 回呼函式來中斷所有連線。當客戶端自動重新連線時,它將會與新版本的程式碼對話。

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

4.1 設定

只有在 config.enable_reloadingtrueconfig.reload_classes_only_on_change 也是 true 時,重新載入器才會檢查檔案變更。這些是 development 環境中的預設值。

config.enable_reloadingfalse 時(預設為 production),重新載入器只會傳遞到執行器。

執行器總是有重要的工作要做,例如資料庫連線管理。當 config.enable_reloadingfalseconfig.eager_loadtrueproduction 預設值)時,不會發生重新載入,因此不需要載入互鎖。在 development 環境中使用預設設定時,執行器將使用載入互鎖來確保只在安全時載入常數。

5 載入互鎖

載入互鎖允許在多執行緒執行時間環境中啟用自動載入和重新載入。

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

類似地,只有在沒有應用程式程式碼正在執行中時,才安全地執行卸載/重新載入:重新載入後,例如 User 常數可能會指向不同的類別。沒有這項規則,時機不佳的重新載入會表示 User.new.class == User,甚至 User == User,可能會是 false。

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

一次只有一個執行緒可以載入或卸載,而且要執行這兩個動作,它必須等到沒有其他執行緒正在執行應用程式程式碼。如果一個執行緒正在等待執行載入,它不會阻止其他執行緒載入(事實上,它們會合作,每個執行緒都會依序執行排隊的載入,然後再一起繼續執行)。

5.1 permit_concurrent_loads

執行器會自動取得其區塊持續時間的 running 鎖定,而自動載入知道何時升級到 load 鎖定,然後再切換回 running

然而,在執行器區塊內執行的其他封鎖操作(包括所有應用程式程式碼)可能會不必要地保留 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 將其包覆起來。

回饋

我們鼓勵你協助提升本指南的品質。

如果你看到任何錯字或事實錯誤,請協助我們修正。要開始,你可以閱讀我們的 文件貢獻 部分。

你可能也會發現不完整或未更新的內容。請為 main 新增任何遺漏的文件。請務必先查看 Edge Guides,以驗證問題是否已在 main 分支中修正。查看 Ruby on Rails 指南準則,以了解風格和慣例。

如果你因為任何原因發現需要修正的地方,但無法自行修正,請 開啟問題

最後但並非最不重要的一點,我們非常歡迎在 官方 Ruby on Rails 論壇 上針對 Ruby on Rails 文件進行任何討論。