コンテキスト
最近、 コードレビューでレビューするためのタイマークラス を投稿しました。 1ユニットテストが失敗するのを見たことがあったので、同時実行のバグがあると直感しましたが、失敗を再現できませんでした。したがって、コードレビューへの私の投稿。
コード内のさまざまな競合状態を強調する素晴らしいフィードバックを受け取りました。 (私は思った)問題と解決策を理解したが、修正を加える前に、単体テストでバグを明らかにしたかった。やってみると難しいことに気づきました。さまざまなスタック交換の回答は、バグを明らかにするためにスレッドの実行を制御する必要があることを示唆しており、不自然なタイミングは必ずしも別のマシンに移植できるとは限りません。これは、私が解決しようとしていた問題を超えた、偶発的な複雑さのように思えました。
代わりに、 Pythonに最適な静的分析(SA)ツール 、PyLintを使用して、バグを検出できるかどうかを確認しましたが、検出できませんでした。なぜ人間はコードレビュー(本質的にSA)を通じてバグを見つけることができたのに、SAツールは見つけられなかったのですか?
Valgrindをpythonで動作させる (ヤクのひげそりのように聞こえた)を試みることを恐れて、最初にバグを再現せずにバグを修正することにしました。今、私はピクルスにいます。
これがコードです。
from threading import Timer, Lock
from time import time
class NotRunningError(Exception): pass
class AlreadyRunningError(Exception): pass
class KitchenTimer(object):
'''
Loosely models a clockwork kitchen timer with the following differences:
You can start the timer with arbitrary duration (e.g. 1.2 seconds).
The timer calls back a given function when time's up.
Querying the time remaining has 0.1 second accuracy.
'''
PRECISION_NUM_DECIMAL_PLACES = 1
RUNNING = "RUNNING"
STOPPED = "STOPPED"
TIMEUP = "TIMEUP"
def __init__(self):
self._stateLock = Lock()
with self._stateLock:
self._state = self.STOPPED
self._timeRemaining = 0
def start(self, duration=1, whenTimeup=None):
'''
Starts the timer to count down from the given duration and call whenTimeup when time's up.
'''
with self._stateLock:
if self.isRunning():
raise AlreadyRunningError
else:
self._state = self.RUNNING
self.duration = duration
self._userWhenTimeup = whenTimeup
self._startTime = time()
self._timer = Timer(duration, self._whenTimeup)
self._timer.start()
def stop(self):
'''
Stops the timer, preventing whenTimeup callback.
'''
with self._stateLock:
if self.isRunning():
self._timer.cancel()
self._state = self.STOPPED
self._timeRemaining = self.duration - self._elapsedTime()
else:
raise NotRunningError()
def isRunning(self):
return self._state == self.RUNNING
def isStopped(self):
return self._state == self.STOPPED
def isTimeup(self):
return self._state == self.TIMEUP
@property
def timeRemaining(self):
if self.isRunning():
self._timeRemaining = self.duration - self._elapsedTime()
return round(self._timeRemaining, self.PRECISION_NUM_DECIMAL_PLACES)
def _whenTimeup(self):
with self._stateLock:
self._state = self.TIMEUP
self._timeRemaining = 0
if callable(self._userWhenTimeup):
self._userWhenTimeup()
def _elapsedTime(self):
return time() - self._startTime
質問
このコード例のコンテキストで、競合状態を公開し、修正し、修正されたことを証明するにはどうすればよいですか?
追加ポイント
特にこのコードではなく、他の実装や問題に適したテストフレームワークの追加ポイント。
持ち帰り
私の要点は、特定された競合状態を再現するための技術的な解決策は、2つのスレッドの同期を制御して、バグが発生する順序で実行されるようにすることです。ここで重要な点は、それらがすでに識別されている競合状態であるということです。競合状態を特定するために私が見つけた最善の方法は、コードをコードレビューのために提出し、より多くの専門家に分析を促すことです。
従来、マルチスレッドコードでの競合状態の強制はセマフォを使用して行われるため、続行する前に、別のスレッドが何らかのエッジ状態に達するまでスレッドを強制的に待機させることができます。
たとえば、オブジェクトには、オブジェクトがすでに実行されている場合にstart
が呼び出されないことを確認するコードがあります。次のような操作を行うことで、この条件を強制して、期待どおりに動作することを確認できます。
KitchenTimer
の開始AlreadyRunningError
これを行うには、KitchenTimerクラスを拡張する必要がある場合があります。正式な単体テストでは、多くの場合、重要なときにブロックするように定義されたモックオブジェクトを使用します。モックオブジェクトは、ここで取り上げることができるよりも大きなトピックですが、「pythonモックオブジェクト」をグーグルで検索すると、多くのドキュメントと多くの実装から選択できます。
コードにAlreadyRunningError
を強制的にスローさせる方法は次のとおりです。
import threading
class TestKitchenTimer(KitchenTimer):
_runningLock = threading.Condition()
def start(self, duration=1, whenTimeUp=None):
KitchenTimer.start(self, duration, whenTimeUp)
with self._runningLock:
print "waiting on _runningLock"
self._runningLock.wait()
def resume(self):
with self._runningLock:
self._runningLock.notify()
timer = TestKitchenTimer()
# Start the timer in a subthread. This thread will block as soon as
# it is started.
thread_1 = threading.Thread(target = timer.start, args = (10, None))
thread_1.start()
# Attempt to start the timer in a second thread, causing it to throw
# an AlreadyRunningError.
try:
thread_2 = threading.Thread(target = timer.start, args = (10, None))
thread_2.start()
except AlreadyRunningError:
print "AlreadyRunningError"
timer.resume()
timer.stop()
コードを読み、テストする境界条件のいくつかを特定し、その条件を強制的に発生させるためにタイマーを一時停止する必要がある場所を検討し、それを実現するために条件、セマフォ、イベントなどを追加します。例えばタイマーがwhenTimeUpコールバックを実行するのと同じように、別のスレッドがそれを停止しようとするとどうなりますか? _whenTimeUp:に入るとすぐにタイマーを待機させることで、その条件を強制できます。
import threading
class TestKitchenTimer(KitchenTimer):
_runningLock = threading.Condition()
def _whenTimeup(self):
with self._runningLock:
self._runningLock.wait()
KitchenTimer._whenTimeup(self)
def resume(self):
with self._runningLock:
self._runningLock.notify()
def TimeupCallback():
print "TimeupCallback was called"
timer = TestKitchenTimer()
# The timer thread will block when the timer expires, but before the callback
# is invoked.
thread_1 = threading.Thread(target = timer.start, args = (1, TimeupCallback))
thread_1.start()
sleep(2)
# The timer is now blocked. In the parent thread, we stop it.
timer.stop()
print "timer is stopped: %r" % timer.isStopped()
# Now allow the countdown thread to resume.
timer.resume()
テストするクラスをサブクラス化することは、テスト用にインストルメント化するための優れた方法ではありません。各メソッドの競合状態をテストするには、基本的にすべてのメソッドをオーバーライドする必要があります。その時点で、次のような良い議論があります。元のコードを実際にテストしていないようにしました。代わりに、セマフォをKitchenTimerオブジェクトに正しく配置し、デフォルトでNoneに初期化して、ロックを取得または待機する前にメソッドにif testRunningLock is not None:
をチェックさせる方がクリーンな場合があります。次に、送信する実際のコードで強制的にレースを行うことができます。
Pythonモックフレームワークが役立つかもしれません。実際、モックがこのコードのテストに役立つかどうかはわかりません。ほぼ完全に自己完結型であり、依存していません。多くの外部オブジェクト。しかし、モックチュートリアルでは、このような問題に触れることがあります。私はこれらのいずれも使用していませんが、これらのドキュメントは、開始するのに適した場所のようです。
スレッド(安全でない)コードをテストするための最も一般的な解決策は、多くのスレッドを開始し、最良のものを期待することです。私、そして他の人が想像できる問題は、それが偶然に依存し、テストを「重く」することです。
しばらく前にこれに遭遇したとき、私はブルートフォースではなく精度を求めていました。その結果、スレッドを首から首まで競合させることにより、競合状態を引き起こすテストコードが作成されます。
spam = []
def set_spam():
spam[:] = foo()
use(spam)
set_spam
が複数のスレッドから呼び出された場合、変更とspam
の使用の間に競合状態が存在します。一貫して再現してみましょう。
class TriggeredThread(threading.Thread):
def __init__(self, sequence=None, *args, **kwargs):
self.sequence = sequence
self.lock = threading.Condition()
self.event = threading.Event()
threading.Thread.__init__(self, *args, **kwargs)
def __enter__(self):
self.lock.acquire()
while not self.event.is_set():
self.lock.wait()
self.event.clear()
def __exit__(self, *args):
self.lock.release()
if self.sequence:
next(self.sequence).trigger()
def trigger(self):
with self.lock:
self.event.set()
self.lock.notify()
次に、このスレッドの使用法を示します。
spam = [] # Use a list to share values across threads.
results = [] # Register the results.
def set_spam():
thread = threading.current_thread()
with thread: # Acquires the lock.
# Set 'spam' to thread name
spam[:] = [thread.name]
# Thread 'releases' the lock upon exiting the context.
# The next thread is triggered and this thread waits for a trigger.
with thread:
# Since each thread overwrites the content of the 'spam'
# list, this should only result in True for the last thread.
results.append(spam == [thread.name])
threads = [
TriggeredThread(name='a', target=set_spam),
TriggeredThread(name='b', target=set_spam),
TriggeredThread(name='c', target=set_spam)]
# Create a shifted sequence of threads and share it among the threads.
thread_sequence = itertools.cycle(threads[1:] + threads[:1])
for thread in threads:
thread.sequence = thread_sequence
# Start each thread
[thread.start() for thread in threads]
# Trigger first thread.
# That thread will trigger the next thread, and so on.
threads[0].trigger()
# Wait for each thread to finish.
[thread.join() for thread in threads]
# The last thread 'has won the race' overwriting the value
# for 'spam', thus [False, False, True].
# If set_spam were thread-safe, all results would be true.
assert results == [False, False, True], "race condition triggered"
assert results == [True, True, True], "code is thread-safe"
この構造について十分に説明したので、自分の状況に合わせて実装できると思います。これは「余分なポイント」セクションに非常にうまく適合していると思います。
特にこのコードではなく、他の実装や問題に適したテストフレームワークの追加ポイント。
各スレッドの問題は、独自の方法で解決されます。上記の例では、スレッド間で値を共有することで競合状態を引き起こしました。モジュール属性などのグローバル変数を使用する場合にも、同様の問題が発生する可能性があります。このような問題を解決するための鍵は、スレッドローカルストレージを使用することかもしれません。
# The thread local storage is a global.
# This may seem weird at first, but it isn't actually shared among threads.
data = threading.local()
data.spam = [] # This list only exists in this thread.
results = [] # Results *are* shared though.
def set_spam():
thread = threading.current_thread()
# 'get' or set the 'spam' list. This actually creates a new list.
# If the list was shared among threads this would cause a race-condition.
data.spam = getattr(data, 'spam', [])
with thread:
data.spam[:] = [thread.name]
with thread:
results.append(data.spam == [thread.name])
# Start the threads as in the example above.
assert all(results) # All results should be True.
一般的なスレッドの問題は、複数のスレッドがデータホルダーに対して同時に読み取りおよび/または書き込みを行う問題です。この問題は、読み取り/書き込みロックを実装することで解決されます。読み取り/書き込みロックの実際の実装は異なる場合があります。読み取り優先ロック、書き込み優先ロック、またはランダムに選択できます。
そのようなロック技術を説明する例がそこにあると確信しています。これはすでにかなり長い答えなので、後で例を書くかもしれません。 ;-)
スレッドモジュールのドキュメント を見て、少し試してみてください。スレッドの問題はそれぞれ異なるため、異なる解決策が適用されます。
スレッド化については、Python GIL(Global Interpreter Lock)をご覧ください。スレッド化は実際にはパフォーマンスを最適化するための最良のアプローチではない可能性があることに注意してください(ただし、これはあなたの目標ではありません)このプレゼンテーションはかなり良いと思いました: https://www.youtube.com/watch?v=zEaosS1U5qY
多くのスレッドを使用してテストできます。
import sys, random, thread
def timeup():
sys.stdout.write("Timer:: Up %f" % time())
def trdfunc(kt, tid):
while True :
sleep(1)
if not kt.isRunning():
if kt.start(1, timeup):
sys.stdout.write("[%d]: started\n" % tid)
else:
if random.random() < 0.1:
kt.stop()
sys.stdout.write("[%d]: stopped\n" % tid)
sys.stdout.write("[%d] remains %f\n" % ( tid, kt.timeRemaining))
kt = KitchenTimer()
kt.start(1, timeup)
for i in range(1, 100):
thread.start_new_thread ( trdfunc, (kt, i) )
trdfunc(kt, 0)
私が見るいくつかの問題の問題:
スレッドがタイマーを実行していないと見なしてタイマーを開始しようとすると、通常、テストと開始の間のコンテキストスイッチが原因で、コードで例外が発生します。例外を設けるのは多すぎると思います。または、アトミックtestAndStart関数を使用できます
停止でも同様の問題が発生します。 testAndStop関数を実装できます。
timeRemaining
関数からのこのコードでさえ:
if self.isRunning():
self._timeRemaining = self.duration - self._elapsedTime()
ある種のアトミック性が必要です。おそらく、isRunningをテストする前にロックを取得する必要があります。
このクラスをスレッド間で共有する場合は、これらの問題に対処する必要があります。
一般的に、これは実行可能な解決策ではありません。デバッガーを使用してこの競合状態を再現できます(ブレークポイントの1つに到達したときよりも、コード内のいくつかの場所にブレークポイントを設定します-スレッドをフリーズし、別のブレークポイントに到達するまでコードを実行してから、このスレッドをフリーズして最初のスレッドをフリーズ解除しますスレッド、この手法を使用して、任意の方法でスレッドの実行をインターリーブできます)。
問題は、スレッドとコードが多いほど、副作用をインターリーブする方法が増えることです。実際、それは指数関数的に成長します。一般的にそれをテストするための実行可能な解決策はありません。それはいくつかの単純な場合にのみ可能です。
この問題の解決策はよく知られています。副作用を認識しているコードを記述し、ロック、セマフォ、キューなどの同期プリミティブを使用して副作用を制御するか、可能であれば不変のデータを使用します。
おそらくより実用的な方法は、ランタイムチェックを使用して正しい呼び出し順序を強制することです。例(擬似コード):
class RacyObject:
def __init__(self):
self.__cnt = 0
...
def isReadyAndLocked(self):
acquire_object_lock
if self.__cnt % 2 != 0:
# another thread is ready to start the Job
return False
if self.__is_ready:
self.__cnt += 1
return True
# Job is in progress or doesn't ready yet
return False
release_object_lock
def doJobAndRelease(self):
acquire_object_lock
if self.__cnt % 2 != 1:
raise RaceConditionDetected("Incorrect order")
self.__cnt += 1
do_job()
release_object_lock
isReadyAndLock
を呼び出す前にdoJobAndRelease
をチェックしないと、このコードは例外をスローします。これは、1つのスレッドだけを使用して簡単にテストできます。
obj = RacyObject()
...
# correct usage
if obj.isReadyAndLocked()
obj.doJobAndRelease()