AFTER / BIND

tkinterが固まる・応答なしの直し方|after・スレッド・bindを逆引き

固まる・タイマーが止まらない・閉じた後のエラーを症状から直し方へ引けます。

🎯 対象: tkinter の初心者〜初級者 ⏱️ 読了: 約12分 🧪 実測: Windows 11・Linux Mint 22.3・2026-10-22

結論:tkinter が固まる(応答なしになる)のは、ボタンなどの処理の中で time.sleep() や重い処理を実行し、イベントループを止めているからです。処理を after() で小分けにするか、別スレッドに出して queue と after() で結果を受け取れば固まりません。

1. 症状からの逆引き表

症状原因直し方
ボタンを押すと「応答なし」処理の中で sleep・重い計算after に予約/計算は小分け
通信中に画面が動かない長い待ちをメインで実行threading+queue
外部コマンドの実行中に固まるsubprocess.run() の待ちスレッドの中で run
タイマーが2倍速・止まらないafter の予約が2本ある二重開始の防止と after_cancel
update() で処理が二重に走る途中で次のクリックを処理update_idletasks
RuntimeError: main thread is not in main loop閉じた後にスレッドから触った7-1
_tkinter.TclError: invalid command name ".!label"消したウィジェットを after が触った7-2
🧪
この記事の検証環境(2026-10-22)

Windows 11 の Python 3.14.7(Tk 9.0.4)と 3.12.10(Tk 8.6.15)では、ウィンドウを非表示(withdraw)にして実行しました。ボタン操作は after から流しています。Linux Mint 22.3(仮想マシン)の Python 3.12.3(Tk 8.6.14)では画面に表示して確認しています(bind の動作確認はこちらのみ)。Python 3.15.0 は構文チェック(py_compile)のみで、tkinter での実行は未確認です。

2. 固まる理由:イベントループが止まる

mainloop() はクリック・キー入力・再描画を1つずつ処理するループで、処理が早く戻らないと他のイベントが止まります(公式ドキュメント)。次のコード(抜粋)は3秒間反応しません。

def heavy():
    label.config(text="処理中…")
    time.sleep(3)  # この3秒間、再描画もクリックも止まる
    label.config(text="完了")

50ミリ秒ごとの after を別に仕込んで測ると、ループは最大 3,018ミリ秒止まりました(3.14.7。3.12.10 は3,001、Mint は3,050)。再描画も待たされるので「処理中…」も出ません(6章)。公式の解決策は after と別スレッドの2つです。

待つだけなら sleep をやめ、続きを after に予約してすぐ戻ります(抜粋)。

def heavy():
    label.config(text="処理中…")
    root.after(3000, lambda: label.config(text="完了"))  # 3秒後に予約して、すぐ戻る

押した直後に「処理中…」、約3秒後に「完了」になり、ループの最大停止は64ミリ秒でした(3.14.7)。lambda で包まないと label.config(...) がその場で実行され、戻り値の None が渡って sleep と同じく3秒固まります。

3. 直し方:after・スレッド・subprocess

3-1. 計算は after で小分けにする

5,000万未満の整数の合計を50万ずつ100回に分け、1回ごとにループへ戻します。

import tkinter as tk

N, STEP = 50_000_000, 500_000
state = {"i": 0, "total": 0}

def start():
    button.config(state="disabled")  # 計算中に押されて二重に走るのを防ぐ
    state.update(i=0, total=0)
    work()

def work():
    i = state["i"]
    state["total"] += sum(range(i, min(i + STEP, N)))  # 1回分だけ計算
    state["i"] = i + STEP
    if state["i"] < N:
        label.config(text=f"{state['i'] * 100 // N}%")
        root.after(1, work)  # 続きを予約して、いったんイベントループに戻る
    else:
        label.config(text=f"合計 {state['total']}")
        button.config(state="normal")

root = tk.Tk()
label = tk.Label(root, text="0%", width=20)
label.pack(padx=20, pady=10)
button = tk.Button(root, text="開始", command=start)
button.pack(pady=10)
root.mainloop()

3.14.7 で全体611ミリ秒、ループの最大停止は63ミリ秒、合計も正確でした。一括の sum() は492ミリ秒なので、全体は約1.2倍かかる代わりに画面が止まらず、進捗も出せます。

3-2. 待ち時間はスレッドに出し、queue で受け取る

通信などの長い待ちは分割できません。別スレッドで実行して結果を queue.Queue に入れ、画面側は after で100ミリ秒ごとに取りに行きます。

import queue
import threading
import time
import tkinter as tk

q = queue.Queue()

def worker(n):
    time.sleep(2)  # 通信や重い計算の代わり
    q.put(f"{n} の2乗は {n * n}")  # GUI には触らず、キューに入れるだけ

def start():
    button.config(state="disabled")
    threading.Thread(target=worker, args=(12,), daemon=True).start()

def poll():
    try:
        msg = q.get_nowait()
    except queue.Empty:
        pass
    else:
        label.config(text=msg)
        button.config(state="normal")
    root.after(100, poll)  # 100ミリ秒ごとにキューを見に行く

root = tk.Tk()
label = tk.Label(root, text="待機中", width=24)
label.pack(padx=20, pady=10)
button = tk.Button(root, text="開始", command=start)
button.pack(pady=10)
poll()
root.mainloop()

2秒待つ間もループの最大停止は63ミリ秒で、ラベルは「12 の2乗は 144」になりました。ワーカーが tkinter に触れないので、閉じた後の RuntimeError も起きません。

ワーカーから root.after(0, 関数, 値) で更新を渡す書き方もあります。公式ドキュメント上、他スレッドからの呼び出しはイベントループに引き渡され、手元でも mainloop 中は更新されました。迷ったら queue 型にしてください。

サイトの実例(after(0) 型):天気予報アプリ(通信結果と例外を表示)/ポートスキャナー(多数のスレッド)/データバックアップツール(ログと進捗)

3-3. 外部コマンドはスレッドの中で subprocess.run

subprocess.run() は子プロセスが終わるまで戻りません。3-2 の worker をこの断片に替えれば固まりません(q は 3-2 のキュー)。

import subprocess
import sys

def worker(n):
    r = subprocess.run([sys.executable, "-c", f"print({n} ** 2)"],
                       capture_output=True, text=True, timeout=60)
    q.put(r.stdout.strip() or r.stderr.strip())

結果は「144」、最大停止は63ミリ秒でした。

サイトの実例:Gitログビューアー(スレッド内で run)/コードランナー(Popen と communicate)/ホットキーランチャー(起動だけで待たない)

4. 一定間隔で動かす:after と after_cancel

基本形は job = widget.after(ミリ秒, 関数, 引数…) です。関数はカッコを付けずに渡し、戻り値の予約 ID を widget.after_cancel(job) に渡すと取り消せます。繰り返しは、関数の最後で自分を予約し直します。

import time
import tkinter as tk

job = None  # after() が返す予約IDを覚えておく

def tick():
    global job
    label.config(text=time.strftime("%H:%M:%S"))
    job = root.after(1000, tick)  # 1秒後に自分をもう一度予約

def start():
    if job is None:  # 二重に開始すると予約が2本になる
        tick()

def stop():
    global job
    if job is not None:
        root.after_cancel(job)  # 予約を取り消す
        job = None

def on_close():
    stop()          # 閉じる前に予約を消す
    root.destroy()

root = tk.Tk()
label = tk.Label(root, text="--:--:--", font=("", 24))
label.pack(padx=20, pady=10)
tk.Button(root, text="開始", command=start).pack(side="left", padx=10, pady=10)
tk.Button(root, text="停止", command=stop).pack(side="right", padx=10, pady=10)
root.protocol("WM_DELETE_WINDOW", on_close)  # ×ボタンで on_close を呼ぶ
root.mainloop()

3環境とも、3秒で4回(0・1・2・3秒)動き、停止後は増えませんでした。ガードを通さず tick() を2回呼ぶと3秒で8回です。予約2本の並走が「2倍速」の正体です。

  • after_cancel() に None や空文字を渡すと ValueError(stop() の if はこのため)。同じ ID を2回取り消しても例外は出ない
  • protocol("WM_DELETE_WINDOW", 関数) を登録すると×で閉じなくなる。関数内で destroy() を呼ぶ

ゲームループはミニゲームの after の章へ。

サイトの実例:テキストアニメーション(開始前に止めて二重起動を防ぐ)/スネークゲーム(新ゲームで after_cancel)/時間追跡アプリ(閉じる前に after_cancel)

5. bind のイベント名一覧と event の属性

widget.bind("<イベント名>", 関数) で、操作に関数(引数は event)を結びつけます。

イベント名いつ届くかよく使う属性
<Return>Enter キーwidget
<Button-1>左クリックx・y(ウィジェット内の座標)
<Double-Button-1>左ダブルクリックx・y
<Button-3>右クリック(Windows・Linux)x_root・y_root
<Enter>/<Leave>マウスが乗った/離れた(Enter キーと別)widget
<B1-Motion>左ボタンを押しながら動かすx・y
<Key>どれかのキーを押したkeysym(キー名)・char
<KeyRelease>キーを離したkeysym・char
<Control-s>Ctrl+S—
<FocusIn>/<FocusOut>フォーカスが入った/外れたwidget
<Configure>大きさ・位置が変わったwidth・height
<MouseWheel>ホイールdelta(正なら上)

Mint で動作を確かめたのは Return・Button-1・Tab(break)・Control-s・Configure です。Linux の Tk 8.6 では、ホイールは <Button-4>/<Button-5> で届きます。Tcl/Tk の TIP 474 で <MouseWheel> に変換されるようになりました(Linux での操作は未確認)。TIP 474 は Tk 8.7 向けの提案で、Tk 9 系に取り込まれたとされます。確認したコードの要所です(抜粋)。

def on_tab(event):
    text.insert("insert", "    ")  # Tab でスペース4つ
    return "break"  # 標準の動作(タブ文字の挿入)を止める

text.bind("<Tab>", on_tab)
root.bind_all("<Control-s>", on_save)  # どこにフォーカスがあっても効く

Mint で操作を流すと「Ctrl+S」と出て、Text にはタブ文字でなくスペース4つが入りました。

5-1. bind と bind_all の違いと return "break"

イベントはウィジェットの「タグ」順に配られます。entry.bindtags() で見ると ('.!entry', 'Entry', '.', 'all')=ウィジェット → 種類(標準動作)→ ウィンドウ → all です。bind_all は最後の all に付くのでどこでも届きますが、手前で "break" を返すと届きません。

同じ理由で root.bind("<Configure>") には子の分も届きます(Mint の別検証で確認)。ウィンドウ自身だけ扱うなら event.widget is root を確かめます。

サイトの実例:お絵描きアプリ(B1-Motion)/ToDoリスト(Return)/フォトギャラリー(Configure とホイール)/コードランナー(Tab で break)

6. update() と update_idletasks()

update() は描画だけでなく、たまったクリックも処理します。実行中にもう一度押すと、途中で同じ処理が始まります(抜粋)。

def handler():
    global depth
    depth += 1
    print("開始 depth =", depth)
    for _ in range(20):
        time.sleep(0.05)
        root.update()  # ここで2回目のクリックまで処理される
    print("終了 depth =", depth)
    depth -= 1

3環境とも depth が 1→2→2→1 と、1回目の中に2回目が入り込みました(再入)。Tcl のコミュニティ Wiki「Update considered harmful」も同じ再入を問題にしています(公式マニュアルの記述ではありません)。

update_idletasks() は再描画や配置の反映だけを処理し、ユーザー操作は処理しません。表示を変えた直後に呼びます。Mint で処理の途中を撮ると、なしでは「待機中」のまま、ありでは「処理中…」が描かれていました。重い処理の間は固まるので、長い待ちは3章へ。

サイトの実例:スクリーンキャプチャー(隠したのを反映してから撮影)/画像フィルターツール(実際の幅を確定)/テキスト音声読上げ(状態表示を先に出す)

7. よく出る2つのエラー

7-1. RuntimeError: main thread is not in main loop

閉じて mainloop を抜けた後、スレッドから tkinter を触ると出ます。公式ドキュメントの「イベントループが回っていないと失敗する」に当たります。

import threading
import time
import tkinter as tk

def worker():
    time.sleep(2)  # この間にウィンドウを閉じる
    root.after(0, label.config, {"text": "完了"})  # 閉じたあとに GUI を触る

root = tk.Tk()
label = tk.Label(root, text="処理中…")
label.pack(padx=20, pady=10)
threading.Thread(target=worker).start()  # daemon=False
root.mainloop()
print("mainloop を抜けた")

3環境とも、2秒以内に閉じると「mainloop を抜けた」の後にこの例外が出ました。

  • ワーカーは queue に入れるだけにする(3-2)
  • daemon=True にする。閉じるとプロセスごと終わり、手元では例外も出ませんでした。ただし daemon スレッドは途中で突然止まる(公式ドキュメント)ので、保存を伴う処理には向きません

7-2. _tkinter.TclError: invalid command name ".!label"

破棄したウィジェットを after の予約が触ると出ます。".!label" はラベルの内部名です(抜粋)。

def tick():
    global count
    count += 1
    label.config(text=count)  # label が消えていると TclError
    root.after(500, tick)

tick()
root.after(1200, label.destroy)  # 途中でラベルだけ消す(予約は残ったまま)

3環境ともこのエラーが出て、count は4で止まりました。対処は、4章の stop() のように予約 ID を覚え、消す前・閉じる前に after_cancel() することです。

8. 当サイトのアプリで数えた使用本数

当サイトの GUI アプリのソース(.py ファイル179本)を文字列検索した本数です(2026-10-22 時点)。

書き方本数補足
.after(73本時計・ゲームなど
after_cancel16本予約の取り消し
threading54本多くは after(0) 型
queue0本—
subprocess7本外部コマンド
time.sleep5本すべてワーカースレッド内
.bind(111本bind_all は0本
マウス移動のイベント4本すべて <B1-Motion>
WM_DELETE_WINDOW16本閉じる前の処理
update_idletasks11本tkinter の update() は3本(matplotlib 分を除く)

これから書くなら、閉じるタイミングを気にしなくて済む queue 型をおすすめします。ほかのアプリは初心者向け・中級者向けのアプリ集へ。

9. よくある質問(FAQ)

Q. time.sleep の代わりに after(1000) と書けば固まりませんか?

関数を渡さない after(1000) は sleep と同じく固まります。root.after(1000, 次の処理) と関数を渡します。

Q. after の時間は正確ですか?

目安です。after(100)×50回が 3.14.7 で5,402ミリ秒でした。経過時間は時計で測ります。

Q. スレッドから直接ラベルを書き換えてはいけませんか?

公式ドキュメント上は呼べます。当サイトの3環境でも mainloop 中は直接 config() してもエラーなしでしたが、閉じた後に呼ぶと約1秒待って RuntimeError です(プロセスは落ちません)。確実なのは queue 型です。

Q. Python 3.13 以降で使い方は変わりましたか?

What's New に挙動の変更はなく、after_info()(3.13)などの追加だけです。

本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。AIの利用方針は免責事項をご覧ください。

10. まとめ

待つ・小分けは after、長い待ちはスレッド+queue、繰り返しは予約 ID と after_cancel です。

まず1本作るなら、after・after_cancel・bind をまとめて使うスネークゲームがおすすめです。ウィジェットはtkinterウィジェット一覧、他の GUI ライブラリはGUI ライブラリ比較へ。スレッドと subprocess は、中級者向けのEffective Python 第3版の9章「並行性と並列性」が扱っています(tkinter の本ではありません。書評記事はアフィリエイト広告を含みます)。

📌
参考にした一次情報(2026-10-22 確認)

Python 公式ドキュメントの tkinter・queue・threading・subprocess、Tcl/Tk マニュアルの after・bind・bindtags・wm、TIP 474、Tcl Wiki の Update considered harmful。