1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

シェルスクリプト&PowerShellAdvent Calendar 2024

Day 8

時間を毎秒カウント出力するシェルスクリプトの遅れの考察

Last updated at Posted at 2024-12-09

この記事は以下の Advent Calendar の記事です。

まえがき:bash シェルスクリプトを即席で試す方法

サンプルに書かれた bash シェルスクリプトを即席で試すには、 それをクリップボードにコピーをした後、クリップボードの中身を出力するコマンドをパイプで bash につなぐ と良いです。

macOS の場合、クリップボード(正式名称はペーストボード)の内容を出力するコマンド pbpaste があるので、

pbpaste | bash

とするとよいでしょう。他の OS でも GUI の Linux 環境で使える xclip コマンドなどがあるので調べてみると良いでしょう。

一方で、クリップボードに任意の値を書き込む外部機構も存在します。リスクと利便性を天秤にとってご利用下さい。

秒数カウントスクリプトのサンプル

カウントダウンやカウントアップのような、秒数をカウント出力しながら待機するスクリプトを書くとき

sec=10 # 10秒待つ
while true ; do
    echo $sec
    sleep 1
    if (( --sec <= 0 )) ; then
        echo 0
        echo finish
        break
    fi
done

# ここで $sec 秒後に実行したい処理を書く

といった書き方がすぐに思い浮かびますが、この書き方だと実際の $sec 秒よりそこそこ長い時間となります。

手元で計測しようと思ったところ、macOS の date コマンドではエポック秒取得の際にミリ秒を測れない1ようなのですが、2024年の macOS Sequoia では perl コマンドは入っているので、以下のようにコアモジュールの Time::HiRes を使ってミリ秒を出力・取得することができます。

perl -MTime::HiRes=time -E 'say time'

これで挟むことで、最初の秒数カウントで実際に待つ時間を測ってみましょう。あと、perl Time::HiRes の time はミリ秒を小数点以下で表すので、その引き算をそのまま行うのも Perl に任せます。

start=$(perl -MTime::HiRes=time -E 'say time')
sec=10 # 10秒待つ
while true ; do
    echo $sec
    sleep 1
    if (( --sec <= 0 )) ; then
        echo 0
        echo finish
        break
    fi
done
end=$(perl -MTime::HiRes=time -E 'say time')

perl -E 'my ($start, $end) = @ARGV; say $end - $start' $start $end

私の環境(MacBook Air、Apple M1、RAM 16GB、macOS Sequoia 15.1)で実行してみると

10
9
8
7
6
5
4
3
2
1
0
finish
10.2365200519562

といった感じで 0.2 秒ほど長くなります。

この時間は、よりたくさん待った場合にチリツモで増えていきます。

sec=100 にしてみると 101.61781001091 あたりになりました。

何が重いのか、推測してみましょう

時間が長くなる原因を探る

少し考えてみると、while の中で何度も sleep をしているのが重いのかなと思えるのですが、原因と思われる部分を潰していきましょう。

while の中の echo や if が重い説

「sleep だけでなく while の中の echo や if 文も重いのでは?」と思うのですが、 echo や if はシェルのビルトインコマンド(組み込みコマンド)なので、それらの影響はあまりない と思います2。コストが高いのは外部コマンド呼び出しです。先ほどのサンプルスクリプトの while 内にある呼び出しにおいて、外部コマンドが実行されるのは通常 sleep のみです(つまりそれ以外はビルトインコマンドです)。

if と数値計算 (()) は break のタイミングの把握で必要なため除去できないですが、以下のように echo を除去すると 10.21 秒くらいになりました。

start=$(perl -MTime::HiRes=time -E 'say time')
sec=10
while true ; do
#    echo $sec
    sleep 1
    if (( --sec <= 0 )) ; then
#        echo 0
#       echo finish
        break
    fi
done
end=$(perl -MTime::HiRes=time -E 'say time')

perl -E 'my ($start, $end) = @ARGV; say $end - $start' $start $end

同条件で echo があるときは 10.23 秒くらいだったので、多少は時短(意図した時間に近くなる)していますが、多数実行されていたものの除去の割には微々たるものです。

それでも 0.02 秒の時短があるのは、 一般的にプログラムの実行において時間がかかるのは I/O 処理 だからでしょう。そう、echo は標準出力という I/O を司っています。Web開発の世界で I/O というと DB 接続など様々な重い接続が想像されますが、標準出力も立派な I/O の一つです。

時間計測で入れた perl のワンライナーがそもそも重い説

たぶんこれもあるので、なんとかしてみましょう。

(計測で入れたものが計測を邪魔しているという、よくありそうな話ですね)

これ、「(ミリ秒を測るために持ち出した)perl が悪い」という主張に沿って他のミリ秒を測ることができるプログラムを持ってきて同様のコードを書いても、そのプログラムの起動も遅いのでは?という話になるので、もうちょっとマシな解決方法を考えてみます。

度重なる(ミリ秒取得プログラムの)起動が重いことを回避する一つの方法として、「時間計測プログラムには、時間計測の間ずっとバックグラウンドで起動してもらって、シグナルを投げられて終了したときに起動時間を計算出力してもらう」と少しマシになりそうです。書いてみましょう。

use v5.40;
use Time::HiRes qw(clock);
my $start;
$SIG{INT} = sub {
    my $end = clock();
    say "$end - $start = " . ($end - $start)
};
$start = clock();
sleep; # 引数無し sleep は無限スリープ

ワンライナーにしてみます

perl -MTime::HiRes=time -E 'my ($start); $SIG{INT} = sub { my $end = clock(); say "$end - $start = " . ($end - $start) }; $start = clock(); sleep;' &

これを使って時間計測

perl -MTime::HiRes=clock -E 'my ($start); $SIG{INT} = sub { my $end = clock(); say "$end - $start = " . ($end - $start) }; $start = clock(); sleep;' &
pid=$!
sec=10
while true ; do
    echo $sec
    sleep 1
    if (( --sec <= 0 )) ; then
        echo 0
       echo finish
        break
    fi
done

kill -INT $pid
wait $pid

これだと

1733672592.88545 - 1733672582.82762 = 10.0578370094299

といった表示になり、10.23 秒から 10.05 秒と、だいぶマシになりました。

「オマエが入れた計測方法がダメだったんじゃん!」というオチではありますが、こういう計測方法って結構やりがちなので、失敗例も書いてみました3。時間がかかった要因として、$start 取得後すぐに本処理に入れなかった説もありますが、主に $end を取得するときの perl インタープリタの生成実行時間がそのまま時間として入ってしまったのかもしれません。

それでもまだ 10秒あたり 0.05 秒あります。この中には先程の echo を除去したときの節約できた 0.02 秒が含まれていると考えて良いでしょう。残りの 0.03 秒には、改善した perl の計測ワンライナー自体の限界(kill ビルトインコマンドで送ったシグナルを受け取って $SIG{INT} コールバックを実行するまでの時間)が反映されているかもしれません。

ともかく、カウントする秒数を数えながら……という趣旨だと、残り秒数を echo で表示することは必須の機能であり、10秒あたり 0.02秒の時間が余計に積もることが気になることは多いと思います。例えば、40分のトークの残り時間をカウントする CLI プログラムだったら……?

さらに sec=100 にして試してみると 101.58 秒程度になり、その誤差(1.58秒)は先ほどの 0.05秒を10倍したより遥かに大きな数値となっています (計測プログラムの実行コストをなるべく低くしたのに)。これは sleep 外部コマンドの都度実行時間に波があって、それが積算したから なのかもしれません(あまり深く追っていません)。

さらに長時間、30分や1時間単位をカウントすることを考えると、もっともっと多くの誤差が出てきてしまいそうです。

どうするとよいか

秒数をカウント出力しながら、実際は素直に sleep $sec を書いたのとなるべく同じだけ待ちたい場合、どうするとよいでしょうか。

こういうのをシェルスクリプトで書こうとしている方は、「余計なバイナリプログラムは極力入れられない」「実行環境で使えるプログラム言語のインタープリタはほぼ限られる」という制限を抱えた方が少なくないと思います。「(非同期処理が強い)****言語で書け!」は正論ではあるのですが、制限された環境を想定してできる限り少ない環境を対象に解決方法を考えてみたいです。

実際の待つ秒数を渡した sleep をバックグラウンドに回して、それに従う

カウント出力は役立つ情報として出しつつ、それは不正確さを(待てば待つほど)含むと諦めて、本当に待つ秒数は sleep 外部プログラムに任せる方法です。

perl -MTime::HiRes=time -E 'my ($start); $SIG{INT} = sub { my $end = time; say "$end - $start = " . ($end - $start) }; $start = time; sleep;' &
measure_pid=$!
sec=100

# 実際に $sec 秒待つのは sleep $sec で行う
sleep $sec &
sleep_pid=$!

# カウント出力プログラムは bash の丸括弧で子プロセスに切り離す。
# なお丸括弧にせずとも `while` の `done` の後に `&` を置いても、ビルトインコマンドのバックグラウンドジョブ化で bash は自動的に bash の子プロセスを作成します
(
while true ; do
    echo $sec
    sleep 1
    if (( --sec <= 0 )) ; then
        echo 0
        echo finish
        break
    fi
done
) &
count_pid=$!

# sleep $sec が終わったらカウント出力が途中でも中止する
wait $sleep_pid
kill -HUP $count_pid

# 秒数計測
kill -INT $measure_pid
wait $measure_pid

これを実行してみると、自分の環境では100秒のカウント出力で、99.91 秒と出ました。まさかの100秒未満ですが、最初の perl 計測プログラムをバックグラウンドに回すのが手間取って、sleep $sec & にたどり着くまでに 0.1秒くらいの時間浪費があるのかもしれません。500秒に増やしてやってみましたが、499.907秒と出て、実際との時間差は0.1秒弱と同程度となりました。0.1秒が計測プログラムの起動コストと等しく見て良いでしょう。

愚直に全部のカウント出力を sleep で回す

これは汎用的にはいい解決方法ではないのですが、「30秒おきに教えて欲しい。待ち時間は5分」みたいに限られた回数のカウント出力が固定で存在する場合、すべての出力を最初から sleep を伴った echo としてバックグラウンドジョブにまわしてしまえという作戦です。

perl -MTime::HiRes=time -E 'my ($start); $SIG{INT} = sub { my $end = time; say "$end - $start = " . ($end - $start) }; $start = time; sleep;' &

# 30秒おきに出力して5分後に実行する
( sleep 30 ; echo "30秒経過" ) &
( sleep 60 ; echo "60秒経過" ) &
( sleep 90 ; echo "90秒経過" ) &
( sleep 120 ; echo "120秒経過" ) &
( sleep 150 ; echo "150秒経過" ) &
( sleep 180 ; echo "180秒経過" ) &
( sleep 210 ; echo "210秒経過" ) &
( sleep 240 ; echo "240秒経過" ) &
( sleep 270 ; echo "270秒経過" ) &
sleep 300
echo "300秒経過"

kill -INT $pid
wait $pid

書いてみると、良い意味で愚直であり、わかりやすいです。もちろん、悪いことを指摘することはいっぱいできて、カウントタイミングが増えれば増えるほど、バックグラウンドジョブが激増します。

あまり汎用的ではありませんが、シェルスクリプトに詳しい人がいないけれど、実行時間が限られている書き捨ての運用スクリプトでたまにカウント出力を出したい場合、この書き方はわかりやすい場面もあるかなと思います。

Perl で簡単なプログラムを書く

さっき「(非同期処理が強い)****言語で書け!」は制限された環境で難しい場合もある……と言ってはみたものの、だいたいの環境に perl は入っているので、簡単な Perl プログラム、perl ワンライナーを書いてしまうのも手かもしれません。

制限された環境だと perl コマンドが入っていたとしても新たな Perl モジュールを入れることは難しいはずなので、AnyEvent などの非同期処理モジュールを入れることは考えないことにしましょう4

先ほど計測プログラムで使った Time::HiRes モジュールには setitimer という関数があり、これを使うと高精度なカウント出力ができます。

use strict;
use warnings;
use feature qw(say);
use Time::HiRes qw(setitimer ITIMER_REAL time);

my $sleep_time = shift || 100;
my $count_time = 0;

my $start_time = time();

$SIG{ALRM} = sub {
    say "ALARM at " . scalar(localtime) . " " . time();
    $count_time++;
    if ($count_time >= $sleep_time) {
        my $end_time = time();
        say "finished at " . scalar(localtime) . " " . $end_time;
        say "elapsed time: " . ($end_time - $start_time) . " seconds";
        exit;
    }
};

my $timer = setitimer(ITIMER_REAL, 1, 1);

# 単に待つだけの意味で STDIN を readline する
my $line = <STDIN>;

上のコードを改造することで、所望のカウント出力になるかなと思います。

perl のプログラム実行自体はシェルから同期的に呼び出されます。よって、上のプログラムを sleep コマンドの代わりに使うことで、カウント出力も行ってくれる sleep となり、かつ精度もそこそこ良いです(少なくともシェルスクリプト内で sleep 外部コマンドを多数呼び出すよりは)。

シェルスクリプトの解説をしていたけれど、だいたいどこでも使える perl の併用もオススメです。

もっと高精度にやるには?

前述の Time::HiRes setitimer の例を使えば、誤差がほぼ溜まらないとは思います。しかし、それでも気になるようであれば、開始時間から適切な sleep 時間を毎秒ごとに割りだして、都度有理数(小数点)指定で sleep することになるでしょう。

余談:カウント出力で新しい行出力をしない

今までのサンプルコードは、わかりやすさのため毎秒のカウントごとに毎行出力していましたが、行を変えずにカウントする場合は、カーソルを一文字戻す \r と、空白での上書きを使うことで、手軽に実現できます。

先ほどの、シェルスクリプトで sleep 1 を毎秒行うプログラムを変更してみます。 \r を伝えるため、 printf ビルトインコマンドを使うことにします。その他のロジックは、前述の通りです。

perl -MTime::HiRes=time -E 'my ($start); $SIG{INT} = sub { my $end = time; say "$end - $start = " . ($end - $start) }; $start = time; sleep;' &
measure_pid=$!
sec=100

# 実際に $sec 秒待つのは sleep $sec で行う
sleep $sec &
sleep_pid=$!

(
while true ; do
    printf $sec
    sleep 1
    # $sec の長さ=桁数だけ、カーソルを1文字戻し、空白を書いて、またカーソルを1文字戻すを繰り返す
    for i in $(seq 1 ${#sec}) ; do
        printf "\b \b"
    done
    if (( --sec <= 0 )) ; then
        printf "0 finished\n"
        break
    fi
done
) &
count_pid=$!

wait $sleep_pid
kill -HUP $count_pid

# 秒数計測
kill -INT $measure_pid
wait $measure_pid

先ほどの perl Time::HiRes setitimer 版も、 print "\b \b" といった記法が書けるので、同様の発想で書くことができます。

さらにリッチに、画面右端とか別の場所でカウントしたい……場合は Curses とか使うことになりそうですが、その場合は外部モジュール等の準備がさらに必要そうですね。

皆さんも色々カウント出力してみて下さい!

  1. Mac 端末のターミナルでミリ秒以下のタイムスタンプを取得したい | DevelopersIO

  2. echo コマンドは外部コマンドとしても用意されていますが、bash や zsh では通常内部コマンドとして実装されており、それらが使用されると思います。お使いのターミナルやシェルスクリプト内で type echo とすると、ビルトインコマンドと外部コマンドどちらを実行するか確認できます。

  3. 教育的な例示として失敗例も書いた……というのはキレイな話ですが、実際は単に試していった過程です。とはいえ、perl のワンライナーを書いたとき、「これもまた重いだろうな……」と思いながら書いていました。

  4. AnyEvent の簡単な解説は AnyEvent のタイマーを自由自在に操る #Perl - Qiita などを参照。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?