Linux 上 Swift 的 Thread Sanitizer

Thread Sanitizer 现在已在 Linux 上可用,作为 Swift 5.1 的一部分!前往 并获取 Swift 5.1 开发快照来试用它。

Swift 语言保证单线程环境下的内存安全。但是,多线程代码中的冲突访问会导致数据竞争。Swift 中的数据竞争会导致意外行为,甚至可能导致内存损坏,破坏 Swift 的内存安全。Thread Sanitizer 是一种错误查找工具,可在运行时诊断数据竞争。它在编译期间检测代码,并在执行期间发生数据竞争时检测到它们。

数据竞争示例

让我们看一个简单的多线程程序。它使用了 DispatchQueue.concurrentPerform,它实现了一个高效的并行 for 循环

import Dispatch

func computePartialResult(chunk: Int) -> Result {
    var result = Result()
    // Computing the result is an expensive operation.
    return result
}

var results = [Result]()

DispatchQueue.concurrentPerform(iterations: 100) { index in
    let r = computePartialResult(chunk: index)
    results.append(r)
}

print("Result count: \(results.count)")

乍一看,人们可能期望此程序打印 “Result count: 100”。但实际上它可能会打印 “91”、“94”,甚至崩溃。原因是该程序包含数据竞争:多个线程在没有同步的情况下修改 results 数组。

在此示例中,很容易发现代码的哪个部分引入了数据竞争。但是,在实际应用中,数据竞争可能非常难以诊断。它们的症状可能只是偶尔观察到,并且它们可以以微妙的方式改变程序行为。在最坏的情况下,它们可能会损坏内存并破坏 Swift 的内存安全。值得庆幸的是,Thread Sanitizer 已被证明是检测和诊断 Swift 中数据竞争的有效工具。

使用 Thread Sanitizer

要使用 Thread Sanitizer 检测您的程序,请使用 -sanitize=thread 编译器标志,并确保以调试模式构建您的程序。Thread Sanitizer 依赖于调试信息来描述它发现的问题。

Swift 编译器

Thread Sanitizer 可以从命令行上的 Swift 编译器调用中使用

swiftc -g -sanitize=thread

由于 Thread Sanitizer 目前在未优化的、使用调试信息构建的代码中效果最佳,因此请省略编译器优化标志,或使用 -Onone 来覆盖预先存在的优化级别。

Swift Package Manager

Thread Sanitizer 也可以直接与 Swift Package Manager 一起使用

swift build -c debug --sanitize=thread

使用 test 目标(而不是 build)来运行启用 Thread Sanitizer 的软件包测试。请注意,您的测试需要实际执行多线程代码,否则 Thread Sanitizer 将找不到数据竞争。

示例

让我们编译并运行简单的示例,看看 Thread Sanitizer 如何报告数据竞争。在 Linux 上,Thread Sanitizer 不会输出未修饰的 Swift 符号名称。您可以使用 swift-demangle 使报告更清晰

➤ swiftc main.swift -g -sanitize=thread -o race
➤ ./race 2>&1 | swift-demangle
==================
WARNING: ThreadSanitizer: Swift access race (pid=96)
  Modifying access of Swift variable at 0x7ffef26e65d0 by thread T2:
    #0 closure #1 (Swift.Int) -> () in main main.swift:41 (swift-linux+0xb9921)
    #1 partial apply forwarder for closure #1 (Swift.Int) -> () in main <compiler-generated>:? (swift-linux+0xb9d4c)
       [... stack frames ...]

  Previous modifying access of Swift variable at 0x7ffef26e65d0 by thread T1:
    #0 closure #1 (Swift.Int) -> () in main main.swift:41 (swift-linux+0xb9921)
    #1 partial apply forwarder for closure #1 (Swift.Int) -> () in main race-b3c26c.o:? (swift-linux+0xb9d4c)
       [... stack frames ...]

  Location is stack of main thread.

  Thread T2 (tid=99, running) created by main thread at:
    #0 pthread_create /home/buildnode/jenkins/workspace/oss-swift-5.1-package-linux-ubuntu-16_04/llvm/projects/compiler-rt/lib/tsan/rtl/tsan_interceptors.cc:980 (swift-linux+0x487b5)
       [... stack frames ...]
    #3 static Dispatch.DispatchQueue.concurrentPerform(iterations: Swift.Int, execute: (Swift.Int) -> ()) -> () ??:? (libswiftDispatch.so+0x1d916)
    #4 __libc_start_main ??:? (libc.so.6+0x2082f)

  Thread T1 (tid=98, running) created by main thread at:
    #0 pthread_create /home/buildnode/jenkins/workspace/oss-swift-5.1-package-linux-ubuntu-16_04/llvm/projects/compiler-rt/lib/tsan/rtl/tsan_interceptors.cc:980 (swift-linux+0x487b5)
       [...stack frames ...]
    #3 static Dispatch.DispatchQueue.concurrentPerform(iterations: Swift.Int, execute: (Swift.Int) -> ()) -> () ??:? (libswiftDispatch.so+0x1d916)
    #4 __libc_start_main ??:? (libc.so.6+0x2082f)

SUMMARY: ThreadSanitizer: Swift access race main.swift:41 in closure #1 (Swift.Int) -> () in main
==================
[... more identical warnings ...]
==================

理解 Thread Sanitizer 报告的一个好的起点是摘要行。它显示了

请注意,数据竞争至少涉及两个线程并发访问同一内存位置(没有适当的同步),其中至少一个线程进行写入。Thread Sanitizer 报告了哪些线程参与其中(“修改访问/之前的修改访问 … 由线程 …”),并提供了这两个冲突访问的堆栈跟踪。

在这个简单的示例中,两个访问都由相同的源代码语句产生。但是,情况并非总是如此。当调试大型应用程序中的微妙交互时,了解两个访问的跟踪可能非常宝贵。该报告还说明了竞争线程是如何创建的(“线程 … 由 … 创建”)。在此示例中,它们是由主线程在调用 concurrentPerform 时创建的。

一旦理解了问题,下一步就是修复它。如何完成修复很大程度上取决于具体情况和代码的目标。例如,目标可能是使用并发来防止长时间运行的任务锁定应用程序的用户界面。另一个目标可能是通过将其工作负载分解为单独的工作项并并行处理它们以利用功能强大的服务器机器上的更多内核来加速服务。

即使在简单的示例中,也有许多不同的修复数据竞争的选择。一般准则是,只要环境和性能约束允许,就优先选择高级抽象而不是低级同步原语。让我们使用串行队列为示例添加适当的同步

let serialQueue = DispatchQueue(label: "Results Queue")

DispatchQueue.concurrentPerform(iterations: 100) { index in
    let r = computePartialResult(chunk: index)
    serialQueue.sync {
        results.append(r);
    }
}

上面的代码通过序列化对 results.append 的调用来建立适当的同步,从而消除了数据竞争。请注意,闭包的其余部分(包括 computePartialResult)仍然并行执行。这意味着部分结果将出现在 results 数组中的顺序可能在程序的每次运行之间发生变化。

Swift 的主要目标之一是使简单的事情变得容易,使困难的事情成为可能。编写高效的多线程程序是那些困难的事情之一。Swift 保证在没有数据竞争的情况下内存安全,并允许开发人员在需要时承担额外的复杂性。借助 Thread Sanitizer,开发人员在其工具包中拥有一个工具,可以帮助将 Swift 的安全性和生产力带入多线程环境。

有问题吗?

请随时在 相关主题Swift 论坛 上发布关于这篇文章的问题。