How to shutdown GServer gracefully?

Ruby の標準添付ライブラリに gserver というのがあって、これを用いれば頗る簡単に TCP サーバを書くことが出来、こりゃいいね、って思いながら使ってみたのですけれども、サーバの停止の手続きについてちょっと悩むことがありました。

いざサーバの停止を行おうとするとき、まだ接続中のクライアントが居たらば、その終了を待ってから、サーバは停止してほしいのは誰しも考える所です(「優雅なシャットダウン」 “Graceful shutdown” )。GServer クラスのインタフェースとしては #shutdown メソッドが、それらしいことを行うはずなのですが、実際はそう上手くは問屋が卸しませんでした。

承前の通り簡単な内容なのでまずは書いたコード全容を貼ります。処理内容はいわゆる echo サーバです。このサーバを(フォアグラウンドで)起動したら、 Ctrl+c ( INT シグナル)で、優雅なシャットダウンをさせようとしています。ちなみにクライアントは、サーバに空行を送ることで接続を終了します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
require 'gserver'

class EchoServer < GServer
  
  def initialize(port=10001, host='127.0.0.1', *args)
    super(port, host, *args)
    self.audit = true
    trap :INT, proc {
      self.shutdown
    }
  end

  def serve(io)
    loop do
      if IO.select([io], nil, nil, 0.1)
        data = io.gets
        break if data.nil? || data =~ /^[\r\n]*$/
        io.puts("echo: #{data}")
      end
    end
    io.close
  end

end

STDERR.sync
STDOUT.sync
server = EchoServer.new
server.start
server.join

いざこれを実行し、別端末からクライアントとして接続します。 echo の動作はうまくいきます。空行を送ることでいったん接続を切り、改めて、今度はクライアントを接続中にしたまま、サーバに INT シグナルを送ってみます。クライアントが接続中なので、まだ終了しません。思惑どおりです。

しかし、そのクライアントを切断させますと、どうでしょうか。サーバの動作ログにはクライアントが切断した旨が出力されますが、サーバは止まりません。おや? と思いました。また、この状態で、再びクライアントから接続を試みると、繋がってしまいます。これもおや? と思いました。サーバはまだ接続を受け付けてしまっていますから、忙しいサイトでは休む間もないことでしょう。いちおう、すべてのクライアントが接続を終了させると、サーバも終了しましたが。

何がいけないのでしょうか?

GServer クラスのソースにあたってみると、 #sutdown メソッドはたんにフラグを立てるだけの様で、実際の停止の手続きは別にありました。サーバのメインの処理は、新たなクライアントの接続を待つ無限ループです。無限ループは、フラグが立っていることで抜け、そして終了の手続きへと進む、といった風に読めます。サーバがループの中で、 ACCEPT 状態、つまり新たなクライアントの接続を待っている間は、文字通りその地点で待っているので、フラグをチェックする所へ来ないのです。事実、気がつけば rdoc での #shutdown の説明には “Schedule a shutdown for the server” と書いてありました。まさしく。端的に表していますが、ではどうしたら、優雅なシャットダウンを実現できるでしょうか。

このからくりからひとつ思いつく解決方法は、 #sutdown メソッドを実行したで、新たなクライアントを作り、接続し、そして切断することです。

1
2
3
4
trap :INT, proc {
  self.shutdown
  TCPSocket.open(host,port).close
}

こうすることで、新たな接続を受け付けたサーバは、ループを一回進め、次のループへのチェックへ制御がやってきます。すると事前の #shutdown メソッドによりフラグが立っているので、ループを抜け、サーバのスレッドは子スレッドの終了を迎える体制に入ります。また同時に、このときは既にサーバは ACCEPT をしていない状態ですから、クライアントからの新しい接続は、期待どおり拒否されることになります。

そこまで行けば、あとは接続中の幾つかのクライアント(子スレッド)がそれぞれ自身の処理を終えて接続を切り次第 server.join のところに集まってくるのを待っているだけです。そうしてすべての子スレッドが集まることでサーバも目出たく、優雅に、終了することが出来ます。

実際にやってみますと、思いどおりにいきました。しかしいまひとつ、釈然としません。なにか方法はないものかと、もう少し探ってみることにしました。

GServer のソースをじっと見つめていると、 #start メソッドによって内部で生成される TCPServer のインスタンス(サーバ・スレッド)が #accept メソッドでクライアントの接続を待っています。このメソッドはスレッドの流れをブロックします。

ここがポイントであることには間違いないので、このブロック状態を解く手だてがあればよさそうなのですが、そのような使命を直接的に背負った機能は、どの API にも見つけられませんでした。その理由を知るには、そもそも TCP 的にどのようにそれを実行するのか(するべきなのか)についてまず知らないといけないと考え、それから関係各所を回ってみたのですが( via Google )、しかしじぶんが開いたそれらしい様々などのドキュメントにも ACCEPT 状態を無理矢理?解く方法は明示されていませんでした(明示されていることを見つけられませんでした)。

そんな中で、試しに、無理矢理サーバのソケットを閉じてはどうだろうと思い至ります。 GServer のインスタンスの中にサーバソケットを持っている TCPServer のインスタンスがあるので、それを摑み取って #close を送ってみることにしました。いうなれば、間接的に ACCEPT 状態のブロックを解くことを期待する魂胆です。

1
2
3
4
trap :INT, proc {
  self.shutdown
  self.instance_variable_get(:@tcpServer).close
}

この方法はどうやら上手くいったようです。 INT シグナルを trap したサーバは、 ACCEPT をやめたと見えて、新しいクライアントを受け付けなくなりました。一方で、接続中のクライアントが既にあった場合は、そのコネクションが切られるまで、待っていました。そして、すべてのクライアントが接続を終了したとき、はじめてサーバも終了しました。いい案配です。

さて、こうしてみると、 Gserver#shutdown の後に、クライアント接続を一回虚しく空振りさせるよりも、 TCPServer#close を発行する方がスマートなように思えます。ただし、それが正攻法ならば。──言うまでもなくこれはアクセス手段が意図的に公開されていないインスタンスを摑み取っている時点で正攻法ではありません。そして一方 TCPServer#close も確信があってやっていることではないので、これでいいのかと不安は残ります。そのような不安を抱えてまでも TCPServer#close するメリットは、虚しいかなあまりないようです。従って、理屈的に正攻法である空振りクライアントを実装するほうが、不格好でも、良いものだと判断するほかありません。とはいえ、いちおう、一定の成果は得られた気がします。──顛末はここまでです。

結局のところ、優雅なシャットダウンのために、空振りクライアント接続をするといったいまひとつ優雅ではない実装を行う、ということでじぶんの悩みはそこに落とさざるを得なかったという話は終わりなのですが、さてしかし、今回の主役であるその GServer クラスのほう、こちらこのへんの動きは(優雅なシャットダウンは新たなクライアントが接続しに来ないと始まらないこと) 、そもそも設計の想定のうちなのでしょうか。──必然的に湧いて来るこの次なる疑問については、でも、またの機会にしたいと思います。長くなりました。

追記:その後、こんなチケットを見つけました。同じことを言っているのでしょうか? ただ、それから二年以上動きがないようです。さて。

Bug #6369: GServer blocking after shutdown called - ruby-trunk - Ruby Issue Tracking System