発生した問題
Pythonには非同期処理を行うためのthreading.Timerクラスがあるが、これをfor文の中で使った際に意図しない挙動になってしまうことがあった。例として、以下のようなスクリプトを考える。
import threading
notifications = [[3, 'first notification'], [5, 'second notificaton']]
for n in notifications:
t = threading.Timer(n[0], lambda: print(n[1]))
t.start()
notificationsは通知のタイミングと内容を含むリストのリストであり、これをfor文で回してそれぞれ指定した秒数後に通知内容を出力する、という処理を意図している。
以下のような出力を想定している。()内の数字は実行してから何秒後に出力されたかを示す。
first notification (3)
second notification (5)
しかし実際には以下のようになる。
second notification (3)
second notification (5)
こうなってしまう原因と解決方法をこれから説明する。
原因
上記のように両方の出力が"second notification"になってしまう原因は、threading.Timerに渡した第2引数の関数オブジェクトの評価が、宣言時ではなく実行時に行われるということである。ここでは1回目のループで lambda: print('first notification')
を渡しているつもりでいるが、実際にはlambda: print(<nへの参照>[1])
が渡されている。そのため、実行時、3秒後にはfor文はすでに回り切っているため変数nはnotifications[1]を参照しており、結果としてlambda: print('second notification')
が実行されてしまうのである。
これはPythonに限らず非同期処理をする場合によくあるミスで、値を渡しているつもりが実際には参照を渡しているために、実行時にfor文の最後のループで代入されている要素を全ての非同期処理が参照してしまうという問題である。
解決策
上記のように、非同期処理に対して参照を渡してしまっていることが原因なため、代わりに値を渡すようにすれば良い。幸いthreading.Timerは第3引数に渡したリストを実行時の実引数としてくれるため、以下のようにすれば良い。
import threading
notifications = [[3, 'first notification'], [5, 'second notificaton']]
for n in notifications:
t = threading.Timer(n[0], print, [n[1]])
t.start()
こうすると期待通りの結果になる。
first notification (3)
second notification (5)