Gecko 中的线程安全分析

Clang 线程安全分析在 Gecko 中受支持。这意味着当静态分析检测到互斥锁/监视器保护的成员和数据结构的锁定问题时,构建将生成警告。请注意,Chrome 使用相同的功能。一个警告示例

warning: dom/media/AudioStream.cpp:504:22 [-Wthread-safety-analysis]
reading variable 'mDataSource' requires holding mutex 'mMonitor'

如果您的补丁导致出现此类警告,则需要解决它们;在签入时,它们将被视为错误。

此分析依赖于源代码中的线程安全属性。这些属性已添加到 Mozilla 的 Mutex 和 Monitor 类及其子类中,但在实践中,分析很大程度上依赖于对正在检查的代码的添加,特别是向成员变量的定义添加 MOZ_GUARDED_BY(mutex) 属性。像这样

mozilla::Mutex mLock;
bool mShutdown MOZ_GUARDED_BY(mLock);

有关 Clang 线程安全支持的背景信息,请参阅其文档

新添加的 Mutex 和 Monitor 必须使用线程安全注释,并且我们正在启用静态检查来验证这一点。Mutex 和 Monitor 的旧版用法标记为 MOZ_UNANNOTATED。

如果您正在修改已使用 MOZ_GUARDED_BY()/MOZ_REQUIRES()/等注释的代码,则应确保正确更新注释;例如,如果您更改了成员变量或方法的线程使用情况,则应相应地标记它,添加注释并解决任何警告。由于警告在 autoland/m-c 中将被视为错误,因此您将无法提交包含活动警告的代码。

在类定义中注释锁定和使用要求

需要锁定才能访问的值,或者只是从多个线程使用的值,都应该在定义中包含有关锁定要求和/或其被哪些线程触发的文档

// This array is accessed from both the direct video thread, and the graph
// thread. Protected by mMutex.

nsTArray<std::pair<ImageContainer::FrameID, VideoChunk>> mFrames
MOZ_GUARDED_BY(mMutex);

// Set on MainThread, deleted on either MainThread mainthread, used on
// MainThread or IO Thread in DoStopSession
nsCOMPtr<nsITimer> mReconnectDelayTimer MOZ_GUARDED_BY(mMutex);

强烈建议按访问模式对值进行分组,但至关重要的是要明确访问值的必要条件。对于受 Mutex 和 Monitor 保护的值,添加 MOZ_GUARDED_BY(mutex/monitor) 应该就足够了,尽管您可能还想记录哪些线程访问它,以及它们是读取还是写入它。

具有更复杂访问要求的值(请参阅下面的单写者和基于时间的锁定)需要在其定义位置进行明确的文档记录

MutexSingleWriter mMutex;

// mResource should only be modified on the main thread with the lock.
// So it can be read without lock on the main thread or on other threads
// with the lock.
RefPtr<ChannelMediaResource> mResource MOZ_GUARDED_BY(mMutex);

警告:线程安全分析并非万能;它取决于您告知其访问周围的要求。如果您没有将某个内容标记为 MOZ_GUARDED_BY(),它将不会为您推断出来,并且您最终可能会出现数据竞争。在编写多线程代码时,您应该始终考虑哪些线程可以在何时访问什么,并记录这些信息。

如何在 Gecko 中注释不同的锁定模式

Gecko 使用多种不同的锁定模式。它们包括

  • 始终锁定 - 多个线程可以读取和写入该值

  • 单写者 - 一个线程执行所有写入操作,其他线程读取该值,但写入线程上的代码也会在没有锁的情况下读取它

  • 带外不变式 - 可以从其他线程访问某个值,但仅在某些其他事件之后或之前或在特定状态下,例如在添加侦听器之后或在处理线程关闭之前。

最简单且使用静态分析最容易检查的是始终锁定,并且通常您应该首选此模式。这非常简单;您添加 MOZ_GUARDED_BY(mutex/monitor),并且必须拥有该锁才能访问该值。这可以通过方法中的一些直接 Lock/AutoLock 调用组合来实现;一个断言,即当前线程已持有该锁,或者在方法定义中将该方法注释为需要该锁 (MOZ_REQUIRES(mutex))

// Ensures mSize is initialized, if it can be.
// mLock must be held when this is called, and mInput must be non-null.
void EnsureSizeInitialized() MOZ_REQUIRES(mLock);
...
// This lock protects mSeekable, mInput, mSize, and mSizeInitialized.
Mutex mLock;
int64_t mSize MOZ_GUARDED_BY(mLock);

单写者对于静态分析来说通常很棘手,因为它不知道访问将在哪个线程上发生。通常,您应该在非性能敏感的代码中首选使用始终锁定,尤其是在这些互斥锁几乎总是无竞争并且因此锁定成本很低的情况下。

为了在 Mozilla 代码中支持这种相当常见的模式,我们添加了 MutexSingleWriter 和 MonitorSingleWriter 子类。要使用这些类,您需要在一个对象(通常是包含 Mutex 的对象)上对 SingleWriterLockOwner 进行子类化,实现 ::OnWritingThread(),并将该对象传递给 MutexSingleWriter 的构造函数。在从写入线程访问受保护值的代码中,您需要添加 mMutex.AssertIsOnWritingThread(),它通过调用 OnWritingThread() 执行仅调试的运行时断言,并且还向静态分析器断言该锁被持有(它没有)。

有一种情况会导致此问题:当某个方法需要访问该值(无需锁),然后决定从同一方法写入该值并获取锁时。对于静态分析器来说,这看起来像双重锁定。您要么需要向该方法添加 MOZ_NO_THREAD_SAFETY_ANALYSIS,要么将写入操作移到您调用的另一个方法中,要么使用 MOZ_PUSH_IGNORE_THREAD_SAFETY 和 MOZ_POP_THREAD_SAFETY 在本地禁用警告。我们正在与 clang 静态分析开发人员讨论如何更好地处理此问题。

另请注意,这不会检查是否已获取锁以写入该值

MutexSingleWriter mMutex;
// mResource should only be modified on the main thread with the lock.
// So it can be read without lock on the main thread or on other threads
// with the lock.
RefPtr<ChannelMediaResource> mResource MOZ_GUARDED_BY(mMutex);
...
nsresult ChannelMediaResource::Listener::OnStartRequest(nsIRequest *aRequest) {
  mMutex.AssertOnWritingThread();

  // Read from the only writing thread; no lock needed
  if (!mResource) {
    return NS_OK;
  }
  return mResource->OnStartRequest(aRequest, mOffset);
}

如果您需要断言您位于写入线程上,然后稍后获取锁以修改某个值,它将导致警告:“获取已持有的互斥锁‘mMutex’”。您可以通过关闭该锁的线程安全分析来解决此问题

mMutex.AssertOnWritingThread();
...
{
  MOZ_PUSH_IGNORE_THREAD_SAFETY
  MutexSingleWriterAutoLock lock(mMutex);
  MOZ_POP_THREAD_SAFETY

带外不变式在许多地方使用(并且可以与上述任何一种模式组合)。它使用有关代码执行模式的其他知识来断言避免获取某些锁是安全的。一个主要示例是当某个值在其生命周期的一部分内只能从单个线程访问时(这也称为“基于时间的锁定”)。

请注意,线程安全分析始终忽略构造函数和析构函数(除非使用非常奇怪的方式,否则不应该与其他线程发生竞争)。由于只有一个线程可以在这些时间段内访问,因此不需要在那里锁定。但是,如果从构造函数调用某个方法,则该方法可能会生成警告,因为编译器不知道它是否可能从其他地方调用

...
class nsFoo {
public:
  nsFoo() {
    mBar = true; // Ok since we're in the constructor, no warning
    Init();
  }
  void Init() {  // we're only called from the constructor
    // This causes a thread-safety warning, since the compiler
    // can't prove that Init() is only called from the constructor
    mQuerty = true;
  }
  ...
  mMutex mMutex;
  uint32_t mBar MOZ_GUARDED_BY(mMutex);
  uint32_t mQuerty MOZ_GUARDED_BY(mMutex);
}

另一个示例可能是从其他线程使用的值,但仅当已安装观察者时才使用。因此,始终在安装观察者之前或删除观察者之后运行的代码不需要锁定。

在大多数情况下,这些模式无法进行静态检查。如果所有仅从一个线程访问的时期都在同一线程上,则可以使用单写者模式支持来涵盖这种情况。您将在仅一个线程可以访问该值的方法中添加 AssertIsOnWritingThread() 调用(但仅当该条件成立时)。但是,与单写者的常规用法不同,无法检查您是否向在“正确”线程上运行但在其他线程可能修改它的时期内运行的代码添加了此类断言。

因此,我们强烈建议您将带外不变式/基于时间的锁定情况转换为始终锁定,如果您正在重构代码或进行重大修改。这不太容易出错,也不太容易导致将来的更改破坏有关其他线程访问该值的假设。除了代码位于非常“热门”路径上的少数情况下,这不会对性能产生任何影响——获取无竞争的锁的成本很低。

要消除这些模式正在使用时出现的警告,您需要添加锁(首选),或者使用 MOZ_NO_THREAD_SAFETY_ANALYSIS 或 MOZ_PUSH_IGNORE_THREAD_SAFETY/MOZ_POP_THREAD_SAFETY 消除警告。

此模式尤其需要在代码中提供良好的文档,说明哪些线程将在什么条件下访问哪些成员!:

// Can't be accessed by multiple threads yet
nsresult nsAsyncStreamCopier::InitInternal(nsIInputStream* source,
                                           nsIOutputStream* sink,
                                           nsIEventTarget* target,
                                           uint32_t chunkSize,
                                           bool closeSource,
                                           bool closeSink)
      MOZ_NO_THREAD_SAFETY_ANALYSIS {

// We can't be accessed by another thread because this hasn't been
// added to the public list yet
MOZ_PUSH_IGNORE_THREAD_SAFETY
mRestrictedPortList.AppendElement(gBadPortList[i]);
MOZ_POP_THREAD_SAFETY

// This is called on entries in another entry's mCallback array, under the lock
// of that other entry. No other threads can access this entry at this time.
bool CacheEntry::Callback::DeferDoom(bool* aDoom) const {

已知限制

静态分析无法处理所有合理的模式。特别是,根据其文档,它无法处理条件锁定,例如

if (OnMainThread()) {
  mMutex.Lock();
}

您应该通过方法上的 MOZ_NO_THREAD_SAFETY_ANALYSIS 或 MOZ_PUSH_IGNORE_THREAD_SAFETY/MOZ_POP_THREAD_SAFETY 来解决此问题。

有时分析器无法确定两个对象都是同一个 Mutex,它会警告您。您可能可以通过确保使用相同的模式访问互斥锁来解决此问题

  mChan->mMonitor->AssertCurrentThreadOwns();
  ...
  {
-    MonitorAutoUnlock guard(*monitor);
+    MonitorAutoUnlock guard(*(mChan->mMonitor.get())); // avoids mutex warning
    ok = node->SendUserMessage(port, std::move(aMessage));
  }

Maybe<MutexAutoLock>不会告诉静态分析器何时拥有或释放互斥锁;通过mutex->AssertCurrentThreadOwns(); 跟踪 Maybe<> 中的锁定(监视器也类似)

Maybe<MonitorAutoLock> lock(std::in_place, *mMonitor);
mMonitor->AssertCurrentThreadOwns(); // for threadsafety analysis

如果重置() Maybe<>,则可能需要将其括在 MOZ_PUSH_IGNORE_THREAD_SAFETY 和 MOZ_POP_THREAD_SAFETY 宏中

MOZ_PUSH_IGNORE_THREAD_SAFETY
aLock.reset();
MOZ_POP_THREAD_SAFETY

通过引用传递受保护的值有时会使分析器感到困惑。使用 MOZ_PUSH_IGNORE_THREAD_SAFETY 和 MOZ_POP_THREAD_SAFETY 宏来解决此问题。

需要线程安全注释的类

  • Mutex

  • StaticMutex

  • RecursiveMutex

  • BaseProfilerMutex

  • Monitor

  • StaticMonitor

  • ReentrantMonitor

  • RWLock

  • 任何隐藏内部 Mutex/等并提供类似 Mutex 的

    API (::Lock() 等)。

其他说明

一些代码传递锁定证明 AutoLock 参数,作为一种糟糕的静态分析形式。虽然如果您传递 AutoLock 引用,则很难犯错,但有可能将锁传递给错误的 Mutex/Monitor。

锁定证明基本上与 MOZ_REQUIRES() 重复且已过时,并且依赖于优化器将其删除,并且如上所述,它可能会被误用,并且需要付出努力。使用 MOZ_REQUIRES(),可以删除任何锁定证明参数,尽管您不必立即这样做。

在任何采用 aProofOfLock 参数的方法中,向定义添加 MOZ_REQUIRES(mutex)(并可选地删除锁定证明),或向该方法添加 mMutex.AssertCurrentThreadOwns()

   nsresult DispatchLockHeld(already_AddRefed<WorkerRunnable> aRunnable,
-                            nsIEventTarget* aSyncLoopTarget,
-                            const MutexAutoLock& aProofOfLock);
+                            nsIEventTarget* aSyncLoopTarget) MOZ_REQUIRES(mMutex);

或(如果由于某种原因难以在标头中指定互斥锁)

   nsresult DispatchLockHeld(already_AddRefed<WorkerRunnable> aRunnable,
-                            nsIEventTarget* aSyncLoopTarget,
-                            const MutexAutoLock& aProofOfLock);
+                            nsIEventTarget* aSyncLoopTarget) {
+  mMutex.AssertCurrentThreadOwns();

除了 MOZ_GUARDED_BY() 之外,还有 MOZ_PT_GUARDED_BY(),它表示指针不受保护,但指针指向的数据受保护。

公开类似 Mutex 的接口的类可以像 Mutex 一样进行注释;请参阅树中使用 MOZ_CAPABILITY 和 MOZ_ACQUIRE()/MOZ_RELEASE() 的一些示例。

共享锁受支持,尽管我们很少使用它们。请参阅 RWLock。