9
3

Elixir スクリプト で `/dev/stdin` を `File.read` で直接読むには?

Last updated at Posted at 2024-08-18

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つ用意します

pipe_test_with_file_read.exs
File.read!("/dev/stdin") |> IO.inspect()
pipe_test_with_io_read.exs
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 オプションを使えば動くよ

とのことでした。以下のスクリプトを作って

pipe_test_with_file_read.erl
#!/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ですが)、

このパフォーマンスをあげることにコントリビュートできるとみんながハッピーになれるかもしれません!


以上です。

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3