Rubyで経過時間を「正確に」測定する方法(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rubyで経過時間を「正確に」測定する方法(翻訳)

Rubyで経過時間を算出したいときはどんなふうにやることが多いですか?

starting = Time.now
# 何か時間のかかることをする
ending = Time.now
elapsed = ending - starting
elapsed # => 10.822178

⚠ブブー️、これは違います。理由は次のとおりです。

「時間は前に進むとは限らない」

RubyのTime.nowは、低レベルのOS(Operating System)設定に応じてLinuxのtime.hにあるgettimeofday関数やclock_gettime関数を使っています

gettimeofdayman pageから引用します。

エポック(epoch)以後の秒およびマイクロ秒の値を返します。

この関数は、秒数とシステムタイムゾーンを含む構造体を1つ返します。Ruby VMはこれを元に値を算出し、それらの情報を追加したTimeオブジェクトを1つ返します。これはLinuxドキュメントで多くの場合wall timeと呼ばれています(訳注: wallは「壁掛け時計」のアナロジーです)。

しかしこんなことも書かれています。

gettimeofday()の返す時間は、システム時間の不連続なジャンプに影響される(例: システム管理者がシステム時間を手動で変更した場合)。

手動管理のほかに、システムクロックの自動調整も時間に影響を与えます。たとえば、サーバーでNTPが利用されていると次のようになります。

参照時刻は、世界中どこでも同一であるのが理想です。同期が完了すれば、OSのクロックと参照時刻の間でいかなる不測のずれも生じるべきではない。したがって、NTPにはこの状況に対処するための特別な手法は存在しない。

さらにこんなことも書かれています。

ntpdは、微細なオフセットについてはローカルのクロックを通常どおりに調整します。

NTPのドキュメントでは、クロックの品質についてまるまる一節を使っています。

残念ながら、よくあるクロックハードウェアはどれも非常に正確であるとは言い難い。理由は単純で、時刻を刻む周波数の増加は決して正しくないからである。この周波数の増加がわずか0.001%の狂っただけでも、1日あたり最大1秒のずれが生じるであろう。

精度が完全でないのは、温度や気圧や、下手をすると磁気も含めたCPUの物理的条件によるものです。

つまり、OSがシステム時間を新たに設定しようとする場合、その新しい値が未来のある時刻を指すという保証にはならないのです。CPUのクロックが「速すぎる」場合、OSは時刻をリセットするために数秒ほど巻き戻しする決定を下すことがあります。

wall clockに欠陥が生じるもうひとつの理由は、一部のCPUがうるう秒を扱えないことにあります。Wikipediaのうるう秒のページには、うるう秒によって生じた歴史的なソフトウェアの問題事例が多数掲載されています。

🤓まとめ: システムクロックは常に揺らぐものであり、常に一方向に進むとは限りません。これを当てにして経過時間を算出すると、計算中に誤差が生じたり、最悪の場合は大規模な停止事故につながる可能性が大いにあります。

経過時間を正しく求める方法

増加が一方向に一定で進む経過時間を算出するにはどうすればよいのでしょうか?🤔

POSIXシステムでは、monotonic clock(単調増加クロック)なるものを導入することでこの問題を解決しました。概念上はタイマーに似ています(そのタイマーはあるイベントをきっかけに開始し、時間のゆらぎ問題に影響されない)。monotonic clockに時間を尋ねるたびに、前回のイベントからの経過時間を返します。macOSの場合、このイベントはシステムの起動時間に設定されます。monotonicの他にも、リアルタイム(realtime)、monotonic raw、virtualといった種類のクロックがいくつもありますが、それぞれが解決する問題は異なっています。

Ruby 2.1以降(MRI 2.1以降およびJRuby 9.0.0.0以降)、どのクロックからでも現在の値を取り出せる新しいメソッドが導入されました。その名もProcess.clock_gettimeというもので、名前はclock_gettimeというLinux関数をそのままいただいています(これもtime.hを使います)。

t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# => 2810266.714992
t / (24 * 60 * 60.0) # time / days
# => 32.52623512722222

上の変数tは、システム起動時からの経過時間を秒で表したものになります。

$ uptime
14:32  up 32 days, 12:29, 2 users, load averages: 1.70 1.74 1.67

Rubyでこれを用いて経過時間を測定するにはどうすればよいでしょうか?

starting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
# 何か時間のかかることをする
ending = Process.clock_gettime(Process::CLOCK_MONOTONIC)
elapsed = ending - starting
elapsed # => 9.183449000120163 seconds

まとめ

Rubyのエコシステムをちょいと眺めただけでも、経過時間の算出方法がまるで間違っている事例が文字どおり数千件単位で見つかります。これを機会にオープンソース💚💎 に貢献して問題を解決してみてはいかがでしょうか😉。

お忘れなく: wall clockは時刻を知らせるものであり、monotonic clockは時間を測定するためのものです⏰。

おまけ: 社内Slackより

参考: iOS の時間関数の精度 - Qiita

関連記事

Rails: 多機能ベンチマークgem「derailed_benchmarks」README(翻訳)

Rails+PostgreSQL: EXTRACT関数で期間をスマートに扱う(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ