Unixシェルとパイプラインとサブプロセス
Live With Linux::basic
- TOP
- Articles
- Live With Linux
- Unixシェルとパイプラインとサブプロセス
序
Unixシェルにおいて、どのようなケースで子プロセス内で実行されるかはシェルの実装により異なる、という衝撃の話を耳にしたので、どういうことなのか検証することにした。
お仕事で「嘘教えちゃったかも!?」疑惑があったので、急遽やった感じである。
これはBashのパイプラインとサブプロセスの話について分かっている、という人には読む価値のない記事である。
また、「1段階目」は失敗した実験なので、読み飛ばしてくれても問題ない。
前提
使用したシェルは以下の通りである。
- Zsh 5.8
- Bash 5.0.18-1
- Bash POSIX sh compat
- ksh2020 2020.0.0
- MirBSD Ksh (mksh) R57
- dash 0.5.11-1
- Bash 4.4.023-1
- Bash 3.2.057-4
v7_sh はビルドできなかったのでBourne Shellについてはご容赦願いたい。
また、tcshは互換性がなく、同じスクリプトを実行できないため、省略している。
1段階目
準備
まず次のようなコードを用意した。
eco() {
echo $$ 1>&2
}
これは、 $$
の展開がコマンドラインそのものでされるのを避けるためと、パイプラインに影響されないようにするためである。
最初のテスト
eco() {
echo $$ 1>&2
}
echo $$
eco
Zsh
% zsh 0
63419
63419
Bash
% bash 0
63429
63429
Ksh
% ksh 0
63776
63776
mksh
% mksh 0
63801
63801
dash
% dash 0
63812
63812
bash4
% bash4 0
107530
107530
bash3
% bash3 0
107589
107589
まず問題ないことが確認された。
list
eco() {
echo $$ 1>&2
}
eco;eco;
Zsh
% zsh 1
63895
63895
Bash
% zsh 1
63895
63895
Bash POSIX
% sh 1
63920
63920
ksh
% ksh 1
63942
63942
mksh
% mksh 1
63963
63963
dash
% dash 1
63988
63988
bash4
% bash4 1
107538
107538
bash3
% bash3 1
107631
107631
パイプライン
パイプラインは並列で実行されるが、この場合両方ともシェル関数であるため同じプロセスになるのが正しい。
eco() {
echo $$ 1>&2
}
eco | eco
Zsh
% zsh 2
64071
64071
Bash
% bash 2
64091
64091
Bash POSIX
% sh 2
64098
64098
ksh
% ksh 2
64122
64122
mksh
% mksh 2
64143
64143
dash
% dash 2
64169
64169
bash4
% bash4 2
107683
107683
107683
bash3
% bash3 2
107692
107692
107692
パイプラインリスト
これがきっかけになった話。
eco() {
echo $$ 1>&2
}
eco | eco; eco
Zsh
% zsh 3
64283
64283
Bash
% bash 3
64291
64291
Bash POSIX
% sh 3
64298
64298
ksh
% ksh 3
64306
64306
mksh
% mksh 3
64314
64314
dash
% dash 3
64327
64327
bash4
% bash4 3
107742
107742
% bash3 3
107752
107752
あれ???
while read
eco() {
echo $$ 1>&2
}
eco
ruby -e 'puts 1,2,3,4,5' | while read REP
do
eco
done
Zsh
% zsh 4
78266
78266
78266
78266
78266
78266
Bash
% bash 4
78276
78276
78276
78276
78276
78276
Bash POSIX
% sh 4
80290
80290
80290
80290
80290
80290
Ksh
% ksh 4
103478
103478
103478
103478
103478
103478
mksh
% mksh 4
104538
104538
104538
104538
104538
104538
dash
% dash 4
107428
107428
107428
107428
107428
107428
bash4
% bash4 4
107783
107783
107783
107783
107783
107783
bash3
% bash3 4
107794
107794
107794
107794
107794
107794
なぜだぁ!!
問題が出るケースとされるケースではちゃんと問題が再現するのに、このコードだと再現しない。
ということで、この話のきっかけになった曽田哲之さんの相談させていただいたところ、$$
の値がパース時点で確定しているのではないか、とのこと。
というわけでこんな実装を用意してやり直す。
val="Ba"
app() {
val=${val}na
}
echo $val
app
echo $val
2段階目
list
val="Ba"
app() {
val=${val}na
}
echo $val
app
app
echo $val
Zsh
% zsh 1
Ba
Banana
Bash
% bash 1
Ba
Banana
Bash POSIX
% sh 1
Ba
Banana
Ksh
% ksh 1
Ba
Banana
mksh
% mksh 1
Ba
Banana
dash
% dash 1
Ba
Banana
bash4
% bash4 1
Ba
Banana
bash3
% bash3 1
Ba
Banana
pipeline
val="Ba"
app() {
val=${val}na
}
echo $val
app | app
echo $val
Zsh
% zsh 2
Ba
Bana
Bash
% bash 2
Ba
Ba
Bash POSIX
% sh 2
Ba
Ba
Ksh
% ksh 2
Ba
Bana
mksh
% mksh 2
Ba
Ba
dash
% dash 2
Ba
Ba
app
は2回実行されるので、全て同一プロセス内であれば
Banana
になるはずだが、 Bana
と
Ba
に分かれている。
ps fで見てみる
こういうコードを用意する。
app() {
ps f >&2
}
app | app
Zshだと
110529 pts/9 Ss 0:02 zsh
111610 pts/9 S+ 0:00 \_ zsh 3
111611 pts/9 S+ 0:00 \_ zsh 3
111613 pts/9 R+ 0:00 | \_ ps f
111612 pts/9 R+ 0:00 \_ ps f
Bashだと
110529 pts/9 Ss 0:02 zsh
111702 pts/9 S+ 0:00 \_ bash 3
111703 pts/9 S+ 0:00 \_ bash 3
111705 pts/9 R+ 0:00 | \_ ps f
111704 pts/9 S+ 0:00 \_ bash 3
111706 pts/9 R+ 0:00 \_ ps f
大元のシェルプロセスで実行されているのはZshだとひとつ、Bashだと全て子プロセス上で実行されており0だ。
pipe while
val="Ba"
app() {
cat <<EOF
Hey
Jude
EOF
}
app | while read REP
do
val=${val}na
done
echo $val
Zsh
% zsh 4
Banana
Bash
% bash 4
Ba
この場合、Zshはちゃんと代入が同プロセス内で行われているため変数が更新されているが、Bashはされていない。
redirect while
val="Ba"
app() {
cat <<EOF
Hey
Jude
EOF
}
app > banana
while read REP
do
val=${val}na
done < banana
echo $val
Zsh
% zsh 5
Banana
Bash
% bash 5
Banana
リダイレクトの場合は問題なし。あくまでパイプの問題らしい。
なんでZshは大丈夫なの…?
Zshも親プロセスは違うわけだからZshでも変数代入は反映されなくない?
という謎に曽田さんにお答えいただきまして
zshのps fの出力を読み間違えてますね。
パイプの左辺が pid 109427
パイプの右辺が pid 109426
です。逆だと誤解してませんか?
readとか代入を実行しているのは親プロセス側です。
じゃあ逆に!!!
val="Ba"
app() {
cat <<EOF
Hey
Jude
EOF
}
app | while read REP
do
val=${val}no
echo $REP
done |
while read REP
do
val=${val}na
done
echo $val
これだとどうなるんだ、と思った。 見づらいので簡単に書くと
app | while | while
である。で、真ん中のwhile
はno
を、右のwhile
はna
を足している。
全部反映されるならBanonanona
(いや、この順番になるとは限らないが)、真ん中だけならBanono
、右だけならBanana
。
さぁ、どうだ!!
% zsh 6
Banana
Oh…………
つまり、pipelineでZsh, kshは「一番右だけ同一プロセス上で実行する」ということらしい。 もちろん、これ、Bashでは
% bash 6
Ba
である。
Bashでもなんとかならないの!?
実際、pipelineで一番右以外でパラメータを変更するということは全然しないので、全く知らなかった。
そして、私の場合Bashを使ってたのは本当によちよち歩きのときだけなので、Zshで全く困難に遭遇してなくて、普通にpthreadで実行されてるものだとばかり思っていた。
実はBashではそこそこ有名な挙動らしい。
で、Bashにlastpipe
というこれを改善するオプションがあるんですってよ!!!
shopt -s lastpipe
val="Ba"
app() {
cat <<EOF
Hey
Jude
EOF
}
app | while read REP
do
val=${val}no
echo $REP
done |
while read REP
do
val=${val}na
done
echo $val
% bash 6.bash
Banana
% bash4 6.bash
Banana
% bash3 6.bash
6.bash: line 1: shopt: lastpipe: 無効なシェルオプション名です
Ba
Bash 4.2からサポートされたオプション、とのことで、Bash3では動かないのでした。ちゃんちゃん。
いかがでしたか?
いや、ほんとに、私これぜんっぜん知らなかった。
ひょっとしたらものすごい当たり前の知識で、「おい、はるかみのブログが低次元なこと言い出したぞ」とか言われるかもしれないけれど、知らなかった私からすればものすごく衝撃的だったので共有させていただいた。
まぁ、本当に低次元な内容になりかねなかったので、できるだけ丁寧に検証したのでご容赦願いたい。
まとめてしまえば話はシンプルで
- Bash, Bourne Shell, mksh, dashに関しては、パイプは全て別プロセスで実行される
- Zsh, Ksh, 及び
lastpipe
が有効なBashはパイプの最後の部分だけが同一プロセスで実行される
である。
パイプだけの話なので、簡単に言えば
echo Hey | read rep; echo $rep
に集約される。
この場合、Zshだとread
とecho
が同一プロセスなので問題ないが、Bashだと同一プロセス内で実行されるのはecho
だけで、read
とecho
が別プロセスになるため$rep
が見えない。
なのでこの2つを
echo Hey | ( read rep; echo $rep )
のように同一プロセスになるようにすれば良い、ということのようだ。
これがwhile
の場合でも、while
全体がプロセスとして実行されるから
echo Hey | while read rep; do echo $rep; done
は問題ない。なおさら問題が見えにくい特徴だ……