【问题标题】:Testing approach for multi-threaded software多线程软件的测试方法
【发布时间】:2011-01-27 02:56:39
【问题描述】:

我有一个成熟的地理空间软件,最近重写了一些区域,以更好地利用现代 PC 中可用的多个处理器。具体来说,显示、GUI、空间搜索和主处理都被划分为单独的线程。该软件有一个相当大的用于功能回归的 GUI 自动化套件,以及另一个用于性能回归的较小的自动化套件。虽然所有自动化测试都通过了,但我不相信它们在查找与竞争条件、死锁和其他与多线程相关的问题相关的错误方面提供了几乎足够的覆盖率。您将使用什么技术来查看是否存在此类错误?假设有一些可以根除的技术,你会提倡什么技术来根除它们?

到目前为止,我正在做的是在调试器下运行的应用程序上运行 GUI 功能自动化,这样我就可以打破死锁并捕获崩溃,并计划构建边界检查器并针对它重复测试版本。我还通过 PC-Lint 对源代码进行了静态分析,希望找到潜在的死锁,但没有任何有价值的结果。

应用程序是 C++、MFC、多文档/视图,每个文档有多个线程。我正在使用的锁定机制基于一个对象,该对象包含一个指向 CMutex 的指针,该指针在 ctor 中锁定并在 dtor 中释放。我根据需要使用该对象的局部变量来锁定各种代码位,并且我的互斥体有一个超时,如果达到超时,我会发出警告。我尽可能避免锁定,而是尽可能使用资源副本。

您还会进行哪些其他测试?

编辑:我已经在许多不同的测试和编程论坛上交叉发布了这个问题,因为我很想看看不同的思维方式和思想流派将如何解决这个问题。因此,如果您看到它在其他地方交叉发布,请道歉。我会在一周左右后提供回复的摘要链接

【问题讨论】:

    标签: c++ multithreading testing mfc


    【解决方案1】:

    一些建议:

    • 使用law of large numbers 并执行被测操作不仅一次,而是多次。
    • 通过夸大场景对代码进行压力测试。例如。要测试您的互斥锁持有类,请使用受互斥锁保护的代码的场景:
      • 非常短且快速(单条指令)
      • 耗时(睡眠值大)
      • 包含显式上下文切换(睡眠 (0))
    • 在各种不同的架构上运行您的测试。 (即使您的软件仅适用于 Windows,也可以在具有和不具有超线程以及各种时钟速度的单核和多核处理器上对其进行测试)
    • 尝试设计您的代码,使其大部分不会受到多线程问题的影响。例如。而不是访问共享数据(这需要锁定或非常精心设计的锁定避免技术),让您的工作线程对数据副本进行操作,并使用队列与它们通信。然后你只需要测试你的队列类的线程安全
    • 在系统空闲以及其他任务的负载下运行测试(例如,我们的构建服务器经常并行运行多个构建。仅此一项就揭示了在系统负载下发生的许多多线程错误。)
    • 避免断言超时。如果这样的断言失败,您不知道代码是否损坏或超时是否太短。相反,使用一个非常大的超时时间(只是为了确保测试最终失败)。如果您想测试一个操作不会超过特定时间,请测量持续时间,但不要为此使用超时。

    【讨论】:

    • 你提到的第一个技术在实践中对我来说效果很好。我将并发性提高了 20 到 100 倍并运行我的应用程序。结合按合同设计编程,我设法解决了大部分并发问题。我还对多线程代码进行了全面检查,因为很少出现细微的错误。
    【解决方案2】:

    虽然我同意 @rstevens 的回答,因为目前无法 100% 确定地对线程问题进行单元测试,但我发现有些东西很有用。

    首先,无论您进行何种测试,请确保您在许多不同规格的盒子上运行它们。我有几台构建机器,全都不同,多核、单核、快速、慢速等。它们的多样性的好处是不同的机器会引发不同的线程问题。我经常对向我的农场添加一台新的构建机器感到惊讶,并且突然暴露了一个新的线程错误;我说的是代码中暴露的一个新错误,该错误已在其他构建机器上运行了 10000 次,并且在新机器上出现十分之一......

    其次,您对代码执行的大多数单元测试根本不需要涉及线程。螺纹通常是正交的。因此,第一步是将代码分开,以便您可以测试执行工作的实际代码,而不必过多担心线程性质。这通常意味着创建线程代码用来驱动真实代码的接口。然后,您可以单独测试真实代码。

    第三,您可以测试线程代码与代码主体交互的位置。这意味着为您开发的接口编写一个模拟来分隔两个代码块。到目前为止,线程代码可能要简单得多,然后您通常可以将同步对象放在您制作的模拟中,以便您可以控制被测代码。因此,您将启动您的线程并通过调用您的模拟来等待它设置一个事件,然后让它阻塞您的测试代码控制的另一个事件。然后,测试代码可以将线程代码从界面中的一个点步进到下一个点。

    最后(如果您已经解耦了足够多的事情,您可以做之前的事情,那么这很容易)然后您可以运行被测应用程序的多线程部分的较大部分,并确保您获得您想要的结果预计;您可以使用线程的优先级,甚至可以添加几个简单地消耗 CPU 的测试线程来搅动一些事情。

    现在您在不同的硬件上多次运行所有这些测试...

    我还发现,在 DevPartner BoundsChecker 之类的工具下运行测试(或应用程序)会很有帮助,因为它会扰乱线程调度,以至于有时很难找到错误。我还编写了一个死锁检测工具,它在程序执行期间检查锁反转,但我很少使用它。

    您可以在此处查看我如何测试多线程 C++ 代码的示例:http://www.lenholgate.com/blog/2004/05/practical-testing.html

    【讨论】:

      【解决方案3】:

      不是一个真正的答案:

      测试多线程错误非常困难。大多数错误只有在两个(或更多)线程以特定顺序进入代码中的特定位置时才会出现。 是否以及何时满足此条件可能取决于进程运行的时间。由于以下前提条件之一,此时间可能会发生变化:

      • 处理器类型
      • 处理器速度
      • 处理器/内核数
      • 优化级别
      • 在调试器内部或外部运行
      • 操作系统

      我肯定忘记了更多的先决条件。

      因为 MT 错误高度依赖于运行 Heisenberg 的“不确定性原则”的代码的确切时间:如果您想测试 MT 错误,您可以通过“措施”更改时间,这可能会阻止错误发生发生...

      时间问题是 MT 错误如此高度不确定的原因。 换句话说:您的软件可能运行了几个月,然后在某天崩溃,之后可能会运行数年。如果您没有一些调试日志/核心转储等,您可能永远不知道它为什么会崩溃。

      所以我的结论是:没有真正好的方法来进行线程安全的单元测试。编程时你总是要睁大眼睛。

      为了说明这一点,我将给你一个现实生活中的(简化的)示例(我在更换雇主并查看那里的现有代码时遇到了这种情况):

      假设你有一堂课。如果没有人再使用它,您希望该类自动删除。因此,您在该类中构建了一个引用计数器: (我知道在其中一个方法中删除类的实例是一种不好的风格。这是因为简化了使用 Ref 类来处理计数引用的真实代码。)

      class A {
        private:
          int refcount;
        public:
          A() : refcount(0) {
          }
          void Ref() {
            refcount++;
          }
          void Release() {
            refcount--;
            if (refcount == 0) {
              delete this;
            }
          }
      };
      

      这个接缝很简单,不用担心。但这不是线程安全的! 这是因为“refcount++”和“refcount--”不是原子操作,而是三个操作:

      • 从内存中读取引用计数到寄存器
      • 递增/递减寄存器
      • 将引用计数从寄存器写入内存

      这些操作中的每一个都可以被中断,并且另一个线程可以同时操作相同的引用计数。因此,例如,如果两个线程想要增加引用计数,可能会发生以下情况:

      • 线程 A:从内存读取引用计数到寄存器(引用计数:8)
      • 线程 A:递增寄存器
        • 上下文更改 -
      • 线程 B:从内存读取引用计数到寄存器(引用计数:8)
      • 线程 B:递增寄存器
      • 线程 B:将引用计数从寄存器写入内存(引用计数:9)
        • 上下文更改 -
      • 线程 A:将引用计数从寄存器写入内存(引用计数:9)

      所以结果是:refcount = 9 但它应该是 10!

      这只能通过使用原子操作(即 Windows 上的 InterlockedIncrement() 和 InterlockedDecrement())来解决。

      这个错误根本无法测试!原因是极不可能有两个线程同时尝试修改同一个实例的引用计数,并且该代码之间存在上下文切换。

      但它可能发生! (如果您有一个多处理器或多核系统,则概率会增加,因为不需要上下文切换来实现它)。 它会在几天、几周或几个月后发生!

      【讨论】:

      • 即使有互锁函数,这仍然不是线程安全的。考虑:线程 1:a->Ref(); // 使用 a->Release();线程 2:a->Release();假设线程 1 调用 Ref() 而线程 2 在 InterlockedDecrement 和 delete this 之间的 Release() 调用中...
      • CPU 中发生的并发和指令重新排序使得并发错误比该帖子中的示例更加不可预测。见infoq.com/presentations/click-crash-course-modern-hardware
      • @Esko Luontola:没错。但是,如果我的简单示例已经引起了问题,那么如果编译器或 CPU 重新排序指令,它肯定不会变得更好:-)
      • @oefe:你是对的。但这是我想研究的范围之外该类架构的一个弱点。缺点是可以调用 Ref()、Release() 和第二个 Release()。所以调用不是对称的。通常你有一个类,将 Ref() 和 Release() 调用封装在构造函数/析构函数中,以确保它们被对称调用。
      【解决方案4】:

      看起来您正在使用 Microsoft 工具。 Microsoft Research 的一个小组一直致力于开发一种专门用于消除并发错误的工具。查看CHESS。其他处于早期阶段的研究项目有CuzzFeatherlite

      VS2010 包含一个非常漂亮的并发分析器,视频是available here.

      【讨论】:

        【解决方案5】:

        正如 Len Holgate 所提到的,我建议重构(如果需要)并为不同线程与携带状态的对象交互的代码部分创建接口。然后可以将代码的这些部分与包含实际功能的代码分开进行测试。为了验证这样的单元测试,我会考虑使用代码覆盖工具(为此我使用 gcov 和 lcov)来验证线程安全接口中的所有内容都被覆盖。

        我认为这是验证测试中是否涵盖新代码的一种非常方便的方法。 下一步是遵循其他答案关于如何运行测试的建议。

        【讨论】:

          【解决方案6】:

          首先,非常感谢您的回复。有关在不同论坛上发布的回复,请参阅;

          http://www.sqaforums.com/showflat.php?Cat=0&Number=617621&an=0&page=0#Post617621

          Testing approach for multi-threaded software

          http://www.softwaretestingclub.com/forum/topics/testing-approach-for?xg_source=activity

          以及以下邮件列表; software-testing@yahoogroups.com

          测试花费的时间比预期的要长得多,因此这个迟到的回复让我得出结论,即使编码非常简单,向现有应用程序添加多线程在测试方面可能会非常昂贵。这对 SQA 社区来说可能很有趣,因为那里正在进行越来越多的多线程开发。

          根据 Joe Strazzere 的建议,我发现解决错误的最有效方法是通过具有不同输入的自动化。我最终在三台 PC 上执行此操作,这三台 PC 在大约六周内反复运行了一系列测试,输入不同。最初,我每天看到每台 PC 崩溃一到两次。当我追踪这些时,它最终在三台 PC 之间每周有一到两台,过去两周我们没有遇到任何进一步的问题。在过去的两周里,我们还推出了一个供用户进行 beta 测试的版本,并且正在内部使用该软件。

          除了在自动化下改变输入,我还从以下得到了不错的结果;

          • 添加一个测试选项,允许从配置文件中读取互斥超时,而这又可以由我的自动化控制。

          • 将互斥体超时延长到预期执行一段线程代码的典型时间之外,并在超时时触发调试异常。

          • 将自动化与调试器 (VS2008) 结合使用,以便在出现问题时有更好的机会进行跟踪。

          • 在没有调试器的情况下运行,以确保调试器没有隐藏其他与时序相关的错误。

          • 针对正常发布、调试和完全优化的构建运行自动化。 FWIW,优化的构建引发了其他构建中无法重现的错误。

          发现的错误类型往往很严重,例如取消引用无效指针,甚至在调试器下也需要进行相当多的跟踪。正如在其他地方所讨论的,SuspendThread 和 ResumeThread 函数最终成为罪魁祸首,并且所有这些函数的使用都被互斥锁所取代。同样,由于没有超时,所有关键部分都被删除了。关闭文档和退出程序也是一个错误来源,在一个实例中,一个文档被销毁而工作线程仍然处于活动状态。为了克服这个问题,每个线程添加了一个互斥锁来控制线程的生命周期,并由文档析构函数获取以确保线程已按预期终止。

          再次,非常感谢所有详细而多样的回复。下次我参加此类活动时,我会做好更充分的准备。

          【讨论】:

            猜你喜欢
            • 2018-09-11
            • 1970-01-01
            • 2011-11-06
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2011-07-29
            相关资源
            最近更新 更多