LoginSignup
4
4

なぜawkの乱数関数でUnix時間を取得できるのか? ~ 乱数の話とUnix時間の深い関係

Last updated at Posted at 2023-07-02

はじめに

awk は srand 関数を使って Unix 時間を取得することができます。srand 関数は乱数のシードを初期化する関数です(乱数値を返す rand 関数と混同しないようにしてください)。これはどの環境のどの awk でも動く移植性が高い方法で、この書き方をすれば srand 関数は必ず Unix 時間を返します

awk で Unix 時間を取得する方法
$ awk 'BEGIN{srand(); print srand()}'
1688282747

$ date +%s # 補足 date コマンドを使っても取得することができます
1688282747

本題の前に一つ注意点です。Unix時間を取得する方法として以下のコードを見たことがある人もいるかも知れませんが、これでは正しくUnix 時間を取得することはできません。有り体に言えばバグです。

❌ 正しく Unix 時間を取得できないコード
$ awk 'BEGIN{print srand() + srand()}'; date +%s
1686750469    ← awk コマンドによる出力(1秒ズレている)
1686750468    ← date コマンドによる出力(date コマンドを使った Unix 時間の取得)

$ readlink -f $(which awk) # awk の実体は GAWK
/usr/bin/gawk

このコードは書籍『「シェル芸」に効くAWK処方箋』で紹介されているコードで、「これは強力! AWKとパイプの新しい関係 ~ 時刻を取得する関数、Socket通信、双方向パイプ」や「第2回 月刊『シェルスクリプトマガジン 2014 November (Vol.19)」にも掲載されているようです。一応 Solaris 10.3 / 11.4 の POSIX awk (/usr/xpg4/bin/awk) に限れば正しく取得できるのですが、同書が対象としているのは GNU awk (gawk) なので間違いです。ちなみに間違っていることは報告済みです。本記事を読むとなぜこのコードで正しく取得できないかの理由も知ることができます。

POSIX (Issue 8) で規定される動作

srand 関数で Unix 時間を取得できるかもしれないけど、そんなのたまたま動く「裏技」のようなもので動かない実装もあるかもしれないし、そんなコード書いたらダメ!と思うかもしれませんが、この動作は POSIX Issue 8 で標準化される仕様です。

参照 0000983: awk's rand()/srand() spec not useful

確かにかつては裏技のようにみなされていましたが、私が調べた既存の全ての awk で期待したとおりに Unix 時間を取得することができる移植性が高い方法です。ちなみに私が調べた awk は GNU awk、オリジナルの新しい awk である One true awk (nawk)、mawk、busybox awk、Solaris の POSIX awk です。

Solaris 10.3 や Solaris 11.3 の /usr/bin/awk では動きませんが、これは 1977年版の最初の awk でそもそもユーザー定義関数や多くの機能が使えず POSIX にも準拠しておらず、現実的には使い物にならないレベルの古い awk なので考慮する必要はありません。Solaris 10.3 や Solaris 11.3 でも /usr/xpg4/bin/awk に POSIX awk があるのでこちらを使ってください。また最新の Solaris 11.4 では /usr/bin/awk は POSIX awk に更新されているようです。

POSIX Issue 8 はまだ完成していませんが、標準化の流れの話から分かる通り、これは新機能ではなく「歴史的な動作を明文化しただけ」の「すでに使える機能」です、標準化される以上、今後この仕様が変わることは考えられないので、この方法で Unix 時間を取得しても問題ありません。もしかしたらマイナーな awk の実装で動かないものがあるかもしれませんが「POSIX に準拠するべきだ」と提案すれば修正されるでしょう。

なぜsrand関数でUnix時間が取得できるのか?

勘のいい人は「シード値として Unix 時間を使用するから」の一言で全てを理解できるでしょう。

srand 関数は awk の関数でランダム値を取得する rand 関数のためのシード値を設定する関数です。シード値とは(疑似)乱数を生成するために使用する値で、シード値が同じであれば awk を再実行しても同じ乱数列が生成されます。ただし異なる awk の実装でも同じ乱数列が生成されるというようなものではありません。

シード値が同じなら何度実行しても同じ乱数列が出力される
$ seq 3 | awk 'BEGIN { srand(42) } { print rand() }'
0.24632
0.396143
0.955075

$ seq 3 | awk 'BEGIN { srand(42) } { print rand() }'
0.24632
0.396143
0.955075

同じ乱数列が生成されるのであれば乱数として使えないと思うかもしれませんが、テストなどのために意図的に同じ乱数列を生成したい場合には便利です。ですが、一般的には異なる乱数列が欲しいでしょう。その場合はシード値を省略して次のような使い方で実現することができます。

シード値を省略すると実行するたびに異なる乱数列が出力される
$ seq 3 | awk 'BEGIN { srand() } { print rand() }'
0.379604
0.109919
0.0617382

$ sleep 1 # 再実行まで1秒以上間隔をあけること

$ seq 3 | awk 'BEGIN { srand() } { print rand() }'
0.684365
0.951084
0.119918

これはなぜかというと awk の srand 関数はシード値を省略すると Unix 時間をシード値として使うという仕様だからです。Unix 時間は一秒毎に自動的に変化する値とみなせるので、Unix 時間をシード値として使うと一秒ごとに別の乱数列が出力されるようになります。逆に言えば一秒以内に再実行すると同じ乱数列が出力されてしまうので注意が必要です。昔に比べてコンピュータの性能が大幅に向上しシェルスクリプトの書き方によっては一秒以内に awk を再実行してしまう可能性が考えられるので、そのような場合には別の工夫が必要になってきます。以下の方法で /dev/urandom (または /dev/random)から乱数を生成することができるので、この値を直接利用するか、どうしても awk を使う必要がある場合は srand 関数のシード値として使うことができます。

乱数を生成するその他の方法(以下は32bit符号なし整数の乱数)
$ od -An -tu4 -N 4 /dev/urandom
                273275427

# 乱数を複数生成したい場合(下記の例では 10 個)
$ od -An -tu4 -N $((4 * 10)) /dev/urandom
               1372449868      2666820430      3972546570      1398767086
               2131978540      2222568716      2805779795      3822953772
               2755474640       685674344

# 複数の乱数を一つずつ行にしたい場合
$ od -An -tu4 /dev/urandom | tr -s ' ' '\n'
3396887147
1064438785
3015535112
3825612160
     ︙

/dev/urandom/dev/random はほとんどの環境で使用できると思いますが、厳密な話をすると POSIX で標準化されていません。

おまけ FreeBSD 10.1までの古いawkは乱数の品質が低い

少々話が脱線しますが、先程「(シード値が変わらないため)1秒以内に再実行すると同じ乱数列が出力されてしまう」と書きましたが、1秒以上待ったとしても、古い FreeBSD(おそらく10.1 まで)では乱数の品質が低い(似たような乱数値を生成する)という問題がありました。

古い FreeBSD は乱数の精度が低い(以下は9.3での実行例)
$ for i in $(seq 5); do awk 'BEGIN { srand(); print rand() }'; sleep 1; done
0.424013
0.424021
0.424028
0.424044

これは awk の過去の実装が品質の低い rand(3) を内部で使用していたためです。この問題に関する修正は2014年8月頃に検討され2014年10月頃に rand(3) から random(3) への修正が行われています。FreeBSD のバージョンではこの修正は 10.1 に含まれていませんが 10.2 には含まれています。

Linuxでは rand(3) は random(3) と同じアルゴリズムを使用しているため rand(3) のままでも問題ありませんが、移植性を考慮し「精度の高い乱数が必要な アプリケーションではこの関数は使用してはいけない」と警告しています。

rand() と srand() の Linux C Library 版は、 random(3) と srandom(3) の両関数と同じ乱数生成 アルゴリズムを使用している。そのため、下位のビットは上位のビットと 同じくらいにランダムである。 しかし、旧版の rand() の実装や、他のシステムの現在の実装では、下位のビットが上位のビットほど ランダムになっていない。移植性を高める場合でも、精度の高い乱数が必要な アプリケーションではこの関数は使用してはいけない (代わりに random(3) を使うこと)。

いずれにしても、FreeBSD 10.1 は 2016年12月31日 にサポートが終了しており、FreeBSD 版 awk の乱数の品質が低いという問題は(2023年7月現在時点で)9 年近く前に修正されているので、もはや気にするような話ではありません。

srand関数の戻り値はなに?

さて srand 関数が引数を省略するとシード値として Unix 時間を使うという話までしました。ではなぜ srand 関数を使用すると Unix 時間を取得することが出来るのでしょうか? そしてなぜ srand 関数をニ回呼び出しているのでしょうか?

srand() の戻り値は Unix 時間?
$ awk 'BEGIN{ srand(); print srand() }'
1686749521

srand 関数の戻り値が Unix 時間となっている理由は srand 関数が「前回設定したシード値を返す」という仕様だからです。例えば srand(12345) でシード値を 12345 に設定すれば、次の srand 関数の呼び出しは 12345 を返します。

$ awk 'BEGIN{ srand(12345); print srand() }'
12345

つまり最初の srand 関数の呼び出しで引数を省略して Unix 時間をシード値として設定し、次の print srand() で前回設定したシード値を取得しているので Unix 時間が出力されるのです。

初回のsrand関数呼び出しの戻り値

srand 関数が戻り値として「前回設定したシード値を返す」仕様であることを説明しました。ここで一つの疑問が浮かびます、一度もシード値を設定しない場合の最初の呼び出しでは何を返すのか?です。

その答えは内部で初期化されたデフォルトのシード値です。実はこの値は awk の実装によって異なります。実装によって動作が異なるため POSIX では規定されていません(正確には「未指定」と標準化されています)。

Ubuntu
$ gawk 'BEGIN{print srand()}'
1
$ mawk 'BEGIN{print srand()}'
1688291452
$ original-awk 'BEGIN{print srand()}'
1
$ busybox awk 'BEGIN{print srand()}'
1
FreeBSD, NetBSD, OpenBSD, macOS
$ awk 'BEGIN{print srand()}'
1
Solaris 11.4
$ /usr/bin/awk 'BEGIN{print srand()}'
1688289906
$ /usr/xpg4/bin/awk 'BEGIN{print srand()}'
0

「正しくUnix時間を取得できないコード」は何が間違っていたのか?

さてここで冒頭の「正しく Unix 時間を取得できないコード」を思い出してみましょう。もうおわかりですね。1秒ズレる理由は srand 関数の最初の呼び出しで GNU awk は 1 を返すからです。つまり最初の srand 関数の戻り値は捨てなければいけないということです。

❌ 正しく Unix 時間を取得できないコード
$ awk 'BEGIN{print srand() + srand()}'; date +%s
1686750469    ← awk コマンドによる出力(1秒ズレている)
1686750468    ← date コマンドによる出力(date コマンドを使った Unix 時間の取得)

一応 Debian 2.2 (2000年)でも試したのですが、1 を返していたので、昔の GNU awk は 0 を返していたという話でもないはずです。1 秒のズレは気づきにくいですし、もしかしたら Solaris 11.4 の /usr/xpg4/bin/awk の挙動と勘違いしていたのかもしれません。またちょっと起動が遅れた程度と考えれば実害もあまりないでしょう。しかし移植性を考えた場合、Debian や Ubuntu でデフォルトで使われている mawk や Solaris 11.4 の /usr/bin/awk では大幅にズレてしまうことになります。

GNU awkではsystimeでもUnix時間を取得できる

GNU awk には Unix 時間を取得するための専用の systime 関数 があるので、こちらを使っても構いません。

$ gawk 'BEGIN{srand(); print srand(); print systime();}'; date +%s
1688292598
1688292598
1688292598

また date +%s でも Unix 時間を取得できるのでこちらを利用するという方法もあります。+%s は POSIX Issue 7 では標準化されておらず、Solaris 10.3 では使えなかったりしますが、こちらも POSIX Issue 8 で標準化され、少なくとも今も主要な OS では問題なく使用することができます。awk の中から呼び出すこともできます。

$ awk 'BEGIN{ system("date +%s") }'
1688293955

$ # 変数に入れる場合
$ awk 'BEGIN{ "date +%s" | getline ut; print ut }'
1688294020

$ # 厳密にはコマンド呼び出しは close しなければいけません
$ awk 'BEGIN{ cmd = "date +%s"; cmd | getline ut; print ut; close(cmd) }'
1688294135

さいごに

awk で Unix 時間を取得する裏技は POSIX で標準化されます。とは言ってもあまり unix 時間を取得したいということもないでしょう。シェルスクリプトからであれば date +%s を使えばほぼ問題ないのでなおさらです。知っていてもあまり役に立たない雑学に近い話かなーとは思いつつも、今までの常識を更新するという意味でこれを書く意味はあるかなーと思っています。昔の話をいつまでもしていても意味はないですからね。

余談ですが他にもシェルスクリプトや awk で乱数やUnix時間を扱う方法は色々あったりします。興味がある方は参考にどうぞ。

4
4
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
4
4