LoginSignup
8
3

More than 1 year has passed since last update.

Python: Ctrl+c (KeyboardInterrupt)での中断と例外の基本

Last updated at Posted at 2021-12-20

Python: Ctrl+c (KeyboardInterrupt)での中断と例外の基本

はじめに

簡単なプログラムの場合、Ctrl+cによるKeyboardInterruptによって中断させる事が良くあります。
最近、理解不足から中断しない理由がわからず苦労しました。
そのため、Ctrl+cで中断させるにはどうしたら良いか例外について勉強しなおしてみました。

C言語で実装された時間のかかる処理は、Ctrl+cを押してもその処理が終わるまで中断しないので注意してください。

環境

OS
エディション  Windows 11 Pro
バージョン 21H2
OS ビルド    22000.318
エクスペリエンス    Windows 機能エクスペリエンス パック 1000.22000.318.0
システムの種類   64 ビット オペレーティング システム、x64 ベース プロセッサ
python
Python 3.10.1 (tags/v3.10.1:2cd268a, Dec  6 2021, 19:10:37) [MSC v.1929 64 bit (AMD64)]

結論

通常は、Ctrl+cを押すと(KeyboardInterruptが送られて)プログラムが終了します。
押しても実行を続ける場合は、どこかでCTRL+cのシグナルかKeyboardInterruptが処理されていて例外の連鎖が終了しているのだと思います。

KeyboardInterruptを処理してしまうものは、筆者の知るものとしては、

  • except KeyboardInterrupt: : KeyboardInterruptを指定したexcept
  • except: : 何も指定しないexceptbare-exceptと呼ぶことにします。

があります。

CTRL+cのシグナルを処理するものは、

  • signal.signal(signal.SIGINT, handler)

があります。

つまり、これらの処理を見直す必要があるという事です。

except KeyboardInterrupt:

except KeyboardInterrupt:は、KeyboardInterruptを捕らえて処理する事を意図して書かれます。
そのため、ここの処理で例外の連鎖を切ってしまうとCtrl+cを押しても中断しないわけです。
連鎖が切れている場合は、raiseを入れて連鎖するようにします。

ptyhon
try:
  # 何らかの処理
except KeyboardInterrupt:
  # 何らかの処理
  raise

bare-except

次に、bare-exceptの場合も、KeyboardInterruptを捕らえてしまい例外の連鎖を切ってしまう事があります。
(あまり使われる事はないと思いますが、except BaseException:KeyboardInterruptを捕らえます)
修正する一番良い方法は、bore-exceptを使うのはやめて処理できる例外だけを対象にする事です。
(それが面倒だからbare-exceptを使ってしまうのだとは思いますが)
それが出来ない場合は、except KeyboardInterrupt:raiseするか、

python
except KeyboardInterrupt:
  raise
except:
  #

または、bore-exceptraiseするかしかないかと思います。

ptyhon
except:
  #
  raise

また、bore-exceptは、sys.exit()などによって出されたSystemExitも捕らえてしまいます。
sys.exit()で終了しないのは、bore-exceptが原因だったりします。

また、except Exception:(broad-exceptと呼ぶことにします)の例外処理は、逆にKeyboardInterruptを素通りしてしまうので意図通りにならない事があります。

PyLintbroad-except, bare-exceptと警告が出すのはこんなところが原因だったりします。

もっとも、broad-except, bare-exceptが問題視されるのは、意図せずに例外の連鎖を切ってしまう事があるからであり、上述しているように、例外の連鎖をするならば問題ないです。

ptyhon
except Exception as e:
  print(type(e), e) # メッセージだけ表示する
  raise
except:
  print("unknown exception")
  raise

signal.signal(signal.SIGINT, signal.SIG_DFL)

signal.signal(signalnum, handler)は、signalnumで登録されたシグナルが発行されるとhandlerで登録された関数を呼び出すようにする関数です。
Ctrl+cシグナルによってhandlerを呼び出すようにするには、signalnumsignal.SIGINTを設定します。
Ctrl+cで終了しない場合は、signal.signal(signal.SIGINT, handler)がどこかで登録されている可能性があります。
この場合は、登録されているhandlerを修正する必要があります。
(新しくhandlerを登録すると古いhandlerが上書きされるので、既にhandlerが登録されているプログラムで安易に新しいhandlerを登録するのは危険です。)

途中経過の表示などの処理が必要なくhandlerの登録がない場合は、下記を実行すればexceptに関係なくCtrl+cシグナルで終了するようになります。
Ctrl+cシグナルをOS標準のhandlerで処理させるようになります。

python
import signal

signal.signal(signal.SIGINT, signal.SIG_DFL)

signal.signal(signal.SIGINT, handler)

終了前に何か処理を挟みたい場合は、signal.signal(signal.SIGINT, handler)で以下のようなhandlerを登録します。

python
def handler(signum, frame):
  # 何らかの処理
  sys.exit(0)

Ctrl+cシグナルを受け取るとhandler()が実行されそこからsys.exit()を実行してプログラムを終了させます。
このような記述は、よく目にしますが少し注意が必要です。
sys.exit()はプログラムを終了するのではなくSystemExitを発生させる関数です。
上述したように、このhandlerbore-exceptが両方存在する場合、handlerから送出されたSystemExitbore-exceptで処理されてしまい意図しない結果になる事があります。

python
import time
import signal
import sys

def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def handler(signum, frame):
  # 何らかの処理
  sys.exit(0)


signal.signal(signal.SIGFPE, handler)
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("except")

Ctrl+cで終了したいならば、下記のようなhandlerの方が良いかもしれません。
Ctrl+c(KeyboardInterrupt) を受け取って必要な処理をしたから、再度KeyboardInterruptを送るので例外の連鎖と同じ挙動になります。
若干、再帰的な呼び出しになりそうで不安ですが、試してみたところ問題はないようです。

python
def handler(signum, frame):
  # 何らかの処理
  raise KeyboardInterrupt

例外処理

ここからは、例外処理のおさらいをしていきたいと思います。

基本

例外を処理する場合は、try文を使います。
以下のように書くと、funcで何らかの例外が発生するとexcept句が実行されます。

def func(n):
  return 1/n

print("start")
try:
  func(1)
  print("success")
except:
  print("fail") # 例外が発生した場合に実行される
print("end")

必要に応じて、elsefinallyも指定できます。
elseは、例外が発生しなかった場合に実行されます。
finallyは、例外が発生してもしなくても最後に実行されます。

print("start")
try:
  func(1)
  print("success")
except:
  print("fail")  # 例外が発生した場合に実行される
else:
  print("else")  # 例外が発生しなかった場合に実行される
finally:
  print("finish")  # 例外が発生してもしなくても最後に実行される
print("end")

特定の例外を処理する

except:と書いてしまうとKeyboardInterruptを含めた全ての例外を捕らえてしまいます。
処理したい例外だけを捕らえたい場合は、

except 例外名

と記述します。
こうすると一致する例外とその派生先の例外が捕らえられます。

python
try:
  func(0)
  print("success")
except ZeroDivisionError: 
  print("ZeroDivisionError") # "ZeroDivisionError"が発生した場合に実行される
except:
  print("fail") # "ZeroDivisionError"以外の例外が発生した場合に実行される

複数の種類の例外に対して同じ処理をする

いくつかの種類の例外を処理したい場合は、タプルで複数を指定できます。

python
try:
  func(0)
  print("success")
except (ZeroDivisionError, TypeError): 
  print("ZeroDivisionError, TypeError") # "ZeroDivisionError もしくは TypeError"が発生した場合に実行される
except:
  print("fail") # 上記以外の例外が発生した場合に実行される

もしくは、上述したように例外の派生元を指定する事でも可能です。
ほぼ全ての組み込み例外は、Exceptionから派生しているので以下のようにすると大部分の例外を捕らえられます。

python
try:
  func(0)
  print("success")
except Exception:
  print("Exception") # Exceptionから派生した例外が発生した場合に実行される
except:
  print("fail") # "Exception"で捕まえられなかった例外が発生した場合に実行される

注意点は、KeyboardInterruptExceptionから派生していない事です。
KeyboardInterruptは、except Exception:で捕らえられないようになっています。

以下は、pythonの公式ドキュメントからの抜粋です。

exception Exception
システム終了以外の全ての組み込み例外はこのクラスから派生しています。
全てのユーザ定義例外もこのクラスから派生させるべきです。

exception KeyboardInterrupt
ユーザが割り込みキー (通常は Control-C または Delete) を押した場合に送出されます。
実行中、割り込みは定期的に監視されます。
Exception を捕捉するコードに誤って捕捉されてインタプリタの終了が阻害されないように、この例外は BaseException を継承しています。

例外の情報を得る

複数の例外に同じ処理をした場合など、例外の情報を得たい時があります。
その場合、except 例外名 as 変数名などとすると変数に例外のインスタンスが格納されます。

python
try:
  func(0)
  print("success")
except (ZeroDivisionError, TypeError) as e: # 例外の情報がeに格納される
  print("ZeroDivisionError, TypeError")
  print(e, type(e))
except Exception as e:
  print("Exception")
  print(e, type(e))

例外を発生させる

自分で任意に例外を発生させる事も出来ます。
raise 例外名で例外を発生させられます。

python
def func(n):
  raise ValueError

try:
  func(1)
  print("success")
except ValueError: 
  print("ValueError")

ユーザ定義例外

組み込み例外以外に、ユーザ定義例外を発生させることも出来ます。
ユーザ定義例外は、Exceptionを継承したクラスならば何でも良いようです。
しかし、最低限必要な情報だけを持つように定義するのが良いようです。

python
class Error(Exception):
  def __init__(self, message):
    self.message = message

def func(n):
  raise Error("on func")

try:
  func(1)
  print("success")
except Error as e: 
  print("Error", e.message)

例外処理

例外が起きた場合の挙動について詳しく見ていきたいと思います。

例外なしのサンプル

下記のようなプログラムがあるとします。

python
import time

def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
  print("end")

実行結果は、以下のようになります。

start
main:   10/10
func:   10/10
main:   10/10
func:   10/10
main:   10/10
func:   10/10
end

例外を起こす

故意に例外を起こすために以下のようにします。

python
if __name__ == "__main__":
  print("start")
  for i in range(3):
    raise KeyboardInterrupt
    wait("main", 10)
    func()
  print("end")

例外に対する処理を行っていないので例外発生時の時点でプログラムが中断されます。

pythono
start
Traceback (most recent call last):
  File "\test2.py", line 18, in <module>
    raise KeyboardInterrupt
KeyboardInterrupt

例外を処理する(ループ外)

try文を使って例外を処理していきたいと思います。
まずは、for文の外にtry文を書いてみます。

python
if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      raise KeyboardInterrupt
      wait("main", 10)
      func()
  except KeyboardInterrupt:
    print("catch on main")
  print("end")

for文の中で発生した例外がfor文の外のexceptで処理されたので処理は1回で終了します。

start
catch on main
end

例外を処理する(ループ内)

今度は、for文の中にtry文を書いてみます。

python
if __name__ == "__main__":
  print("start")
  for i in range(3):
    try:
      raise KeyboardInterrupt
      wait("main", 10)
      func()
    except KeyboardInterrupt:
      print("catch on main")
  print("end")

for文の中のexceptで処理されるので同じ処理が3回繰り返されます。

start
catch on main
catch on main
catch on main
end

ここでは、明示的にexcept KeyboardInterrupt:を使っているので当たり前ですが、except:とした場合でも、KeyboardInterruptが捕らえられてしまいます。
その結果、意図した結果にならない可能性もあります。

関数内で例外を起こす

関数内で例外を起こします。

python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
    print("after func")
  print("end")

実行結果は、以下のようになります。

start
main:   10/10
Traceback (most recent call last):
  File "\test5.py", line 20, in <module>
    func()
  File "\test5.py", line 12, in func
    raise KeyboardInterrupt
KeyboardInterrupt

関数内の例外を関数内で処理する

python
import time


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")


if __name__ == "__main__":
  print("start")
  for i in range(3):
    wait("main", 10)
    func()
    print("after func")
  print("end")

関数内で起こった例外を関数内で処理すれば、外のスコープの処理は正常に続きます。

start
main:   10/10
catch on func
after func
main:   10/10
catch on func
after func
main:   10/10
catch on func
after func
end

関数内の例外を関数外で処理する

以下のように、関数外でかつループ外で処理すると

python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except KeyboardInterrupt:
    print("catch on main")
  print("end")

結果は、以下のようになります。

start
main:   10/10
catch on main
end

一方で、関数外でかつループ内で処理すると

python
def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  for i in range(3):
    try:
      wait("main", 10)
      func()
      print("after func")
    except KeyboardInterrupt:
      print("catch on main")
  print("end")

以下のようになります。
こちらは、処理が飛ばされているために"after func"が表示されません。

start
main:   10/10
catch on main
main:   10/10
catch on main
main:   10/10
catch on main
end

例外を連鎖させる

例外が発生したら処理をするけれども、そのままプログラムを終了したい時など例外を連鎖させる必要があります。
(KeyboardInterruptを検出したら途中結果を表示して終了するなど)
その場合は、以下のようにexcept句にraiseだけを記述します。
こうするとそのexceptが捕らえた例外をそのまま外部に投げることになります。

python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except KeyboardInterrupt:
    print("catch on main")
    raise
  print("end")

func()で発生した例外をfunc()で処理し、それをそのままmainに渡します。
mainでも処理をしますが、ここでもまたそのまま例外を投げるので以下のようになります。

start
main:   10/10
catch on func
catch on main
Traceback (most recent call last):
  File "\test9.py", line 25, in <module>
    func()
  File "\test9.py", line 13, in func
    raise KeyboardInterrupt
KeyboardInterrupt

例外の種類を変えて例外を連鎖させる

単純に連鎖させるのではなく、付属情報を付けたり例外の種類を変えたりして連鎖させたいこともあります。
その場合は、raise 例外名で実現できます。
下記のように、Exceptionにメッセージを設定したものを渡しても良いですし、組み込み例外や値を設定したユーザ定義例外なども使用できます。

python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func")


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except Exception as e:
    print("catch on main")
    print(type(e), e)
    raise
  print("end")

funcで発生したKeyboardInterruptが、mainに渡ったらExceptionになっているのが分かるかと思います。

start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
Traceback (most recent call last):
  File "\test10.py", line 13, in func
    raise KeyboardInterrupt
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "\test10.py", line 25, in <module>
    func()
  File "\test10.py", line 17, in func
    raise Exception("Exception on func")
Exception: Exception on func

ここで注意していただきたいのが、以下のメッセージです。
例外処理中に意図しない例外が発生したとも読み取れます。
意図通りに例外の種類を変えたのでそれを明示したいと思います。

During handling of the above exception, another exception occurred:

例外の種類を変えて例外を連鎖させる(連鎖の意図を明確にする): raise from

明示的に例外を連鎖させたいと思います。
その場合は、raise 連鎖させる例外名 from 元の例外名を使います。
下記の場合は、except KeyboardInterrupt:で捕らえたKeyboardInterruptから、Exception("Exception on func")を連鎖させる事になります。

python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func") from KeyboardInterrupt

結果は、以下になります。

start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
KeyboardInterrupt

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "\test11.py", line 25, in <module>
    func()
  File "\test11.py", line 17, in func
    raise Exception("Exception on func") from KeyboardInterrupt
Exception: Exception on func

下記に注目すると、KeyboardInterruptからExceptionが発生したのが分かるかと思います。

The above exception was the direct cause of the following exception:

例外の種類を変えて例外を連鎖させる(連鎖元の情報を隠す): raise from None

例外を連鎖させたいが連鎖元の情報が要らない場合は、raise 連鎖させる例外名 from Noneを使う事で実現できます。
厳密に言えば、スタックトレースあたりの挙動を変えるようですが細かいところまではわかりません。

python
def func():
  try:
    raise KeyboardInterrupt
    wait("func", 10)
  except KeyboardInterrupt:
    print("catch on func")
    raise Exception("Exception on func") from None

Exception("Exception on func") from Noneと表示されているので連鎖したことはわかりますが、その前の例外が何だったのか標準のメッセージからは分からなくなっています。

start
main:   10/10
catch on func
catch on main
<class 'Exception'> Exception on func
Traceback (most recent call last):
  File "\test12.py", line 25, in <module>
    func()
  File "\test12.py", line 17, in func
    raise Exception("Exception on func") from None
Exception: Exception on func

関連: スタックトレースを表示する

例外を処理しているとスタックトレースの情報が知りたくなる事があります。
その様な場合に使えるのがtracebackモジュールです。
このモジュールには色々ありますが、正直違いがよくわからなかったり簡易表記なだけだったりで、基本的に使うのはtraceback.format_exception()traceback.format_exc()だけで良いと思います。

python
impoart traceback
type, value, tb, = sys.exc_info()
print(traceback.format_exception(type, value, tb))

print(traceback.format_exc())

下記のように、main文でのみ例外処理をしていると何が原因で発生した例外なのか分からなくなります。
そこでmainexceptprint(traceback.format_exc())を入れてみます。

python
import time
import traceback


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def func():
  raise KeyboardInterrupt
  wait("func", 10)


if __name__ == "__main__":
  print("start")
  try:
    for i in range(3):
      wait("main", 10)
      func()
      print("after func")
  except:
    print(traceback.format_exc())
  print("end")

そうすると、見慣れたトレースバックが表示されます。

start
main:   10/10
Traceback (most recent call last):
  File "\test15.py", line 23, in <module>
    func()
  File "\test15.py", line 14, in func
    raise KeyboardInterrupt
KeyboardInterrupt

end

signal.signal()によるシグナルの処理

上述したようにsignalモジュールを使うとCtrl+cシグナル(KeyboardInterruptに相当)を処理する事が出来ます。
例えば以下のようなプログラムでは、bore-exceptfor文の中にあるために1回のCtrl+cシグナル(KeyboardInterrupt)でプログラムが終了しません。

python
import time
import signal


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


print("start")
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("\nexcept")
print("end")

そんな場合に、signal.signal(signal.SIGINT, signal.SIG_DFL)を追加すると1回のCtrl+cシグナルでプログラムが終了することになります。

signal.signal(signal.SIGINT, signal.SIG_DFL)

  • signal.SIGINT: Ctrl+cのシグナル。KeyboardInterruptに相当する
  • signal.SIG_DFL: シグナルに対する標準の関数。Windowsの場合は、終了コード3で終了します。

を意味します。
ただ、これの欠点は、プログラムが即座に終了してしまい、追加の処理が行えない事です。

python
import time
import signal


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


signal.signal(signal.SIGINT, signal.SIG_DFL)

print("start")
for i in range(10):
  try:
    wait("main", 10)
  except:
    print("\nexcept")
print("end")

そこで、追加の処理が必要な場合は、独自のhandlerを登録する必要があります。
例えば、以下のようにします。
こちらの欠点は、KeyboardInterruptSystemExitbore-exceptで処理される恐れがある事です。

python
import time
import signal
import functools


def wait(name, n):
  for i in range(1, n + 1):
    print(f"\r{name}: {i:>4}/{n}", end="")
    time.sleep(0.1)
  print("")


def handler(signum, frame, exc):
  print(f"\n{frame.f_locals}")
  raise exc


handler = functools.partial(handler, exc=KeyboardInterrupt)
signal.signal(signal.SIGINT, handler)

print("start")
for i in range(10):
  wait("main", 10)
print("end")

参考

Microsoft Documentation: Technical documentation: Signal

By default, signal terminates the calling program with exit code 3, regardless of the value of sig.
8
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3