uhyo/blog

setTimeoutに大きい数値を与えるとどうなる? 仕様を読んで完全理解

2020年9月6日 公開

JavaScriptではsetTimeoutという関数を使うことができます。 しかし、実はこの関数は言語仕様(ECMAScript)に組み込まれているものではありません。 ブラウザ上で動くJavaScriptの場合、setTimeoutHTMLの仕様によって定義されています。 このHTMLの仕様はHTMLとは名ばかりの巨大な仕様で、今時のブラウザの挙動をほぼ全て規定しているといっても過言ではありません。

さて、setTimeoutにとても大きな数値を渡したときの挙動に関するツイートをTwitterで見かけました。 曰く、setTimeoutに渡す数値は32ビット整数しかサポートされていないというのです。 試してみると、次のようなものは確かに一瞬でタイマーが呼ばれてしまい、想定した挙動とは違うように思えます。

setTimeout(() => {
  console.log(`${2 ** 53 / 1000}秒経ちました!`);
}, 2 ** 53);

setTimeout(() => {
  console.log('無限の彼方');
}, Infinity);

実際、setTimeoutが32ビット整数しかサポートしていないというのはその通りです。 では、それはブラウザが実装をサボったからでしょうか? 否、「setTimeoutは32ビット整数しかサポートしない」ということが仕様でちゃんと定められており、ブラウザはそれに従っているだけなのです。 この記事では、そのことを仕様書を読みながら確かめていきます。

setTimeoutのインターフェースを調べる

仕様リーディングの出発点は、HTML仕様書で定義されているsetTimeoutのインターフェースを見ることです。 そのためには、WindowOrWorkerGlobalScope mixinの定義を見ることになります。 これは、HTML仕様によって定義されているグローバル変数の一覧と思っていただければ構いません。 より正確には、その中でも特にWindow(通常のWebページ内のグローバル変数)とWorkerGlobalScope(WebWorker内のスクリプト内のグローバル変数)のどちらでも使えるものがここで定義されています。

よく見ると、次のようなインターフェース定義が見て取れます。

long setTimeout(TimerHandler handler, optional long timeout = 0, any... arguments);

フィーリングで読むと、setTimeoutの最初の引数はTimerHandler型の値であり、次の引数はオプショナルでlong型の値、そして省略された場合のデフォルト値は0である……と読めます。 ここで怪しいのは、引数timeoutの型がlongと書かれていることです。 数値の型としてlongという概念はJavaScriptにはありませんが、これは一体どういうことなのでしょうか。

WebIDLでlongの仕様を調べる

実は、上記のような記法の意味それ自体もWebIDLとして仕様化されています。 つまり、longの意味もWebIDL仕様を読めば分かるということです。

まず、longの意味はWebIDL仕様書の3.10.7 longの項に書かれています。 そこから引用します。

The long type is a signed integer type that has values in the range [−2147483648, 2147483647].

つまり、longというのは-2147483648以上2147483647以下の整数を表すと定義されています。 これはまさに符号付き32ビット整数のことを指しています。 こうなると、setTimeoutが32ビット整数しかサポートしていないのは確実ですね。

ここで生じる次の疑問は、long型と定義されている値にその範囲外の値を渡した場合に何が起きるのかということです。 実は、これもWebIDL仕様によって定義されています。 WebIDLにはECMAScript Mappingという節があり、WebIDLで定義された関数や型がJavaScriptでどのような振る舞いをするのかが厳密に定められています。 WebIDL自体はJavaScript以外の言語からも解釈できるように作られていますが、現在ではもっぱらWebIDLで定義されたAPIを実装するのはJavaScript上のことなので、JavaScriptが特別扱いされて厳密な定義が与えられているのです。

さて、long型のJavaScriptにおける振る舞いはWebIDL仕様書の4.2.8 longの項で定義されています。

An ECMAScript value V is converted to an IDL long value by running the following algorithm:

とあるように、ECMAScript (JavaScript) の値VがWebIDLで言うlongの値(32ビット符号付き整数)に変換されるときの挙動がここで定義されています。 1〜5までのステップがありますが、setTimeoutの引数の場合は2と3は関係ありません。 よって、setTimeoutの引数に渡された値は次の3ステップでlongに変換されることになります。

  • Initialize x to ToNumber(V).
  • Set x to ToInt32(x).
  • Return the IDL long value that represents the same numeric value as x.

要するに、ToNumberでまずVを数値に変換し、それにさらにToInt32を噛ませると書いてありますね。

ECMAScript仕様書でToInt32の定義を調べる

実は、ToNumberやToInt32の定義はECMAScript仕様書に書いてあります。 ToNumberはその名前の通り与えられた値を数値に変換する操作です。 問題はToInt32で、こちらも名前の通り、与えられた数値を32ビット整数に変換する操作であることが伺えます。 これがどうなっているか調べれば、大きな数値がどのようにlongに変換されるのかいよいよ明らかになりますね。

ということで見てみましょう。ToInt32の定義はECMAScript仕様書の7.1.6 ToInt32(argument)の項にあります。 短いので全文引用します。

  1. Let number be ? ToNumber(argument).
  2. If number is NaN, +0, -0, +∞, or -∞, return +0.
  3. Let int be the Number value that is the same sign as number and whose magnitude is floor(abs(number)).
  4. Let int32bit be int modulo 232.
  5. If int32bit ≥ 231, return int32bit - 232; otherwise return int32bit.

ステップ2からは、NaNや無限大が+0に変換されることが見て取れます。 InfinitysetTimeoutに渡したときに一瞬でタイマーが呼ばれたのは、Infinityが0に変換されたからであることが分かりますね。 ステップ3は、小数を整数に変換する処理です。 ステップ4は、intを232で割った余りをint32bitにすると言っています。 ここが値を32ビット整数に変換する本命の部分です。 ちなみに、「int module 232」という言葉の意味は5.2.5 Mathematical Operationsで厳密に定義されています。 このままだと場合によっては32ビット整数の範囲におさまらない([231, 232)の範囲に入る)ことがあるので、その場合は[-231, 231)の範囲に移します。 これがステップ5です。

以上のことから、2 ** 53setTimeoutに渡したときに一瞬でタイマーが発火した理由も明らかになります。 253を232で割った余りは0なので、ToInt32により0に変換されたからです。

より中途半端な値を渡すと、中途半端な値に変換されます。 例えば、2 ** 53 + 3000setTimeoutに渡すと、232で割った余りが3000になるので3秒後に発火するでしょう。

setTimeout(() => {
  console.log(`3秒経ちました!`);
}, 2 ** 53 + 3000);

結果が負の数になったときは?

ToInt32の結果が負の数になることもあります。 例えば2 ** 31 + 1000を渡した場合です。

setTimeout(() => {
  console.log('この場合はどうなる?');
}, 2 ** 31 + 1000);

実はこの場合は一瞬で発火します。 その理由を知るには、HTML仕様書のsetTimeoutの定義に舞い戻る必要があります。 具体的には、timer initialization stepsを見ます。 これは長いので全文引用はしませんが、ステップ10に次のように書いてあります。

  1. If timeout is less than 0, then set timeout to 0.

ここで、負の数は0として扱われることが分かります。 なぜ負の数をサポートしないのにlongという型なのか(WebIDLにはunsigned long型も定義されています)という疑問が残りますが、それはおそらく歴史的経緯でしょう。

まとめ

以上により、setTimeoutに大きな数値を与えたときの挙動を完全に理解できましたね。 完全に理解するには、HTML仕様書・WebIDL仕様書・ECMAScript仕様書の3つを渡り歩く必要がありました。 まさに3つの仕様書の美しいコラボレーションと言えます。

余談: node.jsの場合

ところで、node.js(あとDenoなど)にもsetTimeoutが存在します。 しかし、setTimeoutはHTML仕様書で定義されている概念だったので、node.jsのsetTimeoutはHTML仕様書に縛られません。 ブラウザに合わせてnode.jsが気を利かせてsetTimeoutを実装してくれているのです。 つまり、ここまで解説した内容にnode.jsが律儀に従う義理は無いということです。

実際、node.jsのsetTimeoutは、HTML仕様書に定義されているのとは多少異なる挙動をします。 Node.jsのsetTimeoutのドキュメントを読んでみると、与えられる数値に関して次のような記述があります。

When delay is larger than 2147483647 or less than 1, the delay will be set to 1. Non-integer delays are truncated to an integer.

つまり、2147483647(231-1)より大きいか1より小さい数値は全て1として扱われると言うことです。 32ビット整数の範囲をサポートするという点は共通していますが、232で割った余りを取るといった挙動はありません。 実際、次のコードをnode.jsで実行すると、3秒待つのではなく一瞬で実行されます。

setTimeout(() => {
  console.log(`3秒経ちました!`);
}, 2 ** 53 + 3000);