Closed10

pumaのコードリーディングメモ

sawasakisawasaki

前提と準備

この時点のソースを読んでいく

実際に読んでいく前に、cloneしたpumaを動かせる状態にする
まずbundle installする

そしてbin/pumaを参考に以下の内容のスクリプト(app.rb)を作る

require './lib/puma/cli'
cli = Puma::CLI.new ARGV
cli.run

次に以下の内容のconfig.ruを作る

run do |env|
  [200, {}, ["Hello World"]]
end

ruby app.rbを実行してpumaを起動
引数無しでpumaを起動した場合、自動的にconfig.ruが読み込まれる
curl localhost:9292を実行してレスポンスが返ってきたら成功

sawasakisawasaki

目標の設定

今回の目標は以下の2つ

  1. pumaが行っている「リクエストを受け取ってからレスポンスを返すまでの一連の処理の流れ」を掴む
  2. pumaのリポジトリのissueの中から調査できそうな物を探して調査する
sawasakisawasaki

Entry Point

pumaはbin/pumaがentry pointになる
このスクリプト内でPuma::Cliクラスのrunメソッドを呼び出している
早速lib/puma/cli.rbの中身を覗く

Handles invoke a Puma::Server in a command line styleというコメントがある
どうやらPuma::CliのインスタンスはコマンドラインとPuma::Serverとの橋渡しを行うようだ

Puma::Cli#initializeの処理を見る
渡ってきた引数をパースしたりインスタンス変数を初期化したりして、最終的にはPuma::Launcherのインスタンスをインスタンス変数に保存していた
重要そうなインスタンスだが後で使われる時になって調べる事にした

Puma::Cli#runの処理を見る
先程のPuma::Launcherのインスタンスのrunメソッドが呼ばれていた

sawasakisawasaki

Puma::Launcher

lib/puma/launcher.rbを開いてPuma::Launcherが何者か確かめる事にした
クラス定義の上に以下のコメントがあった

  # Puma::Launcher is the single entry point for starting a Puma server based on user
  # configuration. It is responsible for taking user supplied arguments and resolving them
  # with configuration in `config/puma.rb` or `config/puma/<env>.rb`.
  #
  # It is responsible for either launching a cluster of Puma workers or a single
  # puma server.

どうやらPuma::Launcherはpumaサーバの唯一のentry pointらしい
そしてPuma::Launcherがpuma workersのクラスターもしくは単一のpumaサーバを立ち上げる責務を負うとの事
つまりはLauncherはpumaの実体を立ち上げる抽象レイヤーで、Cliから渡ってきた設定情報を元にpuma workersもしくは単一のpumaサーバを立ち上げる

Puma::Launcherの正体が分かった所でPuma::Launcher#runが何をしているのか見ることにした
以下の事を行っていた

  • シグナルのハンドラを登録
  • プロセス名を設定
  • systemdとの連携
    一番大事なのはこれだろう
# This blocks until the server is stopped
@runner.run

Puma::Launcher#initializeの中で@runnerは以下のように初期化されていた

if clustered?
  @options[:logger] = @log_writer

  @runner = Cluster.new(self)
else
  @runner = Single.new(self)
end

Clusterのインスタンスがworkersを取りまとめるインスタンスでSingleのインスタンスが単一のpumaサーバを担当するのだろう
なので次はPuma::Singleクラス及びPuma::Single#runを見る

sawasakisawasaki

Puma::Single

Puma::Single#runに以下のコードを発見

@server = server = start_server
server_thread = server.run
sawasakisawasaki

Puma::Server

コメントによるとPuma::SingleまたはPuma::Clusterから1個以上のPuma::Serverが作られるようだ

start_serverPuma::Serverのインスタンスを作る
Puma::Server#runは以下の処理を行う

  • Puma::ThreadPoolインスタンスの初期化
  • Puma::Reactorインスタンスの初期化
  • Puma::Server#handle_serversを呼び出す

Puma::ThreadPoolPuma::Reactorが何者なのかの調査はアーキテクチャのドキュメントも参照しながら行った

Puma::Reactorはpumaのworkerが持つ単一のスレッド(を管理するクラス)
これのインスタンスの役割は、socketに到着したリクエストを拾い上げバッファリング(リクエストが全て届くまで待つ)し、todoリストに入れる。リクエストはtodoに入った時点では何も処理されない

Puma::ThreadPoolはpumaの各workerが持つスレッドプール(Puma::Reactorとは別のスレッド)
todoの中のリクエストを取り出し、rackアプリケーションを実行する

そしてPuma::ServerのインスタンスはSingleまたはClusterから1個以上作られる事を考えると、Puma::Serverは名前に反してpumaのworkerに対応するクラスのようだ

Puma::Server#handle_servers

ReactorThreadPoolの役割が分かったのでhandle_serversの中身を見る
一番大事そうな所はここ

while @status == :run || (drain && shutting_down?)
  begin
    ios = IO.select sockets, nil, nil, (shutting_down? ? 0 : nil)
    ...
  end
end

@status:runの間、IO#selectを行い続けている
IO#selectは引数に与えられたIOオブジェクトの中から準備ができた物を返す
つまり今回の場合は第一引数に渡されたsocketsの中から準備が出来たオブジェクトを返している
ちなみにIO#selectの第一引数は入力待ちIOオブジェクトを期待している

socketsには@binder.iosが代入されている
そして@binderPuma::Binderのインスタンス

sawasakisawasaki

Puma::Binder

lib/puma/binder.rbの中のPuma::Binderのクラス定義周辺には何のコメントも書かれていないが、おそらくソケットをbindするオブジェクトだと思う

ソケット通信についておさらいしておく
ソケットはプロセスが他のプログラムとの通信を行う時のインターフェースである
他のプログラムと通信する際にはそのソケットを対象に送信を行い、他のプログラムからの送信はソケットが受けとる
ソケット通信に必要な物はお互いを一意に特定する為の情報(名前)である
名前はソケットのドメインによって異なるが、この名前を設定する事をbindと呼ぶ

IO#selectに渡すIOオブジェクトとして@binder.iosが使われているので、@binder.iosに何のIOオブジェクトがどのタイミングで追加されているのか調べる
lib/puma/binder.rbの中で@binderを検索すると以下のメソッドの中で何らかのIOオブジェクトが追加されていた

  • add_tcp_listener
  • inherit_tcp_listener
  • add_ssl_listener
  • inherit_tcp_listener
  • add_unix_listener
  • inherit_unix_listener

ssl listenerが何かは分からないが、tcp listenerとunix listenerはそれぞれINETドメインソケットとUNIXドメインソケットだと思う
一番簡単そうなadd_unix_listenerを見ると以下の処理があった

# Tell the server to listen on +path+ as a UNIX domain socket.
#
def add_unix_listener(path, umask=nil, mode=nil, backlog=1024)
  ...
  s = UNIXServer.new path.sub(/\A@/, "\0") # check for abstract UNIXSocket
  s.listen backlog
  @ios << s
  ...
end

UNIXServerのインスタンスを作ってlistenしている
UNIXストリーム型接続のサーバ側のソケットのクラスであるUNIXServerのインスタンスを作り、listenし、そして@iosに追加している
つまりソケットを作ってbind、そしてlistenまで行っている
ではこれはどのタイミングで呼ばれているのだろうか?
RubyMineで辿っていくと最終的にPuma::Single#runの中でadd_unix_listenerが呼ばれる事がわかった

sawasakisawasaki

ここまでのまとめ

以下のような流れ

  1. Puma::Cli#runPuma::Launcher#runを呼び出す
  2. Puma::Launcher#runPuma::Single#runを呼び出す
  3. 2の時点でUNIXソケットやINETソケットが作られlistenする
  4. Puma::Single#runPuma::Server#runを呼び出す
  5. Puma::Server#runの中でPuma::ReactorPuma::ThreadPoolを初期化し、Puma::Server#handle_serversを呼び出す
  6. Puma::Server#handle_serversの中で3で作ったソケットにリクエストが来るのを待つ
sawasakisawasaki

Puma::Binder#handle_serversの続き

IO#selectからIOオブジェクト(つまりリクエストが来たソケット)が返ってくると、以下の処理が行われる

  1. スレッドプール内のbusyなスレッドが最大スレッド数を下回るまで待つ(Puma::ThreadPool#wait_until_not_full)
  2. MRIの場合、更にほんの少しだけ待つ(Puma::ThreadPool#wait_for_less_busy_worker)

1に関しては直感的に理解できるが、2に関してはよく分からない
どうやら2の処理を挟む事でlatencyが下がるらしい(https://github.com/puma/puma/pull/2079)

そして次の処理が行われる

  1. ソケットのaccept(accept_nonblockメソッド)
  2. Puma::Clientのインスタンス化と、それを引数にPuma::ThreadPool#<<の呼び出し

2が重要そうなのでPuma::ClientPuma::ThreadPool#<<をそれぞれ見ていく

sawasakisawasaki

Puma::Client

lib/puma/client.rbPuma::Clientの定義の上に以下のコメントがある

  # An instance of this class represents a unique request from a client.
  # For example, this could be a web request from a browser or from CURL.
  #
  # An instance of `Puma::Client` can be used as if it were an IO object
  # by the reactor. The reactor is expected to call `#to_io`
  # on any non-IO objects it polls. For example, nio4r internally calls
  # `IO::try_convert` (which may call `#to_io`) when a new socket is
  # registered.

一段落目は「このクラスのインスタンスはクライアントからの一意なリクエストを表現する。例えばブラウザやCURLからのリクエストとか」
二段落目は「Puma::ClientはリアクターからIOオブジェクトの様に扱われる。リアクターはポーリングしている非IOオブジェクトに#to_ioを呼び出す事を期待されている。例えばnir4rは新しいソケットが登録された時に内部的にIO::try_convertを呼ぶ」とある
正直二段落目は意味不明なので一旦放置

このスクラップは2023/01/02にクローズされました