コードだけ先に見たい方へ(すべて上記の環境で実際に動かしたコードです)

定期実行は after()戻り値の ID を持っておかないと止められません3 章

self._job = root.after(500, self.tick)   # 戻り値は「予約 ID」
root.after_cancel(self._job)             # 停止ボタン・画面切替・終了時はこれを呼ぶ

時間のかかる処理は別スレッドへ。GUI は触らせず queue に置くだけにする(5 章

q.put_nowait(value)                      # 通信スレッド側はこれだけ
def tick():                              # UI 側(メインスレッド)
    try:
        while True:
            label.configure(text=str(q.get_nowait()))
    except queue.Empty:
        pass
    root.after(200, tick)

これを書かないと、exe 配布した監視アプリは何の痕跡も残さずに更新を止めます6 章

logging.basicConfig(filename="monitor.log", encoding="utf-8")   # 先にファイル出力にしておく
root.report_callback_exception = lambda e, v, tb: logging.getLogger("tk").error(
    "".join(traceback.format_exception(e, v, tb)))

グラフは Figure を作り直さず set_data で差し替える(当サイト実測で 1 更新 67.65 ミリ秒 → 18.16 ミリ秒・7 章

line.set_data(xs, ys)      # Artist は 1 回だけ作り、以降はデータだけ入れ替える
canvas.draw_idle()

このうちグラフを除く 3 つを 1 本に結線した完成版(標準ライブラリのみ・コピペで画面が出ます)は 4 章末にあります(グラフは matplotlib が必要なので 7 章で足します)

2026 年 9 月 1 日更新: 全面的に書き直しました。旧版の「別スレッドから直接ウィジェットを操作すると未定義動作が起きる」という記述は公式ドキュメントと食い違っていたため撤回し、実際に出るエラーと発生条件の実測に差し替えています(5 章)。あわせて after_cancel・ドリフト補正・例外が消える問題・グラフ・スリープ抑止を新設し、サンプルは標準ライブラリだけで動く形にしました。

この記事の目次

1. 「動くデモ」と「現場で動くアプリ」の差 — そもそも tkinter でいいのか

ラベルと after() で値を更新するコードは Web にいくらでもあります。問題は現場に置いて連続稼働させたときで、次の症状は最初の 1 時間では出ず、数日後に出ます

原因は tkinter が弱いことではなく、1 本のイベントループで全部を回す設計を前提に書いていないことです。先に「そもそも tkinter でよいのか」を片付けます。内製アプリで第一候補になるのは、次の表の上 2 行が効くからです。

判断軸tkinter(標準ライブラリ)外部 GUI ライブラリ
閉域ネットワークで入るかPython を入れれば入っているpip か wheel の持ち込みが必要
exe にしたときの大きさ・起動同梱物が少なく軽いライブラリ本体を丸ごと同梱する
見た目・部品の豊富さ素朴(ttk で OS 標準の見た目になる程度)こちらが有利

「工場の PC にインターネットが来ていない」条件では 1 行目が決定的です。逆に入力フォーム中心の業務アプリなら外部ライブラリを検討する価値があります(customtkinterttkbootstrapsv-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 択を挙げています)。通信は待ち時間が読めないので実質②一択です。

監視アプリの全体構成図 メインスレッドのイベントループがクリック・再描画・afterの定期処理を順に処理し、別スレッドの通信ワーカーはキューへ値を置くだけで、UIはafterでキューから取り出す。ログは両方から書く。 境界は 1 つだけ: 「ウィジェットに触るのはメインスレッドだけ」 メインスレッド(mainloop) イベントキュー(1 本): クリック → 再描画 → after の予約 → クリック … ボタン・入力 ウィジェット更新 after(200, tick) ここで 1 秒かかる処理を書くと、1 秒間すべてが止まる 通信スレッド(1 本) センサー / PLC / シリアル 読み取り・再接続・待ち q.put_nowait(値) ウィジェットには触らない queue ロギング(両方から書く・スレッド安全) 起動 / 停止 / 通信断 / 復旧 / 未捕捉例外 — 後から時刻で追える粒度で残す この形にすると、あとの章がすべて「境界の管理」になる 3 章 = after の管理 / 5 章 = queue の管理 / 6 章 = 例外の出口の管理 / 9 章 = 状態の見せ方
図 1: 監視アプリの全体構成(スマートフォンでは図を横にスクロールできます)

要件の優先順位も先に決めます。落ちない > 機能が多い、復旧できる > 完全に防ぐ、ログが残る > リアルタイム性——監視アプリで迷ったときはこの順で決まります。

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 通りしかないことです。実機に差し替えるときに触るのはこのクラスだけで済みます。差し替え先は用途ごとに次の記事へ。

実機のライブラリを入れる段で社内プロキシに阻まれる場合は、学習ロードマップ 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 apartmentmainloop が回っていても即座に失敗します。CPython で CHECK_TCL_APPARTMENT マクロが付く API が該当し、その中には mainloop 自身も含まれます。「別スレッドで root.mainloop() を呼んで GUI を裏で動かす」設計は原理的に成立しません

別スレッドからのtkinter呼び出しがどう処理されるかと、失敗する条件 別スレッドからのafterやconfigはイベントキューに積まれてメインスレッドで実行される。mainloopが回っていない場合は1秒待ってからmain thread is not in main loopエラー。evalやmainloopはいつでもCalling Tcl from different apartmentで即失敗する。 別スレッドからの呼び出しは「弾かれる」のではなく「キューに積まれる」 別スレッド widget.after(...) widget.config(...) イベントとして投函 イベントキュー mainloop が回っている? 回っている 回っていない メインスレッドで実行される 当サイト実測 0.5〜0.7 ミリ秒 100 ミリ秒 × 10 回 待ってから失敗 RuntimeError: main thread is not in main loop eval / mainloop は別扱い キューに積まれず即失敗 different apartment 結論: 「触れる」からこそ危ない mainloop 稼働中は成功するため、起動直後と終了直後にだけ落ちる=手元では再現しないバグになる。 別スレッドは queue に置くだけ、取り出しは after —— にすれば、この条件を一切考えなくてよい。
図 2: 別スレッドからの呼び出しの行方と、失敗する条件(スマートフォンでは図を横にスクロールできます)

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.Queuedeque(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=Truejoin() なし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.stdoutsys.stderr例外の traceback
アイコンから普通に起動(端末なし)NoneNoneどこにも残らない
コマンドプロンプトから 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.stderrNone のときは上書きすべきである)と明記されています。

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.stderrNone の状態で動かした実測でも、ファイルは 1 つも作られませんでした(相対パスの出力先は実行時のカレントディレクトリです)。

この 2 行を足した exe をアイコンから起動すると、sys.stdoutsys.stderrNone のまま、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_datadraw_idle18.16 ms24.96 ms+1.1 MB
ax.clear()ax.plot()draw37.84 ms70.41 ms−1.2 MB
毎回 FigureCanvasTkAgg を作り直す67.65 ms120.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 つに絞れます。

溜まるもの典型的な書き方対策
履歴データ表示用の listappend() し続ける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 点を守ってください。

  • 状態は色と文字の両方で示す: 赤=異常、黄=注意、緑=正常、灰=待機。色覚多様性への配慮にもなります
  • 情報の階層を上から下へ: 全体ステータス → 主要な計測値 → ログや詳細 → 隅に通信状態と稼働時間。ボタンは手袋でも押せる大きさと間隔に(タッチパネルなら防塵カバー越しの操作も想定)
現場向け監視画面の情報階層 上から全体ステータス、主要な計測値と最終更新時刻、アラーム一覧、最下部に通信状態と稼働時間と連絡先を配置する構成。右側に誤操作を防ぐ4つの原則。 遠くから見て 3 秒で分かる順に、上から並べる 正常 ① 全体ステータス — 色+文字。設備が動いているかだけを示す 26.41 ℃ 最終更新 10:38:41(3 秒前) ② 主要な計測値 — 必ず「いつの値か」を添える(9 章) ③ 直近のアラーム(ttk.Treeview) 10:31:02 しきい値超過 28.6 ℃ 10:12:55 通信断 → 10:13:04 復旧 ④ 通信: 接続中 / 稼働 14 時間 22 分 / 担当: 生産技術 内線 XXXX 誤操作を防ぐ 4 点 ・停止 / 設定変更は確認ダイアログ ・押せない操作はグレーアウト  (押せて何も起きないのが最悪) ・閲覧モードと設定モードを分ける ・設定変更は元に戻せるようにする 画面に連絡先を出しておくと、 「誰に言えばいいか分からない」 で放置される時間が消える。
図 3: 現場向け監視画面の情報階層(スマートフォンでは図を横にスクロールできます)

アラーム一覧には 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 回目が 0S_OK)、2 回目は 0x80070005E_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 以外であること+スクリーンセーバー OFF11.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 は責任を負いません。

関連記事

参考文献・一次情報

いずれも 2026-09-01 に参照しました。