2026 年 9 月 1 日更新: 全面的に書き直しました。旧版の「別スレッドから直接ウィジェットを操作すると未定義動作が起きる」という記述は公式ドキュメントと食い違っていたため撤回し、実際に出るエラーと発生条件の実測に差し替えています(5 章)。あわせて after_cancel・ドリフト補正・例外が消える問題・グラフ・スリープ抑止を新設し、サンプルは標準ライブラリだけで動く形にしました。
1. 「動くデモ」と「現場で動くアプリ」の差 — そもそも tkinter でいいのか
ラベルと after() で値を更新するコードは Web にいくらでもあります。問題は現場に置いて連続稼働させたときで、次の症状は最初の 1 時間では出ず、数日後に出ます。
- 翌朝には画面が固まっている、あるいは数値だけが止まっている
- 停止ボタンを押したのに、裏では更新ループが走り続けている
- exe にして配ったら、ログにも画面にも何も残らないまま止まる
- 夜間に PC がスリープして、朝になっても通信が再開しない
原因は tkinter が弱いことではなく、1 本のイベントループで全部を回す設計を前提に書いていないことです。先に「そもそも tkinter でよいのか」を片付けます。内製アプリで第一候補になるのは、次の表の上 2 行が効くからです。
| 判断軸 | tkinter(標準ライブラリ) | 外部 GUI ライブラリ |
|---|---|---|
| 閉域ネットワークで入るか | Python を入れれば入っている | pip か wheel の持ち込みが必要 |
| exe にしたときの大きさ・起動 | 同梱物が少なく軽い | ライブラリ本体を丸ごと同梱する |
| 見た目・部品の豊富さ | 素朴(ttk で OS 標準の見た目になる程度) | こちらが有利 |
「工場の PC にインターネットが来ていない」条件では 1 行目が決定的です。逆に入力フォーム中心の業務アプリなら外部ライブラリを検討する価値があります(customtkinter・ttkbootstrap・sv-ttk はいずれも実在しますが、pip での取得が前提です)。本命の PyQt6 / PySide6 との選定はPyQt6 / PySide6 で本格的なデスクトップアプリを作る記事に譲り、本記事はtkinter を選んだあとに止まらないアプリを作るところだけを扱います。なお pip install tkinter は失敗します(標準ライブラリなので PyPI に無い。実測: ERROR: Could not find a version that satisfies the requirement tkinter (from versions: none))。Windows の公式インストーラでは「tcl/tk and IDLE」にチェックを入れます。
2. 全体設計 — 1 本のイベントループと、それ以外
2.1 画面が固まる(フリーズする)のは、イベントハンドラが返ってこないとき
tkinter アプリの中身は 1 本のイベントループです。クリック処理も再描画も after() の定期処理も、すべて同じキューに並んで順番に実行されます。公式ドキュメントも "Because it is single-threaded, event handlers must respond quickly, otherwise they will block other events from being processed."(単一スレッドなので、イベントハンドラは素早く返さないと他のイベント処理を止めてしまう)と書いています。
2.2 選択肢は 2 つ — 細切れにするか、別スレッドへ出すか
ここから選択肢は 2 つに決まります。①長い処理を細かく割って after() で少しずつ回すか、②別スレッドに追い出して結果だけ受け取るか(公式も同じ 2 択を挙げています)。通信は待ち時間が読めないので実質②一択です。
要件の優先順位も先に決めます。落ちない > 機能が多い、復旧できる > 完全に防ぐ、ログが残る > リアルタイム性——監視アプリで迷ったときはこの順で決まります。
3. after() を正しく使う — 止め方・二重登録・ドリフト
widget.after(ms, func) は「ms ミリ秒後に func をメインスレッドで 1 回だけ呼ぶ」予約で、繰り返すなら呼ばれた側でもう一度予約します。現場で効くのはその先の 3 点です。
3.1 戻り値の ID を捨てない — 止められないループが生まれる
after() は予約 ID を返します。この ID を after_cancel() に渡すのが唯一の止め方で、捨てていると停止ボタンを押しても画面を切り替えてもループは回り続けます。
class Monitor:
def __init__(self, root):
self.root = root
self._job = None # 予約 ID の置き場を必ず持つ
self._schedule()
def _schedule(self):
self._job = self.root.after(500, self._tick)
def _tick(self):
try:
self.update_display()
finally:
self._schedule() # 例外が出ても次回だけは必ず予約する
def stop(self):
if self._job is not None:
self.root.after_cancel(self._job)
self._job = None # 二重キャンセルを避けるため戻しておく
10 ミリ秒間隔のループを 300 ミリ秒走らせると 18 回実行され、after_cancel() のあとはさらに 300 ミリ秒待っても18 回のままでした(当サイト実測)。finally も重要で、再予約を try の中に書くと、例外が出た瞬間にループが止まります。画面は出たまま数値だけが凍る、いちばん気づきにくい壊れ方です。
3.2 二重登録は実行回数が倍になる
「初期化でも呼び、開始ボタンでも呼ぶ」と書くとループが 2 本並走します。起動を 2 回書いた場合の 300 ミリ秒間の実行回数は 38 回(1 回起動なら 18 回)でほぼ倍。通信もログも 2 倍になりますが、画面上は「なんとなく速い」だけなので気づけません。
Python 3.13 以降なら len(root.after_info()) をログに出せば二重登録を運用中に検出できます。ただし当サイトの検証機は 3.12 のため実行できていません(3.12 で呼ぶと AttributeError: '_tkinter.tkapp' object has no attribute 'after_info' になることは実測)。3.12 では自分で予約 ID を数えるのが確実です。
3.3 ドリフト — 1 秒間隔のつもりが 1 秒ではない
after(1000, ...) の再帰は「前回の処理が終わってから 1000 ミリ秒後」です。コールバックの実行時間ぶん、毎回ずれが積み上がります。1 回 30 ミリ秒かかるループを 1000 ミリ秒間隔で 60 回回した実測です。
| 方式 | 予定 | 実測(当サイト検証機) | 1 回あたりのずれ |
|---|---|---|---|
after(1000, ...) をそのまま再帰 | 60.0 秒 | 62.19 秒 | +36.5 ミリ秒 |
| 目標時刻から逆算して待つ(下記) | 60.0 秒 | 60.04 秒 | +0.6 ミリ秒 |
処理 30 ミリ秒に対しずれは 36.5 ミリ秒で、差の 6.5 ミリ秒は処理時間では説明できません(要因の切り分けまではしていません)。1 秒間隔で記録を残すなら1 時間で約 2 分(36.5 ミリ秒 × 3600 回 ≒ 130 秒)ぶんが抜ける計算です(60 回の実測から引き延ばした計算値です)。直し方は 3 行。
self._next_at = time.monotonic() # __init__ で初期化しておく
def _schedule(self):
self._next_at += 1.0 # 目標時刻を等間隔で進める
delay = max(0, int(round((self._next_at - time.monotonic()) * 1000)))
self._job = self.root.after(delay, self._tick) # 遅れたぶんだけ短く待つ
time.monotonic() を使うのは、時刻同期や夏時間でシステム時計が飛んでも影響を受けないためです。間隔の計算に time.time() を使わないでください。
3.4 after チェーンそのものはリークしない
「after の予約が溜まってメモリを食う」という説明は正確ではありません。after() が作る Tcl コマンドは実行後に削除され、再帰を 1000 回回したあとの len(root._tclCommands) は 3〜4 のままでした。溜まるのは bind() や command= の登録で、ラベル 1000 個に bind() すると 1000 件残り、500 個を destroy() すると 500 件に減ります。動的に作ったウィジェットは捨てるときに必ず destroy() を呼ぶのが対策です。
4. 実機なしで動かす — 標準ライブラリだけのダミーセンサー
ここから先を試すのに実機は要りません。正弦波にノイズを乗せ、ときどき例外を投げる「センサーのふりをするクラス」を用意します。追加インストールは不要なので、閉域ネットワークの PC でも動きます。
import math, random, time
class DummySensor:
"""実機の代わり。60 秒周期の正弦波+ノイズを返し、
40 回に 1 回だけ TimeoutError を投げて通信断を再現する。"""
def __init__(self, base=25.0, amp=4.0, period_s=60.0):
self.base, self.amp, self.period_s = base, amp, period_s
self._t0 = time.monotonic()
self._n = 0
def read(self):
self._n += 1
if self._n % 40 == 0:
raise TimeoutError("sensor read timeout") # 通信断のふり
t = time.monotonic() - self._t0
wave = self.amp * math.sin(2 * math.pi * t / self.period_s)
return round(self.base + wave + random.uniform(-0.2, 0.2), 2)
大事なのは read() が値を返すか例外を投げるかの 2 通りしかないことです。実機に差し替えるときに触るのはこのクラスだけで済みます。差し替え先は用途ごとに次の記事へ。
- Modbus TCP / RTU の機器 → pymodbus 完全ガイド
- RS-232C / RS-485 の計測器 → pyserial の使い方
- 独自プロトコルの装置(socket 直叩き) → Python socket の使い方
実機のライブラリを入れる段で社内プロキシに阻まれる場合は、学習ロードマップ STEP 2 に手順があります。
4.1 これ 1 本で動く完成版 — 保存して python monitoring_app.py
3 章の after_cancel、5 章のワーカーと queue、6 章の例外の出口を 1 ファイルに結線したものです。monitoring_app.py として保存して実行すると、窓が出て 1 秒ごとに数値が変わります(当サイトで実行し、表示・更新・× での停止まで確認しました)。以降の章はこのコードの部分解説です。
"""tkinter 監視アプリの最小構成(標準ライブラリだけで動きます)
3 章の after_cancel / 5 章の queue と join / 6 章の例外の出口 を 1 本にまとめたもの"""
import logging, math, queue, random, threading, time, traceback
import tkinter as tk
logging.basicConfig(filename="monitor.log", encoding="utf-8", level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("monitor")
class DummySensor: # 4 章のクラスをそのまま使う
def __init__(self, base=25.0, amp=4.0, period_s=60.0):
self.base, self.amp, self.period_s = base, amp, period_s
self._t0, self._n = time.monotonic(), 0
def read(self):
self._n += 1
if self._n % 40 == 0:
raise TimeoutError("sensor read timeout")
t = time.monotonic() - self._t0
wave = self.amp * math.sin(2 * math.pi * t / self.period_s)
return round(self.base + wave + random.uniform(-0.2, 0.2), 2)
class SensorWorker(threading.Thread): # 5.1 のワーカー
def __init__(self, sensor, out_queue):
super().__init__(name="sensor-worker", daemon=False)
self._sensor, self._q = sensor, out_queue
self._stop_event = threading.Event()
def run(self):
backoff = 1.0
while not self._stop_event.is_set():
try:
value = self._sensor.read()
except Exception as e:
self._emit(("error", str(e)))
self._stop_event.wait(backoff)
backoff = min(backoff * 2, 30.0)
continue
self._emit(("data", value))
backoff = 1.0
self._stop_event.wait(1.0)
def _emit(self, item):
try:
self._q.put_nowait(item)
except queue.Full:
try:
self._q.get_nowait()
except queue.Empty:
pass
self._q.put_nowait(item)
def stop(self):
self._stop_event.set()
class App:
def __init__(self, root):
self.root, self._job = root, None
self.q = queue.Queue(maxsize=100) # maxsize を必ず付ける(5.1)
self.value = tk.Label(root, text="--.- ℃", font=("Meiryo UI", 32))
self.state = tk.Label(root, text="起動中", font=("Meiryo UI", 11))
self.value.pack(padx=40, pady=(24, 4))
self.state.pack(pady=(0, 24))
root.report_callback_exception = self.on_exc # 6 章の例外の出口
root.protocol("WM_DELETE_WINDOW", self.on_close)
self.worker = SensorWorker(DummySensor(), self.q)
self.worker.start()
self._next_at = time.monotonic() # 3.3 のドリフト補正の起点
self._schedule()
def _schedule(self): # 3.3
self._next_at += 0.5
delay = max(0, int(round((self._next_at - time.monotonic()) * 1000)))
self._job = self.root.after(delay, self._tick) # 予約 ID を必ず持つ(3.1)
def _tick(self):
try:
while True: # 溜まっているぶんを空になるまで取る
kind, payload = self.q.get_nowait()
if kind == "data":
self.value.configure(text=f"{payload} ℃")
self.state.configure(text="正常 / 最終更新 " + time.strftime("%H:%M:%S"))
else:
self.state.configure(text=f"通信エラー: {payload}")
except queue.Empty:
pass
finally:
self._schedule() # 例外が出ても次回だけは予約する
def on_exc(self, exc, val, tb): # 6 章
log.error("Tkinter callback で未捕捉例外:\n"
+ "".join(traceback.format_exception(exc, val, tb)))
def on_close(self): # 5.4
if self._job is not None:
self.root.after_cancel(self._job) # ① after の連鎖を止める
self._job = None
self.worker.stop() # ② 停止フラグを立てる
self.worker.join(timeout=5.0) # ③ 後片付けを待つ
if self.worker.is_alive():
log.error("worker が 5 秒で終わらなかった")
self.root.destroy() # ④ 最後にウィンドウを壊す
if __name__ == "__main__":
root = tk.Tk()
root.title("監視デモ")
App(root)
log.info("起動")
root.mainloop()
log.info("終了")
× で閉じると after_cancel → ワーカー停止 → join の順に片付き、同じフォルダの monitor.log に起動と終了が 1 行ずつ残ります。
5. 別スレッドから GUI を触ると何が起きるか(実測した 3 つのエラー)
日本語の解説記事のほとんどは「別スレッドから tkinter を触ってはいけない」と書いていますが、これは正確ではありません。公式ドキュメントはこう書いています。
Calls to tkinter can be made from any Python thread.(tkinter の呼び出しはどの Python スレッドからでも行える)
If the Tcl interpreter is not running the event loop and processing events, any tkinter calls made from threads other than the one running the Tcl interpreter will fail.(Tcl インタプリタがイベントループを回していないときは、他スレッドからの呼び出しは失敗する)
— Python 公式ドキュメント tkinter「Threading model」
別スレッドからの呼び出しは弾かれるのではなく、イベントキューに積まれてメインスレッドで実行されます。失敗の分かれ目は「別スレッドかどうか」ではなく「イベントループが回っているかどうか」です。測るとこうなります。
| 別スレッドからの呼び出し | mainloop() 起動前 | 起動中 | 終了後 |
|---|---|---|---|
widget.after(...) / widget.config(...) | RuntimeError: main thread is not in main loop約 1.1 秒かかってから(実測 after 1107.7 / config 1120.7 ミリ秒) | 成功(0.5〜0.7 ミリ秒) | 同左のエラー 同じく約 1.1 秒かかってから |
root.tk.eval(...) / root.mainloop() | RuntimeError: Calling Tcl from different apartment(いつでも即座に・0 ミリ秒) | ||
この約 1.1 秒には根拠があります。CPython の Modules/_tkinter.c にある WaitForMainloop() のコメントは "Wait up to 1s for the mainloop to come up."——100 ミリ秒の待機を 10 回繰り返し、それでも回っていなければ RuntimeError を投げます。現場での見え方は「起動直後や終了直後だけ、一瞬固まってからエラーになる」。mainloop() が回っている間は成功するため普段はまったく再現しません。
もう一方の Calling Tcl from different apartment は mainloop が回っていても即座に失敗します。CPython で CHECK_TCL_APPARTMENT マクロが付く API が該当し、その中には mainloop 自身も含まれます。「別スレッドで root.mainloop() を呼んで GUI を裏で動かす」設計は原理的に成立しません。
5.1 だから queue に置くだけにする
結論は従来どおり「スレッド間は queue で受け渡し、ウィジェットに触るのは after の中だけ」です。変わったのは根拠で、「未定義動作が怖いから」ではなく「起動・終了の境目でだけ失敗するから」避けます。
import queue, threading
q = queue.Queue(maxsize=100) # maxsize は必ず指定する
# 省略すると無制限になり、下の queue.Full 分岐が一度も働かない
class SensorWorker(threading.Thread):
def __init__(self, sensor, out_queue):
super().__init__(name="sensor-worker", daemon=False)
self._sensor, self._q = sensor, out_queue
self._stop_event = threading.Event() # 名前に注意(5.2)
def run(self):
backoff = 1.0
while not self._stop_event.is_set():
try:
value = self._sensor.read()
except Exception as e:
self._emit(("error", str(e)))
self._stop_event.wait(backoff) # time.sleep ではなく wait
backoff = min(backoff * 2, 30.0)
continue
self._emit(("data", value))
backoff = 1.0
self._stop_event.wait(1.0)
def _emit(self, item):
try:
self._q.put_nowait(item)
except queue.Full: # 満杯なら最古を捨てて入れ直す
try:
self._q.get_nowait()
except queue.Empty:
pass
self._q.put_nowait(item)
def stop(self):
self._stop_event.set()
UI 側は after の中で get_nowait() を使い、1 回のコールバックでキューを空にするまで回します。1 件しか取らないと、更新周期より速くデータが来たときに追いつけません。
5.2 threading.Thread を継承するときの落とし穴
停止フラグを self._stop_event という名前にしているのには理由があります。self._stop にすると Thread の内部メソッド _stop() を上書きし、join() が落ちます(当サイトの検証中に実際に踏みました)。
TypeError: 'Event' object is not callable
File "threading.py", line 1171, in _wait_for_tstate_lock
self._stop()
この例外は終了処理の中で出たため、アプリは後片付けを完了しないまま黙って残りました(気づけたのは 6 章のログ差し替えを入れていたからです)。
5.3 queue.Queue と deque(maxlen=N) の使い分け
上の _emit() は「満杯なら古いものを捨てる」を手で書いています。公式ドキュメントによれば deque の両端操作はスレッド安全で、上限に達すると反対側から自動的に捨てられます。
| 用途 | 使うもの | 理由 |
|---|---|---|
| 通信断・アラームなど取りこぼしたくないイベント | queue.Queue(maxsize=N) | 1 件も落とさない。満杯時の扱いは自分で決める |
| 最新値だけ/グラフ用の直近 N 点 | deque(maxlen=N) | 古い値は自動で捨てられ、上限が保証される。溢れの処理を書かなくてよい |
5.4 停止は Event.wait()、ワーカーは daemon=False
ワーカーの待機に time.sleep() を使うと、停止要求が来ても寝ている間は気づきません。30 秒のバックオフ中に停止を要求した実測では、time.sleep(30) は29.50 秒、stop_event.wait(30) は0.00 秒で終わりました。1 行の置き換えで済みます。
「daemon=True なら join() は要らない」ともよく言われますが、公式ドキュメントは "Daemon threads are abruptly stopped at shutdown. Their resources ... may not be released properly."(デーモンスレッドは突然停止され、開いているファイル等が正しく解放されないことがある)と逆のことを書いています。ワーカーが CSV を書き続けている状態でメインを終了させて比べました。
| 終わらせ方 | CSV に残った行数 | finally の後片付け |
|---|---|---|
daemon=True・join() なし | 0 行(書き込みバッファごと消えた) | 実行されず |
stop_event.set() → join() | 682 行 | 実行された(最終行に締めの記録あり) |
0 行=監視アプリなら「終了直前の計測値が丸ごと消える」ということです。終了処理は次を型にしてください。
def on_close(self):
if self._job is not None:
self.root.after_cancel(self._job) # ① after の連鎖を止める
self._job = None
self.worker.stop() # ② 停止フラグを立てる
self.worker.join(timeout=5.0) # ③ 後片付けを待つ
if self.worker.is_alive(): # ④ join は戻り値で判定できない
log.error("worker が 5 秒で終わらなかった")
self.root.destroy() # ⑤ 最後にウィンドウを壊す
6. 例外が消える — exe 配布で監視アプリが黙って死ぬ仕組み
tkinter のコールバックの中で例外が出ても、アプリは落ちません。CallWrapper.__call__ が例外を捕まえて report_callback_exception に流し、mainloop は続きます。出力先は sys.stderr で、コンソールがあれば次の形で読めます(コマンドプロンプトから起動して 2> err.txt に落とした実測・244 バイト)。
Exception in Tkinter callback
Traceback (most recent call last):
File "tkinter\__init__.py", line 1968, in __call__
File "tkinter\__init__.py", line 862, in callit
File "app.py", line 36, in tick
ZeroDivisionError: division by zero
開発中は親切な仕組みですが、問題は exe にして現場に配ったあと——GUI アプリはコンソールを出さない設定(PyInstaller なら --noconsole)でビルドするのが普通で、そうすると出力先が無くなります。after ループの 3 回目で ZeroDivisionError を出す GUI アプリを PyInstaller 6.22.2 でビルドし、起動時の状態をアプリ自身にファイルへ書かせました。
| 起動のしかた | sys.stdout | sys.stderr | 例外の traceback |
|---|---|---|---|
| アイコンから普通に起動(端末なし) | None | None | どこにも残らない |
コマンドプロンプトから demo.exe 2> err.txt | ファイル | ファイル | err.txt に 244 バイト記録された |
そして after ループは 3 回で止まったまま、ウィンドウは残り続けました。現場から見れば「画面は出ているのに数値が更新されない、ログにも何も無い」——最も原因を追いにくい壊れ方です。これは仕様どおりの動作でバグではないので、設計側で塞ぎます。公式ソースの docstring にも "Applications may want to override this internal function, and should when sys.stderr is None."(sys.stderr が None のときは上書きすべきである)と明記されています。
import logging, traceback
# 出力先を必ずファイルにする。この 2 行が無いと logging は sys.stderr に出ようとし、
# --noconsole の exe では stderr が None なので結局どこにも残らない
logging.basicConfig(filename="monitor.log", encoding="utf-8", level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s")
def on_callback_exception(exc, val, tb):
logging.getLogger("tk").error(
"Tkinter callback で未捕捉例外:\n" + "".join(traceback.format_exception(exc, val, tb)))
root.report_callback_exception = on_callback_exception
basicConfig を省くとハンドラが 1 つも無い状態になり、logging は lastResort(出力先は sys.stderr)に落ちます。差し替えたのにログが残らないのはこれが原因で、ハンドラ未設定で sys.stderr が None の状態で動かした実測でも、ファイルは 1 つも作られませんでした(相対パスの出力先は実行時のカレントディレクトリです)。
この 2 行を足した exe をアイコンから起動すると、sys.stdout も sys.stderr も None のまま、monitor.log に traceback が残りました(PyInstaller 6.22.2 で --noconsole ビルドした当サイト実測)。
2026-09-01 11:30:12,002 [ERROR] Tkinter callback で未捕捉例外:
Traceback (most recent call last):
File "tkinter\__init__.py", line 1968, in __call__
File "tkinter\__init__.py", line 862, in callit
File "app.py", line 36, in tick
ZeroDivisionError: division by zero
ワーカースレッド側には threading.excepthook の差し替えで同じ出口を用意します。ただし公式が "Storing exc_value using a custom hook can create a reference cycle." と注意するとおり、例外オブジェクトを溜め込まないこと(__traceback__ がスタックフレームを掴み続けてリークになります)。ログ設計は ロギング設計の記事、exe 化の手順は PyInstaller の記事にあります。
7. matplotlib を埋め込んでリアルタイムグラフにする — 24 時間回して壊れない書き方
時系列グラフが欲しくなったら matplotlib を埋め込みます。ここから先は pip install matplotlib が必要で、標準ライブラリだけで動く範囲はここで終わります(6 章の exe 化の確認だけは PyInstaller が要ります。閉域網では wheel の持ち込みが必要です)。
7.1 FigureCanvasTkAgg で埋め込む — pyplot を使わない理由
埋め込みでは pyplot を経由せず Figure を直接作ります。公式の埋め込み例と同じ書き方で、リーク回避にもなります。公式 FAQ は "Not closing the figure causes a memory leak, because pyplot keeps references to all not-yet-shown figures."(figure を閉じないとリークする。pyplot が未表示の figure の参照を保持し続けるため)と説明しており、Figure() を自分で作れば pyplot は参照を持たないので plt.close() という考え方自体が不要になります。
from collections import deque
from matplotlib.figure import Figure # pyplot は import しない
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
xs, ys = deque(maxlen=300), deque(maxlen=300) # 履歴の上限を決める
fig = Figure(figsize=(5, 2.5), dpi=100) # Figure を直接作る
ax = fig.add_subplot(111)
line, = ax.plot([], []) # 線は 1 回だけ作る
ax.set_ylim(20, 30)
canvas = FigureCanvasTkAgg(fig, master=root) # Canvas も 1 回だけ作る
canvas.get_tk_widget().pack(fill="both", expand=True)
canvas.draw()
def update(t, value): # after から呼ぶ
xs.append(t); ys.append(value)
line.set_data(xs, ys) # 作り直さず差し替える
ax.set_xlim(xs[0], xs[-1] + 0.1)
canvas.draw_idle() # 再描画はイベントループに任せる
7.2 更新は set_data — 当サイト実測で 67.65 ミリ秒 → 18.16 ミリ秒
「毎回 ax.clear() する」「Canvas ごと作り直す」実装もよく見ます。同じ 400 回の更新を 3 通りで測りました。
| 更新方法 | 1 更新の平均 | 最大 | 400 更新でのメモリ増加(RSS) |
|---|---|---|---|
set_data + draw_idle | 18.16 ms | 24.96 ms | +1.1 MB |
ax.clear() + ax.plot() + draw | 37.84 ms | 70.41 ms | −1.2 MB |
毎回 FigureCanvasTkAgg を作り直す | 67.65 ms | 120.69 ms | +22.1 MB |
Canvas を作り直す実装は 3.7 倍遅いうえにメモリが増え続けます。1 更新あたり約 55 KB なので、1 秒に 1 回更新するなら単純計算で 1 時間に約 200 MB(この増え方が続くと仮定した計算値で、24 時間の連続実測ではありません)。matplotlib 側にも 同種の報告があり 3.7.0 でクローズされていますが、上の数値は 3.11.1 での当サイト実測です。対策は Canvas と Artist を 1 回だけ作ることだけです。
更新も必ずメインスレッドで。matplotlib 公式 FAQ も "Matplotlib is not thread-safe" と明記しています。軸ラベルに日本語を入れると既定フォントに字形が無く豆腐になります(実測: UserWarning: Glyph 32076 ... missing from font(s) DejaVu Sans.)。matplotlib.rcParams["font.family"] = "Meiryo" の 1 行で直ります。集計側の作り込みは 品質管理グラフの記事へ。
8. メモリリーク対策 — 何が溜まるのかを特定する
24 時間動かして初めて見えるのがメモリの増加です。監視アプリで溜まるものは 4 つに絞れます。
| 溜まるもの | 典型的な書き方 | 対策 |
|---|---|---|
| 履歴データ | 表示用の list に append() し続ける | deque(maxlen=N) にする。上限が構造的に保証される |
| ウィジェットとコールバック | 行を動的に追加し、消すときに destroy() を呼ばない | 捨てるときは必ず destroy()(3.4 の実測) |
| グラフの Artist / Canvas | 更新のたびに作り直す | 1 回だけ作って set_data(7 章の実測: 400 更新で +22.1 MB) |
| 例外オブジェクト | 捕まえた例外をリストや属性に保存する | 保存しない。必要ならメッセージ文字列だけ残す |
検知は 3 段構えです。まず OS から見た実使用メモリ RSS(タスクマネージャの「メモリ」列か psutil)を継続観測し、増えているなら tracemalloc の差分でどの行が確保しているかを特定し、最後に24 時間動かして開始時と比較します。ただし tracemalloc は本番で常時 ON にしないでください。公式が "Storing more frames increases the memory and CPU overhead of the tracemalloc module." と書くとおり、保持フレーム数を増やすほど自身が重くなります。定番だった memory_profiler は最終リリースが 0.61.0(2022-11-15)で、PyPI に "This package is no longer actively maintained." とあるため新規採用は避けてください。
どうしてもリークが避けられない場合は定期再起動も選択肢ですが、3 交代・連続操業の現場に「夜間=止めていい時間」はありません。再起動は、段取り替え・計画停止・監視対象の設備が止まる瞬間に合わせて決めるのが前提です。
9. 通信断を「異常」ではなく「状態」として画面に出す
製造現場のネットワークは切れます。スイッチの再起動、ケーブルの抜き差し、機器側の保守、PC のスリープ復帰——どれも日常的です。したがって切断は例外処理ではなく、アプリが持つべき「状態」です。
4 章のダミーセンサーで試すぶんには 5.1 のワーカーが再試行まで面倒を見るので、この記事だけで完結します。実機の TCP 通信に差し替える段から先——切断検知、TCP Keep-Alive、指数バックオフ、状態遷移——は Python socket の使い方(11 章 DeviceClient)に完成品があります。外から見える API は start / stop / send / recv_queue の 4 つだけなので、UI からはこう繋ぐだけです。
client = DeviceClient(host, port) # tcp-ip 記事 11 章のクラス
client.start() # 接続・再接続はクラス側の責務
def tick(): # UI 側は recv_queue を吸い出すだけ
try:
while True:
show(client.recv_queue.get_nowait())
except queue.Empty:
pass
root.after(200, tick)
UI 側の仕事は、通信層の状態をオペレーターに分かる言葉で見せることです。最低限、次の 3 状態を区別できれば現場は回ります。
| 状態 | 画面の見せ方 | オペレーターが取るべき行動 |
|---|---|---|
| 正常 | 緑・現在値と最終更新時刻 | なし |
| 再接続中 | 黄・「再接続中(N 回目)」+最後に取れた値の時刻 | 数分続くようなら担当へ連絡 |
| 停止 | 灰・「停止中」(アプリが自分で止めた状態) | 開始ボタンを押す |
ここで効くのが「最後に値が取れた時刻」を常に出しておくことです。数値だけを大きく出す画面は、通信が止まったときに古い値を表示し続けているのか、いま取れた値なのかを区別できません。無人の時間帯は画面を誰も見ていないので、メール通知・アンドンへの出力・別 PC からの死活監視のどれかを併用してください。
10. 現場オペレーター向けの画面を ttk で作る
オペレーターは設備のプロですが、PC 操作のプロではありません。「ログを開いて確認してください」は通じない前提で設計します。
10.1 tk から ttk に移した瞬間に色が消える
見た目を整えるなら tkinter.ttk(OS の見た目に合わせたウィジェット群)ですが、移行の罠があります。公式は "widget options such as fg, bg and others related to widget styling are no longer present in Ttk widgets" と書いています。各ウィジェットのオプションを列挙しました。
| ウィジェット | bg= / fg= | background= / foreground= | 色の変え方 |
|---|---|---|---|
tk.Label | あり | あり | そのまま指定できる |
ttk.Label | 無い(TclError: unknown option "-bg") | ある | 短縮形が使えないだけ。background= なら通る |
ttk.Button / ttk.Frame | 無い | 無い | ttk.Style 経由でしか指定できない |
「ttk では bg が使えない」と一括りにされがちですが、正確には ttk.Label は短縮形が無いだけで background= は持っており、ttk.Button はオプション自体が無いという違いです。
style = ttk.Style()
style.configure("Status.TLabel", font=("Meiryo UI", 28, "bold"),
foreground="#ffffff", background="#808080", anchor="center", padding=12)
status = ttk.Label(root, text="待機中", style="Status.TLabel", width=16)
def set_status(text, color):
status.configure(text=text)
ttk.Style().configure("Status.TLabel", background=color) # スタイル側を書き換える
Windows の既定テーマ(検証機では vista)の ttk.Button は OS が描く 1 つの部品で構成され、その部品が受け付けるオプションは 0 個でした(Style.element_options('Button.button') が空のタプルを返す)。clam テーマなら bordercolor など 5 個を持ちます。色分けしたいならテーマを clam にするか、素の tk.Button を使うのが確実です(実際の描画結果までは当サイトの環境で確認できていないため、配布先で目視確認してください)。
10.2 文字サイズは「pt」だけで決めない
「数値は 24pt 以上」といった目安をよく見ますが、pt はディスプレイの設定によって実寸が変わります。pt は 1/72 インチなので 24pt の文字高はおよそ 8.5mm。検証機は 1 インチ=96 ピクセル(winfo_fpixels('1i') = 96.0)でしたが、拡大率を上げればピクセル数は変わります。確実なのは現物を現場の立ち位置から見て決めることです。そのうえで次の 2 点を守ってください。
- 状態は色と文字の両方で示す: 赤=異常、黄=注意、緑=正常、灰=待機。色覚多様性への配慮にもなります
- 情報の階層を上から下へ: 全体ステータス → 主要な計測値 → ログや詳細 → 隅に通信状態と稼働時間。ボタンは手袋でも押せる大きさと間隔に(タッチパネルなら防塵カバー越しの操作も想定)
アラーム一覧には ttk.Treeview、複数設備の切り替えには ttk.Notebook(タブ)が使えます。どちらも ttk にしか無いウィジェットで、追加インストールは不要です。検査結果を並べる画面なら OpenCV で外観検査の判定結果をこの構成に載せます。
11. Windows で 24 時間動かす — スリープ抑止・高 DPI・Tcl/Tk の版
この章は現場 PC で確認すべき項目のリストです。拡大率・長時間稼働・Tk 9.0 は検証機で再現できないため、断定ではなく確認手順を渡します。
11.1 スリープはアプリ自身が止める
「電源プランをスリープなしに設定する」は、配布先の PC で誰かが設定を戻したら終わりです。Windows にはアプリが「自分は動作中だ」と宣言する API があり、ctypes だけで呼べます(追加インストール不要)。公式リファレンスは用途として "backup agents, and network management applications" を挙げており、監視アプリはここに当たります。
import ctypes
ES_CONTINUOUS = 0x80000000 # 次に指定し直すまで効果を継続する
ES_SYSTEM_REQUIRED = 0x00000001 # システムをスリープさせない
ES_DISPLAY_REQUIRED = 0x00000002 # ディスプレイを消さない
def keep_awake(on: bool):
flags = ES_CONTINUOUS | (ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED if on else 0)
return ctypes.windll.kernel32.SetThreadExecutionState(flags)
keep_awake(True) # 起動時に宣言し、終了時に keep_awake(False) で解除する
成功すると直前の状態が返ります(実測: 宣言時 0x80000000、解除時 0x80000003)。公式が「サポートしない」と書く ES_USER_PRESENT も試したところ戻り値 0(失敗)、GetLastError() は 87で、ドキュメントどおりでした。
公式が明記する限界が 2 つあります。①スクリーンセーバーは止まりません("This function does not stop the screen saver from executing.")。②ユーザーの手動スリープは防げません。スクリーンセーバー OFF は OS 側で設定してください。なお、実際にスリープしなくなるかの長時間確認は行っていません(戻り値の確認までです)。配布先では 1 晩置いて、翌朝も画面が更新され続けているかを 1 回確かめてください。
11.2 高 DPI — 何もしないとぼやける
拡大率が 125% や 150% の PC では、DPI 対応を宣言していないアプリは Windows 側で引き伸ばされ、文字がぼやけます。公式リファレンスは "If the DPI awareness level is not set, the default value is PROCESS_DPI_UNAWARE."——何もしなければ「非対応」扱いが既定です。
import ctypes
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1) # tk.Tk() より前に、1 回だけ
except Exception:
pass # Windows 8.1 未満では失敗する
root = tk.Tk()
実測では 1 回目が 0(S_OK)、2 回目は 0x80070005(E_ACCESSDENIED)で、公式の "Once API awareness is set for an app, any future calls to this API will fail." のとおりでした。他のライブラリが先に設定していることもあるので、戻り値を見て黙って続行します。
ただし公式はこの API 呼び出しを推奨していません("It is recommended that you set the process-default DPI awareness via application manifest, not an API call.")。exe で配布するなら PyInstaller のマニフェスト設定が本筋で、ctypes 版はスクリプトのまま動かす場合の解です。なお当サイトの検証機は 96 DPI(拡大率 100%)だったため、設定あり/なしで表示に差は出ませんでした。配布先の拡大率が 125% 以上なら、この 1 行を入れた版と入れない版を並べて目視で比べてください。差が無ければ入れる必要はなく、ぼやけるならマニフェスト設定に進みます。
11.3 Python のバージョンで Tcl/Tk が変わる
tkinter の中身は Tcl/Tk です。Python のバージョンによって変わり、変わったことがリリースノートに書かれないことがあります。まず確認方法を持ちます。
python -c "import tkinter; r=tkinter.Tk(); print(r.tk.call('info','patchlevel'))"
当サイトの検証機(Python 3.12.10 / Windows)では 8.6.15 が返りました。CPython のビルド設定ファイル(PCbuild/get_externals.bat)では 3.12 と 3.13 が 8.6.15 を取得し、3.14 では 9.0 系を取得する記述になっています。Tk 9.0 のリリースノートには "Built-in widgets and themes are scaling-aware" とあり、同じコードでも見た目が変わりうるということです。
⚠️ ただし「Python 3.14 は Tk 9.0 である」と当サイトは断定しません。確認できたのは CPython のソースにあるビルド設定で、配布インストーラの同梱物は検証していません(検証機に 3.14 を入れていないためです)。運用上の結論は 現場 PC の Python はバージョンを固定し、上げるときは同じ画面を目視で比較する——それだけです。
11.4 GUI アプリは Windows サービスにできない
「ログオンしなくても動かしたい」という要望は必ず出ますが、Windows サービスは画面を持つセッションから分離されているため GUI を表示できません。取れる形は 2 つ。①常駐部分だけをサービス化し、画面は別プロセスの GUI として開く(作り方は Windows サービス化の記事)。②GUI アプリのまま、タスクスケジューラのログオン時実行で起動する。
Windows Update の再起動も避けて通れません。Pro ならグループポリシーで制御できますが Home では選択肢が限られるため、24 時間稼働の設備 PC には Pro を選定するのが本筋です。連続操業なら計画停止日に手動更新する運用を文書化してください(既に Home 機が置かれているなら、アクティブ時間の設定と更新時の立ち会いで凌ぎます)。
12. 本番投入前チェックリスト
「気をつける」ではなく「確認する」に落としたものだけを並べます。
| 確認項目 | 確認のしかた | 本記事 |
|---|---|---|
予約 ID を保持し、終了・画面切替で after_cancel している | 停止操作のあとにログの更新が本当に止まるか見る | 3.1 |
| ループが二重に走っていない | 3.13 以降は len(root.after_info())、3.12 以前は自分で ID を数える | 3.2 |
ワーカーの待機が Event.wait() になっている | バックオフ中に終了操作をし、即座に終わるか計る | 5.4 |
ワーカーが daemon=False で、終了時に join() している | 終了直前のデータがファイルに残っているか確認 | 5.4 |
report_callback_exception を差し替えている | exe をアイコンから起動し、わざと例外を出してログに残るか | 6 |
| グラフの Canvas / Artist を作り直していない | 更新処理に FigureCanvasTkAgg( や ax.clear() が無いこと | 7 |
| メモリ(RSS)が増え続けない | 24 時間動かし、開始・12 時間・24 時間で記録して比較 | 8 |
| 通信断から自動復旧し、画面が状態を示す | ケーブルを抜いて挿す。復旧までの秒数を計る | 9 |
| 「最後に値が取れた時刻」が画面に出ている | 通信を止めて、古い値と区別できるか見る | 9 |
| スリープ・画面オフを抑止している | 戻り値が 0 以外であること+スクリーンセーバー OFF | 11.1 |
| 本番前に 1 週間以上の連続稼働テストを終えている | 期間中のログを通しで読み、想定外の WARNING が無いか | — |
13. おわりに
監視アプリで効くのは、凝った書き方ではなく境界の管理でした。after の予約 ID を持って必ず止める、ウィジェットに触るのは after の中だけにする、例外の出口を 1 か所に作る——この 3 つで冒頭の症状はほとんど起きなくなります。
今回の書き直しで一番大きかったのは、「別スレッドから GUI を触ると未定義動作」という旧版の記述が公式ドキュメントと食い違っていたことです。実際には mainloop が回っている間は成功し、起動直後と終了直後にだけ失敗します。結論(queue を使う)は変わりませんが、理由が「怖いから」から「テストで踏みにくい条件でだけ壊れるから」に変わりました。
4 章末の完成版は追加インストールなしで動くので、実機の手配を待たずに 3・5・6 章を手元で確認できます。まずは保存して実行し、after_cancel を入れるところから始めてください。
次に読む 1 本は作ったアプリを現場の PC に配る話です → PyInstaller の使い方と主要オプション(6 章で塞いだ例外の出口を持ったまま exe にします)。
⚠️ 実機適用時の注意: 本記事のコードは自宅検証機(Windows 11 Pro / Python 3.12.10)での確認に基づくもので、設備の安全を担う装置の代替にはなりません。ここで作るのは値を「見せる」アプリです。インターロック・安全 PLC・法定の警報装置など、止めるべきときに設備を止める仕組みの置き換えには使わないでください。実際の設備・現場 PC に適用するときは、①対象の PC とネットワークについて設備保全部門・情報システム部門の承認を得る、②スリープ抑止(11.1)と Windows Update の運用変更(11.4)が社内規程・セキュリティ方針に反しないか確認する(更新の延期は、ネットワークに繋がった PC では脆弱性を残す判断です)、③画面表示だけを判断根拠にせず、通信断とアプリ停止をメール通知・アンドン・別 PC の死活監視でも検知する、④12 章のチェックリストと 1 週間以上の連続稼働テストを本番投入前に終える——この 4 点が前提です。適用による設備の停止・データ欠損・機会損失について、筆者・GenbaPy は責任を負いません。