LoginSignup
38
32

More than 3 years have passed since last update.

Bash/Zsh + POSIX で sleep 0.01 する方法

Last updated at Posted at 2016-06-27

■ 問: シェルスクリプトで 0.01 秒間などのごく短い時間だけ sleep する方法は? それもできるだけ精確に。

この問いは一見して簡単な問いに思われるが、考えて見ると意外と難しい。

後で詳しく説明するが、POSIX (Unix の規格) では sleep に小数は指定できない。また、外部コマンド sleep の起動自体に余分な時間が掛かることも問題になる。

おまけ: sleep 0.01 を使ったシェルアニメーション (スクリプトはこの記事の最後)

snake.gif

:pencil: 先に答え? (現状の案 for bash-4.0+/zsh)

上記の問いがどう難しいのかだとかそういう説明を全部飛ばして先に結果を書いてしまう。色々考えたり測定してみたりした結果、今のところの最良案 (シェル関数版 sleep) は以下である。組み込みコマンド read のタイムアウト (小数の指定は bash-4.0 以上) を使っている。

sleep.sh
# function sleep for bash-4.0+/zsh

##
## 関数 sleep time
##   スリープする。
##
##   @param[in] time 待ち時間。単位は秒。小数も可能。
##
##   例: $ sleep 0.1
##
##   注意: ファイルディスクリプタ 9 を別の用途に使うと駄目
##
function sleep { local REPLY=; ! read -u 9 -t "$1"; }

if [[ $OSTYPE == cygwin* ]]; then
  # Cygwin work around

  exec 9< <(
    [[ $- == *i* ]] && trap -- '' INT QUIT
    while :; do command sleep 2147483647; done
  )

  if [[ $BASH_VERSION ]]; then
    function sleep {
      local s="${1%%.*}"
      if ((s>0)); then
        ! read -u 9 -t "$1" s
      else
        # 修正 (2018-08-18): 最近の Cygwin (もしくは最近の Windows?)
        #   では /dev/tcp/... を使うと失敗するので、/dev/udp/... に変更。
        ! read -t "$1" s < /dev/udp/0.0.0.0/80
      fi
    }
  fi
else
  rm -f ~/.sleep.tmp
  mkfifo ~/.sleep.tmp
  exec 9<> ~/.sleep.tmp
  rm -f ~/.sleep.tmp
fi

シェル関数で sleep を上書きする実装にしているので、sleep 小数 を使っている既存のスクリプトがあれば、source sleep.sh するだけで POSIX 環境に対応できる。

もっと良い sleep 小数 の方法・綺麗な解があれば教えてほしい。

改善の余地?

  • 予め sleep 自体にかかる余分な時間を計測して補正を行う?
  • /proc/uptime が利用可能なシステムでは、それを見てずれを補正可能かも。
  • bash-4.1+/zsh なら {fd}<> ... で未使用のファイルディスクリプタを自動で割り当てられる。

※本当はアニメーションにするには、単なる sleep では余りよくない。フレームの描画に掛かる時間を差し引いて sleep しなければならない。そのためには精確で高速な時刻計測が必要になるが、話がややこしくなるのでここでは考えない。


■ 前文

まあ、実際にこういう sleep 小数 の需要がそうそうあるのかどうかは分からないけれど…うーん…例えば シェルスクリプトで端末にアニメーションを表示したい とか? 正直なところ、まともに考えればそんなことでシェルスクリプトに拘る理由はない。もっと便利な別の言語を使えば良いのである。

しかし、稀にシェルスクリプトで書かなければならないときがある。例えば、bash の拡張 (.bashrc から読み込むようなの) をシェル関数で書くときである。特に、それを他の人に配布する場合には、できるだけ他のツールに依存せず bash (+ POSIX 環境) だけ存在していれば動くようにしたい。実際に bash 用の拡張の ble.sh を書いていてちょっと困ったのである。

以降ではもう少し詳しくこの sleep の問題について調べる。構成は以下の通り。1. で問題点を説明し、2. で複数の解決法を挙げる。3. で実測して比較し、4. で応用例としてアニメーションを表示してみる。ちなみに、この記事では bash での実現が主目的であり zsh は序でである。

■ 1. シェルスクリプトの sleep 問題

二種類の問題がある。

:arrow_forward: 1.1 POSIX では sleep 小数 は使えない

ひとつ目の問題。POSIX では sleep コマンドは整数 (単位は秒) を指定した時のことしか規定されていない (sleep - The Open Group)。つまり、sleep コマンドに小数を指定しても動かない UNIX 環境がありうるということである。少なくとも GNU/Linux だとか Cygwin では sleep (GNU coreutils) が入っていて 0.01 などの小数を使うことができる。一方で AIX では小数は指定できないようだ (AIXで1秒未満のsleepを実施 - Qiita)。Mac OS X, FreeBSD, Solaris, HP-UX, etc. ではどうかしら。

ところで、それぞれの環境で sleep 小数 が使えるかどうか調べるために Qiita を徘徊していたら sleep 小数 を使っているスクリプトは結構たくさんある。やはり需要は大きいようだし、また、sleep 小数 は事実上の標準になっている気配がする。

Qiita内の調査結果

以下は sleep 小数 を使っている記事。

以下は環境を指定した記事なので sleep 小数 でも大丈夫。

以下は分かりやすくするためにわざわざ sleep 1s だとか sleep 5m だとかとしているが、この指定方法は勿論 POSIX にはない。

下の記事では "おまけ" で無限スリープ sleep inf についての考察がある。

追記: また usleepsleepenh などの "秒以下を扱える強化版コマンド" を使っている記事もある。もちろんこれらのコマンドがあるかどうかは sleep 小数 にもまして環境に依存する。2番目の記事のように sleep を fallback とするのが賢いやり方である。

:arrow_forward: 1.2 sleep 自体の起動に時間がかかる

もうひとつの問題は sleep コマンドの起動にかかる時間である。シェルから外部コマンドの sleep を立ち上げるためには、新しくプロセスを生成することになるので多少時間がかかる。sleep は自分が起動される原因となったイベントがいつ起こったかを知らないから、当然自分が起動完了した瞬間から測ってスリープを行う。つまり、sleep 呼び出し元から見た停止時間は 起動時間 + 指定した時間 になる。

ではこの起動時間はいかほどだろうか。プロセスの起動時間はシステムによる。環境によっては大体ミリ秒程度で済むが、十ミリ秒ぐらいかかることも普通にある。Cygwin などの環境にいたっては数十ミリ秒かかるのが普通である。(Cygwin なんか使うのが悪いという声が聞こえてきそうだが、Cygwin でもシェルくらいはまともに動作してほしい。) 最近 "Bash が Windows 10 上で動く" という話で賑わしている Windows Subsystem for Linux ではもっと遅いという噂 (Windows Subsystem for Linux の fork は速いのか - Qiita) も。つまり、整数単位の sleep では誤差にしかならなかった起動時間が、ミリ秒単位でのスリープでは深刻なずれを引き起こす。

また、プロセスの起動に時間が掛かるということは、それなりのコストがあるということである。例えば、アニメーションなどに使うとなると一秒間に何十回も sleep を実行することになるがこれは結構な負荷である。たかが sleep ごときでホストに負荷をかけたくない。したがって bash/zsh の組み込みコマンドの範囲内でどうにかしたい。

■ 2. 色々の方法

ここでは sleep を実現するための様々な方法を挙げている。特に 2.3.2/2.3.4 の方法を用いれば POSIX すら必要なくて、bash の組み込み機能だけで実現可能であることが興味深い。

:arrow_forward: 2.1 ビジーウェイト

さて、シェルの組み込み機能だけで sleep を実現したい。しかし bash/zsh で sleep が組み込み機能として提供されていない以上は、普通に考えて不可能な気がする。そこで "最終手段" ビジーウェイトに手を出す誘惑に駆られる。つまり、何もしないループを回して時間を潰す。

while 指定の時刻になるまで; do :; done

この方法を忌避する第一の理由は、CPU に負荷をかけることである。sleep するだけのために、ファンをいつもより多く回すという虚しさ。

しかし、CPU の負荷を気にしないとしてもこの方法はすぐ頓挫する。"指定の時間になるまで" をミリ秒単位で実現する可搬な方法がBash 5.0 未満では[追記 2018-08-18]ないからである。

  1. date: 例えば date (GNU coreutils) を使えば %N で秒以下の単位を取得できるが、それならそもそも sleep (GNU coreutils) を使っておけば良い話である。そして POSIX の date には %N はないので秒単位の計測はできない。
  2. printf '%()T': ミリ秒単位の精度を保証するためにはプロセスを起動せずにシェルの組み込み機能だけで実現したい。組み込み機能で時刻を計測するといえば Bash-4.2 で追加された printf '%()T' が使えるが、これも残念ながら %N など秒以下の単位に対応していない。
  3. /proc/uptime: もしシステムが /proc/uptime に対応していれば、ひたすら read time < /proc/uptime で値を読みだすという手も使える。プロセスを起動するわけではないので高速である。しかし /proc/uptime が使えるシステムは sleep が小数に対応しているシステムよりも少なそうだ。
  4. EPOCHREALTIME: Bash 5.0 以降では特殊シェル変数 EPOCHREALTIME が追加され、この変数を読み取ればその瞬間の Unix 時間を秒単位の小数で取得できるようになる。例えば、1534564384.985884 などの文字列が取得できる。[追記 2018-08-18]
  5. ループ速度の計測: @ko1nksm さんの記事 "POSIX準拠シェルスクリプトだけで1秒未満sleepを実現する - Qiita" ではループ速度を計測してループ回数を事前に計算してビジーウェイトしている。さらにCPU負荷を下げるために、複数の sleep 1 ループをバックグラウンドでずらして起動して、本体のシェルにシグナルを送っている。POSIX の範囲内で実現しているが、記事中にもあるように精度に難あり。sleep 0.01 相当を実現するにはバックグラウンドで sleep 1 を1秒に100回実行する負荷があるし、長時間の実行でバックグランド間のタイミングがずれる。ミリ秒精度の用途には難しそう。[追記 2020-04-11]

:arrow_forward: 2.2 Bash Loadable Builtins (enable -f ./sleep.so sleep) を使う

Bash には強力な機能がある。動的に組み込みコマンドを追加できる機能だ。それ専用にビルドされた共有ライブラリを用意して、それを enable -f で読み込む。後付なのに "組み込みコマンド" という名前は妙だが、シェルコマンドの区分としては確かに "組み込みコマンド" として取り扱われる。つまり、外部コマンドとしてプロセスを起動するのではなく、bash のプロセス内で実行される。C/C++ などで書いた自分の好きなプログラムを bash 内で実行させることができるのだ。ちなみに zsh にも enable -f はあるが意味はまったく異なり、この目的では使えない。

そして実際に Bash の開発者もスリープ問題に気づいていたのか、なんと bash のソースコードに附録として sleep.c がついてくる。sleep.c をコンパイルしてできる sleep.so を使えば以下のようにして新しい組み込みコマンド sleep を動的に読み込める。

enable -f ./sleep.so sleep

しかし、この sleep.soBash 4.4 未満では[追記 2018-07-26]デフォルトでビルドされないし、Linux の distribution でも配布されているのを見たことはなかった[修正 2018-07-26]Bash 4.4 以降ではデフォルトで附録のビルトインコマンドがビルドされる様になり、それに伴って一部の Linux distribution では bash-builtins などのパッケージ名で配布されるようになった。[追記 2018-07-26]

Bash 4.3 以前において[追記 2018-07-26] sleep をビルドするためには Bash 本体のソースコードが必要である。そして、様々なシステムや Bash のバージョンがあることを考えるとバイナリを直接配布するわけには行かない。つまり、この機能を利用するためには、シェルスクリプトのユーザの側でおのおの以下のようなことをしてもらう必要がある。

  1. 自分の使っているのと同じ version の bash のソースコードを手に入れる
  2. ./configure & make する
  3. sleep.cbash-4.3/example/loadables 的な場所にある。そこに入って make する。
  4. 完成した sleep をシェルスクリプトによって予め定められた場所に配置する

可搬ではないどころかとてつもなく面倒くさい。また、Cygwin では Makefile やら何やらを色々書き換えてようやく sleep.c をビルドできた。更に、共有ライブラリ (DLL) の仕組みが特殊なシステムの場合、そもそもこのような仕組みを使えないという可能性もある (MinGW などではそもそもビルドできるのかどうかすら怪しいんじゃないかこれ…)。

追記: Bash Loadable Builtins の使い方に関しては以下が詳しい。

:arrow_forward: 2.3 読み込みのタイムアウトを使う: read -t 0.01

さて、bash/zsh には組み込みのコマンドとして sleep はないものの、考える範囲を拡げれば実行をブロックする組み込みコマンドが存在する。read だ。そして、なんとタイムアウト (単位は秒) を引数に指定することができる。特に bash-4.0 以上ではタイムアウトとして小数を指定することができる。

read -t 0.01

しかしこのままの形で使ってしまうと、なにかが標準入力にあるとタイムアウトする前に読み取りが完了して read がすぐに終了してしまう。そこで、何か読み取りに無限に時間のかかる (つまり何も読み取れない) ストリームに標準入力をつなぐ。

:bulb: 2.3.1 /dev/null

read -t 0.01 < /dev/null

は駄目だ。"何も文字が読み取れない (ストリームの終端)" ということがすぐにばれてしまうので、read はタイムアウトする前に読み取りを諦めてしまう。

つまり、必要なのは "内容を送る送るといいつつも、いつまで経っても送ってくれないストリーム" である。

:bulb: 2.3.2 /dev/tcp/0.0.0.0/80

次に試したのは bash の特別ファイル名 /dev/tcp/ip/port である。このファイル名にリダイレクトを行うと、Bash が勝手に ip:port に対して TCP 接続 (socket) を開いてくれる。ここで、存在しないサーバに対して接続を試みればブロックされるのではないか、ということである。0.0.0.0 という IP アドレスは使われないはずなのでこれで試してみた。

read -t 0.01 < /dev/tcp/0.0.0.0/80

TCP 接続のタイムアウト (少なくとも 1 秒よりは長かろう) よりも短い時間の sleep ならばこの方法で動く。

しかし、この方法の問題点は "実際に TCP 接続を試みる" ということである。一秒間に何十回も sleep を実行するとネットワークに負荷をかけるおそれがあるし、そもそも socket の数の上限とかそういうのにひっかかるかもしれない。お手軽ではあるが実用にはならないだろう。

:bulb: 2.3.3 exec 9<> 名前付きパイプ

あるいは名前付きパイプを開いて、書き込み側が何も書き込まずに待てば良い。読み書き両方を自分の適当なファイルディスクリプタに割り当ててしまい、書き込みはせずに読み込みだけすることにすれば良い。

# 前準備
mkfifo sleep.tmp
exec 9<> sleep.tmp
rm -f sleep.tmp

read -t 0.01 <&9

なかなかいい感じに動く。

名前付きパイプの扱いに慣れない人のために、前準備の部分について細かい説明を追加しておく:

  • まず mkfifo は名前付きパイプをファイルシステムの何処かに作る。
  • exec でその名前付きパイプを読み書き両用 (<>) で開く。exec リダイレクト を用いて自プロセス(シェル)の ファイルディスクリプタを弄れることを使う。上記の例では 9 番にパイプを紐付ける。
  • 設置した名前付きパイプを後で削除するのは面倒なので、すぐに rm で削除する。ファイルシステム上ディレクトリから unlink されても、ファイルディスクリプタが生きている限りは、その名前付きパイプはシステム上に残ることを利用したやり方。

:bulb: 2.3.4 exec 9< プロセス置換

上の名前付きパイプの方法はいい感じに動く。しかし Cygwin では名前付きパイプが完全には実装されていない (Eric Blake - bi-directional named pipe) ので、この方法をやろうとすると失敗するreadCommunication error on send とかいうエラーが出る。

一方で、プロセス置換は (内部的に名前付きパイプを使っているものの) Cygwin 上でも動く。というわけでプロセス置換にしてみる。

# 前準備
exec 9< <(
  [[ $- == *i* ]] && trap -- '' INT QUIT
  while :; do command sleep 2147483647; done
)

read -t 0.01 <&9

書き込みを行う側の子プロセスでは何も書き込みをせずにひたすら sleep する。

ただし、子プロセスが本体のシェルより先に終了してしまわない様に注意する。特に、対話シェル上 ([[ $- == *i* ]]) では C-c (SIGINT) や C-\ (SIGQUIT) を押しても本体のシェルは終了しない。しかし、子プロセスはこれで見事に終了してしまう。これを防ぐために trap している。しかし、この様に対策したとしても子プロセスが勝手に死なないかどうか不安が残る。

:black_large_square: 3. 実測してみよう

2.2 enable -f と 2.3 の read -t のアイディアを利用して様々な sleep を実装する。それぞれで sleep 0.001 の時間を計測してみる。Bash-4.3 を使う。結果が 1.00 ms に近ければ嬉しい。

:hourglass: 外部コマンド (比較用)

先ずは比較のためにコマンド date (GNU coreutils)usleepsleepenh を計測する。ソースコードは、適当に検索して function-src-8.53/usleep.cnsc-deb/sleepenh-debian から持ってきた。

コマンド 環境1 Cygwin 環境2 GNU/Linux
command sleep 0.001 (GNU coreutils) 61 ms 4.17 ms
command usleep 1000 62 ms 4.03 ms
command sleepenh 0.001 62 ms 3.76 ms

:hourglass: Bash Loadable Builtin sleep

準備
enable -f ./sleep.so sleep
コマンド 環境1 Cygwin 環境2 GNU/Linux
builtin sleep 0.001 (enable -f) 1.8 ms 1.21 ms

:hourglass: Bash ネットワーク用特殊ファイルを read

コマンド 環境1 Cygwin 環境2 GNU/Linux
read -t 0.001 < /dev/tcp/0.0.0.0/80 2.0 ms 1.41 ms
read -t 0.001 < /dev/tcp/127.0.0.1/80 2.0 ms 1.41 ms
read -t 0.001 < /dev/tcp/##.##.##.2/80 23 ms 14.1 ms
read -t 0.001 < /dev/udp/0.0.0.0/80 3.1 ms 1.31 ms

追記 (2018-08-18): 最近の Windows では read < /dev/tcp/0.0.0.0/80 は失敗するようである。read < /dev/udp/0.0.0.0/80 は動く。

$ time read -t 0.1 < /dev/tcp/0.0.0.0/80
bash: connect: Connection refused
bash: /dev/tcp/0.0.0.0/80: Connection refused

real    0m1.001s # ← 1秒間 (指定したよりも長い
user    0m0.000s #   時間) 待って失敗している。
sys     0m0.000s #
$ time read -t 0.1 < /dev/udp/0.0.0.0/80

real    0m0.101s
user    0m0.000s
sys     0m0.000s

:hourglass: 名前付きパイプを read

ファイルディスクリプタを準備
mkfifo tmp1
exec 7<> tmp1

mkfifo tmp2
(exec 3> tmp2; sleep 2147483647) & exec 8< tmp2

exec 9< <(
  [[ $- == *i* ]] && trap -- '' INT QUIT
  while :; do command sleep 2147483647; done
)
コマンド 環境1 Cygwin 環境2 GNU/Linux
read -t 0.001 <&9 12 ms 1.21 ms
read -t 0.001 -u 9 12 ms 1.22 ms
read -t 0.001 -u 8 12 ms 1.21 ms
read -t 0.001 -u 7 エラー 1.20 ms

3.1 グラフ

正しい待ち時間 1.00 ms からのずれ (遅延) をグラフにしてみる。縦軸は対数スケールであることに注意。短い方がよい。(※編集2016-06-27: コマンド sleep たちを追加。縦軸を 1 ms からのずれ(対数スケール)に変更。)

benchmark2.png

:pushpin: GNU/Linux

  • 基本的に read -t & 名前付きパイプが速い(ずれが少ない)ようだ。
  • 驚くべきことに、enable -f を通して読み込んだ組み込みコマンド sleep は別に read -t と較べて速い訳ではないようだ。名前付きパイプと変わらない。

:pushpin: Cygwin

  • 一番遅いのはもちろん外部コマンド起動だが、名前付きパイプも遅い
  • 代わりに、/dev/tcp/0.0.0.0/80 が速い。ネットワークに負荷をかけるのではないかと懸念したが、実際にやってみると通信をしている気配はない。少なくとも Cygwin では /dev/tcp/0.0.0.0/80 にアクセスしまくっても問題はなさそうだ。
  • 序でに、実際に存在する IP アドレス ##.##.##.2 について同様に read してみたらネットワーク I/O が生じるようだし遅延時間も大きい。つまり、有効な範囲の IP アドレスで /dev/tcp による sleep をするのは良くない

■ 4. おまけスクリプト (bash-4.0+/zsh)

この記事で何か楽しいことをしているようにに見せかける ために sleep を使って端末の中で動くアニメーションを作ってみる。スクリーンセーバー的な何か。ダウンロードは Shell Animation using sleep - GitHub GistDownload Zip から。xterm 系の端末制御シーケンス(256色対応含む)を吐くので、端末によっては動かないことに注意。

snake.sh
#!/bin/bash

source sleep.sh

[[ $ZSH_VERSION ]] && setopt KSH_ARRAYS

: ${COLUMNS:=$(tput cols)} ${LINES:=$(tput lines)}
((xN=${COLUMNS:-80}-1,yN=${LINES:-24}-1,mfp=((xN+yN)/15+3)*2))

printf '\e[?47h\e7\e[m'
trap -- "printf '\e[m\e8\e[?47l'" 0
[[ $ZSH_VERSION ]] && trap -- exit INT QUIT

let color=196 'colors[cN++]=color+='{6,-36,1,-6,36,-1}{,,,,}
x_iswall='x==0 &&d==2||x==xN-1&&d==0||y==0 &&d==3||y==yN-1&&d==1'
movechar=CBDA
x_move=(x++ y++ x-- y--)
while :; do
  ((x=RANDOM%xN,y=RANDOM%yN,d=0,c=0))
  printf '\e[H\e[2J\e[%d;%dH' $((y+1)) $((x+1))
  for ((i=0;i<xN*yN;i++)); do
    ((((a=RANDOM%mfp)<2||x_iswall)&&(
        d=(d+1+(a&1)*2)%4,
        x_iswall&&(d=(d+2)%4)),
      x_move[d]))
    printf "\e[${movechar:$d:1}\e[48;5;${colors[c++/5%cN]}m \e[D"
    sleep 0.01
  done

  printf "\e[H\e[${yN}B\r\e[m"
done

使い方
動作には sleep.sh と上記 snake.sh が必要。終了は C-c (SIGINT) で。

使い方
$ ls
sleep.sh snake.sh
$ ./snake.sh

snake2.png
※ファイルサイズが大きくなるので静止画

幅と高さは既定では端末の画面の大きさになっている。これらを明示的に指定するときは環境変数 COLUMNSLINES でどうぞ。

幅と高さの指定
$ COLUMNS=15 LINES=10 ./snake.sh

snake.gif

38
32
2

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
38
32