【Python】Tkinterで画面が固まる原因と解決方法

# 1. GUIが固まる原因

GUIが固まる場合、様々な原因が考えられる。その中でもよくある原因は、時間のかかる処理をメインスレッドで行うことである。これにより、GUIの応答が止まってしまう。

# 2. 実装

例として、ボタンを押したら時間のかかる処理を行い、その後に表示の更新を行うプログラムを作成する。時間のかかる処理は、データベース操作やファイル操作など様々である。本記事の例では、`time.sleep()` を時間のかかる処理として記述する。

## NG例1

```Python:main.py
import time
import tkinter as tk
from datetime import datetime
from tkinter import ttk


def on_button():
    # ボタン無効化（連打防止用）
    button['state'] = tk.DISABLED

    # 時間のかかる処理
    time.sleep(5)

    # ラベル表示更新
    svar.set(datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f'))

    # ボタン有効化
    button['state'] = tk.NORMAL


if __name__ == '__main__':
    root = tk.Tk()

    button = ttk.Button(root, text='Button', command=on_button)
    button.pack()

    svar = tk.StringVar()
    label = ttk.Label(root, textvariable=svar)
    label.pack()

    root.mainloop()
```

直感的なロジックは上記のようになると思われる。
問題点を挙げる。

- メインスレッドで時間のかかる処理をしている。

Tkinterでは、ボタンを押したときの処理などが終わる度に `mainloop()` というイベント待ちの無限ループのような状態に切り替わる。メインスレッドで時間のかかる処理をすると、`mainloop()` に戻るまでに時間がかかってしまう。`mainloop()` に戻らないとイベントの検知ができない。結果的に、下記のような状態になる。

- タイトルバーに「応答なし」が表示される。
- ウィジェット操作（ウィンドウの移動・リサイズ、ボタン押下など）が、時間のかかる処理が終わるまで反映されない。
- ボタン連打などをすると、GUIは応答していないが操作によるタスクが蓄積され、次々に実行されてしまう。

## NG例2

NG例1の問題点は、時間のかかる処理をメインスレッドで行うことであった。そのため、サブスレッドを使うことにする。
共通部分は省略。

```Python
import threading


def on_button():
    # ボタン無効化（連打防止用）
    button['state'] = tk.DISABLED

    threading.Thread(target=long_process).start()


def long_process():
    # 時間のかかる処理
    time.sleep(5)

    # ラベル表示更新
    svar.set(datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f'))

    # ボタン有効化
    button['state'] = tk.NORMAL
```

これも実装方法としては不適切である。
問題点を挙げる。

- サブスレッドでGUIを操作している。

正常な振る舞いをしている場合でも、実装方法としては不適切。
`Tkinter` にGUI機能を提供する `Tcl/Tk` はスレッドセーフ（複数のスレッドが同時に同じ処理やデータにアクセスしても、プログラムが正しく動作し、データの整合性が保たれる）ではないことがドキュメントに明記されている。

[Tcl Threading Model - Tcl Developer Xchange](https://www.tcl-lang.org/doc/howto/thread_model.html):
> One Thread Per Interpreter
Tcl lets you have one or more Tcl interpreters (e.g., created with Tcl_CreateInterp()) in each operating system thread. However, each interpreter is tightly bound to its OS thread and errors will occur if you let more than one thread call into the same interpreter (e.g., with Tcl_Eval).

Google Translate 和訳:
> インタープリタごとに1つのスレッド
Tclでは、各オペレーティングシステムスレッドに1つ以上のTclインタープリタ（例えば、Tcl_CreateInterp()で作成）を作成できます。ただし、各インタープリタはそれぞれのOSスレッドに密接に結びついており、複数のスレッドから同じインタープリタを呼び出すと（例えば、Tcl_Evalを使用して）、エラーが発生します。

`Tcl/Tk` がスレッドセーフではないのであれば、`Tcl/Tk` を利用している `Tkinter` もスレッドセーフではないことになる。

そのため、時間のかかる処理はサブスレッドで行い、GUI操作はメインスレッドで行う。同じようなロジックは他のGUIプログラミングでもあるらしい。具体的な関数などはPythonと同じではないが、下記リンクに示されているシーケンス図と同じロジックにすればよい。

[invokeでなぜ解決するのか - 【C#】UIスレッド以外からUIのコントロールを操作する](https://hikotech.net/post-388/#outline__4)

## NG例3

tkinterの `after()` を使うことで、遅延してメインスレッドで関数を実行することができる。

```Python
def on_button():
    # ボタン無効化（連打防止用）
    button['state'] = tk.DISABLED

    threading.Thread(target=long_process).start()


def long_process():
    # 当該スレッドの確認
    print(threading.current_thread())

    # 時間のかかる処理
    time.sleep(5)

    root.after(0, refresh_label)


def refresh_label():
    # 当該スレッドの確認
    print(threading.current_thread())

    # ラベル表示更新
    svar.set(datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f'))

    # ボタン有効化
    button['state'] = tk.NORMAL
```

```:Console
<Thread(Thread-1 (long_process), started 18736)>
<_MainThread(MainThread, started 12032)>
```

コンソール画面の表示的には、メインスレッドで実行できている。
NG例2で挙げた問題点は解決できているようだが、[issue33479 - Python bug tracker](https://bugs.python.org/issue33479) では `Queue` を使ってスレッド間でやりとりすることが最も安全な方法であるとして、推奨している。
`after()` も tkinterの機能である。コールバック関数はメインスレッドで実行されるが、`after()` の呼び出し自体はサブスレッドで行っている。先ほどの 「スレッドセーフではない `Tcl/Tk` を使っている `Tkinter` もスレッドセーフではない」ことを考慮すると、これが安全な方法であるとは断言できないため、この方法も避けた方が良いかもしれない。

## OK例

Queueを使ってスレッド間でやり取りする方法。これにより、Tkinterをサブスレッドで操作せずに実装できた。
コード全体を記載する。

```Python:main.py
import queue
import threading
import time
import tkinter as tk
from datetime import datetime
from tkinter import ttk


def on_button():
    # ボタン無効化（連打防止用）
    button['state'] = tk.DISABLED

    # 時間のかかる処理をサブスレッドで実行
    threading.Thread(target=long_process).start()

    # キューをポーリング
    root.after(0, polling)


def long_process():
    # 時間のかかる処理
    time.sleep(5)

    def func():
        # ラベル表示更新
        svar.set(datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f'))

        # ボタン有効化
        button['state'] = tk.NORMAL

    # メインスレッドに実行してもらう処理を関数化してキューに入れる
    queue.put(func)


def polling():
    while not queue.empty():
        # キューから処理を取り出し実行
        func = queue.get_nowait()
        func()
        return
    # キューが空だったら、ポーリングを継続
    root.after(100, polling)


if __name__ == '__main__':
    # サブスレッドからメインスレッドにデータを渡すためのキュー
    queue = queue.Queue()

    root = tk.Tk()

    button = ttk.Button(root, text='Button', command=on_button)
    button.pack()

    svar = tk.StringVar()
    label = ttk.Label(root, textvariable=svar)
    label.pack()

    root.mainloop()
```

最も安全かつ一般的とされている方法。

Queue自体はスレッドセーフであることがドキュメントに明記されているため、この使い方は適切である。

[queue - Python documentation](https://docs.python.org/ja/3.13/library/queue.html):
> queue モジュールは、複数プロデューサ-複数コンシューマ(multi-producer, multi-consumer)キューを実装します。これは、複数のスレッドの間で情報を安全に交換しなければならないときのマルチスレッドプログラミングで特に有益です。このモジュールの Queue クラスは、必要なすべてのロックセマンティクスを実装しています。

また、このコードではウィンドウを閉じてアプリを終了する際に、`after` や `Thread` を終了していない。タスクの残存やリソースリークの可能性があるため、`after_cancel()` や `Thread().join()` によって必ず後始末をすること。
デーモンスレッドなど、処理の強制終了はデータ破損やシステム不整合などに繋がるリスクがあるため、基本的には使わない。

# 3. まとめ

- Tkinterの操作は、メインスレッドのみで行う。
- 時間のかかる処理は、メインスレッドではなくサブスレッドに記述する。
- 時間のかかる処理をした後にTkinterを操作する場合は、キューをポーリングして処理の受け渡しを行う。