Sidekiq 訊號處理原始碼分析

Martin91發表於2016-11-20

引言

在之前的文章《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 是怎麼跟訊號處理聯絡起來的呢?這些就是今天這篇文章的重點了!

注意

  1. 今天的分析所參考的 sidekiq 的原始碼對應版本是 4.2.3;

  2. 今天所討論的內容,將主要圍繞系統訊號處理進行分析,無關細節將不贅述,如有需要,請自行翻閱 sidekiq 原始碼;

  3. 今天的文章跟上篇的《Sidekiq任務排程流程分析》緊密相關,上篇文章介紹的啟動過程跟任務排程會幫助這篇文章的理解,如果還沒有閱讀上篇文章的,建議先閱讀後再來閱讀這一篇訊號處理的文章。

你將瞭解到什麼?

  1. Sidekiq 訊號處理機制;

  2. 為什麼重啟 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 文件,發現 trapSignal 模組下的一個方法,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 註冊了對 INTTERMUSR1USR2以及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.selectRuby 官方文件介紹如下:

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

這裡的程式碼挺長,但是一點都不難理解,我簡單解釋下就夠了。當程式:

  1. 收到 TERM 或者 INT訊號時,直接丟擲 Interrupt 中斷;

  2. 收到 USR1 訊號時,則通知 launcher 執行 .quiet 方法,Sidekiq 在這裡進入 Quiet 模式(怎麼進入?);

  3. 收到 USR2 訊號時,重新開啟日誌;

  4. 收到 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.quietlauncher.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 的訊息傳遞給了 @managerSidekiq::Manager 物件,同時通知 @pollerSidekiq::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 後返回,但是,如果其引數 waittrue,則會保持主執行緒等待,直到 @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 變數可是一個重要的開關,當 @donefalse 時,worker 一直周而復始地從佇列中取任務並且老老實實幹活;而當 @donetrue 時,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 方法裡的迴圈體在新執行緒中執行,當迴圈結束時,執行緒自然也退出了。

小結

  1. 當 Sidekiq 收到 USR1 系統訊號時,Sidekiq 主執行緒向 @launcher 傳送 quiet 訊息,@launcher 又將訊息傳遞給 @manager ,同時向 @poller 發出 terminate 訊息;

  2. @manager 在收到 quiet 訊息時,逐一對執行中的 worker 傳送 terminate 訊息,worker 收到訊息後,設定自己的 @donetrue,標識不再處理新任務,當前任務處理完成後退出執行緒;

  3. @poller 在收到 terminate 訊息後,也是設定自己的 @donetrue,在本次任務執行完畢後,執行緒也退出;

  4. 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 傳送 quietterminate 訊息,這個過程就是我們上面說的 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 訊息時,首先設定自己的 @donetrue,最後向 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 也都正常退出了。

小結

  1. launcher 在執行退出時,首先按照 quiet 的流程先通知各個執行緒準備退出;

  2. 接著 launcher 向 manager 下達 stop 指令,並且給出最後期限(deadline);

  3. manager 在給定的限時內,儘可能等待所有 worker 執行完自己退出,對於到達限時仍未退出的 worker,manager 備份了每個 worker 的當前任務,重新加入佇列,確保任務至少完整執行一次,然後通過向執行緒丟擲異常的方式,迫使 worker 的執行緒被動退出。

總結

  1. Sidekiq 簡單高效利用了系統訊號,並且有比較清晰明瞭的訊號處理過程;

  2. Sidekiq 在訊號處理的過程中,各個元件協調很有條理,訊息逐級傳遞,而且對被強制停止的任務也有備份方案;

  3. 我們可以從 Sidekiq 的系統訊號處理機制上借鑑不少東西,比如常用系統訊號的分類處理等;

  4. 對於多執行緒的控制,通過共享變數以及異常的方式做到 graceful 以及 hard 兩種方式的退出處理。

  5. 還有很多,一百個人心中有一百個哈姆萊特,同樣一份程式碼,不同的人學習閱讀,肯定收穫不同,你可以在評論區留下你的感悟,跟看到這篇文章的人一起分享!

問題思考

  1. 為了儘可能確保所有 Sidekiq 的任務能夠正常主動退出,所以在部署指令碼中,都會盡可能早地讓 Sidekiq 進入 quiet 模式,但是 Sidekiq 的 quiet 是不可逆的,所以一旦部署指令碼中途失敗,Sidekiq 得不到重啟,將會一直保持 quiet 狀態,如果長時間未重啟,任務就會積壓。所以,一般我都會在部署指令碼中,額外捕捉部署指令碼失敗異常,然後主動執行 sidekiq 的重啟。如果你的部署指令碼中有涉及 Sidekiq 的,一定要注意檢查部署失敗是否會影響 Sidekiq 的狀態

  2. 雖然 Sidekiq 在強制退出當前長時間未退出的任務時,會將 job 的資料寫回佇列,等待重啟後重新執行,那麼這裡就有個細節需要注意了,就是你的 job 必須是冪等的,否則就不能允許重新執行了。所以,請注意,如果你有需要長時間執行的 job,請注意檢查其冪等性

好了,今天就寫到這吧!仍然挺長一篇,囉嗦了。感謝看到這裡!

相關文章