RACCOON TECH BLOG

株式会社ラクーンホールディングスのエンジニア/デザイナーから技術情報をはじめ、世の中のためになることや社内のことなどを発信してます。

bashで手軽に非同期処理しつつ実行結果を簡単に受け取る方法

こんにちは、羽山です。
みなさん元気にシェルスクリプトを書いていますか?

今回は bash で任意のコマンドを非同期実行しつつ、その実行結果を手軽に受け取る方法を紹介します。

よく利用される bash の非同期実行は & をコマンドの最後に付けてバックグランドで実行する方法ですが、以下のような制限や面倒くささがありがちです。

今回紹介する方法を利用すればメモリ上だけでデータのやりとりを完結できるのでファイルシステムへの依存がなく、これらの制限や面倒くささを解消することができます。

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 1tail -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 したり、元の処理から非同期処理用に書き換えが必要な点などには面倒くささが残ります。

では次が本題です。手軽に非同期処理しつつ実行結果を取得してみましょう。

非同期処理から実行結果を受け取る

以下の vmstatping の実行を非同期化してみます。

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プロセス置換vmstatping を含むサブシェルを入力方向で開きます。

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 の実行時点で vmstatping などの処理が終了していなければ終了まで待ってくれるので非同期処理にありがちな同期タイミングの面倒くささもありません。

開いたファイルディスクリプタは 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のプロセス置換で遊んでみよう!」の記事も是非ご覧になっていただけたらと思います。

さて、ラクーングループは一緒に働く仲間を絶賛大募集中です!
この記事に偶然たどり着いたインフラエンジニアの方、開発バリバリの方などなど、もし少しでも興味を持っていただけたら是非こちらからエントリーお待ちしています!

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc