はじめに
いつもと違って今回のはネタです。でも本当に POSIX 準拠シェルスクリプトだけで 1 秒未満のスリープを実現しています。bash 依存もしていません。使うのはシェルと POSIX 準拠では最小 1 秒単位でしか指定できないはずの sleep
コマンドだけです。ネタというのは精度が良くないのと最近の sleep
コマンドの実装は大抵小数点以下の指定ができるんだからもう使ってしまえばいいじゃん?という実用上の理由からです。また今回はネタなのでまともにテストしていません。(どのシェルでも動くと思いますが。)
実現方法
さて最小 1 秒しか指定できない sleep
でどうやれば 1 秒未満のスリープが実現できるでしょうか?実現方法を簡単に言うならば**「パソコンの性能どれくらい?一秒間に何回足し算できる?」**ということです。ピンときましたでしょうか?
さて計測してしてみましょう。
trap 'stop=1' HUP
( sleep 1; kill -HUP $$ ) &
stop='' cnt=0
until [ "$stop" ]; do
cnt=$((cnt+1))
done
echo "$cnt"
私の PC では 706,027
回でした。(ちなみに同等のスペックの WSL1上では 565,384
回でした。)つまりおよそ 70 万回ループで足し算すれば、1 秒間、時間が経過するということです。もうわかりましたね? 0.1 秒スリープするには約 7 万回、0.01 秒スリープするには約 7000 回足し算を行えばいいのです。(いにしえの NOP
を使ったウェイトテクニック。年がバレる。)もちろんこの回数はマシンの性能によって変わるのでスクリプト実行時に 1 秒の時間を使って何回足し算が出来るか計測する必要があります。またマシンの負荷などによって計測結果にずれが生じますので正確性は期待できません。計測を何回か繰り返せば精度は上がると思いますがその分時間がかかります。(計測結果を保存しておけば次回以降は計測する必要はなくなりますね。)
実装
ではこれを使用して、0.01 秒単位で更新するカウンターを作ってみます。
# 計測
trap 'sleep=0' HUP
( sleep 1; kill -HUP $$ ) &
loop=0 sleep=2147483647 # 1秒でカウントできない十分大きな値
while [ "$loop" -lt "$sleep" ]; do
loop=$((loop + 1))
done
echo "$loop"
# カウンター
sleep=$((loop / 100))
echo "$sleep"
while :; do
printf "\r%d.%02d" $((n / 100)) $((n % 100))
loop=0 # sleep 0.01 相当のループ処理
while [ "$loop" -lt "$sleep" ]; do
loop=$((loop + 1))
done
n=$((n + 1))
done
「何回実行できるか?」の計測処理とスリープ処理のコードを合わせていることに注意して下さい。カウント回数を計測していますが、実際にはループの比較処理も実行時間に含まれるため、そこで計測結果とスリープ時間で違いがでないようにするためです。
改善
「なんだビジーウェイトかよ」と思った方。その通りです。(ネタですし・・・。)ただし条件次第でビジーウェイトさせずに sleep
させることが出来ます。その条件とは一定間隔で処理を行うために sleep
している場合です。(例えばアニメーションなどで 0.1 秒単位でコマを進めるといった処理です。JavaScript でいえば setInterval
関数です。)
シェルスクリプトは最小 1 秒のスリープが出来ます。言い換えると 1 秒間隔で処理を行えます。つまりバックグラウンドでサブシェル(サブプロセス)を実行して呼び出し元に 1 秒間隔でシグナルを送ることが出来るということです。では、このサブシェルが 10 個いれば・・・?
はい、そういうことです。サブシェルを 0.1 秒ごとにタイミングをずらして 10 個バックグラウンド実行すれば、10 個のサブシェルが 1 秒毎に 0.1 秒タイミングをずらしてシグナルを送ってくれる、つまり 0.1 秒ごとのシグナルが実現できるということです。呼び出し元で wait
を実行していれば(trap
を使う実装もありでしょう。)シグナルが送られてきたタイミングで wait
が解除されるので、ビジーウェイトすることなしに 0.1
秒スリープが実現できます。もちろんサブシェルの数を 100 個増やせば 0.01
スリープも出来ると思いますが、プロセスツリーが酷いことになりますね(笑)
では実装です。
# 計測
trap 'sleep=0' HUP
loop=0 sleep=2147483647 # 1秒でカウントできない十分大きな値
( sleep 1; kill -HUP $$ ) &
while [ "$loop" -lt "$sleep" ]; do
loop=$((loop + 1))
done
trap ':' HUP
echo "$loop"
# カウンター
sleep=$((loop / 10))
echo "$sleep"
i=0
while [ "$i" -lt 10 ] && i=$((i+1)); do
loop=0 # sleep 0.1 相当のループ処理
while [ "$loop" -lt "$sleep" ]; do
loop=$((loop + 1))
done
while kill -HUP $$ 2>/dev/null; do
sleep 1
done &
done
while :; do
printf "\r%d.%d" $((n / 10)) $((n % 10))
wait
n=$((n + 1))
done
top
や htop
などで見てみると CPU 使用率が減っていることがわかると思います。
おまけ
ShellSpec のプロファイラ機能
この記事はネタですが、実はこの記事と同じ発想で ShellSpec のプロファイラ機能は実現されています。(正確に言うと ShellSpec で使用した技術を応用したのがこの記事です。)ShellSpec は私が開発しているシェルスクリプト用のテスティングフレームワークですが、個々のテストの実行時間を計測するプロファイラ機能を持っています。出力は以下のような感じです。
$ shellspec --profile
Running: /bin/sh [sh]
...................................................................................................
(略)
...................................
Finished in 2.73 seconds (user 3.37 seconds, sys 0.43 seconds)
830 examples, 0 failures, 9 skips (muted 9 skips)
Top 10 slowest examples of the 830 examples
1 0.0144 spec/libexec/reporter_spec.sh:248-266
2 0.0106 spec/install_spec.sh:36-39
3 0.0087 spec/install_spec.sh:157-161
4 0.0084 spec/install_spec.sh:173-177
5 0.0082 spec/install_spec.sh:19-23
6 0.0081 spec/libexec/list_spec.sh:19-30
7 0.0077 spec/core/dsl/text_spec.sh:4-19
8 0.0073 spec/libexec/shellspec_spec.sh:12-24
9 0.0072 spec/core/matchers_spec.sh:21-24
10 0.0071 spec/core/dsl/data_spec.sh:16-22
POSIX 準拠の範囲ではシェルスクリプトで 1 秒未満の時間を取得できるのは time
コマンドと times
コマンドの 2 つしかありません。このうち times
コマンドはシェルで使用された CPU 時間を測定するものですが、ユーザーCPU時間、システムCPU時間時間しか取れず、ウェイト(何もしない)時間を含めた実時間が取得できないので使用していません。ShellSpec では time
コマンドを使って実行時間を計測しています。time
コマンドは外部コマンドを実行し、その実時間、ユーザー時間、システム時間を取得することが出来ます。上記の出力の「Finished・・・」の行は time
コマンドの出力を整形して表示しています。出力例からもわかるように time
(-p
) の出力は多くの場合、小数点以下 2 桁です。しかしプロファイラの出力(「Top 10・・・」以下)は、小数点以下 4 桁まで出力できています。(この機能は POSIX 準拠シェルの全てで同じように使用できます。)
よくあるプロファイラ機能の実現方法は、処理(テスト)の実行前の時間を計測し、処理終了後の時間との差を求めるのが一般的だと思います。これと同じことをシェルスクリプトでやるならば(ミリ秒単位の時間は POSIX 準拠の範囲で取れないというのは置いておいて) date
コマンドを呼び出して現在の時刻を取得することになるでしょう。しかしそうすると date
コマンドの呼び出しで時間がかかってしまいます。遅くなりますし計測結果にも影響します。(ちなみに time
コマンドは外部コマンドの実行時間を計測するためのものなので、外部プロセスではない個々のテストの計測には使用できません。)そのため ShellSpec ではその方法は採用していません。ここで出てくるのが「何回足し算できる?」です。
プロファイラ機能を有効にしてテストの実行を開始すると、テスト実行プロセス(個々のテストではなく全てのテストを一つのプロセスで実行しています。)とは別のプロセス(プロファイラー)がバックグラウンドプロセスとして起動します。そのプロファイラーは「何回足し算できる?」と同じように無限ループで数値をカウントしています。そしてテスト実行プロセスは個々のテストを実行する時、テストの実行前と実行後にシグナル(実装の都合でシグナルで実現するのが難しかったのでファイルの有無で代用しています。)をプロファイラーに送りプロファイラーはシグナルを受け取った時点のカウントの値を記録しています。そして最後にはテスト完了時点のカウントの値も知ることが出来ます。
ここまで来たら後は簡単です。テスト全体にかかった時間は time
コマンドで小数点以下第二位まで取得できるので、テスト全体にかかったカウント値で割って、個々のテストにかかったカウント値をかければ、個々のテストの実行時間が更に小さい単位まで求めることが出来ます。
この方法のメリットは呼び出しに時間がかかる外部コマンドを使ってないので(シングルコア CPU の場合を除き)テスト実行時間にはほとんど影響しないということです。(CPU をフルに使用する仕組みなので CPU のターボブースト機能によって周波数があがり、逆に速くなる場合すらあります。最初はこの理由がわからず謎でした。そしてこれを利用してプロファイラを出力件数 0 件で実行する --boost
というネタオプションを作りました。笑)
精度が気になるかと思いますが、細かく検証はしていないのですが大幅に外れてないようです。少なくともプロファイラの目的である「どのテストの時間がかかってるか?」の目安にはなると思います。(個人的にテストの細かい実行時間を知った所でどうするんだ?って思ってるのでこの程度で十分だと思っています。)