bashで手軽に非同期処理しつつ実行結果を簡単に受け取る方法
こんにちは、羽山です。
みなさん元気にシェルスクリプトを書いていますか?
今回は bash で任意のコマンドを非同期実行しつつ、その実行結果を手軽に受け取る方法を紹介します。
よく利用される bash の非同期実行は &
をコマンドの最後に付けてバックグランドで実行する方法ですが、以下のような制限や面倒くささがありがちです。
- 実行結果として簡単に受け取れるのは終了ステータスだけ
- 実行結果を得たい場合は一時ファイルへのリダイレクトや名前付きパイプを利用する
- 終了後に生成したファイルのクリーンナップ処理が必要
- 重複の回避などファイル名の生成に気を遣う必要がある
- 非同期処理の終了を wait で待機するなど、タイミングを意識する必要がある
今回紹介する方法を利用すればメモリ上だけでデータのやりとりを完結できるのでファイルシステムへの依存がなく、これらの制限や面倒くささを解消することができます。
TL;DR
本稿では &
を利用した方法ではなく exec
コマンドとプロセス置換を利用した非同期処理を解説します。
time (
# 60秒かかる処理を非同期で3つ同時に実行
exec {task1_fd}< <( echo "Start: task1"; sleep 60; echo "Done: task1" )
exec {task2_fd}< <( echo "Start: task2"; sleep 60; echo "Done: task2" )
exec {task3_fd}< <( echo "Start: task3"; sleep 60; echo "Done: task3" )
# 処理結果の受け取り、約60秒後に完了
cat <&${task1_fd}
cat <&${task2_fd}
cat <&${task3_fd}
)
処理に60秒ずつかかるタスクを並列で実行しながら以下のようにタスク毎の実行結果を取得できます。
Start: task1
Done: task1
Start: task2
Done: task2
Start: task3
Done: task3
real 1m0.006s
user 0m0.007s
sys 0m0.001s
非同期で実行したい用途は?
まずは非同期処理が活躍するシーンを考えてみます。
以下のような同期処理で書かれたサーバーの状態を取得するコードがあるとします。
echo "-- vmstat --"
vmstat_result=$(vmstat 60 2)
echo "$vmstat_result" |head -n 1
echo "$vmstat_result" |tail -n 1
echo
echo "-- ping --"
ping -c 60 host1 |tail -n 3
ping -c 60 host2 |tail -n 3
ping -c 60 host3 |tail -n 3
echo
echo "-- tcp(host1) --"
echo -n "host1:80 : "
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null && echo ok || echo ng
echo -n "host1:3000: "
timeout 10 bash -c "</dev/tcp/host1/3000" 2>/dev/null && echo ok || echo ng
echo -n "host1:3001: "
timeout 10 bash -c "</dev/tcp/host1/3001" 2>/dev/null && echo ok || echo ng
echo -n "host1:3002: "
timeout 10 bash -c "</dev/tcp/host1/3002" 2>/dev/null && echo ok || echo ng
echo
echo "-- tcp(host2) --"
echo -n "host2:80 : "
timeout 10 bash -c "</dev/tcp/host2/80" 2>/dev/null && echo ok || echo ng
echo -n "host2:3000: "
timeout 10 bash -c "</dev/tcp/host2/3000" 2>/dev/null && echo ok || echo ng
echo -n "host2:3001: "
timeout 10 bash -c "</dev/tcp/host2/3001" 2>/dev/null && echo ok || echo ng
echo -n "host2:3002: "
timeout 10 bash -c "</dev/tcp/host2/3002" 2>/dev/null && echo ok || echo ng
echo
echo "-- tcp(host3) --"
echo -n "host3:80 : "
timeout 10 bash -c "</dev/tcp/host3/80" 2>/dev/null && echo ok || echo ng
echo -n "host3:3000: "
timeout 10 bash -c "</dev/tcp/host3/3000" 2>/dev/null && echo ok || echo ng
echo -n "host3:3001: "
timeout 10 bash -c "</dev/tcp/host3/3001" 2>/dev/null && echo ok || echo ng
echo -n "host3:3002: "
timeout 10 bash -c "</dev/tcp/host3/3002" 2>/dev/null && echo ok || echo ng
非同期処理の説明の前にまずはこの中身を見ていきましょう。
vmstat
vmstat_result=$(vmstat 60 2)
echo "$vmstat_result" |head -n 1
echo "$vmstat_result" |tail -n 1
上記 vmstat
コマンドの実行結果は以下のようになります。
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
0 0 0 4286336 77996 1173900 0 0 0 3 8 69 0 0 100 0 0
vmstat
コマンドは指定したインターバルごとにその間のサーバーの状況を報告してくれるコマンドです。
ただし最初に報告される結果はシステムが起動してから今までの累計値(平均値)で、それ以降が指定したインターバルに基づいた値になります。また vmstat
コマンドが情報を収集するためにインターバルで指定した秒数分だけ実行に時間がかかります。
vmstat 60 2
とすることでインターバルを60秒で結果を2回出力します。
最初に戻される行はヘッダーで以降に結果が続きますが、結果の最初の行は前述の通りシステムが起動してからの累積値になるので今回利用しません。インターバルで指定した60秒後に表示される行が今回必要な値です。
つまりこのスクリプトは vmstat
から戻される全3行のうちの1行目と3行目を取得しています。
ちなみに最初と最後の行を取得するために、コマンド置換 vmstat_result=$(vmstat 60 2)
で結果を変数に保持してから head -n 1
と tail -n 1
にそれぞれ渡しています。
もし1行で書きたいなら vmstat 60 2 |(read t; echo $t; tail -n 1)
とすることもできますが、少々技巧的すぎますね。インフラ関連のコードは保守性のためにわかりやすさを優先すべきです。
これで vmstat
から望むデータは取得できますが実行には60秒間かかります。
1回のコマンド実行で先頭行と最後行を取得する
seq 10 |(head -n 1; tail -n 1)
とすればうまくいきそうですが、これでは望む結果は得られません。
なぜならhead
には読み込みバッファがあるからです。標準入力から厳密に1行分だけを読み込むように処理すると1バイトずつI/Oが必要で効率が悪くなるので、まとめてバッファに読み込んだ後にその中から先頭行を取得します。その結果tail
が読み込むべきだった最終行をhead
に消費されてしまうのでtail -n 1
は正しく動作しません。
入力値がhead
の読み込みバッファを越えるほど大きい場合、例えばseq 10000 |(head -n 1; tail -n 1)
とすれば手元の環境では望む結果を得られましたが、バッファサイズ次第なのでこういった書き方は望ましくありません。もしくはseq 10 |(head -c 2; tail -n 1)
のように読み込むバイト数を指定すれば余分なバッファ読み込みがなくなるので望む結果が得られますが、これも根本的な解決にはなっていません。
ではseq 10 |(read t; echo $t; tail -n 1)
だとなぜうまく動くかというと、read
コマンドは厳密に1行分しか読み込まないためです。
ping
ping -c 60 host1 |tail -n 3
ping -c 60 host2 |tail -n 3
ping -c 60 host3 |tail -n 3
続いて ping
コマンドの実行結果は以下のようになります。
--- host1 ping statistics ---
60 packets transmitted, 60 received, 0% packet loss, time 59102ms
rtt min/avg/max/mdev = 16.038/22.368/171.614/20.877 ms
--- host2 ping statistics ---
60 packets transmitted, 60 received, 0% packet loss, time 59105ms
rtt min/avg/max/mdev = 16.231/18.398/21.588/1.331 ms
--- host3 ping statistics ---
60 packets transmitted, 60 received, 0% packet loss, time 59098ms
rtt min/avg/max/mdev = 16.289/18.569/38.241/2.853 ms
ping -c 60 host1
は1秒間隔で60回送信します。そして実行結果として tail -n 3
で最後の3行だけを取得しています。
ping
コマンドは最後の3行に集計結果が出力されるので、60回に対する成功率やパケットの往復時間の平均値などを得ることができます。
ただし1秒間隔で60回の送受信が発生するため、結果を得るためには少なくとも59秒間かかります。
今回は対象ホストを3つとしているので全体で約3分間必要です。
tcp
以下のコマンドはサービスが起動していることを簡易的に確認する目的でTCPレベルの疎通確認を行っています。
echo -n "host1:80 : "
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null && echo ok || echo ng
echo -n "host1:3000: "
timeout 10 bash -c "</dev/tcp/host1/3000" 2>/dev/null && echo ok || echo ng
echo -n "host1:3001: "
timeout 10 bash -c "</dev/tcp/host1/3001" 2>/dev/null && echo ok || echo ng
echo -n "host1:3002: "
timeout 10 bash -c "</dev/tcp/host1/3002" 2>/dev/null && echo ok || echo ng
...
今回の例では host1, host2, host3 の 80, 3000, 3001, 3002番ポートを対象として実行結果は以下のようになります。
host1:80 : ok
host1:3000: ok
host1:3001: ng
host1:3002: ng
...
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null && echo ok || echo ng
は少し説明が必要そうなので分解して解説します。
bash はリダイレクト先を /dev/tcp/<host>/<port>
とすると内部的にソケットを接続してくれる機能があります。
これを利用すると例えば以下のように簡易的なHTTPクライアントとすることも可能です。ちょうど telnet
でやりとりしているような感じですね。
# 現在のbashプロセスで www.example.com の 80番ポートへのソケットを読み書き両方で接続
exec {fd}<>/dev/tcp/www.example.com/80
# 開いたソケットに対してHTTP/1.1のリクエストを送信
# http://www.example.com/ へのアクセスに相当する
echo -ne "GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n" >&${fd}
# 応答を cat で取得
cat <&${fd}
# 接続を閉じる
exec {fd}>&-
上記の例では実際にデータのやりとりをしていますが、疎通確認だけならば片方向でソケットを接続する所まで試行できれば十分です。そこで次に思い付くのは以下のような形式です。
: </dev/tcp/www.example.com/80
なにも実行しないヌルコマンド :
に対して www.example.com
の80番ポートから入力方向でTCPのソケットを接続しています。
bash がソケットの接続まではしてくれますが、ヌルコマンドなので実際のデータ送受信は行われません。
さらに bash ではリダイレクトだけすることもできるのでヌルコマンドすらも省略して以下でも同様の結果になります。
</dev/tcp/www.example.com/80
次は接続が成功したかどうかの判定ですが、これには終了ステータスを利用します。
ソケット接続に成功した場合の終了ステータスは 0
で、失敗した場合は 0
以外になるため、&& echo ok || echo ng
を繋げると接続結果に応じて望む出力を得られます。
</dev/tcp/www.example.com/80 && echo ok || echo ng
これをインターネットに接続された端末で実行すると ok
と表示されることが確認できます。
最後に timeout 10
についてです。
成功失敗にかかわらず相手が即座に応答してくれるならば問題ないのですが、例えば相手のホストが停止しているなどで到達できない場合は接続タイムアウトまで待たされてしまいます。
そこで timeout 10 bash -c
経由で実行することで10秒間接続ができなかったら失敗とみなすこととします。
疎通確認にかかる時間は相手先のホスト数と管理対象のポート数や稼働状況次第ですが、停止中のホストが多い場合は10秒ずつ待つことになるので多大なる時間がかかります。
これらの処理を全て同期処理で実行したところ、手元の環境では実時間で4分ほどかかりました。
では次項からこのスクリプトを非同期化していきます。
非同期処理からステータスコードのみ受け取る
まずは非同期実行しつつステータスコードだけを受け取る方法です。
これはごく一般的なやり方なので本稿の主題に入る前の肩慣らしだと思ってください。
終了ステータスだけが必要なケースはTCPでの疎通確認を行っていた以下の部分です。
echo "-- tcp(host1) --"
echo -n "host1:80 : "
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null && echo ok || echo ng
echo -n "host1:3000: "
timeout 10 bash -c "</dev/tcp/host1/3000" 2>/dev/null && echo ok || echo ng
echo -n "host1:3001: "
timeout 10 bash -c "</dev/tcp/host1/3001" 2>/dev/null && echo ok || echo ng
echo -n "host1:3002: "
timeout 10 bash -c "</dev/tcp/host1/3002" 2>/dev/null && echo ok || echo ng
みなさんもご存じの通り &
を付けて実行します。
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null &
pid_host1_80=$!
timeout 10 bash -c "</dev/tcp/host1/3000" 2>/dev/null &
pid_host1_3000=$!
timeout 10 bash -c "</dev/tcp/host1/3001" 2>/dev/null &
pid_host1_3001=$!
timeout 10 bash -c "</dev/tcp/host1/3002" 2>/dev/null &
pid_host1_3002=$!
echo -n "host1:80 : "
wait $pid_host1_80 && echo ok || echo ng
echo -n "host1:3000: "
wait $pid_host1_3000 && echo ok || echo ng
echo -n "host1:3001: "
wait $pid_host1_3001 && echo ok || echo ng
echo -n "host1:3002: "
wait $pid_host1_3002 && echo ok || echo ng
&
で生成したバックグランドプロセスのプロセスIDを $!
で取得して変数に保持しておき、あとから wait <pid>
で終了ステータスを得ています。
これで非同期化すれば実行時間の短縮はできますが、プロセスIDを保持したり wait
したり、元の処理から非同期処理用に書き換えが必要な点などには面倒くささが残ります。
では次が本題です。手軽に非同期処理しつつ実行結果を取得してみましょう。
非同期処理から実行結果を受け取る
以下の vmstat
と ping
の実行を非同期化してみます。
echo "-- vmstat --"
vmstat_result=$(vmstat 60 2)
echo "$vmstat_result" |head -n 1
echo "$vmstat_result" |tail -n 1
echo
echo "-- ping --"
ping -c 60 host1 |tail -n 3
ping -c 60 host2 |tail -n 3
ping -c 60 host3 |tail -n 3
echo
まずは exec
とプロセス置換で vmstat
や ping
を含むサブシェルを入力方向で開きます。
exec {vmstat_result_fd}< <(
vmstat_result=$(vmstat 60 2)
echo "$vmstat_result" |head -n 1
echo "$vmstat_result" |tail -n 1
)
exec {ping_host1_fd}< <(ping -c 60 host1 |tail -n 3)
exec {ping_host2_fd}< <(ping -c 60 host2 |tail -n 3)
exec {ping_host3_fd}< <(ping -c 60 host3 |tail -n 3)
$vmstat_result_fd
や $ping_host1_fd
などにはそれぞれ開かれたファイルディスクリプタの数値が入ります。
exec
を実行した時点でコマンドの実行も始まるので、時間のかかるコマンドをすべて並列で開始しておきます。
続けて以下のようにそれぞれのファイルディスクリプタから実行結果を cat
コマンドで読み込みます。
cat
の代わりに read
コマンドを利用すれば非同期処理側から1行ずつ逐次データを受け取るので柔軟な制御も可能です。
echo "-- vmstat --"
cat <&${vmstat_result_fd}
echo
echo "-- ping --"
cat <&${ping_host1_fd}
cat <&${ping_host2_fd}
cat <&${ping_host3_fd}
echo
※動作原理を理解するためにはプロセス置換の知識が必要になるので、気になる方は「bashのプロセス置換で遊んでみよう!」の記事も合わせてご確認ください。
cat
の実行時点で vmstat
や ping
などの処理が終了していなければ終了まで待ってくれるので非同期処理にありがちな同期タイミングの面倒くささもありません。
開いたファイルディスクリプタは exec
を実行した bash プロセスの終了と共に閉じられますが、もし必要ならば以下のように明示的にクローズすることも可能です。
exec {vmstat_result_fd}<&-
exec {ping_host1_fd}<&-
exec {ping_host2_fd}<&-
exec {ping_host3_fd}<&-
前項ではTCPレベルの疎通確認処理を &
で非同期化しましたが、それを今回の方法に置き換えてみます。
&
と違ってコマンドの書き換え・プロセスIDの保持・wait
などが必要なく、元のコマンドをそのまま非同期化できます。
exec {tcp_host1_80_fd}< <(
echo -n "host1:80 : "
timeout 10 bash -c "</dev/tcp/host1/80" 2>/dev/null && echo ok || echo ng
)
exec {tcp_host1_3000_fd}< <(
echo -n "host1:3000: "
timeout 10 bash -c "</dev/tcp/host1/3000" 2>/dev/null && echo ok || echo ng
)
exec {tcp_host1_3001_fd}< <(
echo -n "host1:3001: "
timeout 10 bash -c "</dev/tcp/host1/3001" 2>/dev/null && echo ok || echo ng
)
exec {tcp_host1_3002_fd}< <(
echo -n "host1:3002: "
timeout 10 bash -c "</dev/tcp/host1/3002" 2>/dev/null && echo ok || echo ng
)
echo "-- tcp(host1) --"
cat <&${tcp_host1_80_fd}
cat <&${tcp_host1_3000_fd}
cat <&${tcp_host1_3001_fd}
cat <&${tcp_host1_3002_fd}
これらすべてを非同期化したところ、元々は4分ほどかかっていた処理が1分で完了できるようになりました。
まとめ
今回はプロセス置換を利用した非同期処理の実行方法の紹介でした。
実は bash には coproc という双方向でデータの送受信が可能な非同期処理がありますが、bash の man に同時に一つしか動作しないというバグが記載されていたりと動作に微妙な部分もあるので、現時点ではこういった方法を利用するのがベターな状況だと思われます。
プロセス置換はこの他にもいろいろと便利な使い方があるので気になる方は「bashのプロセス置換で遊んでみよう!」の記事も是非ご覧になっていただけたらと思います。
さて、ラクーングループは一緒に働く仲間を絶賛大募集中です!
この記事に偶然たどり着いたインフラエンジニアの方、開発バリバリの方などなど、もし少しでも興味を持っていただけたら是非こちらからエントリーお待ちしています!