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

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

- [Perl Advent Calendar 2024](https://qiita.com/advent-calendar/2024/perl) 8日目
- [シェルスクリプト＆PowerShell Advent Calendar 2024](https://qiita.com/advent-calendar/2024/shell) 8日目

<details><summary>まえがき：bash シェルスクリプトを即席で試す方法</summary>

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

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

```shell
pbpaste | bash
```

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

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

</details>

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

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

```shell
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 コマンドではエポック秒取得の際にミリ秒を測れない[^macos-date-ms]ようなのですが、2024年の macOS Sequoia では perl コマンドは入っているので、以下のようにコアモジュールの [Time::HiRes](https://metacpan.org/pod/Time::HiRes) を使ってミリ秒を出力・取得することができます。

[^macos-date-ms]: [Mac 端末のターミナルでミリ秒以下のタイムスタンプを取得したい | DevelopersIO](https://dev.classmethod.jp/articles/macos-date-millisecond/)

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

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

```shell
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 はシェルのビルトインコマンド（組み込みコマンド）なので、それらの影響はあまりない** と思います[^echo-builtin]。コストが高いのは外部コマンド呼び出しです。先ほどのサンプルスクリプトの while 内にある呼び出しにおいて、外部コマンドが実行されるのは通常 sleep のみです（つまりそれ以外はビルトインコマンドです）。

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

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

```shell
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 が悪い」という主張に沿って他のミリ秒を測ることができるプログラムを持ってきて同様のコードを書いても、そのプログラムの起動も遅いのでは？という話になるので、もうちょっとマシな解決方法を考えてみます。

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

```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;' &
```

これを使って時間計測

```shell
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 秒と、だいぶマシになりました。

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

[^failing-sample-inside-story]: 教育的な例示として失敗例も書いた……というのはキレイな話ですが、実際は単に試していった過程です。とはいえ、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 外部プログラムに任せる方法です。

```shell
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 としてバックグラウンドジョブにまわしてしまえという作戦です。

```shell
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](https://metacpan.org/pod/AnyEvent) などの非同期処理モジュールを入れることは考えないことにしましょう[^anyevent-beginner-ref]。

[^anyevent-beginner-ref]: AnyEvent の簡単な解説は [AnyEvent のタイマーを自由自在に操る #Perl - Qiita](https://qiita.com/xtetsuji/items/293e1a83ac9c622d07fc) などを参照。

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

```perl
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` ビルトインコマンドを使うことにします。その他のロジックは、前述の通りです。

```shell
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 とか使うことになりそうですが、その場合は外部モジュール等の準備がさらに必要そうですね。

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