elixir.jp slack で以下の質問があり、面白かったので共有します。
File.read("/dev/stdin") なのですが、いまいち動作が納得できないので、こちらで質問させてください。
"/dev/stdin" |> File.read!() |> IO.inspect()
という elixir script である test.exs を作成しました。
Linux 上でelixir test.exs < test.exs
の実行結果は、"\"/dev/stdin\" |> File.read!() |> IO.inspect()\n"
で期待通りでした。
しかし、Linux 上でcat test.exs | elixir test.exs
、および mac 上でのelixir test.exs < test.exs
およびcat test.exs | elixir test.exs
の実行結果は""
と予想外のものになりました。
※質問者様には公開の許可をいただきました。
現象について
※私は手元に Mac がないので、以降は Linux 環境下での実行結果です。
実行環境は以下です。
elixir -v
Erlang/OTP 27 [erts-15.0.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]
Elixir 1.17.2 (compiled with Erlang/OTP 27)
スクリプトを2つ用意します
File.read!("/dev/stdin") |> IO.inspect()
IO.read(:stdio, :eof) |> IO.inspect()
どちらも、標準入力を読んでそれを表示させるという内容です。
これらは実行すると興味深いことに以下のようになります。
echo hello | elixir pipe_test_with_file_read.exs
""
echo hello | elixir pipe_test_with_io_read.exs
"hello\n"
前者の /dev/stdin
をファイルとして直接読むスクリプトは実行結果が期待する結果になりません。
なぜだか不思議で興味深いです。ただ、空文字列の出力は得ているので、エラーを起こしているわけではないようです。
elixir の issue を掘っていて、 race
という単語を見つけたからだったと思いますが、以下を試すことを思いつきます。
LC_ALL=c ping -c 2 example.com | elixir pipe_test_with_file_read.exs
"64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=2 ttl=54 time=130 ms\n\n--- example.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1002ms\nrtt min/avg/max/mdev = 129.863/130.133/130.403/0.270 ms\n"
LC_ALL=c ping -c 2 example.com | elixir pipe_test_with_io_read.exs
"PING example.com (93.184.215.14) 56(84) bytes of data.\n64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=1 ttl=54 time=126 ms\n64 bytes from 93.184.215.14 (93.184.215.14): icmp_seq=2 ttl=54 time=128 ms\n\n--- example.com ping statistics ---\n2 packets transmitted, 2 received, 0% packet loss, time 1001ms\nrtt min/avg/max/mdev = 126.211/127.262/128.313/1.051 ms\n"
この場合、 pipe_test_with_file_read.exs
でも出力を得ることができました。ただ、 icmp_seq=1
は出力されていません。つまり、 ping
の一回目の出力が読めて無さそうです。
また、何度か pipe_test_with_file_read.exs
を実行すると、elixir pipe_test_with_io_read.exs
と同様の結果を得られることがありました。なんとなく読む タイミング
で結果が変わっていそうな感じがします。
issue なのか確かめる
elixir のリポジトリで issue を立てました。
即日に Andrea さんと Jose さんが返信をしてくださいました。要約すると
- Erlang のふるまい
- Erlang が入力をバッファリングしてるからだと思うよ
とのことでした。なので、 erlang のリポジトリでさらに issue を立てました。
こちらも即日 Lukas さんが返信をしてくださいました。
-
-noinput
オプションを使えば動くよ
とのことでした。以下のスクリプトを作って
#!/usr/bin/env escript
%%! -noinput
main(_) ->
{ok, Binary} = file:read_file("/dev/stdin"),
io:format("~s", [binary_to_list(Binary)]).
試すと、
echo hello | escript pipe_test_with_file_read.erl
hello
たしかに動きます。(オプション無しだと、出力を得ません。
このオプションを elixir
コマンドで使えれば良さそうです。
elixir コマンドで -noinput
を使う
-noinput
を調べると erl
コマンドのオプションであることが分かります。
ref. https://www.erlang.org/doc/apps/erts/erl_cmd.html
また、 elixir -h
によると
--erl "SWITCHES" Switches to be passed down to Erlang
とのことなので、以下のようにしてやれば良さそうです。
echo hello | elixir --erl -noinput pipe_test_with_file_read.exs
"hello\n"
お、動きました🎉
race
, タイミング
とは結局なんだったのか?
Elixir や Erlang で処理を実行するとき、その処理を実行するのは VM 上のプロセスです。
スクリプトの実行ではそのスクリプトを実行するプロセスが存在します。
これは self() |> IO.inspect()
と書いたスクリプトを実行することで確認できます。
つまり、 pipe_test_with_file_read.exs
の実行では、スクリプトを実行するプロセスが File.read
で直接 /dev/stdin
を読んでいます。
一方で、 pipe_test_with_file_read.exs
の実行では、スクリプトを実行するプロセスがIO.read(:stdio, :eof)
で何をしているかというと https://hexdocs.pm/elixir/IO.html#module-io-devices を読むと
:stdio - a shortcut for :standard_io, which maps to the current Process.group_leader/0 in Erlang
という標準入出力を司るプロセスに問い合わせをしているようです。
これらを踏まえると、 VM をオプションを付けずに使う場合、
:stdio(:standard_io)
と名付けられたプロセスが /dev/stdin
, /dev/stdout
をハンドリングしているので、File.read("/dev/stdin")
をすることはそのハンドリングと 競合
しそうです。
たとえば、どちらが早く読むのか。プロセスの処理の実行順序はスケジューリングされているので、それ依存になるはずです。よって、VM をオプションを付けずに使う場合、標準入出力は IO
モジュールの関数を介して stdio
プロセス経由で使うのが良さそうです。
-noinput
オプション と -noshell
オプションの意味するところ
ref. https://www.erlang.org/doc/apps/erts/erl_cmd.html
- -noinput - Ensures that the Erlang runtime system never tries to read any input. Implies -noshell.
:standard_io
プロセスに入力を読まなくさせるオプションととらえることができそうです。
なので、このオプションを使うと File.read("/dev/stdin")
を使っても競合が起きなくなります。(もちろん、 IO.read(:stdio, :eof)
は動作しなくなります。)
Implies -noshell
は、 VM(ERTS) が shell
の入力取得を :standard_io
プロセスに頼っていると想像するので、必然的にそうなると考えて良さそうです。
- -noshell - Starts an Erlang runtime system with no shell. This flag makes it possible to have the Erlang runtime system as a component in a series of Unix pipes.
shell
が存在するとそこからの入力が :standard_io
に入るので、それを避けるためにあると考えられます。これは elixir
コマンド実行時にはすでに設定されていることを以下で確認することができます。
echo hello | ELIXIR_CLI_DRY_RUN=1 elixir --erl -noinput pipe_test_with_file_read.exs
erl -noshell -elixir_root /home/pojiro/.asdf/installs/elixir/1.17.2-otp-27/bin/../lib -pa /home/pojiro/.asdf/installs/elixir/1.17.2-otp-27/bin/../lib/elixir/ebin -elixir ansi_enabled true -s elixir start_cli -noinput -extra pipe_test_with_file_read.exs
そもそもなんで /dev/stdin
を直接読みたくなったのか?
質問者は IO.read
が遅いために /dev/stdin
を使いたくなったようでした。
遅いことについては issue がすでにあるので(IO.write
ですが)、
このパフォーマンスをあげることにコントリビュートできるとみんながハッピーになれるかもしれません!
以上です。