tkinterが固まる・応答なしの直し方|after・スレッド・bindを逆引き
固まる・タイマーが止まらない・閉じた後のエラーを症状から直し方へ引けます。
結論: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 |
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_cancel | 16本 | 予約の取り消し |
threading | 54本 | 多くは after(0) 型 |
queue | 0本 | — |
subprocess | 7本 | 外部コマンド |
time.sleep | 5本 | すべてワーカースレッド内 |
.bind( | 111本 | bind_all は0本 |
| マウス移動のイベント | 4本 | すべて <B1-Motion> |
WM_DELETE_WINDOW | 16本 | 閉じる前の処理 |
update_idletasks | 11本 | 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 の本ではありません。書評記事はアフィリエイト広告を含みます)。
Python 公式ドキュメントの tkinter・queue・threading・subprocess、Tcl/Tk マニュアルの after・bind・bindtags・wm、TIP 474、Tcl Wiki の Update considered harmful。