引言
在之前的文章《Sidekiq任務排程流程分析》中,我們一起仔細分析了 Sidekiq 是如何基於多執行緒完成佇列任務處理以及排程的。我們在之前的分析裡,看到了不管是 Sidekiq::Scheduled::Poller
還是 Sidekiq::Processor
的核心程式碼裡,都會有一個由 @done
例項變數控制的迴圈體:
<!-- More -->
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/scheduled.rb#L63-L73
def start
@thread ||= safe_thread("scheduler") do
initial_wait
while !@done # 這是 poller 的迴圈控制
enqueue
wait
end
Sidekiq.logger.info("Scheduler exiting...")
end
end
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/processor.rb#L66-L77
def run
begin
while !@done # 這是我們常說的 worker 迴圈控制
process_one
end
@mgr.processor_stopped(self)
rescue Sidekiq::Shutdown
@mgr.processor_stopped(self)
rescue Exception => ex
@mgr.processor_died(self, ex)
end
end
也就是說,這些 @done
例項變數決定了 poller
執行緒跟 worker
執行緒是否迴圈執行?一旦 @done
被改為 true
,那迴圈體就不再執行,執行緒自然也就是退出了。於是,單從這些程式碼,我們可以斷定, Sidekiq 就是通過設定 @done
的值來通知一個執行緒安全退出(graceful exit)的。我們也知道,生產環境中,我們是通過傳送訊號的方式來告訴 sidekiq 退出或者進入靜默(quiet)狀態的,那麼,這裡的 @done
是怎麼跟訊號處理聯絡起來的呢?這些就是今天這篇文章的重點了!
注意
今天的分析所參考的 sidekiq 的原始碼對應版本是 4.2.3;
今天所討論的內容,將主要圍繞系統訊號處理進行分析,無關細節將不贅述,如有需要,請自行翻閱 sidekiq 原始碼;
今天的文章跟上篇的《Sidekiq任務排程流程分析》緊密相關,上篇文章介紹的啟動過程跟任務排程會幫助這篇文章的理解,如果還沒有閱讀上篇文章的,建議先閱讀後再來閱讀這一篇訊號處理的文章。
你將瞭解到什麼?
Sidekiq 訊號處理機制;
為什麼重啟 Sidekiq 時,
USR1
訊號(即進入quiet
模式)需要儘可能早,而程式的退出重啟需要儘可能晚。
從頭再來
因為前一篇文章著眼於任務排程,所以略過了其他無關細節,包括訊號處理,這篇文章則將鏡頭對準訊號處理,所以讓我們從頭再來一遍,只是這一次,我們只關心與訊號處理有關的程式碼。
依舊是從 cli.rb
檔案開始,它是 Sidekiq 核心程式碼的生命起點,因為 Sidekiq 命令列啟動後,它是第一個被執行的程式碼,Sidekiq 啟動過程中呼叫了 Sidekiq::CLI#run
方法:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/cli.rb#L49-L106
def run
boot_system
print_banner
self_read, self_write = IO.pipe
%w(INT TERM USR1 USR2 TTIN).each do |sig|
begin
trap sig do
self_write.puts(sig)
end
rescue ArgumentError
puts "Signal #{sig} not supported"
end
end
# ... other codes
begin
launcher.run
while readable_io = IO.select([self_read])
signal = readable_io.first[0].gets.strip
handle_signal(signal)
end
rescue Interrupt
logger.info 'Shutting down'
launcher.stop
# Explicitly exit so busy Processor threads can't block
# process shutdown.
logger.info "Bye!"
exit(0)
end
以上的程式碼就是整個 Sidekiq 最頂層的訊號處理的核心程式碼了,讓我們慢慢分析!
首先,self_read, self_write = IO.pipe
建立了一個模擬管道的 IO 物件,並且同時返回這個 管道的一個寫端以及一個讀端,通過這兩端,就可以實現對管道的讀寫了。需要注意的是,IO.pipe
建立的讀端在讀的時候不會自動生成 EOF
符,所以這就要求讀時,寫端是關閉的,而寫時,讀端是關閉的,一句話說,就是這樣的管道不允許讀寫端同時開啟。關於 IO.pipe
還有挺多細節跟需要注意的點,如果還需要了解,請閱讀官方文件。
上面說的管道本質上只是一個 IO 物件而已,暫時不用糾結太多,讓我們接著往下讀:
%w(INT TERM USR1 USR2 TTIN).each do |sig|
begin
trap sig do
self_write.puts(sig)
end
rescue ArgumentError
puts "Signal #{sig} not supported"
end
end
這段程式碼就比較有意思了,最外層遍歷了一個系統訊號的陣列,然後逐個訊號進行監聽(trap,或者叫捕捉?)。讓我們聚焦在 trap
方法的呼叫跟其 block 上,查閱 Ruby 文件,發現 trap
是 Signal
模組下的一個方法,Signal
主要是處理與系統訊號有關的任務,然後 trap
的作用是:
Specifies the handling of signals. The first parameter is a signal name (a string such as “SIGALRM”, “SIGUSR1”, and so on) or a signal number...
所以,前面的那段程式碼的意思就很容易理解了,Sidekiq 註冊了對 INT
、TERM
、USR1
、USR2
以及TTIN
等系統訊號的處理,而在程式收到這些訊號時,就會執行 self_write.puts(sig)
,也就是將收到的訊號通過之前介紹的管道寫端 self_write
記錄下來。什麼?只記錄下來,那還得處理啊?!
稍安勿躁,讓我們接著往下分析 Sidekiq::CLI#run
方法末尾的程式碼:
begin
launcher.run
while readable_io = IO.select([self_read])
signal = readable_io.first[0].gets.strip
handle_signal(signal)
end
rescue Interrupt
logger.info 'Shutting down'
launcher.stop
# Explicitly exit so busy Processor threads can't block
# process shutdown.
logger.info "Bye!"
exit(0)
end
看到沒有,這裡有個迴圈,迴圈控制條件裡,readable_io = IO.select([self_read])
是從前面的管道的讀端 self_read
阻塞地等待訊號的到達。對於 IO.select
,Ruby 官方文件介紹如下:
Calls select(2) system call. It monitors given arrays of IO objects, waits until one or more of IO objects are ready for reading, are ready for writing, and have pending exceptions respectively, and returns an array that contains arrays of those IO objects.
所以這裡就是說 Sidekiq 主執行緒首先負責執行完其他初始化工作,最後阻塞在訊號等待以及處理。在其等到新的訊號之後,進入上面程式碼展示的迴圈體:
signal = readable_io.first[0].gets.strip
handle_signal(signal)
這裡語法細節先不深究,我們看下這兩行程式碼第一行是從前面說的管道中讀取訊號,並且將訊號傳遞給 handle_signal
方法,讓我們接著往下看 handle_signal
方法的定義:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/cli.rb#L125-L153
def handle_signal(sig)
Sidekiq.logger.debug "Got #{sig} signal"
case sig
when 'INT'
# Handle Ctrl-C in JRuby like MRI
# http://jira.codehaus.org/browse/JRUBY-4637
raise Interrupt
when 'TERM'
# Heroku sends TERM and then waits 10 seconds for process to exit.
raise Interrupt
when 'USR1'
Sidekiq.logger.info "Received USR1, no longer accepting new work"
launcher.quiet
when 'USR2'
if Sidekiq.options[:logfile]
Sidekiq.logger.info "Received USR2, reopening log file"
Sidekiq::Logging.reopen_logs
end
when 'TTIN'
Thread.list.each do |thread|
Sidekiq.logger.warn "Thread TID-#{thread.object_id.to_s(36)} #{thread['label']}"
if thread.backtrace
Sidekiq.logger.warn thread.backtrace.join("\n")
else
Sidekiq.logger.warn "<no backtrace available>"
end
end
end
end
這裡的程式碼挺長,但是一點都不難理解,我簡單解釋下就夠了。當程式:
收到
TERM
或者INT
訊號時,直接丟擲Interrupt
中斷;收到
USR1
訊號時,則通知launcher
執行.quiet
方法,Sidekiq 在這裡進入 Quiet 模式(怎麼進入?);收到
USR2
訊號時,重新開啟日誌;收到
TTIN
訊號時,列印所有執行緒當前正在執行的程式碼列表。
到此,一個訊號從收到被存下,到被取出處理的大致過程就是這樣的,至於具體的處理方式,我們下個章節詳細展開。現在有一點需要補充的是,上面講當 Sidekiq 收到 TERM
或者 INT
訊號時,都會丟擲 Interrupt
中斷異常,那這個異常又是如何處理的呢?我們回過頭去看剛才最開始的 Sidekiq::CLI#run
方法末尾的程式碼:
begin
launcher.run
while readable_io = IO.select([self_read])
signal = readable_io.first[0].gets.strip
handle_signal(signal)
end
rescue Interrupt
logger.info 'Shutting down'
launcher.stop
# Explicitly exit so busy Processor threads can't block
# process shutdown.
logger.info "Bye!"
exit(0)
end
原來是 run
方法在處理訊號時,宣告瞭 rescue Interrupt
,捕捉了 Interrupt
中斷異常,並且在異常處理時列印必要日誌,同時執行 launcher.stop
通知各個執行緒停止工作,最後呼叫 exit
方法強制退出程式,到此,一個 Sidekiq 程式就徹底退出了。
但是問題又來了,訊號處理的大致過程我是知道了,但是具體的 launcher.quiet
跟 launcher.stop
都幹了些什麼呢?
Sidekiq::Launcher#quiet 原始碼探索
老規矩,先上程式碼:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/launcher.rb#L32-L36
def quiet
@done = true
@manager.quiet
@poller.terminate
end
程式碼只有短短三行。 Launcher 物件首先設定自己的例項變數 @done
的值為 true
,接著執行 @manager.quiet
以及 @poller.terminate
。看方法命名上理解,應該是 Luancher 物件又將 quiet 的訊息傳遞給了 @manager
即 Sidekiq::Manager
物件,同時通知 @poller
即 Sidekiq::Scheduled::Poller
物件結束工作。那到底是不是真的這樣呢?讓我們繼續深挖!
Sidekiq::Manager#quiet
讓我們來看看 Sidekiq::Manager#quiet
方法的程式碼
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/manager.rb#L51-L58
def quiet
return if @done
@done = true
logger.info { "Terminating quiet workers" }
@workers.each { |x| x.terminate }
fire_event(:quiet, true)
end
上面的程式碼也很短,首先將 Sidekiq::Manager
物件自身的 @done
例項變數的值設定為 true
,接著對其所管理的每一個 worker,都發出一個 terminate
訊息。讓我們接著往下看 worker 物件(Sidekiq::Processor
物件)的 #terminate
方法定義:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/processor.rb#L42-L46
def terminate(wait=false)
@done = true
return if !@thread
@thread.value if wait
end
這裡的程式碼依然保持了精短的特點!跟上一層邏輯一樣,worker 在處理 terminate
時,同樣設定自己的 @done
例項變數為 true
後返回,但是,如果其引數 wait
為 true
,則會保持主執行緒等待,直到 @thread
執行緒退出(@thread.value
相當於執行 @thread.join
並且返回執行緒的返回值,可參考 Ruby 文件)。
那麼,這裡就要問了,worker 設定 @done
為 true 是要幹嘛?這裡好像也沒有做什麼特別的事啊?!勿急,還記得上篇文章介紹 worker 執行時的核心程式碼嗎?
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/processor.rb#L66-L77
def run
begin
while !@done
process_one
end
@mgr.processor_stopped(self)
rescue Sidekiq::Shutdown
@mgr.processor_stopped(self)
rescue Exception => ex
@mgr.processor_died(self, ex)
end
end
看到了吧,@done
變數可是一個重要的開關,當 @done
為 false
時,worker 一直周而復始地從佇列中取任務並且老老實實幹活;而當 @done
為 true
時,worker 在處理完當前的任務之後,便不再執行新的任務,執行 @msg.processor_stopped(self)
通知 worker 管理器自己已經退出工作,最終 #run
方法返回。由於 #run
方法是在獨立執行緒裡執行的,所以當 #run
方法返回時,其所在的執行緒自然也就退出了。
那關於 worker 的 quiet 模式進入過程就是這麼簡單,通過一個共享變數 @done
便實現了對工作執行緒的控制。
Sidekiq::Scheduled::Poller#terminate
前面說到 Sidekiq::Launcher#quiet
執行時,先將訊息傳遞給了 worker 管理器,隨後執行了 @poller.terminate
,那我們來看看 #terminate
方法的定義:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/scheduled.rb#L53-L61
def terminate
@done = true
if @thread
t = @thread
@thread = nil
@sleeper << 0
t.value
end
end
又是如此簡短的程式碼。poller 退出的邏輯跟 worker 退出的邏輯非常一致,都是同樣先設定自己的 @done
例項變數的值為 true
,接著等待執行緒 @thread
退出,最後 poller 返回。
那麼,poller 的 @done
是不是也是用來控制執行緒退出呢?答案是肯定的!
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/scheduled.rb#L63-L73
def start
@thread ||= safe_thread("scheduler") do
initial_wait
while !@done
enqueue
wait
end
Sidekiq.logger.info("Scheduler exiting...")
end
end
還記得上面這段程式碼嗎? poller 在每次將定時任務壓回任務佇列之後,等待一定時間,然後重新檢查 @done
的值,如果為 true
,則 poller 直接返回退出,因為 #start
方法裡的迴圈體在新執行緒中執行,當迴圈結束時,執行緒自然也退出了。
小結
當 Sidekiq 收到
USR1
系統訊號時,Sidekiq 主執行緒向@launcher
傳送quiet
訊息,@launcher
又將訊息傳遞給@manager
,同時向@poller
發出terminate
訊息;@manager
在收到quiet
訊息時,逐一對執行中的 worker 傳送terminate
訊息,worker 收到訊息後,設定自己的@done
為true
,標識不再處理新任務,當前任務處理完成後退出執行緒;@poller
在收到terminate
訊息後,也是設定自己的@done
為true
,在本次任務執行完畢後,執行緒也退出;Sidekiq 進入 quiet 模式之後,所有未處理任務以及新任務都不再處理,直到 sidekiq 的下一次重啟。
Sidekiq::Launcher#stop 原始碼探索
前面介紹的是 Sidekiq 進入 quiet 模式的過程,那 Sidekiq 的停止過程又是怎樣的呢?
讓我們從 Sidekiq::Launcher#stop
方法開始尋找答案:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/launcher.rb#L41-L56
def stop
deadline = Time.now + @options[:timeout]
@done = true
@manager.quiet
@poller.terminate
@manager.stop(deadline)
# Requeue everything in case there was a worker who grabbed work while stopped
# This call is a no-op in Sidekiq but necessary for Sidekiq Pro.
strategy = (@options[:fetch] || Sidekiq::BasicFetch)
strategy.bulk_requeue([], @options)
clear_heartbeat
end
首先,Sidekiq::Launcher
物件設定了一個強制退出的 deadline
,時間是以當前時間加上配置的 timeout
,這個時間預設是 8 秒。
接著,設定物件本身的 @done
變數的值為 true
,然後分別對 @manager
和 @poller
傳送 quiet
和 terminate
訊息,這個過程就是我們上面說的 Sidekiq::Launcher#quiet
的過程,所以,這裡的程式碼主要是 Sidekiq 要確保退出前已經通知各個執行緒準備退出。
接下來的程式碼就比較重要了,我們先看這一行:
@manager.stop(deadline)
在通知完 @manager
進入 quiet 模式之後,launcher 向 @manager
傳送了 stop
訊息,並且同時傳遞了 deadline
引數。讓我們接著繼續往下看:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/manager.rb#L61-L83
PAUSE_TIME = STDOUT.tty? ? 0.1 : 0.5
def stop(deadline)
quiet
fire_event(:shutdown, true)
# some of the shutdown events can be async,
# we don't have any way to know when they're done but
# give them a little time to take effect
sleep PAUSE_TIME
return if @workers.empty?
logger.info { "Pausing to allow workers to finish..." }
remaining = deadline - Time.now
while remaining > PAUSE_TIME
return if @workers.empty?
sleep PAUSE_TIME
remaining = deadline - Time.now
end
return if @workers.empty?
hard_shutdown
end
上面的程式碼,manager 首先呼叫了自身的 quiet
方法(這裡就真的多此一舉了,因為外層的 launcher 已經呼叫過一次了),然後 manager 執行 sleep
系統呼叫進入休眠,持續時間為 0.5 秒,休眠結束後檢查所有 worker 是否已經都退出,如果退出,則直接返回,任務提前結束;如果仍有 worker 未退出,則檢查當前時間是否接近強制退出的 deadline,如果不是,則重複“檢查所有 worker 退出 - 休眠” 的過程,直到 deadline 來臨,或者 worker 執行緒都已經全部退出。如果最後到達 deadline,仍有 worker 執行緒未退出,則最後執行 hard_shutdown
。
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/manager.rb#L108-L135
def hard_shutdown
cleanup = nil
@plock.synchronize do
cleanup = @workers.dup
end
if cleanup.size > 0
jobs = cleanup.map {|p| p.job }.compact
# ... other codes
strategy = (@options[:fetch] || Sidekiq::BasicFetch)
strategy.bulk_requeue(jobs, @options)
end
cleanup.each do |processor|
processor.kill
end
end
這裡 hard_shutdown
方法在執行時,首先克隆了當前仍未退出的 @workers
列表,接著獲取每個 worker 當前正在處理的任務,將這些正在執行中的任務資料通過 strategy.bulk_requeue(jobs, @options)
重新寫回佇列,而最後對每一個 worker 傳送 kill
訊息:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/processor.rb#L48-L58
def kill(wait=false)
@done = true
return if !@thread
@thread.raise ::Sidekiq::Shutdown
@thread.value if wait
end
worker 在收到 kill
訊息時,首先設定自己的 @done
為 true
,最後向 worker 所關聯的執行緒丟擲 ::Sidekiq::Shutdown
異常。讓我們看看 worker 的執行緒又是如何處理異常的:
# https://github.com/mperham/sidekiq/blob/5ebd857e3020d55f5c701037c2d7bedf9a18e897/lib/sidekiq/processor.rb#L66-L77
def run
begin
while !@done
process_one
end
@mgr.processor_stopped(self)
rescue Sidekiq::Shutdown
@mgr.processor_stopped(self)
rescue Exception => ex
@mgr.processor_died(self, ex)
end
end
又回到 worker 的 run
方法這裡,可以看到,run
方法捕捉了 Sidekiq::Shutdown
異常,並且在處理異常時,只是執行 @mgr.processor_stopped(self)
,通知 manager 自己已經退出,由於已經跳出正常流程,worker 的 run
方法返回,執行緒也因此得以退出。至此,worker 也都正常退出了。
小結
launcher 在執行退出時,首先按照 quiet 的流程先通知各個執行緒準備退出;
接著 launcher 向 manager 下達
stop
指令,並且給出最後期限(deadline
);manager 在給定的限時內,儘可能等待所有 worker 執行完自己退出,對於到達限時仍未退出的 worker,manager 備份了每個 worker 的當前任務,重新加入佇列,確保任務至少完整執行一次,然後通過向執行緒丟擲異常的方式,迫使 worker 的執行緒被動退出。
總結
Sidekiq 簡單高效利用了系統訊號,並且有比較清晰明瞭的訊號處理過程;
Sidekiq 在訊號處理的過程中,各個元件協調很有條理,訊息逐級傳遞,而且對被強制停止的任務也有備份方案;
我們可以從 Sidekiq 的系統訊號處理機制上借鑑不少東西,比如常用系統訊號的分類處理等;
對於多執行緒的控制,通過共享變數以及異常的方式做到
graceful
以及hard
兩種方式的退出處理。還有很多,一百個人心中有一百個哈姆萊特,同樣一份程式碼,不同的人學習閱讀,肯定收穫不同,你可以在評論區留下你的感悟,跟看到這篇文章的人一起分享!
問題思考
為了儘可能確保所有 Sidekiq 的任務能夠正常主動退出,所以在部署指令碼中,都會盡可能早地讓 Sidekiq 進入 quiet 模式,但是 Sidekiq 的 quiet 是不可逆的,所以一旦部署指令碼中途失敗,Sidekiq 得不到重啟,將會一直保持 quiet 狀態,如果長時間未重啟,任務就會積壓。所以,一般我都會在部署指令碼中,額外捕捉部署指令碼失敗異常,然後主動執行 sidekiq 的重啟。如果你的部署指令碼中有涉及 Sidekiq 的,一定要注意檢查部署失敗是否會影響 Sidekiq 的狀態
雖然 Sidekiq 在強制退出當前長時間未退出的任務時,會將 job 的資料寫回佇列,等待重啟後重新執行,那麼這裡就有個細節需要注意了,就是你的 job 必須是冪等的,否則就不能允許重新執行了。所以,請注意,如果你有需要長時間執行的 job,請注意檢查其冪等性。
好了,今天就寫到這吧!仍然挺長一篇,囉嗦了。感謝看到這裡!