静かなる名辞

pythonとプログラミングのこと



tkinterで遅い処理を別スレッドに投げ画面が固まらないようにする

はじめに

 pythonで書いた何かの処理にGUIのwrapperを付けたいので、お手軽にtkinterで作ろう! みたいなことをする人が果たしてどれだけいるのかは知りませんが、tkinterのコールバックで遅い処理をすることがあります。まあ、あるいは、練習でタイマーとかを作るときにwaitさせたくなるというケースの方が多いのかもしれませんが。

 この状況を再現した下のようなコードについて考えてみます。

import time
import tkinter as tk

def f(event):
    print("start callback")
    s.set("start callback")
    time.sleep(2)
    print("end callback")
    s.set("end callback")
    time.sleep(2)
    print("(空白に戻す)")
    s.set("")

def main():
    global s
    root = tk.Tk()

    s = tk.StringVar()
    s.set("")
    label = tk.Label(root, textvariable=s)
    label.pack()

    button = tk.Button(root, text="Button")
    button.bind("<1>", f)
    button.pack()
    
    root.mainloop()

if __name__ == "__main__":
    main()

 ボタンが押されたとき、コールバック関数の中で動的にラベルを書き換えようという訳ですね。

 でも実行してみるとわかりますが、stdoutに対するprintは出てきても、画面はうまく書き換わりません。

空白の画面のまま・・・
空白の画面のまま・・・

原因

 コールバック関数がreturnするまでの間、呼び出し元の処理は止まっています。よって、画面の更新処理などもすべて止まってしまい、StringVarへの書き換えも反映されません。

 ちなみに、この状態のときに操作しようとすると「応答なし」になると思います。

対策

 コールバック関数で別スレッドにやりたい処理の実行を投げ、コールバック関数そのものはさっさとreturnします。

import time
import tkinter as tk
import threading

def f(event):
    print("start callback")
    s.set("start callback")
    time.sleep(2)
    print("end callback")
    s.set("end callback")
    time.sleep(2)
    print("(空白に戻す)")
    s.set("")

def callback(event):
    th = threading.Thread(target=f, args=(event,))
    th.start()

def main():
    global s
    root = tk.Tk()

    s = tk.StringVar()
    s.set("")
    label = tk.Label(root, textvariable=s)
    label.pack()

    button = tk.Button(root, text="Button")
    button.bind("<1>", callback)
    button.pack()
    
    root.mainloop()

if __name__ == "__main__":
    main()

 今度は問題なく意図通りの結果が得られます。スレッドをjoinしていませんが、コールバック関数で待つわけには行きませんからこうするしかありません。このスレッドは放っておけば(実行が終われば)勝手に消えるので、たぶん大丈夫です。

このように画面が変化する
このように画面が変化する

多重実行対策

 上の方法だとスレッドが幾つでも同時に走り得ます。end callbackになったあたりでもう一度ボタンを押すとカオスな切り替わりが観察できます。

 一つの考え方としては、実行中はボタンをDISABLEDにしてしまえば良いというものがあります。ただし、これはcommandでコールバックを指定した場合しか効きません。bindを使った場合は別の手立てが必要になります。

import time
import tkinter as tk
import threading

def f():
    print("start callback")
    s.set("start callback")
    time.sleep(2)
    print("end callback")
    s.set("end callback")
    time.sleep(2)
    print("(空白に戻す)")
    s.set("")
    button.configure(state=tk.NORMAL)

def callback():
    button.configure(state=tk.DISABLED)
    th = threading.Thread(target=f)
    th.start()

def main():
    global s, button
    root = tk.Tk()

    s = tk.StringVar()
    s.set("")
    label = tk.Label(root, textvariable=s)
    label.pack()

    button = tk.Button(root, command=callback, text="Button")
    button.pack()
    
    root.mainloop()

if __name__ == "__main__":
    main()

このような表示になりクリックが効かなくなる
このような表示になりクリックが効かなくなる

 bindを使うので別の方法で多重実行を防ぎたいというような場合、ロックを使うのが正攻法だと思います。

import time
import tkinter as tk
import threading

def f(event):
    print("start callback")
    s.set("start callback")
    time.sleep(2)
    print("end callback")
    s.set("end callback")
    time.sleep(2)
    print("(空白に戻す)")
    s.set("")
    button.configure(state=tk.NORMAL)
    lock.release()

def callback(event):
    if lock.acquire(blocking=False):
        button.configure(state=tk.DISABLED)
        th = threading.Thread(target=f, args=(event,))
        th.start()
    else:
        print("(すでに実行中)")

def main():
    global s, button, lock

    lock = threading.Lock()
    root = tk.Tk()

    s = tk.StringVar()
    s.set("")
    label = tk.Label(root, textvariable=s)
    label.pack()

    button = tk.Button(root, text="Button")
    button.bind("<1>", callback)
    button.pack()
    
    root.mainloop()

if __name__ == "__main__":
    main()

 threading.Lock.acquireメソッドで、ロックが獲得されていない場合はロックを獲得してTrueを返します。獲得されていた場合の挙動ですが、第一引数blockingをTrueにしていればロックが解放されるまで待ち(デフォルトの挙動)、FalseにすればすぐにFalseを返してくれます。今回はTrueにしたら台無しなのでFalseにし、ifで動作を切り分けることにしています。

17.1. threading — スレッドベースの並列処理 — Python 3.6.5 ドキュメント

 このようにロックを使うことでも多重実行を防止できます。

まとめ

 何もしなければシングルスレッドで動く、というのはわかっていれば当たり前のことなのですが、それに気づかないと困惑すると思います。

 固まるのを妥協してしまうのも一つの考え方ですが、遅い処理を走らせているときでも画面を動かすのはそんなに難しい処理という訳でもないので、よかったら皆さんやってみてください。