LoginSignup
10
13

More than 1 year has passed since last update.

mkfifoコマンドと名前付きパイプをシェルスクリプトで使う方法

Last updated at Posted at 2022-09-05

はじめに

mkfifo コマンドはシェルスクリプトの初心者にとって何に使うのか分かりづらいコマンドの一つだと思います。mkfifo コマンドの説明として「名前付きパイプ (FIFO) を作成する」と言われても、名前付きパイプとはなにか?それで何が出来るのか?どのように使うのか?が詳しく説明されていないのがその原因の一つではないかと思います。シェルスクリプトにおける mkfifo や名前付きパイプに関する解説記事も検索するといくつか見つかるのですが、解説や検証が中途半端だったり間違いや奇妙なシェルスクリプトがあったりします。

mkfifo コマンドがあまり知られていないもう一つの原因は使う必要があまりないからです。名前付きパイプを使う例の一つは、厳密に POSIX に準拠したシェルスクリプトを書く場合です。bash などの拡張機能を持つシェルに備わっている「プロセス置換」相当のことを dash などの最小限の POSIX シェルの機能しか持たないシェルで行うために使うことが出来ます。ですが通常はプロセス置換を使いたいのであれば、bash を使いましょうとなって終わりでしょう。

名前付きパイプはプロセス間通信の手段の一つです。プロセス間通信の手段には他にパイプやソケットがあります。名前付きパイプが、パイプと似ている名前を持っていることからもわかるように、名前付きパイプはパイプのように動作します。違いは名前があることです。この名前というのはファイル名のことで、mkfifo コマンドを使って「FIFO ファイル」と呼ばれる特殊なファイルを作成することが、名前付きパイプの作成を意味しています。

とまあ、言葉で説明していても分かりづらいので、実際のシェルスクリプトを使って説明しましょう。手元で動作確認したい人は、準備として以下のファイルを作成しておいてください。

fruits.txt
apple
orange
banana
melon
lemon

なお、mkfifo コマンドで作成した FIFO ファイルの削除は通常のファイルと同じで rm コマンドを使って削除します。

プロセス置換とは?

bash 等の拡張シェルで使用可能なプロセス置換

少し遠回りになりますが、mkfifo コマンドと名前付きパイプの話の前に、プロセス置換とはなにかを説明します。プロセス置換とは bash などの多くの拡張機能を持った POSIX シェルに実装されている機能で <(command) のように書いて使います。

script.sh
#!/bin/bash
i=0
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < <(cat ./fruits.txt)
echo "total: $i"

上記のシェルスクリプトを実行すると、以下のように表示されます。最後の total は出力した行数(果物の数)です。

1: apple
2: orange
3: banana
4: melon
5: lemon
total: 5

このシェルスクリプトの <(cat ./fruits.txt) の部分がプロセス置換です。プロセス置換の部分はまるでファイルのように使うことが出来ます。上記のシェルスクリプトはプロセス置換を使わずにファイルから直接読み込むことが可能です。

script.sh
#!/bin/bash
i=0
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < ./fruits.txt
echo "total: $i"

上記のシェルスクリプトの出力は先程と同じです。<(cat ./fruits.txt) の部分がそのまま ./fruits.txt に対応しています。少し余談になりますが、echo <(cat ./fruits.txt) のように実行すると /dev/fd/63 (数字の部分は異なる場合があります)と出力されるはずです。これはプロセス置換の部分が(ファイルディスクリプタに対応した)ファイル名に置き換わって、echo コマンドの引数として渡されているからです。

# こんな事をやる意味はまず無いが、ファイル名(文字列)として展開されていることを示すため
echo <(cat ./fruits.txt) # => /dev/fd/63
echo -e foo:<(cat ./fruits.txt):bar # => foo:/dev/fd/63:bar

(ファイルディスクリプタに対応した)ファイル名を渡されたコマンドは、そのファイル(ディスクリプタ)からデータを読み込むことが出来ます。ちなみに /dev/fd/ で始まるファイル名はプロセスごとに分離されているので、他のプロセスから読み取ることは出来ません。

ファイルの中身を出力するだけならプロセス置換は不要です。<(cat ./fruits.txt) の代わりに ./fruits.txt を使えばよいだけだからです。プロセス置換が意味があるのは cat ではなく別のコマンドを使うような場合です。以下は sort コマンドを使った例です。

script.sh
#!/bin/bash
i=0
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < <(sort ./fruits.txt)
echo "total: $i"
1: apple
2: banana
3: lemon
4: melon
5: orange
total: 5

このように「なにかのコマンドの出力をファイルのように読みとることが出来る」のがプロセス置換の機能です。

プロセス置換でパイプのサブシェル問題を回避する

ここまで読んで、それならプロセス置換を使わなくてパイプでも良いのでは?と思った人もいるかも知れません。しかしそれではうまく行かない場合があります。プロセス置換の代わりにパイプを使ったシェルスクリプトとその実行結果です。

script.sh
#!/bin/bash
i=0
sort ./fruits.txt | while read line; do
  i=$((i + 1))
  echo "$i: $line"
done
echo "total: $i"
1: apple
2: banana
3: lemon
4: melon
5: orange
total: 0

注目すべきは最後の行です。total の部分が 5 ではなく 0 となっており正しくカウントできていません。これはパイプで繋がれたコマンドが、それぞれサブシェルで実行されているためです。(正確にはサブシェルで実行されるかどうかはシェル依存です。)

サブシェルは、一般的に現在のシェル環境をコピーした子プロセスとして実装されています(正確には子プロセスとして実装されたのと同じような動きを実現しているのであれば、子プロセスとして実装する必要はない)。上記のシェルスクリプトから呼び出される sort コマンドと while ・・・ done がそれぞれ子プロセスとして実行され、その処理が終わった後に最後の echo "total: $i" が実行されます。したがって子プロセスの中でカウントしたとしても親プロセスには反映されないわけです。プロセス置換を使った場合は、子プロセスで実行されないため正しくカウントすることができます。

参考 パイプのサブシェル問題点のその他の回避方法

プロセス置換はこの問題の回避策ですが、bash 4.2 以降では shopt -s lastpipe を使った回避策もあります。このシェルオプションを有効にするとパイプでつながったコマンドの最後の部分が、呼び出し元と同じ環境で実行されるため、正しくカウントできるようになります。また ksh と zsh では元から lastpipe と同じ挙動です。

script.sh
#!/bin/bash

# bash 4.2 以降(macOS の /bin/bash は 3.2.57 なので使えません)
shopt -s lastpipe

i=0
sort ./fruits.txt | while read line; do
  i=$((i + 1))
  echo "$i: $line"
done
echo "total: $i"

もう一つの補足ですが、この問題(total が正しくない)は以下のようにして解決することが可能です。

script.sh
#!/bin/bash

sort ./fruits.txt | {
  i=0
  while read line; do
    i=$((i + 1))
    echo "$i: $line"
  done
  echo "total: $i"
}

# または、以下のように関数に分離しても良い

output() {
  i=0
  while read line; do
    i=$((i + 1))
    echo "$i: $line"
  done
  echo "total: $i"
}
sort ./fruits.txt | output

パイプで繋がった部分がサブシェルで実行されるのであれば、カウント部分もサブシェルの中に入れてしまえばよいのです。多くの場合は、この方法で十分なはずです。

入力が複数ある場合はパイプが使えない (diff)

パイプはコマンドからの入力が複数あるときには使えません。よく例に挙げられるのが diff コマンドです。diff コマンドは二つのファイルを比較します。

diff a.txt b.txt

この時、コマンドからの入力が一つであれば、パイプとファイルから入力して比較することが出来ます。

cat b.txt | diff a.txt -

しかし、二つのコマンドからの入力を比較したい場合、少なくとも1つはプロセス置換を使う必要があります。以下の例では二つともプロセス置換を使用した場合です。

diff <(cat a.txt) <(cat b.txt)

# 補足 プロセス置換の部分はファイルに展開されるため以下のように実行されます
# diff /dev/fd/63 /dev/fd/62

このようにプロセス置換を使うと、パイプは入力が一つの場合にしか使えないという欠点を解決することが出来ます。

ファイルへの出力をコマンド出力に変換する (tee)

プロセス置換のもう一つの面白い使い方は、ファイルへの出力をコマンドへの出力に変換することです。例として tee コマンドとの併用で面白いことが出来ます。

seq 5 | tee >(wc -l)
1
2
3
4
5
       5

通常 tee コマンドの引数はファイルで、標準出力とファイルの両方に入力したデータを出力します。ファイルの代わりにプロセス置換 (>(wc -l)) を使うことで標準出力と別のコマンドへの出力に変更することができ、最終的に両方の出力が端末に出力されます。

名前付きパイプ (FIFO) とは?

プロセス置換は POSIX で標準化されていない

さて mkfifo コマンドの話に戻りましょう。プロセス置換はこのように便利なことができるのですが、POSIX シェルの標準規格としては標準化されておらず、使用できないシェルがあります。具体的には dash など ash 系のシェルです。

このようなシェルではプロセス置換は使えません・・・が、諦めることはありません。それが名前付きパイプを使った代替実装です。名前付きパイプを使うと FIFO ファイルを作成しなければいけないという点を除いて、プロセス置換と同等のことができます。名前付きパイプの詳細な動きは後回しにして、まずは最初の例を置き換えてみます。

プロセス置換を使った最初の例

script.sh
#!/bin/bash
i=0
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < <(cat ./fruits.txt)
echo "total: $i"

名前付きパイプを使った代替シェルスクリプト

script.sh
#!/bin/dash

# FIFO ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# FIFO ファイルの作成
fifo="$tmpdir/fifo"
mkfifo "$fifo"

i=0
cat ./fruits.txt > "$fifo" &
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < "$fifo"
wait
echo "total: $i"

随分と長くなってしまいました。これは FIFO ファイルの作成と片付けが必要になるからです。プロセス置換がどれだけ便利なものであるかがよく分かる例となっていますが、同等のことは POSIX シェルの範囲でも実現可能であるということもわかると思います。ちなみに FIFO ファイルの片付けに trap ... EXIT を使っている理由は、エラーや CTRL-C で途中で処理が中断された場合でも、問題なくファイルを削除するためです。また mkfifo コマンドではランダムなファイル名で作成することが出来ないので、一時ディレクトリを作成しています。

diff と tee の例を名前付きパイプで実装する

ついでなのでプロセス置換の話で取り上げた difftee の例の名前付きパイプ版を書いておくとします。

script.sh
#!/bin/dash

# FIFO ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# FIFO ファイルの作成
fifo1="$tmpdir/fifo1"
fifo2="$tmpdir/fifo2"
mkfifo "$fifo1" "$fifo2"

cat ./fruits.txt > "$fifo1" &
sort ./fruits.txt > "$fifo2" &
diff "$fifo1" "$fifo2"
wait
script.sh
#!/bin/dash

# FIFO ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# FIFO ファイルの作成
fifo="$tmpdir/fifo"
mkfifo "$fifo"

wc -l < "$fifo" &
seq 5 | tee "$fifo"
wait

一時ファイルを使うのを避けることができる

ここでわざわざ FIFO ファイルを使わなくても、一時ファイル使えば出来るのではないか?と思うかもしれません。つまり以下のようなシェルスクリプトです。

script.sh
#!/bin/dash

# 一時ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# 一時ファイルの作成
tmpfile="$tmpdir/tmp"

i=0
cat ./fruits.txt > "$tmpfile"
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < "$tmpfile"
echo "total: $i"

このシェルスクリプトは正しく動作します。違いは並行動作を行うかどうかです。cat コマンドの後ろから & (バックグラウンド実行)を取り除いたことに注意してください。上記のシェルスクリプトは一時ファイルの作成が完了してから while ループの処理を行います。一方で mkfifo コマンドを使った場合は cat コマンドの処理と while ループが並行で動作します。

以下のシェルスクリプトでその違いを知ることが出来ます。

script.sh
#!/bin/dash

# FIFO ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# FIFO ファイルの作成
fifo="$tmpdir/fifo"
mkfifo "$fifo"

# 1 秒毎に 1 行出力する cat もどき
slowcat() {
  while read line; do
    sleep 1
    echo "$line"
  done < "$1"
}

i=0
slowcat ./fruits.txt > "$fifo" &
while read line; do
  i=$((i + 1))
  echo "$i: $line"
done < "$fifo"
wait
echo "total: $i"

上記のシェルスクリプトを実行すると、1 秒毎に while ループの処理が動きデータが出力されます。これは slowcatwhile ループが並行で動作しているからです。一方、一時ファイルを使った場合は slowcat の処理が全て終わった後に while ループが実行されます。

また一時ファイルは実際にディスクに書き込むためディスクスペースを消費し、メモリを経由して通信を行う名前付きパイプを使うよりも遅くなります。高速で大容量のディスクを使用しているのであれば気にしなくて良いかもしれませんが、IoT デバイスなど低速な SD カードをストレージとして使用する場合もあるでしょう。

このような違いがあるため、一般的には一時ファイルを使うより mkfifo コマンドを使うほうが効率がよいということになります。ファイルの行数が少ない場合などは一時ファイルを使っても構わないと思いますが、行数が多い場合や出力に時間がかかる場合などでは効率のために mkfifo コマンドを使って実装したほうがよいということです。

入出力の両方が揃うまでブロックされる

通常のパイプは | の左側に出力コマンド、右側に入力コマンドと入出力がペアになっています。名前付きパイプも同じで入出力の両方が揃っていなければいけません。揃っていない場合、処理はブロックされます。ここからはシェルスクリプトではなくターミナルから直接実行して解説します。

まずテスト用に mkfifo コマンドを使って FIFO ファイルを作成します。

ターミナル1
$ mkfifo ./fifo

次に FIFO ファイルを出力します。

ターミナル1
$ cat ./fifo

この時、cat コマンドの出力はブロックされ待ち状態になります。別のターミナルを開いて FIFO ファイルにデータを出力します。

ターミナル2
$ echo foo > ./fifo
$

そうすると cat はコマンドは foo と出力し終了します

ターミナル1
$ cat ./fifo
foo
$

入出力のどちらかが全てクローズすると終了する

名前付きパイプの間違った使い方は次のようなシェルスクリプトです。(意図的にこのようにしたい場合を除く)

間違った使い方
for i in foo bar baz; do
    sleep 1
    echo "$i" > ./fifo
done

正しくは以下のように書く必要があります。

正しい使い方
for i in foo bar baz; do
    sleep 1
    echo "$i"
done > ./fifo

なぜなら最初のシェルスクリプトでは、echo "$i" > ./fifo を実行するたびに名前付きパイプがオープンされてすぐにクローズされてしまうからです。名前付きパイプはパイプ通信が開始された後は、入出力のどちらかが全てクローズされているとパイプ通信はそこで終了してしまいます。クローズされた名前付きパイプから読み込もうとした場合は(すべて読みきった後に)EOF で終了し、クローズされた名前付きパイプに書き込もうとした場合はシグナル SIGPIPE によってプロセスが中断します。したがって以下のような問題が発生します。

# 間違った使い方だと最初の foo が出力されたタイミングで終了してしまう
$ cat ./fifo
foo

# 以下のコードは間違った使い方によって cat ./fifo が終了してしまう
# という問題に対応するための間違った回避策としてしばしば見かけます
while true; do cat ./fifo; done

補足ですが、名前付きパイプは複数のプロセスから書き込みを行ったり読み込みを行ったりすることが出来ます。したがってパイプ通信が終了するタイミングは、正確には全ての書き込みプロセスがいなくなるか、全ての読み込みプロセスがいなくなるかのどちらかです。読み書きの両方でそれぞれ一つ以上プロセスが残っているならパイプ通信は終了しません。

パイプ通信の開始と終了のタイミングは、どの時点でファイルがオープンされクローズされるかを意識すると理解できると思います。

名前付きパイプをオープンしたままにする

前項のおさらいです。

ターミナル1
$ cat ./fifo # 下のシェルスクリプトを実行すると foo しか出力されない
ターミナル2
$ echo foo > ./fifo # 出力すると `cat` は終了してしまう
$ echo bar > ./fifo # `cat` がいないため出力はブロックされる
$ echo baz > ./fifo

これを期待通りに動かすには以下のようにする必要があります。

ターミナル1
$ cat ./fifo # foo bar baz が出力される
ターミナル2
$ {
> echo foo
> echo bar
> echo baz
> } > ./fifo # { } 全体を処理する間、名前付きパイプはオープンしたままになる

上記の代わりに以下のように実行すると、1秒おきに出力されるのがわかると思います。

ターミナル2
# for ループ全体を処理する間、名前付きパイプはオープンしたままになる
$ for i in foo bar baz; do sleep 1; echo "$i"; done > ./fifo

どのタイミングでファイルがオープンされて、どのタイミングでファイルがクローズされるかを考えると、このような動作になる理由がわかると思います。上記の例では for ループの開始前に ./fifo ファイルがオープンされ、ループの終了時にクローズされるため期待通りに動くというわけです。

まれな事例として上記のような形でファイルのオープンとクローズを書けない場合、exec コマンドを利用することで、名前付きパイプをオープンしたままにすることができます。

ターミナル2
$ exec 3>./fifo
$ echo foo >&3
$ echo bar >&3
$ echo baz >&3
$ exec 3>&-

echo で名前付きパイプに出力しておりその都度クローズするように思えるかもしれませんが、実際には exec で名前付きパイプをオープンし exec 3>&- でクローズするため、この方法でも期待通りに動作します。この方法を使うことはあまりないと思いますが、バックグラウンドプロセスを使ったプログラムで、名前付きパイプへの書き込みまたは読み込みプロセスのどちらかが、一つも無くなる事がある場合などで使えるかもしれません。

他に、無限ループを行うプロセスを使って、名前付きパイプのオープン状態を維持させる以下のような方法もあるようですが、無駄なプロセスを作るため、どちらかと言えばハックに近い方法ですし、名前付きパイプを閉じるタイミングが面倒になるので、基本的には避けた方が良いでしょう。

ターミナル2
$ while true; do sleep 10; done > ./fifo &
$ echo foo >./fifo
$ echo bar >./fifo
$ echo baz >./fifo
$ kill $! # sleep が終了するまで ./fifo はクローズされない

パイプの最大バッファサイズの制限に注意する

名前付きパイプに限らずパイプでも同じですが、パイプのバッファにはサイズの制限があるので注意してください。例えば書き込み側は 1 秒毎に 1KB のデータを書き込む一方で、読み込み側が 5 秒に 1KB のデータしか読み込まなかった場合、途中で書き込み側はブロックされるようになります。

ターミナル1
$ while true; do printf '%1023s\n'; date > /dev/tty; sleep 1; done > ./fifo
2022年 9月 5日 月曜日 22時40分31秒 JST
2022年 9月 5日 月曜日 22時40分32秒 JST
2022年 9月 5日 月曜日 22時40分33秒 JST
2022年 9月 5日 月曜日 22時40分34秒 JST
2022年 9月 5日 月曜日 22時40分35秒 JST
2022年 9月 5日 月曜日 22時40分36秒 JST
2022年 9月 5日 月曜日 22時40分37秒 JST
2022年 9月 5日 月曜日 22時40分38秒 JST
2022年 9月 5日 月曜日 22時40分39秒 JST
2022年 9月 5日 月曜日 22時40分40秒 JST
2022年 9月 5日 月曜日 22時40分41秒 JST # ここまでは 1 秒間隔で出力できている
2022年 9月 5日 月曜日 22時40分46秒 JST # ここからは 5 秒間隔でしか出力できない
2022年 9月 5日 月曜日 22時40分51秒 JST # (バッファサイズに空きが出来るまで待たされる)
2022年 9月 5日 月曜日 22時40分56秒 JST
^C
$
ターミナル2
$ while read line; do date; sleep 5; done < ./fifo
2022年 9月 5日 月曜日 22時40分31秒 JST
2022年 9月 5日 月曜日 22時40分36秒 JST
2022年 9月 5日 月曜日 22時40分41秒 JST
2022年 9月 5日 月曜日 22時40分46秒 JST
2022年 9月 5日 月曜日 22時40分51秒 JST
2022年 9月 5日 月曜日 22時40分56秒 JST # この時点で書き込み側を停止したが、
2022年 9月 5日 月曜日 22時41分01秒 JST # バッファに溜まったデータを処理し切るまで続く
2022年 9月 5日 月曜日 22時41分06秒 JST
2022年 9月 5日 月曜日 22時41分11秒 JST
2022年 9月 5日 月曜日 22時41分16秒 JST
2022年 9月 5日 月曜日 22時41分21秒 JST
2022年 9月 5日 月曜日 22時41分26秒 JST
2022年 9月 5日 月曜日 22時41分31秒 JST
2022年 9月 5日 月曜日 22時41分36秒 JST
$

名前付きパイプの最大バッファサイズは以下のようにして調べることが出来ます。macOS では 8 KB、Debian Linu では 64 KB のようです。

sh -c 'sleep 5 <./fifo & t=0; s=128; while true; do printf "%${s}s"; t=$((t + s)); printf "$t " >&2; done >./fifo'

複数プロセスから同じ名前付きパイプを読み書きする

名前付きパイプは特殊ファイルとしてファイルシステムに作成されるため、複数のプロセスから書き込み、複数のプロセスから読み込むことが可能です。ただし正しく通信を行うためにはいくつか注意が必要で、より高度なことをするのであれば排他制御が必要になります。排他制御が必要になる理由は以下の二つです。

一つ目の理由は、大きなデータを出力する場合、内部で複数の出力に分割されるからです。例えば一つの名前付きパイプに同時に二つのプロセスから書き込むとして、プロセス A から abcde、プロセス B から 12345 と同時に出力した場合、名前付きパイプに abc1234de5 のように混ざって書き込まれることがあります。実際には 5 バイト程度の出力なら混ざることはありません。POSIX では 512 バイトまでなら混ざらない(アトミックに出力される)ことが保証されています。実際には OS によって異なり macOS では 512 バイト、Linux では 4096 バイトのようです。このサイズは getconf _POSIX_PIPE_BUF で取得することができます。

この挙動は次のような実験で確認することが出来ます。ターミナルを 3 つ開き、1 と 2 で上のシェルスクリプトを実行します。二つのプロセスから何度も名前付きパイプに一定のサイズでデータを出力しています。そして 3 で下のシェルスクリプトを実行します。この実験では読み取りプロセスは一つのみです。(念の為ですが ./fifo ファイルは mkfifo で作成した FIFO ファイルです)

ターミナル1&2
# 511 文字 + 改行 = 512 バイト
$ sh -c 'while true; do printf "%511s\n"; done > ./fifo'
ターミナル3
# 一行の長さを出力
# macOS は 512 バイト、Linux は 4096 バイトを超えると一行の長さが不安定になる
$ sh -c 'while IFS= read line; do printf "${#line} "; done < ./fifo'

getconf _POSIX_PIPE_BUF の値を超えると出力が混ざってしまうために、一行の長さが安定しなくなることがわかると思います。

二つ目の理由は、同時に複数のプロセスから名前付きパイプを読み取る場合、取得するバイト数に気をつける必要があります。一つのデータの長さを固定長サイズにして読み取る時に一気に読み取るようにすれば、おそらくうまくいくでしょう。しかし出力データが改行で終わる可変長の一行の場合はうまくいきません。一行の長さが決まっていないためプロセスは 1 バイトずつ読んで一行を判断するしかありません。そのためプロセス A が 1 バイトを読んだ後にプロセス B が 1 バイトを読み込んだりしておかしなことになります。

この挙動は次のような実験で確認することが出来ます。ターミナルを 3 つ開き、1 と 2 で上のシェルスクリプトを実行します。このシェルスクリプトは dd コマンドを使って (bs=)512 バイトを (count=)1 回で読み取ります。そして 3 で下のシェルスクリプトを実行します。この実験では書き込みプロセスは一つのみです。

ターミナル1&2
# 上手く行かない例
$ sh -c 'while IFS= read line; do printf "%s " $line; done < ./fifo'

# 固定長サイズを読み込む場合はうまくいく
$ sh -c 'while line=$(dd if=./fifo bs=512 count=1 2>/dev/null); do printf "%s " $line; done < ./fifo'
ターミナル3
$ sh -c 'while true; do printf "%511s\n" abc; done > ./fifo'

つまり、排他制御を行わない場合は以下の制限があることに注意する必要があります。

  1. 書き込み側が複数、読み込み側が一つの場合は、512 バイト以下(getconf _POSIX_PIPE_BUF 以下)で出力するようにする
  2. 書き込み側が一つ、読み込み側が複数の場合は、固定長サイズで書き込み、同サイズで読み込むようにする
  3. 書き込みと読み込みの両方が複数の場合は、1 と 2 の組み合わせ

上記の制限を満たせない場合は排他制御が必要になります。ただし私は複数プロセスから同じ名前付きパイプを読み書きするシェルスクリプトを十分に検証したわけではなく、排他制御まで必要なシェルスクリプトを書いたこともないので、なにか見落としがあるかもしれません。実際にそのようなシェルスクリプトを書く場合は注意してください。

並行処理の同期を名前付きパイプで制御する

名前付きパイプがデータが到着するまでブロックするという性質を利用して、並行処理の終了を検出することが出来ます。以下のシェルスクリプトは並行で動作しているプロセスが終了した時に、名前付きパイプにデータを出力し、その出力を名前付きパイプから受け取ることで、終了したことを検出しているます。

#!/bin/dash

# FIFO ファイルのための一時ディレクトリの作成と終了時のクリーンアップ処理
cleanup() {
  [ "$tmpdir" ] || return 0
  rm -rf "$tmpdir"
}
tmpdir=''
trap "cleanup" EXIT
tmpdir=$(mktemp -d)

# FIFO ファイルの作成
fifo="$tmpdir/fifo"
mkfifo "$fifo"

for i in $(seq 5); do
  (
    sec=$(( $(od -An -tu1 -N1 /dev/urandom) % 7 + 3))
    echo "$i: Start (sleep $sec)"
    sleep "$sec"
    echo "$i" >&3
  ) 3>"$fifo" & # サブシェル全体で名前付きパイプをオープンしている
  # サブシェル全体で名前付きパイプをオープンせずに echo "$i" >"$fifo" とした場合
  # sleep してる間は名前付きパイプがオープンされていないことになり途中で中断してしまう
done

while read line; do
    echo "$line: Done"
done <"$fifo"
wait

ただし、この程度のシェルスクリプトであればパイプを使って実装することも可能です。

#!/bin/dash

{
  {
    for i in $(seq 5); do
      (
        sec=$(( $(od -An -tu1 -N1 /dev/urandom) % 7 + 3))
        # while に捉えられないようにするため FD3 に迂回してから出力している
        echo "$i: Start (sleep $sec)" >&3
        sleep "$sec"
        echo "$i"
      ) &
    done
    wait
  } | while read line; do
    echo "$line: Done"
  done
} 3>&1

名前付きパイプの片付け処理を除けば、名前付きパイプを使ったシェルスクリプトの方が少しシンプルになりそうです。

このように名前付きパイプをうまく使うと、並行処理で何かの処理が完了したことを検出したり、また逆になにかの処理が開始されるのをブロックしたりと言ったことが可能になります。

まとめ

以上のように、mkfifo コマンドと名前付きパイプを使うとプロセス置換相当のことを、厳密な POSIX シェルで実現することが可能になります。一時ファイルを作らないと実現できないような場合に対応することが可能になります。並行処理でのプロセス間通信に使うことが出来ます。ただ実際にはシェルスクリプトでそこまで複雑なことをすることはあまりないですし、bash のプロセス置換を使った方が楽でしょう。あまり使う機会は少ないかもしれませんが、どうしても厳密に POSIX に準拠したシェルスクリプトで高度なことをしたいという場合の手段の一つになるかもしれません。

10
13
0

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
10
13