34

语境

我最近在 Code Review 上发布了一个用于审查的计时器类。我有一种直觉,因为我曾经看到 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

问题

在此代码示例的上下文中,我如何公开竞争条件、修复它们并证明它们已修复?

加分

适用于其他实现和问题而不是专门针对此代码的测试框架的额外点。

带走

我的结论是,重现已识别竞态条件的技术解决方案是控制两个线程的同步性,以确保它们按照会暴露错误的顺序执行。这里重要的一点是它们已经被确定为竞争条件。我发现识别竞争条件的最佳方法是将您的代码提交代码审查并鼓励更多专家对其进行分析。

4

4 回答 4

11

传统上,在多线程代码中强制竞争条件是通过信号量完成的,因此您可以强制一个线程等到另一个线程达到某种边缘条件后再继续。

例如,您的对象有一些代码来检查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 模拟框架的阅读可能会有所帮助。事实上,我不确定模拟是否有助于测试这段代码:它几乎完全是独立的,不依赖于许多外部对象。但是模拟教程有时会涉及到这些问题。我没有使用过任何这些,但这些文档是一个很好的开始:

于 2013-11-27T06:07:03.247 回答
8

测试线程(非)安全代码的最常见解决方案是启动大量线程并希望获得最好的结果。我和我可以想象的其他人遇到的问题是它依赖于机会并且它使测试变得“繁重”。

当我不久前遇到这个问题时,我想追求精确而不是蛮力。结果是一段测试代码通过让线程相互竞争而导致竞争条件。

示例竞速代码

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(全局解释器锁)。需要注意的是,线程实际上可能不是优化性能的最佳方法(但这不是您的目标)。我发现这个演示文稿非常好:https ://www.youtube.com/watch?v=zEaosS1U5qY

于 2013-11-27T09:50:45.887 回答
4

您可以使用大量线程对其进行测试:

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 函数

  • stop 也会出现类似的问题。您可以实现一个 testAndStop 函数。

  • 甚至来自timeRemaining函数的这段代码:

    if self.isRunning():
       self._timeRemaining = self.duration - self._elapsedTime()
    

    需要某种原子性,也许您需要在测试 isRunning 之前获取锁。

如果您计划在线程之间共享此类,则需要解决这些问题。

于 2013-11-21T12:18:33.967 回答
3

一般来说 - 这不是可行的解决方案。您可以通过使用调试器重现这种竞争条件(在代码中的某些位置设置断点,然后,当它遇到一个断点时 - 冻结线程并运行代码直到它遇到另一个断点,然后冻结这个线程并解冻第一个线程,您可以使用此技术以任何方式交错线程执行)。

问题是 - 你拥有的线程和代码越多,交叉副作用的方法就越多。实际上 - 它会成倍增长。一般来说,没有可行的解决方案来测试它。只有在一些简单的情况下才有可能。

这个问题的解决方案是众所周知的。编写意识到其副作用的代码,使用同步原语(如锁、信号量或队列)控制副作用,或者尽可能使用不可变数据。

也许更实用的方法是使用运行时检查来强制正确的调用顺序。例如(伪代码):

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。这可以仅使用一个线程轻松测试。

obj = RacyObject()
...
# correct usage
if obj.isReadyAndLocked()
    obj.doJobAndRelease()
于 2013-11-27T08:51:08.340 回答