LoginSignup
40
39

More than 3 years have passed since last update.

Process Substitutionとexec redirectで画面出力を加工するときの問題点と解決、そして無限に寝る話

Last updated at Posted at 2015-09-10

// すんごく久々に日本語長文書いたらなん文体が変……。

はじめに1: Process Substitutionの話

bashにはProcess Substitution(プロセス置換)という機能がある。
簡単にいうと、子プロセスを起動して、その入出力をパイプで繋げられる機能。
タダのパイプと何が違うの?というと、例えば複数のコマンドを繋ぎたいときに便利。

例えば二つのコマンドの結果のdiffを見たいとき、パイプだと1つのプロセスの出力しか繋げないので、一時ファイルや名前付きパイプのお世話になる。

bash-4.1$ command1 > output1
bash-4.1$ command2 | diff output1 - 

これが、プロセス置換を使うと

bash-4.1$ diff <(command1) <(command2)

と書ける。この例だと、 command1commnad2 の出力結果がそれぞれ、一時ファイル(というか名前付きパイプ)に吐かれて、その名前に置き換えられるイメージ。
一時ファイルや名前付きファイルを作るわけではないのでゴミが残らなくて素敵。

echo につかってみると、何をやっているのかが分かり易い。

bash-4.1$ echo <(./foo) <(./bar) <(./baz)
/dev/fd/63 /dev/fd/62 /dev/fd/61

bashがそれぞれ子プロセスを起動して、空いているファイルディスクリプタに接続して、そのプロセス置換の部分をパスに置き換える。
ファイルを前提としている部分に、コマンドが繋ぎ込める、という寸法。

出力にも使えて、例えばファイルへの書き出ししかオプションを提供していないコマンドや、複数出力があるコマンドの出力を、他のプロセスに直接繋ぐ、とかが出来る。
例えばこんな

bash-4.1$ curl -L -D >(cat -n) -o >(gzip > index.html.gz) http://google.com
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
9515   261  9515 19031    0     0  84585      0 --:--:-- --:--:-- --:--:-- 84585
     1  HTTP/1.1 302 Found
     2  Cache-Control: private
...
    21  Vary: Accept-Encoding
    22  Transfer-Encoding: chunked
    23
bash-4.1$ 

ちょっと意味が無い例かもだけど、curlの標準出力は gzip で圧縮しながらファイルに保存しつつ、ヘッダーは cat -nで行番号を振りつつ標準出力に、進捗表示はそのまま標準エラー出力に、みたいな複雑な出力を一時ファイル無しに制御できるようになる。

プロセス置換だけでもかなり便利なんだけど、exec redirectと組み合わせるともっと便利になる

はじめに2: exec とリダイレクト

もういっこbashの便利tips。execとリダイレクト。
execはプロセスを別コマンドに変身させるbash builtinsなのだけど、コマンドを指定しない用法がある。
コマンドを指定しないときには、リダイレクトが現在のシェルに適応されてそれ以後の入出力先を変更することができる。

manpages[bash(1)]
exec [-cl] [-a name] [command [arguments]]
...
If command is not specified, any redirections take effect in the current shell, and the return status is 0.
...

たとえばこんな。

#!/usr/bin/env bash

echo "hello"
exec >output  # 以後の標準出力への出力が output ファイルに保存されるようになる
echo "world"
bash-4.1$ ./test.sh  # helloはそのまま出力される
hello
bash-4.1$ cat output  # worldはファイルに吐かれる
world
bash-4.1$ 

スクリプトの実行結果をログファイルに残してみたり、シェルスクリプトでちょっと大きめなファイルを組み立てるときに一々リダイレクトを書くのをさぼったり、となにげに使い出の多い機能なので覚えておくと結構ハッピー。
複数の処理/コマンドの入出力を一括で切り替えられる。

で、ファイルに出力を吐くのはいいのだけれど、画面にも出したい。
とか、そんなときに、先のプロセス置換との組み合わせ、が非常に強力。

// その目的なら script(1) 使えよ、という話ですが……。

はじめに3: プロセス置換とリダイレクト

ファイルに吐きつつ、画面に出力!といえば、そう tee(1) の出番。
先にでたプロセス置換と、execリダイレクトを使うとこんな感じに書ける

test.sh
#!/usr/bin/env bash

echo "Script started, file is output.log"
exec > >(tee output.log)  # 以後の標準出力をファイルに書きつつも、画面に出力する
echo "hello world"
# ... 以下なにかでっかい作業
bash-4.1$ ./test.sh  # 一行目はコンソールにだけ出力される
Script started, file is output.log
hello world
... でっかい作業の出力

bash-4.1$ cat output.log  # それ以降はコンソールにもファイルにも吐かれる
hello world
... でっかい作業の出力
bash-4.1$ 

実行する人がわざわざファイルに保存しなくても勝手にログを保存してくれるし、スクリプトも先頭にちょこっと追加するだけ、と非常にお手軽かつ強力。便利。

あるいは単に保存するだけじゃなくて加工することもできる。
時間が掛かるスクリプトで、例えば各行にその時の時間を表示したい。
馬鹿正直にやるのであれば、全ての echodate を追加してみたり、あるいは時間を一緒に画面出力する関数を作って、それを叩いたり、ということになるのだろうけど、exec redirect + process substitutionを使うとこんなで出来てしまう。

build.sh
#!/usr/bin/env bash

function add_info() {
  while read -d $'\n' line; do
    # printf '%(%Y-%m-%d %H:%M:%S)T %s\n' -1 "${line}"  # bash 4系ならprintfで時刻が出せる
    echo "$(date +"%Y-%m-%d %H:%M:%S") ${line}"
  done
}
exec > >(add_info | tee build.log)

# do something(e.g. build something)
./configure
make
bash-4.1$ ./build.sh
2015-09-10 21:53:30 checking for a BSD-compatible install... /usr/bin/install -c
2015-09-10 21:53:31 checking whether build environment is sane... yes
2015-09-10 21:53:31 checking for a thread-safe mkdir -p... /bin/mkdir -p
2015-09-10 21:53:31 checking for gawk... gawk
...
2015-09-10 21:53:34 make[2]: Entering directory `/home/takei/jabanner'
2015-09-10 21:53:34 make[2]: Leaving directory `/home/takei/jabanner'
2015-09-10 21:53:34 make[1]: Leaving directory `/home/takei/jabanner'
bash-4.1$ 

// 各行ごとに date 叩いているのでちょっとアレですが……。

make とかの出力でも標準出力をいじっているので問題なく加工できる。
この例だと数秒で終わるビルドですが、かなーり時間掛かるスクリプトとかだと、どこでネックになっているのかなぁ、とか結構便利です。

と、ここまでは色んなところで紹介されているTips。
非常に便利なのだけど、ちょこっとやっかいなことが……。

proc substの問題点: 子プロセスの終了が待てない

先ほどの例だとちゃんと動いている様に見えるのですが、これを試しに seq 30 とかにしてみると……

bash-4.1$ ./hoge.sh
2015-09-10 22:00:11 1
2015-09-10 22:00:11 2
2015-09-10 22:00:11 3
2015-09-10 22:00:11 4
2015-09-10 22:00:11 5
2015-09-10 22:00:11 6
2015-09-10 22:00:11 7
2015-09-10 22:00:11 8
2015-09-10 22:00:11 9
2015-09-10 22:00:11 10
2015-09-10 22:00:11 11
bash-4.1$ 2015-09-10 22:00:11 12  # <- !?
2015-09-10 22:00:11 13
2015-09-10 22:00:11 14
2015-09-10 22:00:11 15
2015-09-10 22:00:11 16
2015-09-10 22:00:11 17
2015-09-10 22:00:11 18
2015-09-10 22:00:11 19
2015-09-10 22:00:11 20
2015-09-10 22:00:11 21
2015-09-10 22:00:11 22
2015-09-10 22:00:11 23
2015-09-10 22:00:11 24
2015-09-10 22:00:11 25
2015-09-10 22:00:11 26
2015-09-10 22:00:11 27
2015-09-10 22:00:11 28
2015-09-10 22:00:11 29
2015-09-10 22:00:11 30
# <- shellの入力待ち

なにやら変な所にプロンプトが……。

proc substの子プロセスは、親プロセスが終了しても終了しないため、seq の様に実行時間の割りに出力が多いコマンドだと、proc substの出力が終わるよりも先に親プロセスの処理が終わり、shellに制御が返ってしまいます。
まぁ、子プロセスが強制的に殺されてもそれはそれで困るのですが。

// というか、非同期な上にパイプのバッファの関係で、加工側のプロセスが重いと表示される時刻が当てにならない……という結構根本的な問題も……。こ、今回はスルーの方向で……。

ということで、前置きが長かったけど、proc substの子プロセスが出力を終えるまで、親プロセスを止めておきたい、というのが今回のメインテーマ。

1. syncしてみる

で、ググってみて出てきた解決策その1。

syncすればokだよ :thumbsup:

みたいなコメントが。ほんまかいな。ということで試して見る。

bash-4.1$ ./hoge.sh
2015-09-10 22:12:51 1
2015-09-10 22:12:51 2
2015-09-10 22:12:51 3
2015-09-10 22:12:51 4
2015-09-10 22:12:51 5
2015-09-10 22:12:51 6
2015-09-10 22:12:51 7
2015-09-10 22:12:51 8
2015-09-10 22:12:51 9
2015-09-10 22:12:51 10
2015-09-10 22:12:51 11
2015-09-10 22:12:51 12
2015-09-10 22:12:51 13
2015-09-10 22:12:51 14
2015-09-10 22:12:51 15
2015-09-10 22:12:51 16
2015-09-10 22:12:51 17
2015-09-10 22:12:51 18
2015-09-10 22:12:51 19
2015-09-10 22:12:51 20
2015-09-10 22:12:51 21
2015-09-10 22:12:51 22
2015-09-10 22:12:51 23
2015-09-10 22:12:51 24
bash-4.1$ 2015-09-10 22:12:51 25
2015-09-10 22:12:51 26
2015-09-10 22:12:51 27
2015-09-10 22:12:51 28
2015-09-10 22:12:51 29
2015-09-10 22:12:51 30

ですよねー。
親プロセス的にはパイプに吐き終わった時点でsync完了なわけで、結局proc subst側の子プロセスが出力にもたつくとやっぱり親プロセスが先に終了してしまう。

→ syncではダメ

2. pipeを使って終了を同期させる

もういっこググって出てきた解決策。
名前付きパイプを作って、proc subst側のプロセスの終了を通知してみる。
つまりこんな

#!/usr/bin/env bash

function add_info() {
  while read -d $'\n' line; do
    sleep 0.1 # テスト用にsleepいれてます
    echo "$(date +"%Y-%m-%d %H:%M:%S") ${line}"
  done
}

rm -f pipe
mkfifo pipe

exec 9>&1     # 標準出力(コンソール)をファイルディスクリプタ9に待避させる

exec > >(add_info >&9 ; echo done > pipe)   # 標準出力つなぎ替え & 終わったらpipeに出力

seq 30

exec >&-      # sub procのパイプを閉じる (下の行だけでもok)
exec >&9      # 標準出力をコンソールに戻す

echo "wait sub-proc"
read < pipe # pipeから入力を待つ
echo "end"
bash-4.1$ ./hoge.sh
wait sub-proc
2015-09-10 22:24:22 1
2015-09-10 22:24:22 2
2015-09-10 22:24:22 3
...
2015-09-10 22:24:25 28
2015-09-10 22:24:25 29
2015-09-10 22:24:25 30
end

こんな感じにちゃんと子プロセスの終了が待てる。
ちなみに、メインプロセス側で標準出力(proc substのパイプ)を閉じておかないと子プロセスが終了しないので、当然の様にデッドロックります。注意。

そんなわけで無事解決、なのだけど、名前付きパイプをわざわざ作るのもなんかなぁ……という感じ。

→ pipeでok。だけど無駄なファイルができてしまう

3. waitしてみる

そんなわけでココカラ試行錯誤。もう少ししっかりググればこの辺もでてくるのかもですが……。

で、というか、子プロセスを待ちたいなら wait(1) bash builtinがあるじゃないか。
そんなわけでこんなのを書いて見たのですが……。

#!/usr/bin/env bash

function add_info() {
...
}

exec 9>&1     # 標準出力(コンソール)をファイルディスクリプタ9に待避させる

exec > >(add_info >&9)   # 標準出力つなぎ替え
sub_pid=$!    # $!で子プロセスのpidが取れる

seq 30

exec >&-      # sub procのパイプを閉じる
exec >&9      # 標準出力をコンソールに戻す

echo "wait sub-proc"
wait $sub_pid # 子プロセスを待つ
echo "end"
bash-4.1$ ./test.sh
wait sub-proc
./test.sh: line 23: wait: pid 28494 is not a child of this shell    # <- !?
end
bash-4.1$ 2015-09-10 22:30:53 1
2015-09-10 22:30:53 2
2015-09-10 22:30:54 3
...
2015-09-10 22:30:56 28
2015-09-10 22:30:56 29
2015-09-10 22:30:56 30

is not a child of this shell とかいわれる。子プロセスじゃいない???
ps --forest で確認してみる……。

bash-4.1$ ps --forest
UID        PID  PPID  C    SZ   RSS PSR STIME TTY          TIME CMD
...
takei    28829 20527  0  2310  1140   1 22:32 pts/1    00:00:00  \_ bash ./test.sh
takei    28832 28829  0  2310   680   0 22:32 pts/1    00:00:00      \_ bash ./test.sh
takei    28834 28832  0  1019   516   2 22:32 pts/1    00:00:00      |   \_ sleep 0.1
takei    28835 28829  0  3343   980   1 22:32 pts/1    00:00:00      \_ ps -F --forest

wait sub-proc
./test.sh: line 25: wait: pid 28832 is not a child of this shell
end

子プロセスじゃんかー!!!!

どうやら軽く調べてみるとbashではproc substの子プロセスは jobs(1)wait(1) の対象になってくれないみたいです。
// TODO: 親が死んでも子が死なないようにする当たりの関係?

→ wait(1) は使えない

4. ps -pでプロセスをチェックしてみる

pidが分かっているなら ps -p で死活がわかるじゃないか!

#!/usr/bin/env bash

function add_info() {
...
}

exec 9>&1     # 標準出力(コンソール)をファイルディスクリプタ9に待避させる

exec > >(add_info >&9)   # 標準出力つなぎ替え
sub_pid=$!    # $!で子プロセスのpidが取れる

seq 30

exec >&-      # sub procのパイプを閉じる
exec >&9      # 標準出力をコンソールに戻す

echo "wait sub-proc"
while ps -p ${sub_pid}; do sleep 1; done # 子プロセスを待つ(テストのためにpsの出力はそのまま出力)
echo "end"
bash-4.1$ ./test.sh
wait sub-proc
  PID TTY          TIME CMD
29145 pts/1    00:00:00 bash
2015-09-10 22:37:18 1
...
2015-09-10 22:37:19 9
  PID TTY          TIME CMD
29145 pts/1    00:00:00 bash
2015-09-10 22:37:19 10
...
2015-09-10 22:37:20 19
  PID TTY          TIME CMD
29145 pts/1    00:00:00 bash
2015-09-10 22:37:20 20
...
2015-09-10 22:37:21 29
  PID TTY          TIME CMD
29145 pts/1    00:00:00 bash
2015-09-10 22:37:21 30
  PID TTY          TIME CMD
end

うん、まぁ、そりゃうまくはいきますが……ちょっとtoo muchというかエレガントじゃないというか……。
ps(1) の出力は /dev/null に投げればいいのですが、ポーリングでwaitはうーんという感じ。

→ できることはできるけど、なんかエレガントじゃない

5. 親殺し

今までは親が子を待つ受け身な方針だったけど、子から親を殺す方向で。
つまり、親は無限スリープで止めて、子が全出力を終えたら親を殺す。


#!/usr/bin/env bash

function add_info() {
...
}

exec 9>&1     # 標準出力(コンソール)をファイルディスクリプタ9に待避させる

exec > >(add_info >&9; kill $$)   # 標準出力つなぎ替え & 終わったら親を殺す

seq 30

exec >&-      # sub procのパイプを閉じる
exec >&9      # 標準出力をコンソールに戻す

echo "wait sub-proc"
sleep inf
bash-4.1$ ./test.sh
wait sub-proc
2015-09-10 22:42:21 1
...
2015-09-10 22:42:24 30
Terminated
bash-4.1$ echo $?
143

proc substの中で $$kill しているので自殺しているように見えますが、スクリプトのメインプロセスのpidを指しています。
で、結果ですが、終了は同期できますが、 kill しているので終了ステータスが……。
trap してみたりなんやりも試して見たのですが、どうにも上手くいかず。

→ 親殺しはできるけど終了ステータスが微妙

6. 兄弟作って殺す

ということで、個人的な最終結論はこれ。

親を直接殺すから終了ステータスがイケてないわけで、親は wait(1) で終了待ちしつつ、ポーリングをしないように子の終了タイミングで kill(1) を飛ばす。
そのために、ダミーの子プロセスを作ってみる。
普通のバックグラウンドプロセスなら wait(1) が使える。
つまりこんな

#!/usr/bin/env bash

function add_info() {
...
}

sleep inf &   # wait用のダミープロセス (%1)

exec 9>&1     # 標準出力(コンソール)をファイルディスクリプタ9に待避させる

exec > >(add_info >&9; kill %1)   # 標準出力つなぎ替え & 終わったらダミー(兄プロセス)を殺す

seq 30

exec >&-      # sub procのパイプを閉じる
exec >&9      # 標準出力をコンソールに戻す

echo "wait sub-proc"
wait # 子プロセスを待つ
echo "end"
bash-4.1$ ./hoge.sh
wait sub-proc
2015-09-10 22:50:13 1
...
2015-09-10 22:50:16 30
./hoge.sh: line 22: 30313 Terminated              sleep inf
end
bash-4.1$

waitが標準エラー出力に吐いちゃってますが、これは /dev/null に流せばok。

ポイントとしては親プロセスは wait(1) で待っていて、子が一回 kill(1) を蹴るだけなので、基本的に余計な処理が走らない。無駄なファイルも発生しない。
加えて副次的なメリットとしては、複数のproc subst(例えば標準エラー用とか)を作る時にも、その分ダミープロセスを作って、 kill %n で指定して殺せば対応可能。
wait(1) は引数を与えないと全バックグランド子プロセスの終了を待つので、メインプロセス側に追加はいらない。

プロセスが一個増えてしまうのがちょっとたまに傷ですが、シェルスクリプトを書いているときにプロセス一個はご愛敬ですよね(と信じる勇気)

ということで、結論
→ ダミープロセスを作ってwait、子プロセスの終了でkillがよさそう?

おまけ: 無限sleepについて

↑の話で、さりげに sleep inf というコマンドを叩いていますが、これは GNU Coreutils限定だったりする。
すくなくともMacの(BSD系の)sleepではダメでした。

今回の範囲で言えば、1時間とか24時間とか、それぐらいの時間 sleep させれば十分事足りることなのですが、そういえば無限スリープってどうするのがよいんでしょう?
sleep inf はGNU限定ですしお寿司。

a. tail -f /dev/null

ということで調べて出てきたのが tail -f /dev/null 。なるほど。
と思ったら、そのコメントで、

tail -f /dev/null は inotify 使っているから、/dev/null叩くたびにスレッドが起きるぞ!sleep inf の方がいいよ!

という旨のコメントが(意訳)

実際試しに strace tail -f /dev/null して他のシェルでなんか作業してると、結構モリモリtraceが伸びる。

inotify_init()                          = 4
inotify_add_watch(4, "/dev/null", IN_MODIFY|IN_ATTRIB|IN_DELETE_SELF|IN_MOVE_SELF) = 1
fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0
read(3, "", 8192)                       = 0
read(4, "\1\0\0\0\2\0\0\0\0\0\0\0\0\0\0\0", 26) = 16
fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0
read(3, "", 8192)                       = 0
read(4, "\1\0\0\0\2\0\0\0\0\0\0\0\0\0\0\0", 26) = 16
fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0
read(3, "", 8192)                       = 0
read(4, "\1\0\0\0\2\0\0\0\0\0\0\0\0\0\0\0", 26) = 16
fstat(3, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 3), ...}) = 0
read(3, "", 8192)                       = 0
read(3, "", 8192)                       = 0
read(4,

そんなに気にするほどの処理ではないかもですが、うーん、言われると確かに気になってくる。

で、結局はファイルが叩かれなければいいので

b. mktemp -> tail -f -> rm

bash-4.1$ mktemp
/tmp/tmp.Mt1imMyS34
bash-4.1$ tail -f /tmp/tmp.Mt1imMyS34 &
[1] 31662
bash-4.1$ rm /tmp/tmp.Mt1imMyS34
removed `/tmp/tmp.Mt1imMyS34'

がよさそう?これなら tail が起きることもないし、sleep inf よりも可搬性がある。……はず。
あー、でも実ファイルにしてしまうとfdをつかんでいる間、そのfilesystemがumount出来ない可能性が?


そんなわけで(bash/coreutilsで)無限スリープって何がいいんでしょうねぇ。
長文書いて疲れたのでEOF

-- 2018/07/18追記 --
単に sleep を無限ループにいれてもいいのではって気もしてきた。
下の例だと一日おきにプロセス作りなおしになっちゃうけど、そんなコストでもないし……?

while :; do sleep 86400; done
40
39
1

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
40
39