Chienomi

Unixシェルとパイプラインとサブプロセス

Live With Linux::basic

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 になるはずだが、 BanaBa に分かれている。

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

である。で、真ん中のwhilenoを、右のwhilenaを足している。 全部反映されるなら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だとreadechoが同一プロセスなので問題ないが、Bashだと同一プロセス内で実行されるのはechoだけで、readechoが別プロセスになるため$repが見えない。

なのでこの2つを

echo Hey | ( read rep; echo $rep )

のように同一プロセスになるようにすれば良い、ということのようだ。 これがwhileの場合でも、while全体がプロセスとして実行されるから

echo Hey | while read rep; do echo $rep; done

は問題ない。なおさら問題が見えにくい特徴だ……