モンモンブログ

技術的な話など

【シェルスクリプト】Ctrl-C で実行中のジョブを突然殺すことなく、ジョブの終了をちゃんと待ちたい

動画のエンコードのような「時間のかかる処理」をループ内で繰り返し実行するようなシェルスクリプトを書いた場合、

Ctrl-C でいきなり終了するのではなく、現在実行中の「時間のかかる処理」が終わってから終了するようにしたい、て場合があるかと思います。

trap コマンドで SIGINT (Ctrl-C) をトラップしてゴニョゴニョすればできそうですが、それだけだと「時間のかかる処理」は Ctrl-C を受け取って終了してしまいます。

じゃあどうするかというと、こう。

#!/usr/bin/env sh

# なにやら時間のかかる処理を定義
heavy_task () {
    sleep 3
}

# SIGINT (Ctrl-C) を受け取ったら interrupted 変数に値を代入
trap "echo "Ctrl-C をトラップ"; interrupted=1" INT

# ループ開始
while true; do
    echo "処理開始…"
    # なにやら時間のかかる処理を「バックグラウンドで」実行
    heavy_task &
    # バックグラウンドで起動したコマンドの終了を wait $! で待機。
    # なおかつ、wait コマンドがシグナルにより終了させられたらすかさず再wait
    while wait $!; [ $? -ge 128 ]; do
        :
    done
    # 時間のかかる処理が終了
    echo "終了!"
    # interrupted 変数に値が代入されてたらループを抜ける
    if [ -n "$interrupted" ]; then
        break
    fi
done
echo "bye!"

この中でポイントはここでしょうか。ちょっとブレークダウンして説明します。

    while wait $!; [ $? -ge 128 ]; do
        :
    done

$! は直前に起動したバックグラウンドプロセスのPID。wait $! とすることで、バックグラウンドジョブとして起動した heavy_task が終了するのを待機することになります。

待機中、もしユーザーが Ctrl-C をタイプすると wait がそれを受け取って終了してしまうのですが、その時の終了コードが 128 + SIGINTの番号 になります。これはシェルの仕様です。

$ man bash
…
    The return value of a simple command is its exit status, or 128+n if
    the command is terminated by signal n.

    ざっくり訳:コマンドがシグナル n により終了した場合、終了コードは 128 + n になる
…

wait の終了コード $? が 128 より大きいかチェックすれば ([ $? -ge 128 ] ) 、シグナルによる終了なのか、それとも単に heavy_task が無事完了した結果 wait が終了したのか、区別できるわけです。

シグナルによる終了だった場合は即 wait し直しているので、heavy_task が終わるまで待ち続けられます。

同時に、Ctrl-C を trap コマンドで検知して interrupted 変数に値を代入し、その内容をループの最後でチェックすることで、ループを抜けるべきか続けるべきか判断しています。

応用編:Ctrl-C 2回で強制終了

さらに拡張して、2回 Ctrl-C したら有無を言わさず強制終了するようにしたのがこちら。

#!/usr/bin/env sh

heavy_task () {
    sleep 3
}

# SIGINT (Ctrl-C) を受け取ったら1回目は interrupted 変数に、2回目以降は force_finish 変数に値を代入
function sigint_handler () {
    if [ -z "$interrupted" ]; then
        echo "実行中の処理の完了後に終了します(もう1度 ^C で強制終了)"
        interrupted=1
    else
        echo "強制終了!"
        force_finish=1
    fi
}
trap sigint_handler INT

while true; do
    echo "処理開始…"
    heavy_task &
    while wait $!; [ $? -ge 128 ]; do
        # trap により force_finish 変数に値が代入されていたら「強制終了」の指示とみなす
        if [ -n "$force_finish" ]; then
            # 実行中の heavy_task を kill
            if ps $! >/dev/null; then
                kill $!
            fi
            break
        fi
    done
    echo "終了!"
    if [ -n "$interrupted" ]; then
        break
    fi
done
echo "bye!"

便利!