如何对多线程代码进行单元测试?

798

迄今为止,我一直避免测试多线程代码的噩梦,因为它似乎太过危险。 我想问一下人们如何测试依赖于线程成功执行的代码,或者人们如何测试那些只有在两个线程以特定方式交互时才会出现的问题?

这似乎是程序员今天面临的一个非常关键的问题,汇集我们对此的知识将是有用的。


2
我在考虑发布一个关于这个确切问题的问题。虽然威尔在下面提出了许多好观点,但我认为我们可以做得更好。我同意没有单一的“方法”可以干净地处理这个问题。然而,“尽可能进行测试”设置了非常低的门槛。我会回来分享我的发现。 - Zach Burlingame
请问您能提供一下您之前关于单元测试的问题的链接吗? - Andrew Grimm
http://stackoverflow.com/questions/4418373/designing-a-test-class-for-a-custom-barrier/4427499#4427499 - Toby
19
我认为需要注意的是,这个问题已经有8年了,应用程序库在此期间已经取得了很大进展。在“现代时代”(2016年),多线程开发主要出现在嵌入式系统中。但如果你正在开发桌面或手机应用程序,请先探索替代方案。像.NET这样的应用程序环境现在包括管理或极大简化可能90%的常见多线程场景的工具(asnync/await、PLinq、IObservable、TPL等)。多线程代码很难。如果你不重复造轮子,就不必重新测试它。 - Paul Williams
这可能是不受欢迎的想法,但如果你在Rust中编写代码而不使用unsafe块,编译器实际上可以保证无竞争的线程安全。对我来说,这甚至比通常提到的内存安全更重要。 - Mikko Rantalainen
显示剩余2条评论
29个回答

273

看,没有简单的方法来做这件事。我正在处理一个本质上是多线程的项目。操作系统中的事件会同时发生,我必须并行处理它们。

处理测试复杂的、多线程应用程序代码的最简单方法是:如果测试过于复杂,那么你的设计就有问题了。如果你有一个单一实例,有多个线程在其上运行,并且你无法测试这些线程相互干扰的情况,那么你的设计需要重新进行。这既是简单又是复杂的。

有许多编写多线程程序的方法可以避免线程同时运行实例。最简单的方法是使所有对象都是不可变的。当然,这通常是不可能的。因此,您必须确定设计中线程与同一实例交互的位置,并减少这些位置的数量。通过这样做,您可以隔离出一些类,其中实际发生多线程,从而减少测试系统的总体复杂性。

但你必须意识到,即使这样做,你仍然不能测试出每个情况下两个线程相互干扰的情况。要做到这一点,你必须在同一个测试中同时运行两个线程,然后精确控制它们在任何给定时刻执行的代码行。你所能做的最好的事情就是模拟这种情况。但这可能需要你专门为测试编写代码,并且这只是朝着真正解决问题的半步而已。

也许测试多线程代码最好的方法是通过对代码进行静态分析。如果你的多线程代码没有遵循有限的一组线程安全模式,那么你可能会有问题。我认为 VS 中的代码分析包含了一些关于线程的知识,但可能并不多。

看,目前的情况(也可能会持续相当长一段时间),测试多线程应用程序的最佳方法是尽可能地减少线程代码的复杂性。最小化线程交互的区域,尽可能地进行测试,并使用代码分析来确定风险区域。


1
如果你处理的是一种允许代码分析的语言/框架,那么代码分析非常有用。例如:Findbugs可以找到使用静态变量的非常简单和容易共享的并发问题。但它无法找到单例设计模式,因为它假设所有对象都可以被创建多次。对于像Spring这样的框架来说,这个插件显然是不够用的。 - Zombies
3
实际上有一种治疗方法:活跃对象。 http://www.drdobbs.com/parallel/prefer-using-active-objects-instead-of-n/225700095 - Dill
12
虽然这是个好建议,但我仍然不明白如何测试那些需要多个线程的最小区域。 - Bryan Rayner
15
"If its too complex to test, you're doing it wrong" - 我们都不得不深入研究我们没有编写的旧代码。这个观察结果如何确切地帮助任何人呢? - Ronna
5
静态分析可能是个好主意,但它并不是测试。这篇文章实际上没有回答问题,问题是关于如何进行测试。 - Warren Dew
显示剩余3条评论

114

这个问题已经发布一段时间了,但仍未得到解答...

kleolb02 的回答是不错的。我会试着提供更多细节。

我有一个应用于 C# 代码的方法,用于编写能够实现 可重复 测试的单元测试,这在多线程代码中是最大的挑战。因此,我的答案旨在将异步代码强制放入测试工具中,并使之可以以 同步 的方式运行。

这是来自 Gerard Meszaros 的书籍 "xUnit Test Patterns" 中的一个想法,称为“Humble Object”(第695页):您必须将核心逻辑代码与任何类似于异步代码的内容分开。这将导致出现一个只处理核心逻辑、使用 同步 方法的类。

这样,您就可以将核心逻辑代码以 同步 方式进行测试。您完全控制所进行的调用的时间,从而可以实现 可重复 的测试。这就是将核心逻辑与异步逻辑分离的收获。

这个核心逻辑需要被另一个类包装,该类负责以异步方式接收对核心逻辑的调用并将这些调用 委托 给核心逻辑。生产代码只能通过该类访问核心逻辑。由于该类应该仅委托调用,因此它是一个非常“愚蠢”的类,没有太多逻辑。因此,您可以将针对此异步工作类的单元测试保持在最小限度。

任何高于此级别的(测试类间交互)都属于组件测试。在这种情况下,如果遵循“Humble Object”模式,您也应该能够完全控制时间。


2
但是有时候线程之间的协作也是需要测试的,对吧? 在阅读了您的回答后,我肯定会将核心逻辑与异步部分分开。但我仍然会通过异步接口进行逻辑测试,并使用一个“所有线程都完成工作”的回调函数。 - CopperCash
1
这似乎非常适用于单线程程序和具有某种并发形式但实际上不相互交互的算法。我认为它在测试真正的并行算法时效果不佳。 - Nicolas Bousquet
这种测试也无法帮助您可能遇到的死锁问题,其中多个线程以不同的顺序获取多个锁,并最终等待彼此释放锁才能继续。通用解决方案是在所有线程中以相同的顺序获取所有所需的锁,并且可以通过单元测试进行验证。 - Mikko Rantalainen

74

确实是个棘手的问题!在我的(C ++)单元测试中,我将其分解为几个类别,沿用并发模式的方式:

  1. 针对在单个线程中运行且不具备线程感知能力的类的单元测试-易于操作,像通常一样进行测试。

  2. 针对监视器对象(这些对象在调用者线程的控制下执行同步方法)公开同步公共API的单元测试-实例化多个模拟线程来执行API。构建场景,以便测试被测对象内部条件。包括一个长时间运行的测试,基本上会从多个线程中对其进行大量操作。我知道这并不科学,但它确实可以增加信心。

  3. 针对主动对象(封装自己的线程或控制线程的对象)的单元测试-与上述第2条类似,并根据类设计进行变化。公共API可能是阻塞或非阻塞的,调用者可能会获取futures,数据可能会到达队列或需要被脱队。这里有许多组合可用;请白盒测试。仍需要多个模拟线程调用正在测试的对象。

另外:

在我所做的内部开发人员培训中,我会教授并发支柱和这两种模式作为思考和分解并发问题的主要框架。显然还有更高级的概念,但我发现这组基本知识有助于使工程师避免麻烦。它还导致了更易于进行单元测试的代码,如上所述。


63

最近几年在为几个项目编写线程处理代码时,我已经多次遇到了这个问题。我现在提供一个晚音答案,因为大多数其他答案虽然提供了替代方案,但实际上没有回答有关测试的问题。我的答案是针对没有替代多线程代码的情况;我涵盖了代码设计问题以保证完整性,但也讨论了单元测试。

编写可测试的多线程代码

首先要做的是将生产线程处理代码与所有实际数据处理的代码分开。这样,数据处理可以作为单线程代码进行测试,而多线程代码所做的唯一事情就是协调线程。

其次要记住的是,多线程代码中的错误是概率性的;那些表现得最不频繁的错误是混入生产环境的错误,即使在生产环境中也很难重现,因此会引起最大的问题。因此,编写代码的标准方法是快速编写代码,然后调试它,直到它工作是对于多线程代码是不好的想法;它将导致容易修复的错误被修复,而危险的错误仍然存在。

相反,编写多线程代码时,你必须以避免一开始就写入错误的态度编写代码。如果你已经正确地移除了数据处理代码,那么线程处理代码应该足够小 - 最好只有几行,最多只有几十行 - 你有机会在不写bug的情况下编写它,而且如果你理解线程处理、花费时间和仔细的话,肯定不会写很多的bug。

为多线程代码编写单元测试

一旦多线程代码被尽可能仔细地编写出来,仍然值得为该代码编写测试。测试的主要目的不是测试高度依赖时间的竞争条件bug - 这种竞争条件无法重复测试 - 而是测试你用于防止此类bug的锁定策略是否允许多个线程按预期进行交互。

为了正确地测试锁定行为,测试必须启动多个线程。为了使测试可重复,我们希望线程之间的交互以可预测的顺序发生。我们不希望在测试中外部同步线程,因为这会掩盖可能发生在未经外部同步的生产环境中的错误。这就只剩下使用时间延迟进行线程同步的方法,这是我每次编写多线程代码测试时成功使用的技术。
如果延迟时间太短,则测试变得脆弱,因为微小的时间差异(例如在运行测试的不同机器之间)可能导致时间出错并导致测试失败。我通常的做法是从引起测试失败的延迟开始,将延迟时间增加到可以在我的开发机器上可靠通过测试,然后将延迟时间加倍,使测试有很好的机会在其他机器上通过。虽然这意味着测试会花费宏观时间,但根据我的经验,仔细设计测试可以将该时间限制在不超过十几秒钟。由于应用程序中不应该有很多需要线程协调代码的地方,因此对于测试套件来说,这应该是可以接受的。
最后,跟踪测试捕获的错误数量。如果您的测试具有80%的代码覆盖率,可以预期捕获大约80%的错误。如果您的测试经过良好设计但未发现任何错误,则有合理的机会表明您没有其他只会在生产环境中显示出的错误。如果测试捕获了一两个错误,您可能仍然很幸运。超过这个数量,您可能需要考虑仔细审查甚至完全重写线程处理代码,因为该代码很可能仍包含隐藏的错误,这些错误将在代码进入生产环境后非常难以发现并修复。

7
测试只能揭示错误的存在,而不能证明它们不存在。原始问题涉及双线程问题,在这种情况下,可以进行详尽的测试,但通常情况下不行。对于除最简单场景之外的任何内容,您可能需要采取正式方法——但不要跳过单元测试!编写正确的多线程代码本来就很难,同样困难的问题是将其未来化以防止回归。 - Paul Williams
5
对于最不为人所理解的方式之一,你给出了一份精彩的总结。你的回答准确指出了人们通常忽视的真正隔离问题。 - prash
1
十几秒的时间已经相当长了,即使你只有几百个这样长度的测试... - Toby Speight
2
@TobySpeight 这些测试与普通单元测试相比较长。但是,我发现如果线程代码被设计得尽可能简单,那么半打测试就足够了。需要几百个多线程测试几乎肯定表明线程安排过于复杂。 - Warren Dew
2
这是一个很好的理由,让你尽可能将线程逻辑与功能分离(我知道,这比做起来容易得多)。如果可能的话,将测试套件分为“每次更改”和“提交前”两个集合(这样你的分钟级测试不会受到太大影响)。 - Toby Speight
显示剩余4条评论

27

我在测试多线程代码时也遇到了严重的问题。后来在Gerard Meszaros的《xUnit Test Patterns》中找到了一个非常酷的解决方案。他描述的模式称为Humble Object

基本上,它描述了如何将逻辑提取到一个单独的易于测试的组件中,该组件与其环境解耦。在测试了这个逻辑之后,您可以测试复杂的行为(多线程,异步执行等等)。


19

Awaitility可以帮助您编写确定性单元测试。它允许您等待系统中某个位置的状态更新。例如:

await().untilCall( to(myService).myMethod(), greaterThan(3) );
或者
await().atMost(5,SECONDS).until(fieldIn(myObject).ofType(int.class), equalTo(1));

它还支持Scala和Groovy。

await until { something() > 4 } // Scala example

1
Awaitility 真是太棒了 - 正是我所寻找的! - Forge_7

19

有一些非常不错的工具可供使用,以下是一些Java工具的概述。

一些很好的静态分析工具包括FindBugs(提供一些有用的提示)、JLintJava Pathfinder(JPF和JPF2)以及Bogor

MultithreadedTC是一个相当不错的动态分析工具(集成到JUnit中),你需要设置自己的测试用例。

来自IBM研究机构的ConTest很有意思。它通过插入各种线程修改行为(例如sleep和yield)来注入代码,以尝试随机发现错误。

SPIN是一个非常酷的工具,可用于建模Java(和其他)组件,但你需要一些有用的框架。如果你知道如何使用,它会非常强大,但是使用起来比较困难。相当多的工具在其后台使用SPIN。

MultithreadedTC可能是最主流的工具,但是上面列出的一些静态分析工具绝对值得一看。


17
另一种测试多线程代码和非常复杂的系统的方法是通过Fuzz Testing。它并不完美,也不会找到所有问题,但很可能会有用,而且做起来很简单。
引用:
模糊测试或者fuzzing是一种软件测试技术,它为程序的输入提供随机数据("fuzz")。如果程序失败(例如崩溃或失败内置代码断言),则可以注意到缺陷。模糊测试的巨大优势在于测试设计极其简单,并且没有关于系统行为的先入之见。
...
模糊测试经常用于采用黑盒测试的大型软件开发项目中。这些项目通常有预算来开发测试工具,而模糊测试是一种成本效益比高的技术之一。
...
然而,模糊测试不能替代详尽的测试或正式方法:它只能提供系统行为的随机样本,在许多情况下,通过模糊测试只能证明一个软件处理异常而不崩溃,而不能正确地行为。因此,模糊测试只能被视为一个发现错误的工具,而不是质量保证。

14
测试多线程代码的正确性是一个相当困难的问题,正如先前所述。最终归结为确保代码中没有不正确的同步数据竞争。这个问题在于有无限多种可能的线程执行方式(交错),而你对此没有太多控制(确保阅读this文章)。在简单场景下,通过推理可能实际上可以证明正确性,但通常情况下并非如此。特别是如果你想避免/最小化同步,而不是选择最明显/最简单的同步选项。

我采用的一种方法是编写高度并发的测试代码,以使潜在的未被检测到的数据竞争更容易发生。然后我运行这些测试一段时间 :) 我曾经偶然听到过一次演讲,其中一些计算机科学家展示了一种工具,该工具可以从规格中随机设计测试,然后狂野地并行运行它们,检查是否违反了定义的不变式。

顺便提一下,我认为在这里还没有提到测试MT代码的这个方面:识别您可以随机检查的代码不变量。不幸的是,找到这些不变量也是一个相当困难的问题。此外,在执行期间它们可能并不总是成立,因此您必须找到/强制执行可以期望它们为真的执行点。将代码执行带到这样的状态也是一个困难的问题(可能本身会产生并发问题)。哎呀,这太难了!
一些有趣的链接阅读:

作者提到了测试中的随机化。可能是指已经移植到许多语言的QuickCheck(http://www.cse.chalmers.se/~rjmh/QuickCheck/manual.html)。您可以在此处观看有关并发系统测试的演讲(https://www.youtube.com/watch?v=zi0rHwfiX1Q)。 - Max

13

我做过很多这样的事情,而且确实很糟糕。

一些提示:

  • GroboUtils 用于运行多个测试线程
  • alphaWorks ConTest 用于仪器化类以使交错在迭代之间变化
  • 创建一个 throwable 字段并在 tearDown 中检查它(参见清单1)。如果在另一个线程中捕获了错误的异常,只需将其分配给throwable。
  • 我创建了清单2中的“utils”类,并发现它非常有用,特别是waitForVerify和waitForCondition,可以大大提高测试性能。
  • 在测试中充分利用 AtomicBoolean。它是线程安全的,通常需要一个最终的引用类型来存储回调类等的值。请参阅清单3中的示例。
  • 确保始终为测试设置超时时间(例如, @Test(timeout=60*1000)),因为并发测试有时会在出现故障时挂起无限期。

清单1:

@After
public void tearDown() {
    if ( throwable != null )
        throw throwable;
}

清单 2:

import static org.junit.Assert.fail;
import java.io.File;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Random;
import org.apache.commons.collections.Closure;
import org.apache.commons.collections.Predicate;
import org.apache.commons.lang.time.StopWatch;
import org.easymock.EasyMock;
import org.easymock.classextension.internal.ClassExtensionHelper;
import static org.easymock.classextension.EasyMock.*;

import ca.digitalrapids.io.DRFileUtils;

/**
 * Various utilities for testing
 */
public abstract class DRTestUtils
{
    static private Random random = new Random();

/** Calls {@link #waitForCondition(Integer, Integer, Predicate, String)} with
 * default max wait and check period values.
 */
static public void waitForCondition(Predicate predicate, String errorMessage) 
    throws Throwable
{
    waitForCondition(null, null, predicate, errorMessage);
}

/** Blocks until a condition is true, throwing an {@link AssertionError} if
 * it does not become true during a given max time.
 * @param maxWait_ms max time to wait for true condition. Optional; defaults
 * to 30 * 1000 ms (30 seconds).
 * @param checkPeriod_ms period at which to try the condition. Optional; defaults
 * to 100 ms.
 * @param predicate the condition
 * @param errorMessage message use in the {@link AssertionError}
 * @throws Throwable on {@link AssertionError} or any other exception/error
 */
static public void waitForCondition(Integer maxWait_ms, Integer checkPeriod_ms, 
    Predicate predicate, String errorMessage) throws Throwable 
{
    waitForCondition(maxWait_ms, checkPeriod_ms, predicate, new Closure() {
        public void execute(Object errorMessage)
        {
            fail((String)errorMessage);
        }
    }, errorMessage);
}

/** Blocks until a condition is true, running a closure if
 * it does not become true during a given max time.
 * @param maxWait_ms max time to wait for true condition. Optional; defaults
 * to 30 * 1000 ms (30 seconds).
 * @param checkPeriod_ms period at which to try the condition. Optional; defaults
 * to 100 ms.
 * @param predicate the condition
 * @param closure closure to run
 * @param argument argument for closure
 * @throws Throwable on {@link AssertionError} or any other exception/error
 */
static public void waitForCondition(Integer maxWait_ms, Integer checkPeriod_ms, 
    Predicate predicate, Closure closure, Object argument) throws Throwable 
{
    if ( maxWait_ms == null )
        maxWait_ms = 30 * 1000;
    if ( checkPeriod_ms == null )
        checkPeriod_ms = 100;
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    while ( !predicate.evaluate(null) ) {
        Thread.sleep(checkPeriod_ms);
        if ( stopWatch.getTime() > maxWait_ms ) {
            closure.execute(argument);
        }
    }
}

/** Calls {@link #waitForVerify(Integer, Object)} with <code>null</code>
 * for {@code maxWait_ms}
 */
static public void waitForVerify(Object easyMockProxy)
    throws Throwable
{
    waitForVerify(null, easyMockProxy);
}

/** Repeatedly calls {@link EasyMock#verify(Object[])} until it succeeds, or a
 * max wait time has elapsed.
 * @param maxWait_ms Max wait time. <code>null</code> defaults to 30s.
 * @param easyMockProxy Proxy to call verify on
 * @throws Throwable
 */
static public void waitForVerify(Integer maxWait_ms, Object easyMockProxy)
    throws Throwable
{
    if ( maxWait_ms == null )
        maxWait_ms = 30 * 1000;
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    for(;;) {
        try
        {
            verify(easyMockProxy);
            break;
        }
        catch (AssertionError e)
        {
            if ( stopWatch.getTime() > maxWait_ms )
                throw e;
            Thread.sleep(100);
        }
    }
}

/** Returns a path to a directory in the temp dir with the name of the given
 * class. This is useful for temporary test files.
 * @param aClass test class for which to create dir
 * @return the path
 */
static public String getTestDirPathForTestClass(Object object) 
{

    String filename = object instanceof Class ? 
        ((Class)object).getName() :
        object.getClass().getName();
    return DRFileUtils.getTempDir() + File.separator + 
        filename;
}

static public byte[] createRandomByteArray(int bytesLength)
{
    byte[] sourceBytes = new byte[bytesLength];
    random.nextBytes(sourceBytes);
    return sourceBytes;
}

/** Returns <code>true</code> if the given object is an EasyMock mock object 
 */
static public boolean isEasyMockMock(Object object) {
    try {
        InvocationHandler invocationHandler = Proxy
                .getInvocationHandler(object);
        return invocationHandler.getClass().getName().contains("easymock");
    } catch (IllegalArgumentException e) {
        return false;
    }
}
}

清单 3:

@Test
public void testSomething() {
    final AtomicBoolean called = new AtomicBoolean(false);
    subject.setCallback(new SomeCallback() {
        public void callback(Object arg) {
            // check arg here
            called.set(true);
        }
    });
    subject.run();
    assertTrue(called.get());
}

2
超时是个好主意,但如果测试超时了,那么后面的结果都是可疑的。超时的测试可能仍然有一些线程在运行,这可能会影响你的结果。 - Don Kirkby

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接