Help us understand the problem. What is going on with this article?

Pythonのthreading使ったらプログラムが止まらなかったパターンがある

プログラム「俺は止まんねえからよ…」
ぼく「止まってください」

この記事は、N高等学校 Advent Calendar 2019 7日目の記事です。
私は現在2年生2回目(転校するときにいろいろあった)で、通学コースに通っています。
N高は必須課題さえ終わらせれば、プログラミングの時間に自分のプロジェクトができて、非常にありがたいです。

この記事では、PythonでDiscordとTwitterのbotを作っていたら直面した問題について書きます。
何か間違いがあったら指摘してください。

概要

Pythonのthreadingを使ったプログラムをKeyboardInterrupt(Ctrl+C)で止めようとしたら、なぜか一回で止まらなかった。
さらに調べたらsys.exit()でも止まらなかった。

環境

OS: Windows 10 Home
Runtime: Python 3.8.0

実践

とりあえず、検証のためにthreadingを使ったコードを書いてみることにした。

import threading
import time

# 関数定義
def a():
    for i in range(5):
        time.sleep(1)
        print("A" + str(i))

def b():
    time.sleep(0.5)
    for j in range(5):
        time.sleep(1)
        print("B" + str(j))

# スレッドオブジェクト生成
t1 = threading.Thread(target=a)
t2 = threading.Thread(target=b)

# 実行
t1.start()
t2.start()

# 終わるまで待機
t1.join()
t2.join()

print("Finish")

出力は期待通り、以下のようになった。
B4が表示されると同時にFinishが表示された。

A0
B0
A1
B1
A2
B2
A3
B3
A4
B4
Finish

さて、これを無限ループに改造してみる。

import threading
import time

# 関数定義
def a():
    c=1
    while True:
        time.sleep(1)
        print("A" + str(c))
        c+=1

def b():
    k=1
    time.sleep(0.5)
    while True:
        time.sleep(1)
        print("B" + str(k))
        k+=1

# スレッドオブジェクト生成
t1 = threading.Thread(target=a)
t2 = threading.Thread(target=b)

# 実行
t1.start()
t2.start()

# 終わるまで待機
t1.join()
t2.join()

出力は以下の通りになった。

A1
B1
A2
B2
A3
B3
^CTraceback (most recent call last):
  File "***********.py", line 28, in <module>
    t1.join()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 1011, in join
    self._wait_for_tstate_lock()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 1027, in _wait_for_tstate_lock
    elif lock.acquire(block, timeout):
KeyboardInterrupt
A4
B4
A5
B5
A6
B6
^CException ignored in: <module 'threading' from '/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py'>
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 1388, in _shutdown
    lock.acquire()
KeyboardInterrupt: 

なぜか1回目のKeyboardInterruptで止まっていない。
今回は2回やったら止まったからいいが、できれば避けたい。

解決策?

「終わるまで待機」させなければ良い。つまり**.join()を消せばいい。
実際にt1.join()t2.join()を消して実行してみると、こうなる。

A1
B1
A2
B2
^CException ignored in: <module 'threading' from '/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py'>
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/threading.py", line 1388, in _shutdown
    lock.acquire()
KeyboardInterrupt:

「While Trueを使うような処理ならどうせ途中で終わらせないだろうしCtrl+Cで止まればええやろ」とか思っていた時期が僕にもありました。
でも止めたいシーンって意外とある。botをコマンドでシャットダウンさせたいときとか。
そんなときに発覚したのが以下の内容だ。

sys.exit()も効かない

import threading
import time
import sys

# 関数定義
def a():
    c=1
    while True:
        time.sleep(1)
        print("A" + str(c))
        c+=1

def b():
    k=1
    time.sleep(0.5)
    while True:
        time.sleep(1)
        print("B" + str(k))
        k+=1

# スレッドオブジェクト生成
t1 = threading.Thread(target=a)
t2 = threading.Thread(target=b)

# 実行
t1.start()
t2.start()

# 強制終了
print("Terminate")
sys.exit()
print("Terminated")

期待される出力はTerminate1行だ。sys.exit()以降はプログラムが実行されないはずだからだ(後述するがこれは厳密には間違いである)。
しかしこれを実際に動作させると、以下のような出力が得られる。

Terminate
A1
B1
A2
B2
A3
B3
A4
B4

Terminateの後にsys.exit()が走り、Terminatedは表示されないようになっているが、上にある2つの関数は普通に実行されてしまっている。

原因

sys.exit()についての認識に間違いがあった。これはプログラム全部を止めてくれるものではなく、スレッドを停止するものだ。
上記のプログラムで止まるスレッドはprint("Terminate")を実行しているスレッドだけで、無限ループしているスレッド2つにはsys.exit()が届いていない。
止める方法の一つとして、sys.exit()をスレッドオブジェクト内で実行するというものがある。
だが私がやりたいことは「全てのスレッドを一気に止める」ことだ。

解決策

メインスレッド以外のスレッドをデーモン化する。

import threading
import time
import sys

# 関数定義
def a():
    c=1
    while True:
        time.sleep(1)
        print("A" + str(c))
        c+=1

def b():
    k=1
    time.sleep(0.5)
    while True:
        time.sleep(1)
        print("B" + str(k))
        k+=1

def c():
    while True:
        n = input()
        if n == "e":
            print("Terminate")
            sys.exit()

# スレッドオブジェクト生成
t1 = threading.Thread(target=a)
t2 = threading.Thread(target=b)

# デーモン化
t1.setDaemon(True)
t2.setDaemon(True)

# 実行
t1.start()
t2.start()
c() # c()のみメインスレッドで実行

入力eを受け取ったらsys.exit()が走るようにした。
出力結果は以下のようになる。

A1
B1
A2
e
Terminate

eを押してEnter(Return)すれば、そこで実行が止まる。
デーモンスレッドはデーモンスレッド以外のスレッドが動いていない場合に自動的に消えるという挙動をする。
sys.exit()が含まれる処理が1つであるならばメインスレッドで、すなわちthreading.Thread()をしないで一番外側で動かすといいかもしれない。

まとめ

正直、原因も調べてみたけれどわからない。シグナルがどうとか、いくつかそれっぽいものはあったが、自分の技量では正しく書けないので載せるのはやめておく。
この記事を書く過程でthreadingについていろいろと調べたが、まだまだ様々な機能があるらしく、上に載せたもの以外にもやりたいことを実現する方法があるのかもしれない。
ただ、とりあえず自分の理解が及ぶ範囲でやろうとすると上記のようになる。これで詰まったときは、また別の方法を探そうと思う。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした