Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

RubyKaigi 初参加の学びと実践:ソケットタイムアウトからシステムコールまで潜る #rubykaigi

はじめに

こんにちは、Speeeでエンジニアをしている新卒2年目の竹田有真です。今回は RubyKaigi に初参加させていただきましたのでその学びを記事にしました。テーマとして扱わせていただくのは、DAY 3 の塩井美咲さん(@coe401_、以下しおいさん)による発表 "The Less-Told Story of Socket Timeouts" です。正直私はこの発表を聞いている最中、内容を十分に理解できませんでした。帰宅後、しおいさんが Speaker Deck で公開してくださっている発表スライドを元に学ぶうちに少しずつ理解することができました。

本記事の前半ではしおいさんの発表で学んだことを記します。Rubyのソケットライブラリのタイムアウトにおいて、どのように開発されてきたのかを時系列に沿って展開します。後半では、自作Webサーバーにタイムアウトを設定するという観点から、Ruby の実装を読み、少し手を動かしたことを記します。

本記事を書くにあたり、クリアコード須藤さんに多大な支援をしていただきました。また、RubyKaigi への参加も初めてだったので事前にたくさんのことを教えていただきました。ありがとうございました!

そして、先日 RubyKaigi 関連イベントでしおいさんとお会いでき、本記事のことやその他たくさんのことをお話しさせていただき、教えていただきました。ありがとうございました!

目次

ソケット通信におけるタイムアウト設計

Web アプリケーションで HTTP リクエストにタイムアウトをかけたいとき、最も素朴な発想は Timeout.timeout で囲むことです。

require 'net/http'
require 'timeout'

uri = URI('http://example.com/')
Timeout.timeout(3) do
  Net::HTTP.get(uri)
end

ブロックを sec 秒の期限付きで実行します。ブロックの実行時間が制限を過ぎたときは例外 Timeout::Error が発生します。 (Ruby 4.0 リファレンスマニュアル)

3秒以内に終わらなければ Timeout::Error が上がります。多くのケースで期待通りに動きます。ただし、DNS サーバが応答を返さない場合は3秒では抜けず、それ以上待たされることがあります。

Timeout.timeout が届かないレイヤー

Timeout.timeout は別スレッドからの割り込みを入れることでタイムアウトを実現しています。しかし、本体スレッドがC言語の関数の内部で処理待ち(ブロック)している間は、Rubyのスレッド割り込みが届きません。

まずはC言語で標準入力を読むだけのコードで考えます。

#include <unistd.h>

int main(void) {
    char buf[1024];
    read(0, buf, sizeof(buf));
    return 0;
}

このコードはEnterが押されるまで待ちます。 これをRubyからタイムアウトさせられるか試します。

require 'fiddle'
require 'timeout'

# 現在のプロセスにロードされているCの標準ライブラリ(libc)を取得
libc = Fiddle.dlopen(nil)

# C言語の read 関数をRubyから直接呼べるように定義する
# ssize_t read(int fd, void *buf, size_t count);
c_read = Fiddle::Function.new(
  libc['read'],
  [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
  Fiddle::TYPE_SSIZE_T
)

buf = " " * 1024
Timeout.timeout(1) { c_read.call(0, buf, 1024) }

何も入力せずに待ち、Timeout.timeout が効けば1秒でエラーが上がるはずです。しかし、実行して数秒待ってもタイムアウトは発生しませんでした。aaa と入力してEnterを押すとタイムアウトエラーが発生しました。

% ruby read_timeout.rb
aaa
/Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:40:in 'Timeout::Error.handle_timeout': execution expired (Timeout::Error)
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:306:in 'Timeout.timeout'
        from read_timeout.rb:16:in '<main>'
read_timeout.rb:16:in 'Fiddle::Function#call': execution expired (Timeout::ExitException)
        from read_timeout.rb:16:in 'block in <main>'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:296:in 'block in Timeout.timeout'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:38:in 'Timeout::Error.handle_timeout'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:306:in 'Timeout.timeout'
        from read_timeout.rb:16:in '<main>'

役割ごとのタイムアウト

ソケット通信において、プログラムが時間を使う(ブロックする)ポイントは大きく3つに分かれます。

  • 名前解決(IPアドレスを調べる:getaddrinfo(3)
  • TCP 接続(相手のサーバに接続する:connect(2)
  • データ送受信(実際のデータを読み書きする:read(2) / write(2)

しおいさんの発表では、このうち前半の2つをご説明されていました。

TCPSocket.newSocket.tcp には引数としてタイムアウトを渡せるようになっており現在その種類は3つあります。

  • resolv_timeout:名前解決にかけるタイムアウト
  • connect_timeout:TCP 接続にかけるタイムアウト
  • open_timeout:上記2つを合わせた全体のタイムアウト

名前解決とTCP接続でそれぞれ別のタイムアウトが設定できます。

接続タイムアウト — connect_timeout

ソケットライブラリにタイムアウト機能を組み込む最初の提案は、2011年に行われました(Issue #5101)。提案された キーワード引数 は次のようなものです。

TCPSocket.new(remote_host, remote_port, connect_timeout: 0.5)

connect_timeout は Ruby 2.0 で Socket.tcp に追加されました。実装としては、単にconnect(2) を使ってしまうと、前述の通り、システムコール内で待ちが発生した時にタイムアウトが効かなくなってしまいます。そのため、select(2) / poll(2) と組み合わせて実装されています。この詳細は後半の章でより詳しく説明します。

応答を返さない 192.0.2.1 に対して接続を試みると期待通り2秒程でエラーが上がってきました。

irb(main):002> Socket.tcp("192.0.2.1", 80, connect_timeout: 2)
/Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:65:in 'Addrinfo#connect_internal': Operation timed out - user specified timeout for 192.0.2.1:80 (Errno::ETIMEDOUT)
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:141:in 'Addrinfo#connect'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:970:in 'block in Socket.tcp_without_fast_fallback'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:231:in 'Array#each'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:231:in 'Addrinfo.foreach'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:952:in 'Socket.tcp_without_fast_fallback'
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:671:in 'Socket.tcp'
        from (irb):2:in '<main>'
        (以下略)

名前解決タイムアウト — resolv_timeout

次に 名前解決のタイムアウトである resolv_timeout についてです。

初期の実装アプローチと fork(2) による不具合

resolv_timeout の実装には getaddrinfo_a(3) が用いられていました。これは getaddrinfo(3) という名前解決をする関数の非同期版です。しかし2020年、「Rails ActiveJob のテストが落ちる」という報告が上がります(Bug #17220)。

原因は、getaddrinfo_a(3) の worker スレッドプールと、プロセスを複製するOSの機能である fork(2) の仕様の衝突です。fork(2) 実行時、子プロセスへ複製されるのは呼び出し元のメインスレッドのみで、待機中の worker スレッドは複製されません。一方で「worker スレッドが存在する」という内部状態のデータは子プロセスにコピーされます。この状態で子プロセスが getaddrinfo_a(3) を呼ぶと、存在しない worker の応答を永久に待ちハングアップしてしまいます。

pthread を用いた新しいアプローチによる解決

その後、新しいアプローチでは呼び出しごとに新しいスレッド(pthread: POSIX で標準化されたスレッド機構)を生成して getaddrinfo(3) を実行し、完了後にスレッドを終了させる方式が採用されました。スレッドをプールしないため、先述した fork(2) 後の内部状態の不整合を回避できます。この修正により、安全な名前解決の中断が可能になりました。

HEv2 対応

connect_timeout が導入されたことで接続タイムアウトの制御は容易になりましたが、のちに課題が浮上しました。それは「ホスト名が複数の IP アドレスに解決された場合、どうなるのか?」という問題です。

当時の仕様は「1つのIPアドレスへの接続試行ごとに connect_timeout を適用し、タイムアウトしたら次のアドレスへの接続に進む」というものでした。 つまり直列で順番に試すため、もし接続できないIPアドレスが複数あった場合、最悪「アドレス数 × connect_timeout」秒だけ待たされてしまうことになります。この問題は、IPv6 と IPv4 の両方が使える環境(デュアルスタック環境)が一般的になるにつれて顕在化します。

この状況を解決するため、RFC 8305 として標準化されたアルゴリズムが HEv2 です。直列で「失敗したら次を試す(フォールバック)」のではなく、「並行試行して最初に成功した接続を採用する」というアプローチです。主に次の要素を組み合わせます。

  • AAAA (IPv6) と A (IPv4) のクエリを同時に発行する
  • 解決された複数のIPアドレスに対し、間隔をあけながら並行して接続を試みる
  • IPv4 が先に解決された場合でも、IPv6 を優先するため最大 50ms だけ IPv6 の解決を待つ (Resolution Delay)
  • あるIPへの接続が確立しないまま 250ms 経過したら、待機をやめずに次の候補IPへの並行接続を開始する (Connection Attempt Delay)

Ruby では、この HEv2 の実装に fast_fallback という名前が与えられました。 Ruby 3.4 で実装され、Socket.tcp および TCPSocket.new においてデフォルトで有効になっています(Feature #20108 / Feature #20782)。

3つ目のタイムアウト — open_timeout

上記で紹介した resolv_timeout と connect_timeout を組み合わせれば、接続までに要する全体の所要時間を制御できるのではないかと思うかもしれません。しかし、そうではありません。その理由は、前章で解説した fast_fallback(HEv2 の並行試行) です。名前解決と接続試行が並列に走る環境では、2つのタイムアウトは単純な足し算にはなりません。

たとえば resolv_timeout: 2000ms, connect_timeout: 1000ms を指定したとします。全体の最大経過時間は 3000ms になるかと思いきや、実は 2000ms でタイムアウトします。fast_fallback では名前解決ができたアドレスから接続を試みるので、名前解決をしていながら、接続試行もしているという状況が発生します。この時は「細かいことはいいから、とにかく全体を○秒以内に決着させたい」というユーザのシンプルな意図を表現する手段が存在しませんでした。

open_timeoutの実装

この穴を埋めるため、Ruby 4.0 でしおいさんが新たに追加したのが open_timeout です(Feature #21347)。Socket.tcp(host, port, open_timeout: 1) のように指定することで、メソッドの開始時点を起点にした「全体のタイムアウト」として機能してくれます。

実装の鍵は、「期限」による管理です。 HEv2の並列処理下では、複数の名前解決や接続試行が同時に走るため、単純なタイマーの引き継ぎができません。そこで、メソッドが呼ばれた時点の時刻に open_timeout の秒数を足して「○時○分○秒までに終わらせる」という期限を最初に設定します。そして、名前解決と接続を並行試行するループの中で現在時刻と都度見比べ、デッドラインを過ぎていれば直ちにタイムアウト例外を発生させる方式で全体時間を正確に管理しています。

なお、HEv2が無効な環境(従来の直列処理)においては、新しく全体用のタイムアウトを作り直すのではなく、既存のタイムアウト引数を再利用する「残り時間のリレー」という美しい工夫がされています。具体的には以下のような流れです。

  1. メソッドが呼ばれた瞬間に開始時刻を記録する。
  2. 名前解決の直前:指定された open_timeout の値を、そのまま resolv_timeout の代わりとして渡す。
  3. 接続試行の直前:「全体の指定時間 - ここまでの経過時間」を計算する。そして、その残り時間を今度は connect_timeout の代わりとして渡す。

このように、並行処理下ではデッドライン方式を用い、直列処理下では層をまたぐたびに残り時間を計算し直して既存の枠組みを使い回すアプローチで、「全体を○秒以内に決着させる」というユーザーの意図を実現しています。

なお、open_timeout を resolv_timeout や connect_timeout と同時に指定した場合は ArgumentError を発生させる仕様になっています。

irb(main):002> Socket.tcp("192.0.2.1", 80, connect_timeout: 2, open_timeout: 2)
/Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/socket.rb:665:in 'Socket.tcp': Cannot specify open_timeout along with connect_timeout or resolv_timeout (ArgumentError)

      raise ArgumentError, "Cannot specify open_timeout along with connect_timeout or resolv_timeout"
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        from (irb):2:in '<main>'
        (以下略)

3つのタイムアウトと HEv2 の説明は以上です。次に、ソースコードを読んだり手を動かした過程で得た学びを紹介します。

自作Webサーバーにタイムアウトを設定する

私は新卒2年目ですが、普段触れているアプリケーションコードの裏側、より低レイヤーを勉強するための研修を毎週してもらっています。研修はVPoEの大場さんに見てもらっていて、WebアプリケーションフレームワークやORマッパーの再実装も目指してゆくゆくは Ruby on Rails 相当の機能を車輪の再発明しようとしています。

その研修でsocketライブラリを使った簡単なWebサーバーを書いたのでそれに今回学んだタイムアウトを入れようと思いました。しかし、今回のしおいさんの発表で登場した3つのタイムアウトは接続するクライアント側の実装だったので直接キーワード引数を使うことはできませんでした。ただ、Webサーバーも通信の過程で必ず待ちが発生し、タイムアウトを入れるべきタイミングがあるはずだと大場さんから助言をいただきました。

私のWebサーバーでは TCPServer.accept でコネクションが確立された後、クライアントからのHTTPリクエストを受け付けます。その際、 "\r\n" をクライアントのリクエストの終了の合図としていました。もし "\r\n" をなかなか送ってこないクライアントが多くいればサーバーとしては困るのではないかと考えました。後で調べてみるとこれには Slowloris という攻撃の名前が付いていました。スローロリスを調べてみるとロリス科のかわいい動物が出てきました。普段はゆっくり生活しているそうです。

while line = client.gets
  break if line == "\r\n" # クライアントのリクエスト終了の合図
end

サーバー側でタイムアウトを設定する方法を調べてみると以下のように設定できることがわかりました。

loop do
  client = @server.accept
  client.timeout = 5

実際に接続して5秒放置してみると、接続が切れました!

% telnet localhost 80
Trying ::1...
Connected to localhost.
Escape character is '^]'.
Connection closed by foreign host.

サーバー側はIO::TimeoutErrorが出ていることがわかりました。

% ruby server.rb
#<Thread:0x000000010508fa38 server.rb:15 run> terminated with exception (report_on_exception is true):
server.rb:28:in 'IO#gets': Blocking operation timed out! (IO::TimeoutError)
        from server.rb:28:in 'SimpleWebServer#handle_request'
        from server.rb:17:in 'block (2 levels) in SimpleWebServer#start'

IOタイムアウトの実装を読む

ここで、サーバー側でRubyからタイムアウト制御ができるということは、しおいさんの発表であったクライアント側のタイムアウト制御のような工夫があるのではないかと思いました。内部の実装を読んでみることにしました。

C言語の関数を辿っていくと、internal_read_func という関数で read(2) をしている部分にたどり着きました (io.c#L1209)。ここで単純に read(2) をすると前述の通り、タイムアウトができなくなってしまいます。この実装では、select(2) を事前に動かしています。select(2) を使えば読み取り ready になるまでタイムアウト付きで待つことができます。これは connect_timeout でのシステムコールの使い方と同様の方法です。

実際に タイムアウト付き select(2) -> read(2) という流れを試してみます。

#include <stdio.h>
#include <unistd.h>
#include <sys/select.h>

int main(void) {
    char buf[1024];

    fd_set readfds;      // 監視するファイルディスクリプタのリスト
    struct timeval tv;   // タイムアウト時間

    FD_ZERO(&readfds);       // リストすべて0にする
    FD_SET(0, &readfds);     // 0番(標準入力)のビットだけを「1」にする

    tv.tv_sec = 5;           // 5秒
    tv.tv_usec = 0;          // 0マイクロ秒

    // 第1引数は「監視する最大のfd番号 + 1」なので、今回は 0 + 1 = 1
    int ret = select(1, &readfds, NULL, NULL, &tv);

    if (ret == 0) {
        printf("タイムアウトしました\n");
    } else if (ret > 0) {
        int n = read(0, buf, sizeof(buf));
    } else {
        // -1 が返ってきたらエラー
        perror("select error");
        return 1;
    }

    return 0;
}

コンパイルして実行し、入力せずに放置すると5秒後にタイムアウトしました!

% gcc src/select_read.c -o bin/select_read
% ./bin/select_read 
タイムアウトしました

以上のように、select(2) と read(2) という2つのシステムコールを組み合わせることでタイムアウトを設定できることがわかりました。

IO#read でタイムアウトを試してみる

最後に、IO#readTimeout.timeout が効くかを irb で試してみます。

irb(main):001> require 'timeout'
=> true
irb(main):002> Timeout.timeout(1) { $stdin.read }
/Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:40:in 'Timeout::Error.handle_timeout': execution expired (Timeout::Error)
        from /Users/yuma.takeda/.anyenv/envs/rbenv/versions/4.0.4/lib/ruby/4.0.0/timeout.rb:306:in 'Timeout.timeout'
        from (irb):2:in '<main>'
        (以下略)

1秒後にタイムアウトエラーが発生しました!ということは IO#read の実装でもタイムアウトが効くような工夫がされているということです。

先ほどの IO#timeout= と今回の Timeout.timeout はタイムアウトの仕組みが異なります。IO#timeout= は対象のIO処理自体(select(2)など)に制限時間を設け、OSレベルの待機がタイムアウトした際に IO::TimeoutError を出します。一方、Timeout.timeout は実行中のスレッドとは別の監視スレッドを動かし、時間になったら対象のスレッドに割り込み(例外)を投げます。

この2つの違いを踏まえた上で、Rubyの IO#read がどうやって割り込みを安全に受け取っているのか、その実装についてはまた次の機会に読んでみたいと思います。

おわりに

本記事では、前半ではしおいさんの発表について学び直し、後半ではサーバーにタイムアウトを入れるという出発からシステムコールの組み合わせに辿り着きました。私は初め、HEv2 の説明すらできなかったので本記事を書くにあたり、たくさんの基礎知識のインプットから始まりました。また、今までは Ruby の実装を読んだことがなかったのでC言語のコードを読むのにはとても苦労しました。コードリーディングのコツや知識をたくさん須藤さんに教えていただきました。今回のトピック以外でも活かせる知識を教えていただけたので他の実装を読むことに挑戦したいと思えるようになりました。

コードリーディングを進めていく中で一つのメソッドやキーワード引数に対してたくさんの配慮がされていることがわかりました。普段は意識せずに使っているレイヤーを少し覗くとシステムコールやファイルディスクリプタのことまで考えることができて良かったです。部分的に理解していたネットワークの知識がTCPからRubyまで少し線の形に近づいた気がします。

connect_timeout の最初の提案が2011年、3つのキーワード引数すべてが本当に動くようになったのは2025年の Ruby 4.0 です。長い時間をかけて成熟する過程をしおいさんの発表から知ることができました。

しおいさんのスライドは全て惜しみなく図示されていて、難しい話なのにわかりやすかったです。先日イベントでお会いしてたくさんお話しさせていただきましたが、スライド作成にはたくさんの時間を費やされたそうです。学ぶ側の私にとってはとてもありがたかったです。他にもたくさんのことを教えていただきました。ありがとうございました。

speakerdeck.com

この記事を書いたことでたくさんの学びを得られました。RubyKaigi 2026 とその関連イベントに参加したことでたくさんのRubyist、コミッターの方々と会い、お話しさせていただきました。今後も周りの方に感謝しつつ学びを続けたいと思います!