はじめに
この記事は「実践!ポータブルシェルスクリプト 〜 シェル関数によるLinuxとmacOSの移植性・互換性問題の攻略方法」の派生記事です。タイトルが長いのでこの記事では「前回の記事」と呼ぶことにします。
前回の記事では、バッファリングによってデータが遅延する問題を解決するためのコマンドの使い方が、Linux (GNU) と macOS (BSD) で異なるため、それをどうやって解決するかという話をしました。この記事は前回と異なる話で、この現象がパイプ通信によって引き起こされる問題であるため、出力タイミングが重要ならパイプ通信を使ってはいけない、そしてリアルタイム的な処理が必要なソフトウェアはシェルスクリプトで作るべきではないという話をしたいと思います。
検証コード
まず以下のコードを実行してみます。
#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy" # 画面上は何も表示されない文字を出力している
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | sed 's/\//-/g' | sed 's/\./:/g' | sed 's/ /T/' | sed 's/$/Z/'
このコードを実行すると以下のように 1 秒間隔で日付が出力されます。
2021-10-21T01:10:07Z
2021-10-21T01:10:08Z
2021-10-21T01:10:09Z
2021-10-21T01:10:10Z
ただしすぐには出力されません。1 秒毎に画面に出力されるのではなく数秒(Linux だと 5 秒、macOS だと 15 秒程度)毎に表示されるはずです。なぜなかなか画面に出力されないかというというと、sed
の実装が出力先が画面 (tty) でない場合に、パフォーマンスを上げるために一定量バッファに貯めてからまとめて出力するようになっているからです。(注意 画面に出力というのは最終的な出力先の話ではなく、パイプラインの途中にある sed
が、次の sed
に出力しているという話です。)
補足 sed
の実装が画面 (tty) でない場合に完全バッファリングされるのは正確には ISO C の要求に従った挙動です。ただし出力が画面 (tty) の場合に、バッファリングしないか行バッファリングにするかは(ほどんどの実装は行バッファリングですが)ISO C では規定されていません。(参考 詳解UNIXプログラミング 第3版 より)
ISO C では、つぎのようなバッファリング特性を要求します。
- 標準入力と標準出力が対話端末を参照しない場合に限り、標準入力と標準出力は完全バッファリングである。
- 標準エラーはけして完全バッファリングしない。
しかし、これからは、標準入力と標準出力が対話端末を参照する場合それらはアンバッファドなのか行バッファリングなのか、標準エラーはアンバッファドにすべきなのか行バッファリングにすべきなのか、は分かりません。ほとんどの実装のデフォルトは、以下のバッファリングです。
- 標準エラーはつねにアンバッファド。
- それ以外のすべてのストリームはそれらが端末装置を参照する場合には行バッファリング、さもなければ完全バッファリングである。
この問題は以下のように sed
コマンドに -u
(unbuffered) オプションを付ければ解決することができます(一つ覚えておいてください。-u
オプションは POSIX で標準化されていません)。最後の sed
に -u
をつけていないのは、最後の sed
だけは出力先が画面になるからです。
バッファリングを無効にしたコード
#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy"
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | sed -u 's/\//-/g' | sed -u 's/\./:/g' | sed -u 's/ /T/' | sed 's/$/Z/'
前回の記事は、この問題を解決するためには Linux と macOS で別々のコードが必要となるため、それをシェルスクリプトのプログラミングテクニックを使ってどう解決するかという話でしたが、今回はこれがパイプ通信による弊害であり、こういう場合にはパイプを使うべきではないという話をします。
バッファリング無効時の大幅なパフォーマンス低下
さていきなり話がずれますが、バッファリングによって問題が発生しているのであれば常に無効にすれば良くのではないか?と考えるかもしれません。もちろんそれは良くない考えです。なぜならパフォーマンスが低下するからです。では一体どれくらい低下するのでしょうか?
# 1行 76 文字 100 万行のダミーデータを作成する
cat /dev/urandom | base64 | head -n 1000000 > random.txt
# バッファリングあり
$ time cat random.txt | sed 's/00/@@/' >/dev/null
real 0m0.216s
user 0m0.187s
sys 0m0.073s
# バッファリングなし
$ time cat random.txt | sed -u 's/00/@@/' >/dev/null
real 0m38.646s
user 0m17.412s
sys 0m21.651s
実時間 (read) で 179 倍(uesr: 93 倍、sys: 296 倍)とバッファリングを無効にすることで 100 倍を大きく超える差が出てしまいました。これがバッファリングの効果であり、安易にバッファリングを無効にしてはいけないという理由です。
バッファリングの制御はデフォルトの挙動に任せるべきです。もしバッファリングを制御したくなったとしたら、設計に根本的な問題があると考えるべきでしょう。
多段のパイプは可能な限り避ける
多段のパイプが原因でバッファリング処理が自動制御されない
バッファリングを無効にしたコードの最後の行です。
done | sed -u 's/\//-/g' | sed -u 's/\./:/g' | sed -u 's/ /T/' | sed 's/$/Z/'
sed
にバッファリングを無効にする -u
オプションをつけていますが、最後の sed
にだけは使用していません。これはミスではありません。sed
は画面への出力時にはバッファリングが無効になり、ファイルへの出力や別コマンドにパイプで出力する場合には高速化のためにバッファリングが有効になるので必要ないからです。(注意 この動作は POSIX で規定されているものではなく各実装の拡張機能です。)
前項で説明したようにバッファリングを一律無効にするのはパフォーマンスの点から得策ではありません。デフォルトの挙動は状況に応じてバッファリングの有効・無効を行うという望ましいものです。しかしながら、上記のコードから分かるように途中の sed
には -u
をつけなければ画面への出力時にはバッファリングが無効になりません。またこのコードは常にバッファリングが無効にしているためファイルへリダイレクトしたときに大幅のパフォーマンスが低下します。
なぜ、このようなことになるのか?それはパイプが多段になってるからです。実はこの 4 つの sed
は一つにまとめることができます。処理の内容を書き換えるとかではなく単純に結合することができます。
done | sed 's/\//-/g; s/\./:/g; s/ /T/; s/$/Z/'
このようにコマンドの出力を操作するフィルタを一つだけにしておけば、画面に出力するときはバッファリング処理が無効になり、画面以外に出力する場合は、バッファリング処理が有効になります。つまりパイプを多段にしてしまったから、常にバッファリング処理が有効になってしまったのです。
基本的に、複数の sed
は一つにまとめることができます。また間に tr
や grep
などが含まれる場合は awk
によって一つにまとめることができます。awk
は sed
と grep
を併せ持つ文字列処理の万能コマンドであり awk を使えばパイプを多段にすることを避けることができるでしょう。
パイプの段数は減らしたほうがパフォーマンスは良い
多段のパイプは一つにすることでパフォーマンスが向上します。
# パイプを多段にした場合
$ time cat random.txt | sed 's/a/A/g' | sed 's/b/B/g' | sed 's/c/C/g' | sed 's/d/D/g' | sed 's/e/E/g' | sed 's/f/F/g' | sed 's/g/G/g' > /dev/null
real 0m0.853s
user 0m5.278s
sys 0m0.330s
# パイプを一つにした場合
$ time cat random.txt | sed 's/a/A/g; s/b/B/g; s/c/C/g; s/d/D/g; s/e/E/g; s/f/F/g; s/g/G/g' > /dev/null
real 0m2.313s
user 0m2.281s
sys 0m0.099s
「パイプを多段にしたほうが速いじゃないか」と言う前に user
と sys
を見てください。
パイプを多段にすると user
が 2.3 倍、sys
が 3 倍時間がかかっています。これは「sed
による中間データ形式への変換」と「パイプ間のデータ通信」によるオーバーヘッドがあるからです。「sed
による中間データ形式への変換」とは例えば一番はじめの `sed 's/a/A/g`` で生成するデータは最終的には不要です。しかしパイプで転送する以上そのデータを作らねばなりません。つまり最終的に無駄になるデータを作っているわけです。
パイプを一つにした場合でも内部的には無駄となるデータを作っているのでは?というのはそのとおりでしょう。しかし 1 つのプロセスで小さいデータに対してその場で何度も処理しているわけですから、このメモリは CPU のキャッシュに入る可能性が高くなります。細かいことまでは調べていませんが、CPU のキャッシュは 1 〜 3 次キャッシュまであり、それぞれ 64 KB、1 MB、256 MBとかなりの容量があります。同一プロセスで近い場所にあるコードとデータであれば高速に処理できますが、パイプで分かれた別プロセスで処理する場合、キャッシュに入る可能性は低くなるでしょう。また CPU のパイプライン処理、投機実行もプロセスが分かれていては期待できません。
またその無駄になるデータをパイプで通信しているコストも無視できません。プロセス間の通信にはシステムコールの呼び出しが必要となりますから、パイプを使えば使わない場合よりも遅くなるのです。sys
が 3 倍になっているがその証拠です。無駄な処理をしている以上パイプを多段にすると遅くなるのは必然と言えるでしょう。
パイプを多段にした方がパフォーマンスが悪くなるのに実時間が短くなっているのは、マルチコア CPU の場合は並列実行ができるからです。休んでいる CPU があれば、それを活かせるために実時間は速くなります。ただし休んでいる CPU がある場合に限ります。すでに複数のプロセスが並列実行されており CPU を使い切っている場合には、無駄になるフィルタ処理やパイプ通信によるオーバーヘッドで遅くなってしまいます。パイプはうまく使えば並列処理によって実時間を短くすることができますが、CPU 使用量は増えるため、最大のパフォーマンスを得るためには局所的にものを見るのではなく、全体の設計を考えなければいけません。
基本的にパイプは「データ生成 | メイン処理 | 出力」の 3 つのコマンドを繋げる程度で、最大でも 5 つぐらいにしておくべきでしょう。これ以上だと並列処理によるパフォーマンス効果は期待できませんし、なにより現在の中間データ形式がどうなるのかわかりづらくなるためメンテナンス性が悪くなります。
参考 出力先を判断してバッファリングを制御する方法
ここで想定しているコードは次のようなものです。
#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy"
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | sed 's/\//-/g' | sed 's/\./:/g' | sed 's/ /T/' | sed 's/$/Z/'
このコードは画面に出力したとしてもバッファリングされてしまい 1 秒毎に出力されません。これを 1 秒毎に出力するには -u
をつけてバッファリングを無効にすればよいのですが、そうするとファイルに出力したとき(例 script.sh > log.txt
)もバッファリングが無効になってしまいます。参考としてこれを解決する方法を紹介します。
そのためには、スクリプトの出力先が画面 (tty) なのかそうでないのかを判別する必要があります。それは[ -t 1 ]
で簡単に判別することができます。1
は標準出力を意味するファイルディスクリプタ番号です。
そして以下のようにすればよいのですが・・・
SED="sed" SEDOPTS=""
if [ -t 1 ]; then
SED="sed -u" SEDOPTS="-u"
fi
while :; do
... # 省略
done | $SED 's/\//-/g' | $SED 's/\./:/g' | $SED 's/ /T/' | $SED 's/$/Z/'
# または
done | sed $SEDOPTS 's/\//-/g' | sed $SEDOPTS 's/\./:/g' | sed $SEDOPTS 's/ /T/' | sed $SEDOPTS 's/$/Z/'
コードがわかりづらくなってしまいます。ここで前回の記事で紹介したテクニックを使用することができます。
if [ -t 1 ]; then
sed() { command sed -u "$@"; }
fi
while :; do
... # 省略
done | sed 's/\//-/g' | sed 's/\./:/g' | sed 's/ /T/' | sed 's/$/Z/'
このように sed
を使っているコードはそのままに、簡単に出力先を判別して処理を切り替えることができるのです。
awk によるバッファリングの制御
さてここまでの内容で「バッファリング処理を自動制御させる」ためにも「多段のパイプは可能な限り避ける」という方針となりました。しかし解決できていない問題が一つ残っています。それは「それでもバッファリング処理を無効にしたい」とう問題です。
話が飛びまくってややこしくなってしまったので、ここで現在の基準となるコードを提示します。
#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy"
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | sed 's/\//-/g; s/\./:/g; s/ /T/; s/$/Z/'
このコードは画面に出力する時は 1 秒毎に出力されますが、ファイルにリダイレクトするとバッファリングされてしまって 1 秒毎に出力されません。そこで sed
に -u
をつける必要があります。この例ではそれが可能ですが、複雑な文字列処理が必要な場合には sed
だけでは処理するのは大変です。そういう場合は awk
を使うのをおすすめします。awk
は sed
や grep
を組み合わせるよりも遥かに便利な POSIX で標準化されているもう一つのプログラミング言語です。awk
を使えば多段のパイプは必要なくなるでしょう。ということで awk
を使って書き換えます。
そのまま awk
のコードを埋め込んでしまうと長くなって読みづらくなるのが想像つきますので軽くリファクタリングを行います。
#!/bin/sh
to_iso8601_date() {
sed 's/\//-/g; s/\./:/g; s/ /T/; s/$/Z/'
}
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy"
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | to_iso8601_date
はい、あとは to_iso8601_date
関数の中身を awk
で実装するだけですね。処理を適切な名前、適切な粒度の関数で抽象化するのは重要なことです。何をしているのかが明確となり、テストもリファクタリングもしやすくなります。
to_iso8601_date() {
awk '{
gsub("/", "-")
gsub("\\.", ":")
gsub(" ", "T")
gsub(/$/, "Z")
print
}'
}
ということで、awk に置き換えました。
さてこれに置き換えて実行してみるとわかりますが、このコードもバッファリングを行います。awk の実装によりますがパフォーマンスを上げるためにそうなっているわけですね。じゃあどうするかというと fflush()
関数を使います。
これにて完成です。
#!/bin/sh
to_iso8601_date() {
awk '{
gsub("/", "-")
gsub("\\.", ":")
gsub(" ", "T")
gsub(/$/, "Z")
print
fflush()
}'
}
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
printf "$dummy"
date +"%Y/%m/%d %H.%M.%S"
sleep 1
done | to_iso8601_date
fflush()
のもう一つのメリットは POSIX (Issue 8) で標準化される ということです(参考「次期POSIX(Issue 8)で標準化されるawkの機能は delete array, nextfile, fflush()」)。冒頭で書いた sed
の -u
は POSIX で標準化されてないという話を思い出してください。awk であれば POSIX に準拠で実装することができます。(補足 GNU 版の tr
コマンドには -u
オプションがありませんが stdbuf
によって同等のことは実現可能です。)
一応現状はまだ POSIX では標準化されていません。正式に標準化されるのは 2022 年後期に公開予定となっている Issue 8 からです。しかしその事を気にする必要はありません。なぜなら標準化される理由が「すでに殆どの awk の実装で(2012 年時点で)長い間利用可能になっている拡張機能」だからです。詳細については「0000634: add fflush built-in function to awk」を参照してください。
補足 商用 Unix 版の awk は実装されていないものが多いようですが、考慮する必要がない場合が多いでしょう。商用 Unix は 最新の・・・といっても 2008 年の Issue 7 = SUSv4 にすら対応してないものが多いので、このまま Issue 8 に対応しなければもはやレガシー OS として切り捨てたほうが良さそうな気がします。最新の POSIX を基準にすれば Linux の方が POSIX 準拠度が高いのではないかとすら思えてしまいます。
ということで、バッファリング問題は awk を使うことで高い移植性を実現しながら解決することができるのです。また awk に限らず Perl などを使用しても構いません。ほとんどの言語はバッファリング処理を無効にする方法を実装しています。例えば Perl だと以下のように sed
と殆ど変わらない文字数で同じ処理を実装することができます。
to_iso8601_date() {
# sed -u 's/\//-/g; s/\./:/g; s/ /T/; s/$/Z/'
perl -pe '$|=1; s/\//-/g; s/\./:/g; s/ /T/; s/$/Z/'
# $| はバッファリング処理を無効にするためのもの
}
また状況によってはパフォーマンスのために毎行フラッシュするのではなく、一定行 or 一定時間でフラッシュという実装も考えられるでしょう。こういったものは、他の言語では可能ですが awk ではできません。
パイプ通信を使うことがそもそもの設計ミスである
ここまでパイプ通信とバッファリングに関する問題にいろいろな方法で対処してきましたが、この問題はそもそもパイプ通信を使って実装したことが設計ミスである可能性が高いです。これをパイプ通信を使わずに実装してみましょう。
#!/bin/sh
while :; do
date +"%Y/%m/%d %H.%M.%S" | sed 's/\//-/g' | sed 's/\./:/g' | sed 's/ /T/' | sed 's/$/Z/'
sleep 1
done
えーと、パイプ使ってますね(苦笑)。この場合は、毎回処理が終わってバッファはフラッシュされるから良いのです。パイプを使わないというのは大きな外回りのループの話です。
参考までですが、bash であれば単純な sed
と同等の置換は簡単に実装できるので本当にパイプが必要なくなります。(POSIX シェルの範囲でも実装できますが、ここでは省略します。)
#!/bin/sh
while :; do
now=$(date +"%Y/%m/%d %H.%M.%S")
now=${now//\//-}
now=${now//./:}
now=${now/ /T}
now="${now}Z"
echo "$now"
# 今更ですが普通はこう書けばよいです
# date +"%Y-%m-%dT%H:%M:%SZ"
sleep 1
done
パイプ通信を使うからバッファリングされるわけで、そもそもパイプ通信を使わなければバッファリングされません。出力のタイミングが重要な場合はパイプ通信を使うべきではないということなのです。
パイプ通信のバッファ溢れによる処理のブロックについて
ここまではコマンド内部が持つバッファの話をしましたが、パイプ間通信も内部にバッファを持っています。そして 高速なコマンド | 低速なコマンド
という関係になった場合、パイプ間にあるバッファが溢れ、高速なコマンドは処理がブロックされて停止します。この現象は以下のようなコードで確認することができます。
#!/bin/sh
producer() {
for i in $(seq 100000); do
printf '%100s\n'
{
if [ $((i % 10)) -eq 0 ]; then
echo
date +"==== %H:%M:%S ===="
fi
printf "$i "
} >&2
done
}
consumer() {
while read i; do
sleep 1
done
}
producer | consumer
このコードの出力例です。
==== 20:29:30 ====
620 621 622 623 624 625 626 627 628 629
==== 20:29:30 ====
630 631 632 633 634 635 636 637 638 639
==== 20:29:30 ====
640 641 642 643 644 645 646 647 648 649
==== 20:29:30 ====
650 651 652 653 654 655 656 657 658 659
==== 20:29:41 ====
660 661 662 663 664 665 666 667 668 669
==== 20:29:51 ====
670 671 672 673 674 675 676 677 678 679
==== 20:30:01 ====
680 681
このコードは producer
(生産者)側は休み無しに全力でデータを生成しています。そしてある一定の量(バッファが貯まるまで)まではスムーズに出力されます。しかし consumer
(消費者)が遅いためバッファが触れ producer
側の出力はブロックされます。表示される時刻を見ると飛び飛びになっていることからも分かる通り consumer
側の処理の遅さが producer
側の処理に干渉してしまっています。
もちろん consumer
側の処理を速くすればよいのですが、パイプを複数段つないだときに後続の処理がどのようになるかなど想定できるでしょうか?producer
にとって consumer
は未知の外部依存であり、その干渉を受けるとなってしまうと producer
は安定したタイミングでのデータ生成を保証できなくなってしまいます。パイプライン全体の処理速度はパイプで繋いだコマンドの中で一番遅いものに引きずられるので、パイプで繋げば繋ぐほど出力タイミング、処理タイミングの不確実性は上がっていきます。
パイプ通信による遅延
当然の話ですがパイプ通信はゼロ遅延でデータを転送できるわけではありません。データ生成側(上記のコードで言う producer
)からデータ受け取り側(上記コードで言う consumer
)まではわずかですが遅延が発生します。
その様子は以下のようにすれば調べることができるでしょう。(注意 高速にマイクロ秒を取得するために bash 5 の EPOCHREALTIME
変数を使用しています。)
seq 10000 | {
while read i; do echo "- $EPOCHREALTIME) * 1000"; done
} | {
while read i; do echo "($EPOCHREALTIME $i"; done
} > log.txt
bc < log.txt
最初の 1 件目は 0.222 ミリ秒。1 ミリ秒を下回っており、かなり高速です。しかし 1000 件目あたりで 14 ミリ秒、10000 件目あたりで 51 ミリ秒の遅延となりました。シェルスクリプトだけでやろうとするならここが限界です。
もちろん、以下のようにデータの受け取り側が遅いと・・・
seq 10000 | {
while read i; do echo "- $EPOCHREALTIME) * 1000"; done
} | {
while read i; do echo "($EPOCHREALTIME $i"; /bin/true; done
} > log.txt
bc < log.txt
データ受け取り側に /bin/true
を入れただけで、1000 件目で 1083 ミリ秒、つまり約 1 秒、2000 件目で約 2 秒の遅延が発生しています。(それ以降は安定して 2 秒程度でした)。/bin/true
の代わりにもっと遅い sed '' < /dev/null
だとその 2 倍以上の遅延が発生しました。
他の言語であれば、遅い外部コマンド呼び出しを使う必要はないので、パイプ通信を使ってもこのような遅延が発生しづらいとは思いますが、パイプ通信によるタイミング制御は不確定要素が大きいということが分かると思います。
参考 外部コマンドは起動するだけで数ミリ秒もかかってしまう
外部コマンド起動は遅いです。なにもしないであろう /bin/tue
でさえ 1.5 ミリ秒も掛かってしまいます。
$ bash -c 'S=$EPOCHREALTIME; /bin/true; echo "($EPOCHREALTIME-$S)*1000" | bc -l'
1.544000
$ bash -c 'S=$EPOCHREALTIME; cat </dev/null; echo "($EPOCHREALTIME-$S)*1000" | bc -l'
1.909000
$ bash -c 'S=$EPOCHREALTIME; sed "" < /dev/null; echo "($EPOCHREALTIME-$S)*1000" | bc -l'
2.531000
$ bash -c 'S=$EPOCHREALTIME; awk "{}" </dev/null; echo "($EPOCHREALTIME-$S)*1000" | bc -l'
3.335000
数ミリ秒程度大したことないと思うかもしれませんが、これは 1 回だけでこれですからね。地理も積もればと言うやつです。
それに対して以下は Python で実装した場合です。 100 バイトの文字列置換を含めても 0.074148 であったためおよそ 34 倍高速です。C 言語、Go、Rust などのコンパイル言語ならもっと速いでしょう。
#!/usr/bin/env python3
import re
from datetime import datetime
str = "ab" * 50
start = datetime.now()
str = re.sub("[a]", "A", str)
end = datetime.now()
print((datetime.timestamp(end) - datetime.timestamp(start)) * 1000)
sed
自体は C 言語で高速に実装されているから速いのですが、外部コマンド起動のオーバーヘッドがあるためリアルタイム的な処理が必要な場合にはシェルスクリプトは適していません。あまり処理をしないというのであれば、許容できる範囲かもしれませんが、なんだかんだ言って結局ソフトウェアは大きくなります。大きくなったときに対処できない可能性が高いのであれば最初から選択シェルスクリプトを選択するべきではありません。
まとめ
- パイプを多段にするとバッファリング処理が有効になって出力が遅延することがあります
- バッファリングを自動制御させるには出力用のフィルタコマンドをパイプで繋いではいけません
- バッファリング処理を無効にすれば遅延しませんがパフォーマンスは低下します
- パイプ通信はバッファが溢れるとブロックされて処理が停止します
- どのようなバッファリング処理が行われるかは実装によって異なります
- パイプ通信は間に様々な処理が入るため、正確なタイミング制御は困難です
- 外部コマンドは起動するだけで数ミリ秒も時間がかかります
- 他の言語であればバッファリングをより細かく制御できます
- 他の言語であれば起動が遅い外部コマンドを使う必要はありません
- 出力タイミングが重要な場合、パイプ通信を使うべきではありません
- 出力タイミングが重要な場合、そもそもシェルスクリプトを使うべきではありません
パイプラインを使うとバッファリング処理によってデータ到着のタイミングが不安定になります。この事実から明らかにわかることは、タイミングが重要な処理にパイプラインを使うべきではないということです。またシェルスクリプトが呼び出すコマンドは実行コストが高く、タイミングを乱す原因になってしまいます。システムコールレベルでは nanosleep
というナノ秒単位(そこまでの精度は出ないにしろ)でスリープする時間を指定できる関数がありますが、起動するだけで数ミリ秒もの時間が掛かってしまう外部コマンドでは意味がありません。外部コマンドがどのような実装になっているかはベンダーによって異なり処理タイミングも処理速度も変わってくるので、それら複数のコマンドを組み合わせるシェルスクリプトでは不確定要素が大きすぎて厳密なタイミング制御は難しくなります。
またさらに厳密なタイミング制御が必要な場合、POSIX.1-2017 に含まれているリアルタイム拡張 API (元々は POSIX.1b として追加オプションの機能でしたが現在の POSIX では必須機能になっています)を使うことになるでしょうが、C 言語用の API であるためシェルスクリプトから使うことはできません。例え使えたとしてもシェルスクリプトではマイクロ秒レベルを扱うそれらの API に耐えられるようには設計されていません。もっと厳格なタイミング制御が必要な場合はリアルタイムプロセスやリアルタイム OS (RTOS) や使うことになるでしょうが、当然シェルスクリプトではその機能は使えません。結局の所シェルスクリプトで簡単なことはできたとしても、それ以上の高い精度や信頼性を実現するのは不可能なのですぐに限界が来ます。リアルタイム的なことが必要であればシェルスクリプトを使うべきではないのです。
参考 POSIXやUNIXやISO Cで標準化されているC言語APIとシェルコマンド一覧の調べ方のまとめ
これが私が「シェルスクリプトの長所と短所のまとめ」で書いた「リアルタイム処理に適していない」で十分説明しきれていなかったことです。シェルスクリプトに適してないことを無理してシェルスクリプト実現しようとすると奇妙な技術が必要になってきます。それらは他の適切な言語で作れば全く必要のないもので、一番シンプルな方法で実現するのが最も生産性とパフォーマンスに優れています。そこまでやって制限のきついシェルスクリプトでやる理由なんかありませんから、UNIX の性能を活かしたければ UNIX 本来のシステムコールを呼び出せる C 言語またはそれと同等の事ができる言語を使うべきでしょう。シェルスクリプトは本来 C 言語で作られたコマンドを補助するために作られた言語で、C 言語を置き換えるために作られた言語ではありません。だからカーネル、コマンド、UNIX のほぼ全ては C 言語で作られてますよね?