概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Elapsed time with Ruby, the right way - DNSimple Blog
- 原文公開日: 2018/03/28
- 著者: Luca Guidi
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
関数を使っています。
gettimeofday
のman 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は時間を測定するためのものです⏰。