0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScript に WinMain+updateFrame 型のメインループを実装してみた

0
Last updated at Posted at 2026-08-18

🚀 JavaScript に WinMain+updateFrame 型のメインループを実装してみた

― N6LTimerManによる周期タスク管理 ―

JavaScriptは基本的にシングルスレッドです。

一方、ゲームプログラミングでは、

WinMain
   ↓
updateFrame()
   ↓
物理 / AI / 入力 / 描画 / サウンド / タイマー
   ↓
次のフレーム

という「メインループ」を中心に、処理全体を組み立てる設計がよく使われます。

私はこの考え方をJavaScriptでも使いたくなり、
N6LTimerManというタイマー管理クラスを作りました。

ここでいう「疑似マルチスレッド」は、JavaScriptを実際にマルチスレッド化するという意味ではありません。

単一スレッド上で複数の周期処理を独立したタスクとして管理し、それらを1本の管理ループで調整する

という意味です。


1. なぜメインループが欲しくなったのか

JavaScriptには、

setTimeout()
setInterval()
requestAnimationFrame()

など、便利なスケジューリング機構があります。

しかし、ゲームエンジン的な処理を書いていると、

物理      → 20ms
AI        → 100ms
センサー  → 200ms
描画      → 50ms
その他    → 可変周期

のように、処理ごとに適切な周期が違うことがあります。

このとき、それぞれを独立したタイマーとして管理していくと、

  • 多重起動
  • 古い予約の残存
  • 再入
  • タイミングのずれ
  • 複数処理間の調整
  • 可変周期処理の管理

などを個別に考える必要が出てきます。

そこで、

タイマーを個別に動かすのではなく、タイマー全体を1本の管理ループから調整する

という設計にしました。


2. WinMain+updateFrameという考え方

C/C++のゲームプログラミングでは、次のような構造が基本になります。

WinMain
   ↓
updateFrame()
   ↓
各種処理
   ↓
次のフレーム

重要なのは、

処理の流れを一本化する

ことです。

例えば、

メインループ
 ├─ 物理
 ├─ AI
 ├─ 入力
 ├─ サウンド
 ├─ 描画
 └─ タイマー

という形にしておけば、各処理の実行タイミングを全体として調整できます。

N6LTimerManでは、この考え方をJavaScriptのタイマー管理に持ち込みました。


3. N6LTimerManの基本構造

N6LTimerManは、大きく分けると次のような構造です。

             N6LTimerMan
                  │
          ┌───────┴───────┐
          ↓               ↓
      N6LTimer         N6LTimer
          │               │
       Loop0()          Loop1()
          │               │
          └───────┬───────┘
                  ↓
               TMUpdate
                  ↓
             次回更新予約

中心になるのが TMUpdate です。

TMUpdateが全タイマーを確認し、

「この処理は実行時刻か?」

を判断します。

そして必要な処理を実行し、次回の更新を予約します。

つまり、

TMUpdateがWinMain、各N6LTimerが個々の周期タスク

という関係になります。


4. TMUpdateの役割

N6LTimerManでは、TMUpdateがタイマー全体を管理します。

主な役割は、

  • 全タイマーの状態確認
  • 実行時刻のチェック
  • 次回更新の予約
  • 再入防止
  • 古い予約の管理

です。

特に、

isUpdating

による再入防止と、

hnd

による予約ハンドルの管理によって、多重起動を抑制します。

そのため、

タイマーA
タイマーB
タイマーC
タイマーD

があっても、それぞれが勝手にループするのではなく、

             TMUpdate
                │
       ┌────────┼────────┐
       ↓        ↓        ↓
      A         B        C
       │        │        │
       └────────┴────────┘
                ↓
             次回更新

という形で管理できます。


5. タスクごとに周期を持たせる

N6LTimerManの面白いところは、処理ごとに異なる周期を設定できることです。

例えば、

TManIDs = [
    TMan.add(),
    TMan.add(),
    TMan.add(),
    TMan.add(),
    TMan.add()
];

として、各タイマーに処理を割り当てます。

Loop0(TManIDs[0]);
Loop1(TManIDs[1]);
Loop2(TManIDs[2]);
Loop3(TManIDs[3]);
RDLoop(TManIDs[4]);

そして、それぞれが自分の次回実行時間を設定します。


6. 固定周期の処理

例えば50ms周期なら、

function Loop0(id) {

    // 処理

    TMan.timer[id].setalerm(() => Loop0(id), 50);
}

という形です。

100msなら、

TMan.timer[id].setalerm(() => Loop1(id), 100);

とできます。

このように、

Loop0 → 50ms
Loop1 → 100ms
Loop2 → 200ms

というように、処理ごとに異なる周期を設定できます。


7. 可変周期の処理

固定周期だけでなく、次回実行間隔を動的に変更することもできます。

例えば、

var c = (Math.cos(th[id]) + 1.0) / 2.0;
dt[id] = 50 + c * 450;

とすると、

50ms ~ 500ms

の範囲で周期が変化します。

そして、

TMan.timer[id].setalerm(() => LoopX(id), dt[id]);

とすれば、次回の実行間隔もその都度変更できます。


8. 描画処理も同じ仕組みで管理できる

例えば描画を50ms周期にしたい場合、

function RDLoop(id) {

    renderer.render(scene, camera);

    TMan.timer[id].setalerm(() => RDLoop(id), 50);
}

とできます。

ここではThree.jsの描画処理そのものを変更するのではなく、

「いつ描画するか」をN6LTimerMan側で管理する

という考え方です。


9. N6LTimerMan の実装思想

N6LTimerMan が提供する「疑似マルチスレッド」は、
複数のスレッドを並列実行しているわけではありません。

本質はただひとつ:

1本の TimerMan(唯一の時間軸)の上に、複数の処理用アラームを並べているだけ

です。


✔ 単一の時間軸に複数のアラームを載せる構造

イメージとしてはこうです。

                    TimerMan の唯一の時間軸
────────────────────────────────────────→ 時間

        │           │       │
        ▼           ▼       ▼
     Keyboard      Main     AI
       Alarm       Alarm   Alarm
        │           │       │
       20ms        50ms    200ms

しかし、これは「各処理が独自のタイマーを大量に生成している」わけではありません。

実際にはこうです:

TimerMan
   │
   └── 次に処理すべきアラーム時刻を管理
             │
             ├── Keyboard
             ├── Main
             ├── AI
             └── Render

つまり TimerMan が単一の時間管理系として動き、
その上に複数の処理が“乗っているだけ”
なのです。


✔ もっと重要:アラームは「上書き」方式である

N6LTimerMan の最大の特徴はここです。

アラーム設定は「次のアラーム時刻の上書き」であり、予約が積み上がらない。


🔥 setTimeout の最大の罠:多重予約 → 処理の暴走

JavaScript のタイマーで最も厄介なのがこれです。

多重 setTimeout
       ↓
予約が蓄積
       ↓
同じ処理が複数回発火

この問題は、setTimeout が「新しい予約を積み上げる」構造だから起こります。

例えば次のようなコード:

function loop() {
    // 処理
    setTimeout(loop, 20);
}

一見すると「20msごとにループしている」ように見えますが、
実際には 処理が重いと予約がどんどん積み上がる ため、

  • ループが多重起動する
  • 宿題が溜まる
  • CPU が跳ね上がる
  • 収拾がつかなくなる

という厄介な問題が発生します。

特に 描画イベント(requestAnimationFrame / enterFrame)と併用した場合
重い処理があると描画イベント側が先に呼ばれ続けてしまい、
宿題が雪だるま式に増える のが問題のあるパターンです。


🟦 N6LTimerMan がこの問題を構造的に避ける理由

N6LTimerMan の setalarm()(旧 setalerm)は、

次のアラーム時刻を “上書き” するだけで、予約を積み上げない

という構造になっています。

つまり:

現在のアラーム
      ↓
次のアラームへ上書き

であり、

Alarm A
Alarm A
Alarm A
Alarm A
...

のように予約が積み上がることは 絶対にない のです。

例えば、5[sec]のタスク発行システムにおいて、そのシステムが
20[sec]のタスクを毎回要求したらどうなるでしょうか?
20[sec]のタスク実行中に4回のタスク発行があり、そのタスクを
完全実行保証で全てこなすことは不可能なのです。
従ってその場合、最低3回のタスクを破棄する必要があり、
この実現が毎回の予約発行ではなく、タスク発行時刻の上書き
orロック(無効/破棄)で実現します。
これは処理落ち/瞬間移動といわれる現象かもしれませんが、
システムのフリーズを回避する安全設計であり、
それを怒るというよりは楽しんだ方がいいです。
requestAnimationFrame を否定する意図はない。
描画に対してアプリ固有処理が十分軽い場合、rAF は理想的である。
問題になるのは、描画間隔より重い固有処理を、
描画のたびに完全実行保証で発行し続ける設計であり、
その場合に処理が追いつかず宿題が積み上がるのは構造上避けにくい。
updateFrameに必要な要件とは、1回の要求で1回の実行が発行されようが
途中で何回破棄されようが1回の実行において1ステップの状態遷移の
保証で適当に回ってくれればそれでいいのです。
非同期システムでは、外部からのタスク予約の過剰発行はバックログを生みやすい。
一方、タスク実行関数が自分の完了後に次の1回だけを予約する再帰的な発行は、
実行能力にペースを合わせた要求になりやすく、1実行あたり1ステップという
updateFrame 型の契約と相性が良い。重要なのは「再帰であること」より、
「完了を条件に、次を1つだけ出すこと」である。


🧠 だから「疑似マルチスレッド」が成立する

N6LTimerMan は、

  • 単一の時間軸(TimerMan)
  • 複数の処理用アラーム(Keyboard / AI / Render / Main)
  • アラームは上書き方式で多重起動しない

という構造なので、

複数の処理が“並列しているように見える”が、実際には安全に協調実行されている

という「疑似マルチスレッド」が成立します。


✔ 「疑似マルチスレッド」の正体

N6LTimerMan を図にするとこうなります。

             ┌─────────────────────┐
             │      N6LTimerMan    │
             │   唯一の時間軸       │
             └─────────┬───────────┘
                       │
             アラーム時刻を判定
                       │
       ┌───────────────┼───────────────┐
       ▼               ▼               ▼
 KeyboardLoop       MainLoop         AILoop
   20ms              50ms             200ms
       │               │               │
       └─────── 次回アラームを上書き ───┘

つまり、

「スレッドを増やす」のではなく
「単一の時間軸上に複数の処理を安全に並べて見せている」

という構造です。

この意味で「疑似マルチスレッド」という名称は非常に正確です。


✔ 本質的な価値:単一時間軸の協調実行系

N6LTimerMan の価値は、単なる

「setTimeout をまとめました」

ではありません。

本質はもっと深く、

単一時間軸上の優先アラームによる協調実行系

を JavaScript に持ち込んだ点にあります。

  • 多重起動が起きない
  • 可変周期が自然に扱える
  • 複数タスクを安全に同時運用できる
  • ゲームエンジンの WinMain+updateFrame と同じ思想で動く

これが N6LTimerMan の「実装思想」の核心です。


✨ 9章のまとめ

  • setTimeout は 予約が積み上がる
  • 重い処理があると 宿題が溜まって暴走する
  • 描画イベントと併用すると さらに悲惨
  • N6LTimerMan は アラーム上書き方式なので多重起動しない
  • だから 安全に複数タスクを同時運用できる
  • これが「疑似マルチスレッド」の正体

10. 「軽いループで重い処理を回す」と宿題が溜まる

ここが、この設計を考えるきっかけの一つでした。

例えば、

呼び出し周期:16ms
処理時間:30ms

という処理を毎回実行するとします。

すると、

16ms → 次の呼び出し
32ms → 次の呼び出し
48ms → 次の呼び出し

という予定に対して、実際の処理が追いつかなくなります。

簡単に言えば、

軽いループで重い処理を回すと「宿題」が溜まっていく

という状態です。

技術的には、処理時間がスケジューリング周期を上回ることで、

  • フレーム遅延
  • イベント処理の遅延
  • 描画処理の遅延
  • CPU負荷の増加

などが発生する可能性があります。

重要なのは、

呼び出し周期を短くすることと、処理を高速に完了できることは別問題

だということです。

そのため、すべての処理を描画周期に合わせるのではなく、

物理 → 20ms
AI   → 100ms
通信 → 200ms
描画 → 50ms

のように、処理内容に応じて周期を分けることに意味があります。


11. requestAnimationFrameとの違い

requestAnimationFrame()は、ブラウザの描画タイミングに合わせて処理を実行するための仕組みです。

典型的には、

function Loop() {

    renderer.render(scene, camera);

    requestAnimationFrame(Loop);
}

のように使います。

これは描画を中心とした処理には非常に便利です。

ただし、

すべての処理を描画周期で実行する必要があるとは限りません。

例えば、

描画       → 高頻度
物理計算   → 中頻度
AI         → 低頻度
通信       → さらに低頻度

という処理がある場合、それぞれに異なる周期を持たせた方が合理的な場合があります。


12. requestAnimationFrameが悪いわけではない

ここは重要です。

requestAnimationFrame()自体が重いという意味ではありません。

問題になるのは、

描画周期に合わせて重い処理まで毎回実行する設計

です。

例えば、

requestAnimationFrame
       ↓
描画
       ↓
物理
       ↓
AI
       ↓
大量計算
       ↓
次のフレーム

という構造にすると、処理全体が描画周期に拘束されます。

処理が描画周期に追いつかなければ、描画やイベント処理にも影響が及びます。

一方で、

N6LTimerMan
 ├─ 描画   → 50ms
 ├─ 物理   → 20ms
 ├─ AI     → 100ms
 └─ 通信   → 200ms

のように分離すれば、それぞれの処理を個別に調整できます。


13. X3DOMのenterFrameとの比較

X3DOMにも、

x3domRuntime.enterFrame = reDraw;

のような描画イベントに連動した仕組みがあります。

これも、

描画イベントに合わせて処理したい

という用途では便利です。

一方、N6LTimerManは、

自分で処理周期を決めたい

場合に向いています。

したがって、どちらが優れているというより、

描画イベント中心
       ↓
enterFrame / requestAnimationFrame

タスク周期中心
       ↓
N6LTimerMan

という使い分けになります。


14. N6LTimerManの特徴

ここまでを整理すると、N6LTimerManの特徴は次のようになります。

項目 enterFrame / requestAnimationFrame N6LTimerMan
基準 描画タイミング タスクの周期
ループの主導権 描画側 アプリ側
複数周期タスク 実装側で管理 一元管理
固定周期 実装側で管理 可能
可変周期 実装側で管理 自然に扱える
再入対策 実装側で管理 isUpdating 等で抑制
予約管理 個別実装 N6LTimerManで管理
描画との同期 得意 必要に応じて設定

15. コアタイマーのチェック間隔も変更できる

N6LTimerManでは、管理ループ自体のチェック間隔も変更できます。

例えば、

TMan.changeinterval(10);

とすれば、管理処理のチェック間隔を10msに変更できます。

これにより、

高精度にチェックしたい
        ↓
短い間隔

CPU負荷を抑えたい
        ↓
長い間隔

という調整が可能になります。

もちろん、JavaScriptのタイマーはOSやブラウザのスケジューリングの影響を受けるため、リアルタイムOSのような厳密な時間保証をするものではありません。

あくまで、

単一スレッド上の周期タスクを整理・調整するための仕組み

です。


16. Three.jsによるデモ

実際にThree.jsと組み合わせて、

  • 球体4個
  • それぞれ異なる周期
  • 1つは50~500msの可変周期
  • レンダリング50ms周期

というデモを作っています。

すべての処理をN6LTimerManから管理しています。


📚 関連ページ

デモ:

https://nas6.net/mttest.htm

参考:

https://nas6.net/test3jsexp.htm

https://nas6.net/ManagedTimerManager.htm

ライブラリ:

ベーシック版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.js

モジュール版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.module.js

以下はalerm→alarmタイポ修正済みのバージョン

ベーシック版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.js

モジュール版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.module.js


17. N6LTimerManの考え方

N6LTimerManでやっていることは、決してJavaScriptをマルチスレッド化することではありません。

むしろ逆です。

複数の処理
   ↓
1本の管理ループ
   ↓
それぞれの周期を調整
   ↓
必要な処理だけ実行

という、

「一本の道の上で複数の職人を動かす」

ような設計です。

JavaScriptのシングルスレッドという制約の中で、

物理
AI
入力
通信
描画
タイマー

などを、それぞれ適切な周期で動かします。


🧩 18. N6LTimerMan の使い方(サンプル例)

N6LTimerMan は「再予約方式」で動作します。
つまり setalarm() を呼び続ける限りループが継続し、呼ばなければ暗黙的に停止する という仕組みです。

この挙動は setTimeout のように「多重予約が積み上がる」ものではなく、
次のアラーム時刻を上書きするだけ なので、非常に安全で扱いやすいです。

以下は最小構成のサンプルです。

<script src="https://cdn.jsdelivr.net/gh/NAS6mixfoolv/NAS6LIB@main/javascripts/nas6lib/timerEx.js"></script>
<script>
window.addEventListener("DOMContentLoaded", init);

const TMan = new N6LTimerMan();
const TManIDs = [];
const dt = [100, 250/*,...*/]; // 各タイマーの周期(ms)

//初期化
function init() {

  //ここに固有の処理を書く
  
  TManIDs[0] = TMan.add();
  TManIDs[1] = TMan.add();
  //...

  Loop0(TManIDs[0]);
  Loop1(TManIDs[1]);
  //...
}

function Loop0(id) {

  //ここに固有の処理を書く

  console.log("Loop0", Date.now()); //これはコメントアウト可能

  // 再予約しなければこのループは停止する
  //TMan.timer[id].setalerm(() => Loop0(id), dt[0]); // 旧API
  TMan.timer[id].setalarm(() => Loop0(id), dt[0]); // 修正版
}

function Loop1(id) {

  //ここに固有の処理を書く
  
  console.log("Loop1", Date.now());
  TMan.timer[id].setalarm(() => Loop1(id), dt[1]);
}

//...

</script>

✔ 再予約方式のメリット

  • setalarm() を呼ばない=そのループだけ停止
  • 多重起動が起きない(上書き方式)
  • 可変周期を自然に扱える
  • 複数タスクを安全に同時運用できる

N6LTimerMan の「一本の道」思想と非常に相性が良い仕組みです。


🎯 19. N6LTimerMan の役割

N6LTimerMan が内部で回している TMUpdate は、
ゲームエンジンの「メインループ」に相当しますが、役割は非常にシンプルです。

保持している各タイマーの “登録関数の発火時刻” を監視し、
時間になったらその関数を呼び出すだけ。

TMUpdate は「疎結合・単一処理」の原則に忠実で、
複雑なことは一切していません。


✔ setalarm() の本質:再予約ではなく “発火時刻の上書き”

ここが N6LTimerMan の最重要ポイントです。

setalarm()(旧 setalerm)は厳密には 再予約方式ではありません。

setalarm() は「次の発火時刻を変更するだけ」であり、
新しい予約を積み上げるわけではない。

この構造が、setTimeout の最大の弱点である

多重 setTimeout
       ↓
予約が蓄積
       ↓
同じ処理が複数回発火

構造的に防いでいる のです。


✔ 実例:複数回 setalerm() を呼んでも「最後の1つだけが有効」

以下のように、同じタイマーに対して複数回 setalerm() を呼んだ場合を考えます。

function Loop0(id) {

    //何か処理

    TMan.timer[id].setalerm(() => Loop0(id), 100);
    TMan.timer[id].setalerm(() => Loop0(id), 300);
    TMan.timer[id].setalerm(() => Loop0(id), 5000);
    TMan.timer[id].setalerm(() => Loop0(id), 10);
    TMan.timer[id].setalerm(() => Loop0(id), 100);
}

この場合、内部では次のように処理されます。

次の発火時刻 = 100ms に設定
次の発火時刻 = 300ms に上書き
次の発火時刻 = 5000ms に上書き
次の発火時刻 = 10ms に上書き
次の発火時刻 = 100ms に上書き ← 最後のこれだけが有効

つまり 最後の setalerm(100) だけが評価される のです。

✔ setTimeout との違いがここで鮮明になる

もしこれを setTimeout で書いたらどうなるか?

setTimeout(Loop0, 100);
setTimeout(Loop0, 300);
setTimeout(Loop0, 5000);
setTimeout(Loop0, 10);
setTimeout(Loop0, 100);

これは 5つの予約が全部積み上がる ので、

  • 10ms後に1回
  • 100ms後に2回
  • 300ms後に1回
  • 5000ms後に1回

という 多重発火の地獄 が起きます。

N6LTimerMan はこの問題を 構造的に回避 しています。


✔ 予約の完全実行が「常に正しい」とは限らない

JavaScript の setTimeout は、
「予約した処理は必ず実行する」という設計思想を持っています。

しかし、限られた時間と計算リソースの中では、
この“完全実行保証”が常に正しいとは限りません。

描画処理や周期タスクのように、

  • 多少の取りこぼしがあっても問題ない
  • 一定周期で回っていれば十分
  • 重い処理を抱えているため、予約を積み上げると逆に破綻する

というケースでは、むしろ 完全実行保証が害になる ことすらあります。

その典型例がこれです:

多重 setTimeout
       ↓
予約が蓄積
       ↓
同じ処理が複数回発火
       ↓
CPU が跳ね上がる・暴走する

つまり、

「予約を全部実行する」ことが正義なのではなく、
「必要な処理だけを適切なタイミングで実行する」ことが正義になる場面がある。

ここに N6LTimerMan の実装上の価値があります。


✔ N6LTimerMan は「完全実行」ではなく「適切な実行」を選ぶ

N6LTimerMan は、
予約を積み上げず、次の発火時刻を上書きするだけ という設計により、

  • 多重起動しない
  • 宿題が溜まらない
  • CPU負荷が暴走しない
  • 可変周期を自然に扱える
  • 描画処理や周期タスクと相性が良い

という性質を持ちます。

つまり、

限られたリソースの中で「適切に回す」ための仕組み
であり、
「予約を全部実行する」ことを目的にしていない。

この思想は、ゲームエンジンの WinMain+updateFrame と大体一致しています。

ある限られた時間と計算リソースの中で
予約の完全実行を保証することが
毎回正しいわけではない。

N6LTimerMan はまさにその “正しくない場面” を補完するための仕組みです。

  • 完全実行が必要なら setTimeout
  • 適切な周期で回れば十分なら N6LTimerMan

という 使い分けこそが正解 です。


✔ 「再予約方式」と呼んだ理由(利用者視点)

“再予約方式” と表現したのは、
利用者視点での挙動を説明するためです。

登録関数を実行した後、次の発火時刻を設定しなければ
そのタイマーは自動的に処理から外れる(=停止する)。

つまり、

  • setalarm() を呼び続ければループが継続
  • setalarm() を呼ばなければそのループだけ停止

という 「再予約しないと止まる」挙動 を説明するために
“再予約方式” と表現しています。

しかし内部実装としては、

予約を積み上げるのではなく、次の発火時刻を上書きしているだけ

という点が本質です。


✔ 実装上の重要な性質

アラーム時刻になり登録関数が呼び出されたら、
次のアラーム時刻は自動発行されず、明示的に再指定しない限り処理から外れる。

また、

アラーム待機中の setalarm() も「新規予約」ではなく
“アラーム時刻の上書き” として扱われるため、多重起動が起きない。

この仕組みにより、setTimeout のような「予約の雪だるま式蓄積」が発生しません。


✔ 実装例を用いた解説

class N6LTimer {

  constructor(id, rh = null) {
    this.typename = "N6LTimer";
    this.ID = id;
    this.enable = false;
    var dt = new Date();
    this.starttime = dt.getTime();
    this.alerm = -1;
    this.alermfunc = 0;
    this.isUpdating = false; // ★実行中かどうかを管理するフラグ
    this.interval = 25;
    var i;
    if(rh && rh.typename == "N6LTimer"){
        this.ID = rh.ID;
        this.enable = rh.enable;
        this.starttime = rh.starttime;
        this.alerm = rh.alerm;
        this.alermfunc = rh.alermfunc;
        this.isUpdating = rh.isUpdating;
        this.interval = rh.interval;
    }
  }

//中略

  
  setalerm(func,alm) {
    if(this.isUpdating) alm += this.interval;
    this.start();
    this.alerm = alm;
    this.alermfunc = func;
  };


}

class N6LTimerMan {
  constructor(rh) {
    this.typename = "N6LTimerMan";
    this.interval = 25;
    this.enable = true;
    this.timer = new Array();
    this.hnd = null; // ★予約を管理するハンドルを追加
    this.isUpdating = false; // ★実行中かどうかを管理するフラグ
    var i;
    if(rh && rh.typename == "N6LTimerMan"){
        this.interval = rh.interval;
        this.enable = rh.enable;
        this.hnd = rh.hnd;
        this.isUpdating = rh.isUpdating;
        this.timer.length = rh.timer.length;
        for(i = 0; i < rh.timer.length; i++) this.timer[i] = new N6LTimer(rh.timer[i]);
    }
  }
  clone() {
    var ret = new N6LTimerMan(this);
    for(var i = 0; i < this.timer.length; i++) ret = this.timer[i].clone();
    return ret;
  };

  add() {
    var l = this.timer.length;
    if(l == 0) this.start();
    this.timer.push(new N6LTimer(l));
    this.timer[l].start();
    return l;
  };
  changeinterval(int) {
    this.interval = int;
    var me = this;
    var i;
    for(i = 0; i < this.timer.length; i++) this.timer[i].interval = int;
  };
  start() {
    this.enable = true;
    // ★重要:readyなどから重複して呼ばれた際、古い「予約」をここで確実に抹殺する
    if (this.hnd) {
      clearTimeout(this.hnd);
      this.hnd = null;
    }
    // フラグもリセットして、新しい一本の道を作る
    this.isUpdating = false; 
    TMUpdate(this);
  };
  stop() {
    this.enable = false;
    // ★停止時にも予約をクリアする
    if (this.hnd) {
      clearTimeout(this.hnd);
      this.hnd = null;
    }
    // フラグもリセットして、新しい一本の道を作る
    this.isUpdating = false; 
  };
}

function TMUpdate(timerman) {
// 1. すでに別の TMUpdate が実行中なら、即座に終了して重複を防ぐ
  if (timerman.isUpdating) return;
  // 2. 「実行中」のカギをかける
  timerman.isUpdating = true;
  if(DISP_NAS6LIB_COPYRIGHT){
    window.alert("powerd by NAS6LIB : licence : GPL-3.0\ncopyright : NAS6 : contact : nas6@nas6.net");
    DISP_NAS6LIB_COPYRIGHT = false;
  }
  // 1. 物理的な多重起動を根絶する
  // どのルートから TMUpdate が呼ばれても、既存の予約(hnd)をまず殺す
  if (timerman.hnd) {
    clearTimeout(timerman.hnd);
    timerman.hnd = null;
  }
  if (timerman.enable == true) {
    // 2. 各タイマーのアラームチェック
    for (var m in timerman.timer) {
      var tm = timerman.timer[m];
      if (tm.enable == true && 0 <= tm.alerm) {
        var now = tm.now();
        if (tm.alerm <= now) {
          tm.alerm = -1;
          tm.isUpdating = true;
          tm.alermfunc(tm.ID); // GLoopなどが呼ばれる
          tm.isUpdating = false;
        }
      }
    }
    // 3. 全ての処理が終わったら「カギ」を開け、次の予約を入れる
    timerman.isUpdating = false; 
    timerman.hnd = setTimeout(function() { 
      TMUpdate(timerman);
    }, timerman.interval);
  } else {
    timerman.isUpdating = false;
  }
}

N6LTimer.setalerm()はこのように単にタイムスタンプの上書きで済ませてあります。
TMUpdate()側でセマフォ的な多重起動のロックをかけてあります。
またその中では単にタイマーのアラーム時刻を監視して関数を発火させるだけにしています。
これにより、主題の実装を実現しています。


✔ N6LTimerMan(TMUpdate)の役割まとめ

  • 唯一の時間軸を持つ
  • 各タイマーの次の発火時刻を監視する
  • 時間になったら登録関数を呼び出す
  • setalarm() により発火時刻を上書きするだけ
  • 予約が積み上がらないため多重発火が起きない

つまり、

N6LTimerMan は “安全な時間管理の核” であり、
JavaScript の setTimeout が抱える構造的な問題を根本から回避している。


✔ どんな用途に向いているか?

もしも、予約の完全実行が必要で、予約キューを積み上げたい場合は
普通に setTimeout を使えばよい。

しかし、

  • 描画処理
  • 軽量な周期処理
  • 可変周期タスク
  • 多重起動を避けたい処理
  • 複数タスクの協調実行

などでは、

非同期予約の完全実行が必須ではなく、
“一定周期で適当に回ればいい” 場面は
N6LTimerMan が得意とする分野。


🎯 20. まとめ

N6LTimerManを作ってみて感じたのは、

すべての処理を同じ周期で回す必要はない

ということです。

描画は高頻度で必要でも、

AI
通信
センサー
統計処理
ログ処理

まで同じ頻度で実行する必要があるとは限りません。

そこで、

処理ごとに周期を持たせる
        ↓
N6LTimerManで一元管理する
        ↓
1本の管理ループから実行する

という構造にしました。

これはJavaScriptを本当の意味でマルチスレッド化するものではありません。

しかし、

WinMain+updateFrameのように、アプリケーション側が処理の流れを主体的に管理する

というゲームプログラミングの考え方を、JavaScript上で実現することはできます。

requestAnimationFrame()やX3DOMのenterFrameが不要になるわけでもありません。

描画中心なら描画イベント、周期タスク中心ならN6LTimerMan。

用途に応じて使い分ければよいと思います。

N6LTimerManの目的は、JavaScriptに本当のマルチスレッドを導入することではありません。

目的は、

  1. JavaScriptのシングルスレッド性を維持する
  2. 複数の周期タスクを独立して管理する
  3. 1本の管理ループから各タスクを統括する
  4. タスクごとに異なる周期を設定する
  5. 可変周期タスクにも対応する
  6. 描画周期と処理周期を分離する
  7. 再入・多重起動を管理する
  8. 必要な更新頻度に応じてCPU負荷を調整する

ことです。

つまり、

「JavaScriptをマルチスレッドにする」のではなく、
「シングルスレッドのまま、WinMain+updateFrame型の処理統括を実現する」

という考え方です。


📚 関連ページ

デモ:

https://nas6.net/mttest.htm

参考:

https://nas6.net/test3jsexp.htm

https://nas6.net/ManagedTimerManager.htm

ライブラリ:

ベーシック版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.js

モジュール版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.module.js

以下はalerm→alarmタイポ修正済みのバージョン

ベーシック版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.js

モジュール版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.module.js


作者

GitHub: https://github.com/NAS6mixfoolv/NAS6LIB
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net

気に入っていただけたらGitHubに⭐をいただけると嬉しいです!

0
0
6

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?