1秒ごとに状態を取りに行く画面は、リアルタイム制御ではありません
第1回では、2行LCDしかない小型装置へWeb UIを足すときの役割分担を扱いました。ブラウザのjQuery/AjaxがPHPへHTTP要求を送り、PHPが検証してからC製のコンソールプログラムを呼ぶ、という構成です。
今回は、その画面が「装置の今」をどう見せるかへ踏み込みます。
公開サンプルは1秒ごとにstatusを取得し、操作した直後にもう一度取得します。ブラウザには装置の状態が常に映っているように見えますが、これはリアルタイム制御ではありません。ブラウザ、lighttpd、PHPプロセス、Cプログラム、その先のハードウェアは、それぞれ違う速度で動いています。画面に出ているのは、あくまで過去のどこかの時点で装置が返した値です。
ここを曖昧にしたまま作ると、動いているように見えて実は嘘をついている画面ができあがります。この記事では、その嘘が生まれる場所を1つずつ潰していきます。
この記事で分かること
- 状態取得と操作要求を別扱いにする理由
- ポーリング周期を決めるときに見るもの
- 操作中に周期取得を止める理由と、止め方
- 応答が送信順に返らないとき、要求番号で古い応答を捨てる方法
- 通信が切れたときに、古い値を現在値として見せない方法
この記事の前提
- 対象読者:第1回の構成を読んだ方、小型装置へ管理画面を載せたい方
- 前提知識:JavaScript、HTTP、jQueryの基礎
- 対象範囲:信頼できるローカルネットワーク内の教材用構成
- 扱わない範囲:WebSocket、Server-Sent Events、ハードリアルタイム制御
- 公開サンプル:embedded-web-control-demo。特定製品のソースではなく、記事用に新規設計したものです
公開サンプルには認証やTLSがありません。そのまま製品へ組み込んだり、インターネットへ公開したりしないでください。
状態取得と操作要求を分ける
まず、画面がサーバへ送る要求を2種類に分けます。
| 種類 | HTTPメソッド | 頻度 | 失敗したとき |
|---|---|---|---|
状態取得(status) |
GET | 1秒ごとに自動 | 次の周期で再試行。連続失敗時はstale表示 |
操作要求(start/stop/set_level) |
POST | 人が押したときだけ | 人へ知らせる必要がある |
この2つは、性質がまったく違います。このAPIのstatusは装置を変更しない処理として実装しているため、次の周期で自動再試行できます。ただし、失敗が続くなら表示中の値を現在値として扱わず、通信状態を利用者へ知らせる必要があります。操作要求は結果が不明なまま自動再送せず、人へ知らせて装置状態を取り直します。
公開サンプルでは、この違いをそのまま関数の分割にしています。
function refreshStatus(silent) { /* GET・timeout 2000ms・自動 */ }
function sendCommand(payload) { /* POST・timeout 3000ms・人の操作 */ }
タイムアウトも変えています。状態取得は2000ms、操作は3000msです。状態取得は次の周期があるので短く切ってよく、操作はプロセス起動と装置応答の分だけ余裕を持たせる、という考え方です。
silent引数があるのは、周期取得のたびに「更新しました」と表示すると、メッセージ欄が1秒ごとに書き換わって読めなくなるためです。自動取得は黙って行い、人が起こした操作にだけ返事をします。
ポーリング周期は装置側の都合で決まる
公開サンプルの周期は1000msです。この数字は、画面の滑らかさから決めたものではありません。
第1回で書いたとおり、この構成はAjax要求のたびにPHPがexec()でCプログラムを起動します。つまり1回の状態取得ごとに、fork、exec、動的リンク、初期化、状態ファイルの読み取り、終了、という一式が走ります。周期を100msにすれば、1秒間に10回これを繰り返すことになります。
周期を決めるときに見るのは、次の3つです。
- 1回の要求にかかる時間:プロセス起動のコストと、装置が値を返すまでの時間の合計
- 同時に開かれるブラウザの数:2人が開けば負荷は2倍です
- 値が実際に変わる速さ:装置の値が数秒に1回しか変わらないなら、100msで取りに行っても同じ値を9回もらうだけです
小型装置ではCPUもメモリも限られています。周期を詰めると、Web UIが装置本来の処理を圧迫します。「表示が滑らかになるか」ではなく「装置の処理を邪魔しないか」で決めるのが、この構成での考え方です。
もし1秒でも足りないほど頻繁に取得したいなら、それは周期を詰める話ではなく、都度起動をやめて常駐デーモンへ移す判断の合図です。この境界は第4回で扱います。
操作中は周期取得を止める
周期取得と操作要求が同時に飛ぶと、応答の順序が入れ替わることがあります。
たとえば、現在値が88のときにレベル7を送り、その直後に周期取得の応答が返ってきたとします。その周期取得はレベル変更前に投げられたものなので、中身は古い値です。画面はいったん7になり、すぐ88へ戻り、次の周期でまた7になります。値が往復して見えます。
公開サンプルは、まず周期取得そのものを投げないようにします。
function refreshStatus(silent) {
if (commandInFlight) {
return; // 操作中は、新しい状態取得を投げない
}
/* ... */
}
function setBusy(busy) {
commandInFlight = busy;
$('#statusCard').toggleClass('busy', busy);
$('button, input').prop('disabled', busy);
}
setBusy(true)は3つのことを同時に行います。周期取得を止め、状態カードへ「適用中」の見た目を与え、ボタンと入力欄をdisabledにします。最後のものは二重送信の防止です。人はレスポンスが返らないとボタンをもう一度押します。
操作が終わったらsetBusy(false)で解除し、その場でもう一度refreshStatus(true)を呼びます。操作の応答に含まれる値を信じきらず、装置へ改めて聞き直すためです。
フラグだけでは、値の往復は止まりません
前の節のフラグで止められるのは、これから投げる状態取得だけです。ボタンを押した時点ですでに飛んでいた状態取得は取り消されません。そして、この節の冒頭で挙げた「レベル変更前に投げられた周期取得」は、まさにそのケースです。フラグは、自分が防ぎたいと言った現象を防いでいませんでした。
手元で再現させた記録です。装置のレベルは88で、状態取得が1件飛んでいる状態からレベル7を適用しています。#levelの表示だけを追いました。
+116ms #level = 88 初期表示
+2181ms #level = 7 適用が成功した
+3341ms #level = 88 押下前に投げた取得の応答が、いま届いた
+4101ms #level = 7 次の周期取得で戻った
760ミリ秒だけ、装置にはもう存在しない値が画面に出ています。短いので見逃されがちですが、利用者から見れば「適用したのに戻った」です。
止め方は2つあります。ひとつは飛んでいる要求をabort()で取り消すことです。もうひとつは、応答が届いてから捨てることです。サンプルは後者を選びました。取り消しは通信ごとの後始末が要るのに対し、捨てる側は「画面へ出してよいのはどの要求の結果か」という判断を1か所に集められるからです。
送信する要求へ順番に番号を振り、処理した番号を覚えておきます。
var requestSeq = 0; /* 送信した要求へ順に振る番号 */
var handledSeq = 0; /* 処理した、いちばん新しい要求の番号 */
function isOutdated(seq) {
return seq < handledSeq;
}
function acceptResponse(seq) {
if (isOutdated(seq)) {
return false;
}
handledSeq = seq;
return true;
}
ここで「画面へ反映した番号」ではなく「処理した番号」にしているのが要点です。番号を成功時だけ進めると、穴が残ります。状態取得Aを投げ、続けてBを投げ、Bが先に失敗して、そのあとAが成功する順序を考えてください。Bの失敗で番号が進まないままだと、遅れて届いたAは「まだ追い越されていない」と判定され、画面へ出たうえに「取得できている」状態まで戻してしまいます。新しい要求が失敗したという事実を、古い要求の成功が消してしまう形です。
そこで、採用する応答であれば、成功でも失敗でも番号を進めます。状態取得はdoneとfailの両方をacceptResponse()に通します。
function refreshStatus(silent) {
if (commandInFlight) {
return;
}
var seq = ++requestSeq;
$.ajax({ /* ... */ }).done(function (data) {
if (!acceptResponse(seq)) {
return; // より新しい応答を処理済み。この応答は古い
}
renderStatus(data);
markFresh();
}).fail(function (xhr) {
if (!acceptResponse(seq)) {
return;
}
markFailed();
});
}
最新の要求が失敗したときは、その失敗が先にacceptResponse()を通って番号を進めます。ですから「いちばん新しい結果が失敗なら、失敗として画面に出る」という性質はそのまま残ります。変わったのは、そのあとに届く古い成功を捨てられるようになったことだけです。
操作の応答も同じ番号列を使います。成功でも失敗でも、acceptResponse()を通してから次に進めるので、古い操作結果を画面へ出す入口を1か所にまとめています。ここでは「発行順が新しい要求を常に優先する」という単純な方針を採っています。要求の種類ごとに優先度を変えたいなら、状態取得用と操作用で番号列を分ける設計もあります。今回の規模では、番号列を1本にしておくほうが、どの応答がなぜ捨てられたかを追いやすいと考えました。
}).done(function (data) {
if (!acceptResponse(seq)) {
return;
}
levelDirty = false;
renderStatus(data);
markFresh();
}).fail(function (xhr) {
if (!acceptResponse(seq)) {
return;
}
markFailed();
});
フラグと番号は役割が違います。フラグは「投げるか、投げないか」を決めます。番号は「届いたものを出すか、出さないか」を決めます。飛んでしまった要求に対して手が打てるのは、番号のほうだけです。
この仕組みは操作との競合だけに効くものではありません。取得の間隔は1秒、タイムアウトは2秒ですから、遅い取得と次の取得が重なることは通常運転でも起こります。番号を見て捨てる形にしておけば、周期取得どうしの追い越しも同じ経路で処理できます。
通信が切れたら、古い値を現在値として見せない
もう1つ、黙って嘘をつく場所があります。
状態取得が失敗したとき、画面には最後に成功した値が残ります。何もしなければ、その値はRUNNINGのまま、88 %のまま、いつまでも表示され続けます。装置の電源が落ちていても、LANケーブルが抜けていても、画面は元気に動いているように見えます。
かといって、1回失敗しただけで画面を--にするのも困ります。ポーリングは1秒ごとなので、通信の揺らぎで1回落ちることは普通にあります。そのたびに表示が消えると、逆に読めない画面になります。
公開サンプルは、間をとって連続2回失敗したら古い値と見なす設計にしました。
var STALE_AFTER_FAILURES = 2;
function markFailed() {
failureCount += 1;
if (failureCount >= STALE_AFTER_FAILURES) {
$('#statusCard').addClass('stale');
}
renderFetchedAt();
}
staleが付くと、状態カードの色が変わり、値が薄くなり、「この値は現在値ではない可能性があります」というタグが出ます。値そのものは消しません。消してしまうと、直前まで何だったのかが分からなくなるためです。値は残したまま、信用度だけを下げるという見せ方です。
あわせて、「最後に状態を取得できたのはいつか」を常に画面へ出しています。
function renderFetchedAt() {
if (lastFetchedAt === null) {
$('#fetchedAt').text('未取得');
return;
}
var age = Math.round((Date.now() - lastFetchedAt) / 1000);
$('#fetchedAt').text(lastFetchedAt.toLocaleTimeString() + '(' + age + '秒前)');
}
ここで注意したいのは、画面に出る時刻が2種類あることです。
| 表示 | 出どころ | 意味 |
|---|---|---|
| 装置側の更新時刻 | Cが返すupdated_at
|
装置の値が最後に変わった時刻 |
| 最後に状態を取得できた時刻 | ブラウザのDate
|
画面の値がいつ時点のものか |
装置の値が10分間変わっていないだけなのか、10分前から通信できていないのかは、まったく違う話です。前者だけを出していると区別がつきません。両方出しておくと、「装置側の更新は10分前だが、取得は0秒前」=正常に停止中、と読めます。
確認したこと
修正後のサンプルについて、応答の追い越しと通信断をヘッドレスブラウザで確認しています。
| 確認内容 | 結果 |
|---|---|
| 操作前に送った状態取得を1.2秒保留する | 操作成功後に古い値へ戻らない |
| 新しい状態取得を先に失敗させ、古い成功をあとから返す | 古い成功を捨て、失敗回数を戻さない |
| 通信を遮断して3.5秒待つ | 状態カードへstaleが付き、警告タグが出る |
| 通信を復旧する |
staleが外れ、取得時刻が更新される |
要求番号を持たない版では、適用が成功したあとに表示が88へ戻り、7に落ち着くまで760ミリ秒かかりました。要求番号を入れた版では、この戻りは起きませんでした。
番号を成功時だけ進める版では、遅れて届いた古い成功が失敗回数を0へ戻しました。成功・失敗の両方でhandledSeqを進める版では古い成功が捨てられ、連続2回の失敗でstale表示へ入ることを確認しています。
確認日は2026-08-06です。環境はLinux(Ubuntu 24.04ベース)、PHP 8.4.21の内蔵サーバ、Chromium 141.0.7390.37のヘッドレスモード、gcc 13.3.0です。
lighttpd+php-cgi構成、組み込み実機(BusyBox中心のrootfs)、jQuery 3.7.1本体は未確認です。ブラウザ側の確認には最小のjQuery互換シムを使っており、確認できたのはapp.jsの新旧判定ロジックです。
まとめ
- 状態取得は自動再試行できますが、連続失敗を利用者から隠してはいけません。
- ポーリング周期は画面の滑らかさではなく、装置側のコストから決めます。
- 操作中のフラグは新しい取得を抑止します。すでに飛んでいる要求には、要求番号で古い応答を捨てる仕組みが必要です。
- 成功・失敗の両方で処理済み番号を進めると、古い成功が新しい失敗を消すことも防げます。
- 値を残す場合は、取得時刻とstale表示によって、その値をどこまで信用できるか示します。
画面を速く更新するだけでは、装置の今を正しく見せたことにはなりません。どの要求の結果を採用したのか、最後にいつ取得できたのかを説明できることが、この回の結論です。
次回は、画面の中にある「これから送る値」と「装置が返した値」を分けます。周期取得が入力途中の値を消した失敗から、要求値・適用中・適用済みをどう配置するか考えます。
