【Python/tkinter】tkinterでマルチスレッドを利用する

tkinterでのマルチスレッドの使い方解説ページのアイキャッチ

このページでは Python tkinter でのマルチスレッドの使用例について解説していきたいと思います。

スレッドなんて知らないけど困ったことないよ?

特にプログラミング習いたての時はそうだと思うよ!

でも処理を並列に動かすことで高速化したり、

別々の処理を同時に実行したりしたい場合には重要になってくる

特に tkinter ではユーザー操作時のアプリの反応を

良くしたりすることもできるから重要!

僕の作ったアプリ良く止まるんだよね…

興味出てきたよ!

マルチスレッドはプログラムを高速化したり同時処理を行ったりする上で重要な仕組みになります。より高度なアプリも作れるようになるので是非覚えておきましょう!

このページの内容は mainloop に密接に関わるので mainloop について理解しているとより理解しやすくなると思います。ですので mainloop がどんなものかを知らない方は是非下記ページを事前に読んでいただければと思います。

tkinterのmainloopの解説ページのアイキャッチ【Python】tkinterのmainloopについて解説

また、最初はマルチスレッドの基本的なことを解説しますので、マルチスレッドを既にご存知の方はtkinter におけるマルチスレッドまでスキップしていただければと思います。

マルチスレッドとは

マルチスレッドがどのようなものであるかは下記ページで解説しているので参考にしていただければと思います(C言語向けの解説ですが…)。

入門者向け!C言語でのマルチスレッドをわかりやすく解説

要はプログラムの処理を並列で実行するための仕組みです。

スレッドというのは CPU が実行する処理(仕事)のことを言います。考え方によっては処理をする人と考えても良いと思います。

CPU はこのスレッド単位で処理を行います。CPU が同時に動作できる場合(複数のコアから構成される場合)、CPU コアは複数のスレッドを同時に実行することが可能です。

マルチスレッドで同時にCPUが処理する様子

ですが、あくまでも CPU に割り当てられるのはスレッド単位です。

なので、プログラムが1つのスレッドから構成される場合、そのプログラムを処理してくれる CPU コアは1つのみになります。1つのスレッドを複数の CPU コアが同時に実行してくれるようなことはありません。

スレッドが1つしかなくて複数コアから同時に実行されない様子

マルチスレッドなどの並列処理の仕組みを利用しない場合、私たちが作成するプログラムは1つのスレッドから構成されますので、基本的には CPU のコアは1つしか動作していない状態になります。

一方で、マルチスレッドの仕組みを利用してプログラムの中で新たなスレッドを生成した場合、そのスレッドを別の CPU コアに実行させることが可能です。

スレッド生成を行って同時実行できるようになる様子

ただスレッドを作れば必ず複数のコアが同時に処理してくれるというわけではないです。

例えば CPU の全てのコアが他のアプリのスレッドを処理しているような場合は、スレッドを生成しても、順番が回ってくるまで処理が待たされることになります。

なるほど…

マルチスレッド使ってなかったから

僕の作ったアプリは CPU コア1つしか使われてないってことだね…

まあ厳密にいうと tkinter 本体もスレッドを持っていると思うからおそらく複数コアが動いているんだけどね

でもおそらく君が書いたスクリプト部分は CPU コア1つからしか同時に処理されていないと思うよ

Python におけるマルチスレッドの使い方

ではそのマルチスレッドの Python での使い方を簡単に(本当に簡単に)紹介ておきたいと思います。

スポンサーリンク

モジュールのインポート

Python ではマルチスレッドを threadingimport することで利用することができます。

threading は Python の標準モジュールなので Python をインストールしているだけで利用可能だと思います。

モジュールのインポート
import threading

スレッドの生成

さらにスレッドは threading.Thread クラスのコンストラクタを実行することより生成することができます。

スレッドの生成
thread1 = threading.Thread(target=func)

返却値はスレッドのインスタンスになります。

引数 target には「関数」や「メソッド」を指定します。ここで指定する「関数」や「メソッド」が新たなスレッドで具体的に行う処理となります。

ここでは詳しい説明は行いませんが、threading.Thread クラスのコンストラクタの引数としては他にもいろいろ指定することができます。

例えば引数 arg では、target で指定した関数に渡したい引数を指定することができます。

質問!

生成できるスレッドは1つだけなの?

いや、そんなことはないよ!

このページで紹介するスクリプトでは生成するスレッドは1つだけだけど、複数生成することもできる!

最大何個まで生成できるかまでは分からん…

また、スレッドはスクリプト内で複数生成することもできます。

スレッドの開始

さらにスレッドのインスタンスに start メソッドを実行させることにより、生成したスレッドを開始することができます。

スレッドは開始後、CPU によって処理が行われます。

スレッドの開始
thread1.start()

ちなみに start メソッドはスレッドの開始をしたらすぐに終了する関数で、スレッドの処理として指定した関数やメソッドの処理が完了するのを待つようなことはしません。

また、スレッドは指定された処理(引数 target で指定した関数やメソッド)が終了すると自動的に消滅します。

スポンサーリンク

マルチスレッドの使用例

簡単な例でマルチスレッドによる並列処理を説明したいと思います。

シングルスレッドの例

下記はシングルスレッドで動作する Python プログラムになります。

シングルスレッド
# -*- coding:utf-8 -*-
import threading

def func():
    for j in range(100):
        print("sub  : " + str(j))

func()

# メインスレッドでprintのループ
for i in range(100):
    print("main : " + str(i))

単に関数 func を呼んでいるだけですので、まず func 関数のループで print100 回実行されて sub : yy が表示され(yy0 から 99 の値)、その後に 100main : xx が表示されることになります(xx0 から 99 の値)。

シングルスレッドですので、この i のループと j のループが同時に実行されることはありません。

ですので、必ず最初に 100sub : yy が表示され、その後に 100main : xx が表示されます。sub : yy の途中で main : xx が表示されることは絶対にありません。

そもそもプログラムってそういうものだと思ってたよ…

処理が単純に逐次的に実行されていくものだと思ってた

マルチスレッドのことを知らないとそう勘違いする人もいると思うよ

スレッドを使いこなせばプログラミングの世界が一気に広がるよ

次は実際にそのマルチスレッドを用いたプログラム例を見てみよう!

マルチスレッド の例

次に紹介するのはマルチスレッドを利用した例になります。

マルチスレッド
# -*- coding:utf-8 -*-
import threading

# 別スレッドで処理される関数
def func():
    # 別スレッドでprintのループ
    for j in range(100):
        print("sub  : " + str(j))


# スレッドの生成とスタート
thread1 = threading.Thread(target=func)
thread1.start()

# メインスレッドでprintのループ
for i in range(100):
    print("main : " + str(i))

今度は threading.Thread により新たなスレッドの生成をしており、そのスレッドの処理として関数 func を指定しています。

さらに生成したスレッドを start メソッドで開始することで、func 関数を別のスレッドとして CPU コアに処理させることができるようにしています。

これにより、i に対するループと j に対するループは複数の CPU コアにより同時に実行可能になります。

ですので、シングルスレッドの例で示した単なる関数呼び出しとは異なり、main : xx の表示と sub : yy の表示が同時に実行されることになります。

したがって、このマルチスレッドの例では main : xxsub : yy とが入り乱れた状態で表示されます(main : xx の表示の途中で sub : yy の表示が割り込んだりする)。

こんな感じでマルチスレッドを利用することでプログラム内の処理を同時に並列に実行することができます。

交互に表示されるわけじゃあないんだね

そうだね!

そもそも CPU は僕たちが作ったプログラムだけじゃなくて、他に動作しているアプリや OS のスレッドの処理も行っているから、自分が作ったスレッドが処理されるタイミングもまちまちなんだ

だから、同期っていう処理が重要になるよ

これは最後にちょっとだけ解説する!

tkinter におけるマルチスレッド

ここまではマルチスレッドの基本の解説をしてきましたが、ここからは本題の「tkinter におけるマルチスレッド」についてここから解説していきます。

tkinter を用いたプログラミングにおいて、マルチスレッドは非常に便利です。

tkinter ではマルチスレッドが便利

これはなぜかというと、マルチスレッドにすることで、mainloop で待機する必要のないスレッドが使えるようになるからです。

mainloop が何か知らない方は是非下記ページを参考にしていただければと思います。

tkinterのmainloopの解説ページのアイキャッチ【Python】tkinterのmainloopについて解説

上記ページでも解説していますが、tkinter ではボタンクリックなどのイベントが発生した際に、mainloop 実行中ないとそのイベントを処理することができません。

なので、mainloop 実行中でない時にボタンクリックなどのユーザー操作が行われても即座にアプリが反応せず、ユーザーに「反応が悪い」「途中で固まる」などの印象を与えることになります。

そのため、tkinter では出来るだけ mainloop 実行中になるようにプログラミングする必要があります。

ただし、mainloop 実行中である必要のあるスレッドは1つのみです。

マルチスレッドの場合、複数のスレッドを用意することができるので、1つのスレッドのみ mainloop で待機するようにすれば、残りのスレッドは mainloop を意識することなく使用することができます。

例えば時間がかかるような処理や、sleep などで待ちを行うような処理を行うような場合にアプリの反応が悪くなる例を下記ページで紹介しました。

tkinterのmainloopの解説ページのアイキャッチ【Python】tkinterのmainloopについて解説

こういった処理も、新たに生成するスレッド(threading.Thread で生成するスレッド)で行わせるようにすれば、ボタンクリックなどのユーザー操作が行われた際にも、すぐにその操作に対する処理を実行することが可能です。

なるほど!

1つのスレッドは mainloop で待機専用にするってわけだね!

mainloop で待機してたらイベント処理も即座に実行できるからアプリの反応が良くなるのか!

そういうことだね!

使ってみるとすぐマルチスレッドの効果がすぐ実感できるので、マルチスレッドを学ぶには tkinter は良い題材だと思うよ!

確かに!

アプリの反応が一気に改善されるから楽しくマルチスレッド学べそう!

スポンサーリンク

tkinter でのマルチスレッドの使い方

特にプログラミング入門したての方にとって、tkinter でのマルチスレッドの基本的な使い方は下記のようになると思います(メインスレッドはプログラム起動時に生成されるスレッドです)。

  • メインスレッド:
    • 基本的に mainloop で待機
    • 待機中にイベントなどが発生した場合にそのイベント処理も行う
  • threading.Thread で生成するスレッド:
    • メインスレッドで処理するとアプリの反応が悪くなるような処理

tkinter を使ってアプリ開発を行うような際には、最初は(マルチスレッドを使わずに)普通にシングルスレッドでプログラミングを行えば良いと思います。

そして、アプリの反応が悪くなるような処理を行いたくなった時にスレッドを生成し、生成したスレッドでその処理を行わせるようにします。

これにより、アプリの反応の良さを保ったまま様々な処理を行うことができるようになります。

tkinter でのマルチスレッドの使用例

最後に tkinter でのマルチスレッドの具体的な使用例を紹介したいと思います。

下記ページのスリープ処理を after メソッドに置き換えるで紹介した「sleepを使用したタイマーアプリ」をマルチスレッドを用いてアプリの動きを改善していきたいと思います。

tkinterのmainloopの解説ページのアイキャッチ【Python】tkinterのmainloopについて解説

シングルスレッドの例

下記がスリープ処理を after メソッドに置き換えるで紹介した「sleepを使用したタイマーアプリ」のスクリプトを引用したものになります。

シングルスレッドのタイマーアプリ
# -*- coding:utf-8 -*-
import tkinter

start_flag = False

# タイマー
def timer(count):
	global label
	global start_flag

	if start_flag:
		import time
		time.sleep(1)

		label.config(text=count)

# スタートボタンが押された時の処理
def start_button_click(event):
	global start_flag
	start_flag = True

	for i in range(10):
		timer(i + 1)

# ストップボタンが押された時の処理
def stop_button_click(event):
	global start_flag
	start_flag = False

# メインウィンドウを作成
app = tkinter.Tk()
app.geometry("200x100")

# ボタンの作成と配置
start_button = tkinter.Button(
	app,
	text="スタート",
)
start_button.pack()

stop_button = tkinter.Button(
	app,
	text="ストップ",
)
stop_button.pack()


# ラベルの作成と配置
label = tkinter.Label(
	app,
	width=5,
	height=1,
	text=0,
	font=("", 20)
)
label.pack()

# イベント処理の設定
start_button.bind("<ButtonPress>", start_button_click)
stop_button.bind("<ButtonPress>", stop_button_click)

# メインループ
app.mainloop()

実行してみるとわかると思いますが、「スタート」ボタンを押すと0が表示されるだけで、その後表示されている数字は更新されませんし、「ストップ」ボタンを押しても反応しません(10秒後にアプリが反応します)。

これは、start_button_click 関数の中で10秒間待っているからです。この待っている間は処理が mainloop に戻らないので、その間にイベントが発生してもそのイベントの処理が行われないためです。

 

これ結構やりがちだよね…

確か sleep じゃなくて after メソッド使えば改善できたはず

after メソッドを使えば mainloop にすぐに処理が戻せるのでボタンを押しても反応しない現象は改善できるね!

だけど今回は after メソッドを使わずに、マルチスレッドで改善する例を紹介していくよ!

スポンサーリンク

マルチスレッドの例

この「数字が更新されない」「ボタンを押しても反応しない」ところをマルチスレッドを利用して改善したいと思います。

下記がそのマルチスレッドを利用したスクリプトになります。

マルチスレッド使用したタイマーアプリ
# -*- coding:utf-8 -*-
import tkinter
import threading

start_flag = False
quitting_flag = False
count = 0

# タイマー
def timer():
	global label
	global start_flag
	global quitting_flag
	global count

	while not quitting_flag:
		if start_flag:
			label.config(text=count)
			count += 1
			if count > 10:
				start_flag = False

			import time
			time.sleep(1)

# スタートボタンが押された時の処理
def start_button_click(event):
	global start_flag
	global count

	count = 0
	start_flag = True

# ストップボタンが押された時の処理
def stop_button_click(event):
	global start_flag
	start_flag = False

# 終了ボタンが押された時の処理
def quit_app():
	global quitting_flag
	global app
	global thread1

	quitting_flag = True

	# thread1終了まで待つ
	thread1.join()

	# thread1終了後にアプリ終了
	app.destroy()

# メインウィンドウを作成
app = tkinter.Tk()
app.geometry("200x100")

# ボタンの作成と配置
start_button = tkinter.Button(
	app,
	text="スタート",
)
start_button.pack()

stop_button = tkinter.Button(
	app,
	text="ストップ",
)
stop_button.pack()


# ラベルの作成と配置
label = tkinter.Label(
	app,
	width=5,
	height=1,
	text=0,
	font=("", 20)
)
label.pack()

# イベント処理の設定
start_button.bind("<ButtonPress>", start_button_click)
stop_button.bind("<ButtonPress>", stop_button_click)
app.protocol("WM_DELETE_WINDOW", quit_app)

# スレッドの生成と開始
thread1 = threading.Thread(target=timer)
thread1.start()

# メインループ
app.mainloop()

今度はシングルスレッドの時とは異なり、「スタート」ボタンを押せば数字がどんどんカウントアップされ、「ストップ」ボタンを押せばカウントアップが停止します。

つまりシングルスレッドの時に比較してアプリの反応が改善されています。

なぜこのように動作が異なるのか、その理由を解説していきます。

まず、下記でスレッドの生成と開始を行っています。

スレッドの生成と開始
# スレッドの生成と開始
thread1 = threading.Thread(target=timer)
thread1.start()

スレッドで処理する関数として timer を指定していますので、生成されるスレッドでは timer 関数が実行されることになります。

MEMO

ちなみに下記で示す timer 関数以外の処理は全てメインスレッドで実行されます

  • start_button_click 関数
  • stop_button_click 関数
  • ウィジェットの作成
  • イベント処理の受付
  • mainloop の実行

2つのスクリプトの動きの違い

ではここで質問です。

シングルスレッドの例で示したスクリプトと、今回紹介したマルチスレッドの例のスクリプトとでアプリの反応が異なる決定的な違いはなんでしょう?

うーん、「待ち」を行っている関数が違うよね

シングルスレッドの場合は start_button_click 関数で待ってるし、

マルチスレッド の場合は timer 関数で待ってる!

うん、ほぼ正解!

ほぼ?!

まあ他にも多少違う点はあるのですが、待ちを行っている関数が違います。で、スレッドの観点で考えると「待ちを行っているスレッド」が異なります。

この「待ちを行っているスレッド」の違いがシングルスレッドのスクリプトとマルチスレッドのスクリプトとで動き(アプリの反応)が異なる1番の理由になります。

スクリプトの動作を考えると、スクリプトが起動するとまずメインスレッドがメインウィンドウやウィジェットを作成し、さらにイベントを設定した後に mainloop を実行します(マルチスレッドの場合はスレッドの生成と開始も行います)。

さらに「スタート」ボタンがクリックされると、mainloop 実行中のスレッド(つまりメインスレッド)が start_button_click 関数を実行します。

ここまではシングルスレッドの場合でもマルチスレッドの場合でもほぼ同じ動きになります。

ただし、シングルスレッドの場合、start_button_click 関数の中で10秒待つ処理が行われていますので、10秒間関数が終了しません。

したがって、その間は mainloop に戻りませんので、この間は次のイベントが発生してもそのイベントに対する処理が実行できないことになります。

ボタンを押してもアプリが反応しない様子

なので、シングルスレッドの場合は「スタート」ボタンが押したあとはしばらく他のイベント処理が行われずアプリが反応しなくなったように見えてしまいます。

一方、マルチスレッドの場合、start_button_click 関数は count = 0start_flag = True のみを実行して即座に終了します(後述で説明しますが、この start_flag = Truethread1 に「スタート」ボタンがクリックされたことを通知する処理になります)。

そして関数終了後はまた mainloop に戻ります。なので、「ストップ」ボタンクリックなどの次のイベントが発生すると、すぐにそのイベントに対する処理を実行することができます。

ボタンを押すと即座にアプリが反応する様子

これを実現できているのは、待つ処理をメインスレッドでないスレッド(つまり thread1)で行っているためです。

今回は「待つ処理」をメインスレッドでないスレッドで行わせるようにしたけど、

「時間のかかる処理」をメインスレッドでないスレッドで行わせるようにしても良さそうだね!

そうだね!

「時間のかかる処理」をメインスレッドで実行しちゃうとアプリの反応が悪くなるので、生成したスレッドで処理させることでその反応を改善することができるよ!

マルチスレッドにおける同期の必要性

こんな感じでマルチスレッドを利用することでアプリの反応の良さを保ったまま様々な処理を実行できるようになります。

ただし、ただマルチスレッドは便利なだけではなく、マルチスレッドならではの難しい点もあります。

それが同期です。スレッド間でのタイミングの制御ですね。

スレッド間の同期を表す図

スレッド間での同期の例

例えば thread1 で実行される timer 関数では「スタート」ボタンが押された時に数字のカウントアップを開始し、「ストップ」ボタンが押された時にカウントアップを停止するようにしたいので、thread1 はボタンが押されたタイミングを知る必要があります。

ただしボタンが押されたことはメインスレッドしか知らない(mainloop を実行しているスレッドしか知らない)ので、メインスレッドから thread1 にボタンが押されたタイミングを教えてあげる必要があります。

スレッドにボタンが押されたことを通知する様子

なので、start_flag を用意し、「スタート」ボタンが押された時に実行される start_button_click 関数や「ストップ」ボタンが押された時に実行される stopt_button_click 関数で start_flag の値をそれぞれ TrueFalse を設定するようにしています。

start_flagを用いて情報を通知する様子

そして、timer 関数では start_flagTrue になった時にカウントアップを開始し、False になった時にカウントアップを停止するようにしています。

start_flagから情報を受け取る様子

つまり start_flag を利用した情報の通知により、メインスレッドと thread1 とで同期を取っているわけです。

こんな感じで、マルチスレッドプログラミングにおいては他のスレッドと情報の通知を行いながら同期を取る必要があります。

今回の例では単純に情報の通知に global 変数を利用しましたが、同期を取るための手段として Queue などが存在します。

アプリ終了時の同期

さらに tkinter でマルチスレッドを利用する場合、アプリ終了時の同期についても考慮する必要があります。

tkinter で開発したアプリにおいても、終了ボタンが押された時にアプリが終了します。そして、このアプリの終了の時には、ウィジェットの削除なども行われます。

なので、スレッドでウィジェットへの操作を行っている場合、アプリ終了時の同期が取れていないと削除したウィジェットに操作を行ってしまいエラーが発生することになります。

削除済みのウィジェットを操作してエラーが発生する様子

例えばマルチスレッドの例で示したスクリプトでは、thread1 で実行される timer 関数では、ラベルウィジェットの labelconfig メソッドを実行させるようにしています。

ですので、「スタート」ボタンが押されて数字のカウントアップを行っている最中にアプリが終了されると、既に削除されたラベルウィジェットの labelconfig メソッドを実行させることになるのでエラーが発生します。

tkinter でのマルチスレッドの利用時は、こういったアプリ終了時のエラーを防ぐための同期も必要になります。

例えば終了ボタンが押された時に、下記のような順序で処理が行われるように同期をすれば、アプリ終了時のエラーを防ぐことができます。

  • メインスレッドが thread1 にアプリが終了されることを通知する
  • 通知を受けて thread1 は関数を終了する
  • メインスレッドは thread1 が終了するのを待つ
  • thread1 終了後、メインスレッドは実際にアプリの終了を行う

thread1 終了後にアプリを終了するので、削除ずみのウィジェットへの操作が行われるようなことがなくなります。

スレッド終了後にアプリを終了させる様子

これを実現するために、マルチスレッドの例で示したスクリプトでは下記のようなことを行っています。

終了ボタンが実行された時の処理を設定

上記を実現するためには、終了ボタンが押された時に、そのことを thread1 に教えてやる必要があります。

このため、終了ボタンが押された時に自身で定義した関数が実行されるように下記を行っています。

終了ボタンが押された時の処理の設定
app.protocol("WM_DELETE_WINDOW", quit_app)

これにより、終了ボタンが押された時にアプリが終了するのではなく、関数 quit_app が実行されるようになります。この quit_app はメインスレッドで実行されます。

thread1 終了待ち

そして、終了ボタンが押された時に実行される quit_app 関数では、global 変数の quitting_flag を用いて thread1 へのアプリが終了することの通知と thread1 の終了待ちを行っています。

具体的には、quit_app 関数の中で quitting_flagTrue に設定することで、アプリが終了することを thread1 に通知します。

さらに、下記を実行することで thread1 の終了の待ち合わせを行います。join メソッドは実行したインスタンスのスレッドの終了を待つメソッドです。

これを実行することで、スレッドが終了するまでメソッドの中で待つことができます。

スレッド終了の待ち合わせ
# thread1終了まで待つ
thread1.join()

thread1 で実行される timer 関数では quitting_flagTrue になるとループを終了するようにしているので、メインスレッドから quitting_flag に True が設定されると関数が終了してスレッドが終了することになります。 

thread1 が終了すると、thread1.join メソッドも終了しますのでメインスレッドは次の処理を実行することができます。

thread1.join メソッドは thread1 が終了しないと return しないため、thread1.join の後の処理が実行されるタイミングは “確実に” thread1 が終了した後になります。

thread1 終了後のアプリの終了

そして、その次の処理ではアプリの終了を行います。

具体的には下記を実行することでアプリを終了します。

アプリの終了
# thread1終了後にアプリ終了
app.destroy()

このアプリの終了は、thread1 が終了した後に必ず実行されるため、thread1 がアプリの終了により削除されたウィジェットへの操作を行うようなことはあり得ません。

したがって前に述べたアプリ終了時の同期ができていなくて発生したエラーを防ぐことができます。

同期難しい…

同期は難しいよ…

“実行される処理の順番を確実にするためにはどうすれば良いか?” をしっかり考えながらプログラミングする必要がある

まあでも失敗すれば失敗するほど同期について考える力はつくと思うから、どんどん挑戦していけばいいと思うよ!

まとめ

このページでは tkitnter におけるマルチスレッドの利用について解説しました。

tkinter でマルチスレッドを利用すれば、重い処理などもアプリの反応性を落とすことなく実行するができます。

tkinter を利用したアプリ開発におけるマルチスレッドのメリットは大きいので、アプリ開発しながらマルチスレッドを学ぶのはオススメです。効果が実感しやすいので楽しく学べます!

ただし、スレッド間での同期を行う必要もあります。スレッド間でどのようにタイミングを制御するかをしっかり考えながらプログラミングするようにしましょう!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です