介绍
并发操作如果在多线程环境中没有正确使用(或设计),可能会发生冲突,导致以下问题:
-
操作可能需要对共享资源的写访问。 我们称这些问题为内存访问问题。
比如变量a初始值为0,两个线程同时执行a = a + 1的操作,因为内存访问问题,2个线程执行后,结果可能不是2。
-
操作可能需要以特定的顺序发生。 我们有时将这些问题称为同步问题(尽管内存访问问题也是同步问题)。
在任何一种情况下,缺乏对共享资源的控制或缺乏对操作顺序的控制都可能导致竞争条件。 synchronization这个库中的并发抽象的目的是解决这些问题并避免这种竞争条件。
-
内存访问问题
内存访问问题通常通过各种方法解决,包括:
-
将共享资源设为私有或只读。
-
将数据访问转换为“消息传递”方案,以提供共享信息的副本供临时使用,而不是直接访问共享内存。
-
对共享资源的锁定访问,通常用于写操作,以防止多个用户并发读或写。
-
使用原子操作访问共享资源,例如由std::atomic提供的操作。 注意,正确应用原子操作的规则非常复杂,这是您应该避免使用原子的众多原因之一。
对共享资源的锁定访问通常通过互斥锁来实现。 Abseil为此提供了自己的互斥锁类; 类似地,c++标准库为同样的目的提供了std::mutex类。
不管操作的顺序、调度或交错如何,行为正确的类型称为线程安全的。 在大多数情况下,此类类型在底层使用互斥锁和原子操作来保护对对象内部状态的访问。
-
-
同步问题
除了简单的内存访问问题之外,同步问题通常更复杂,需要专门构建抽象来解决底层问题。 同步操作旨在控制不同线程中事件的顺序。
请记住,对“线程安全”类型的操作不一定是同步操作。 当你读取另一个线程写入的值时,你不能假设写入发生在读取之前; 它们可能同时发生。 比如:
foo::counter first, second; void thread1() { first.Add(1); // (a) second.Add(1); // (b) } void thread2() { while (second.value() == 0) { sleep(10); } CHECK(first.value() == 1); // ERROR }
即使foo::counter是线程安全的(并且您不需要担心数据竞争),您可能认为CHECK()将会成功,因为行(a)发生在行(b)之前,并且直到行(b)执行后才能到达CHECK()。 但是,除非Add()和value()也是同步操作,否则thread1中的任何操作都不必发生在thread2中的任何操作之前,而且CHECK()可能会失败。
库内容
同步库包括用于跨不同线程管理任务的抽象和原语。
synchronization/mutex.h
提供用于管理资源上的锁的原语。 互斥锁是这个库中最重要的原语,也是大多数并发实用程序的构建块。synchronization/notification.h
提供通知线程事件的简单机制。synchronization/barrier.h
andsynchronization/blocking_counter.h
为累积事件提供同步抽象。base/thread_annotations.h
提供宏,用于记录多线程代码的锁定策略,并为此类锁的滥用提供警告和错误。base/call_once.h
提供’ std::call_once() '的Abseil版本,用于跨所有线程调用可调用对象仅一次。
mutex (absl/synchronization/mutex.h)
在并发任务中使用的主要原语是mutex,这是一种互斥锁,可用于防止多个线程访问和/或写入共享资源。 调用Lock()通常被称为锁定或获取互斥锁,同时调用unlock()被称为解锁或释放互斥锁。
-
absl::mutex的规则
-
每次线程获得互斥锁,它必须稍后释放锁。可以使用助手类MutexLock,通过RAII自动获得一个互斥器的结构,当锁离开范围时释放它。
-
线程只有在获取到锁的情况下才能释放锁。
-
线程在获取到锁的情况下,不会再去获取锁。也就是它不是可重入,下面会说到。
-
-
absl::mutex增加的功能(对比std::mutex)
Abseil提供了它自己的互斥锁类,在谷歌中,使用这个类代替类似的std::mutex,它提供了std::mutex的大部分功能,但增加了以下附加功能:
-
互斥增加了条件临界区,这是条件变量的替代方案。 Mutex::Await()和Mutex::LockWhen()允许客户端等待一个条件而不需要一个条件变量; 客户端不需要编写while循环,也不需要使用Signal()。
以abseil::mutex和std::mutex配合条件变量的信号量对比如下:
-
abseil::mutex实现的信号量
-
stu::mutex配合条件变量
和abseil::mutex的区别是,如果m_cv不调用notify_all,那条件会一直等待下去。
-
-
互斥锁本质上支持死锁检测(当锁的获取顺序不一致时)。 在大多数非opt构建模式下,死锁检测器默认是启用的,它可以检测死锁风险,甚至Clang的Thread Sanitizer都没有检测到。 (请参阅下面的死锁检测。)
-
通过ReaderLock()和ReaderUnlock()函数,absl::Mutex可以充当读写锁(像std::shared_mutex)。 (请参阅下面的读写锁。)
-
-
absl::mutex未实现的功能:
-
和std::mutex一样,Abseil的互斥锁也不是可重入的(也称为非递归)。
不可重入指在同一方法内,获取到一个锁,在未释放锁的情况下,再次去获取该锁,将导致死锁;如果是可重入的,那再次去获取锁同一个锁时,不会出现死锁。
另外C++11里提供了可重入锁,详细介绍请查看相关文档:std::recursive_mutex
-
它也不能在短期内提供严格的先进先出行为或公平性; 这样做将需要大量的开销。 然而,从长期来看,它往往是近似公平的。 以先进先出行为或公平性举例,比如线程A申请获取锁后,线程B申请获取锁,当其他线程释放锁后,一定是线程A先获取到锁。
-
-
互斥锁的辅助类
-
absl::MutexLock是一个辅助类,它通过RAII获取并释放一个’互斥锁’。示例:
absl::Mutex mtx; absl::MutexLock lck(&mtx); // 等待条件 static auto waitFunction = [&]() -> bool { return m_updateCount >= count; }; absl::MutexLock lck(&m_mtx, absl::Condition(&waitFunction));
-
absl::ReaderMutexLock是一个辅助类,像MutexLock一样,它通过RAII获取并释放’互斥锁’上的共享锁,即读锁。
absl::Mutex mtx; absl::ReaderMutexLock lck(&mtx); // 等待条件 static auto waitFunction = [&]() -> bool { return m_updateCount >= count; }; absl::ReaderMutexLock lck(&m_mtx, absl::Condition(&waitFunction));
-
absl::WriterMutexLock是一个辅助类,像’ MutexLock '一样,它通过RAII获取并释放’互斥锁’上的写(独占)锁。
absl::Mutex mtx; absl::WriterMutexLock lck(&mtx); // 等待条件 static auto waitFunction = [&]() -> bool { return m_updateCount >= count; }; absl::WriterMutexLock lck(&m_mtx, absl::Condition(&waitFunction));
-
absl::MutexLockMaybe类似于MutexLock,但当Mutex为null时,它无操作。
-
absl::ReleasableMutexLock类似于MutexLock,但允许在销毁互斥锁之前释放()。’ Release() '最多只能被调用一次。
-
-
absl::Condition
Abseil的mutex通过添加条件临界区(条件临界区是条件变量的替代)得到了扩展。成员函数如Mutex::Await()和Mutex::LockWhen()使用内在的条件谓词,允许客户端等待条件而不需要条件变量;客户端不需要编写while循环,也不需要使用Signal()【其实在mutex释放锁的时候会自动触发信号让其他等待条件线程检查条件】。
bool f(bool *arg) { return *arg; } bool flag = false; mu.Lock(); ... // arbitrary code A mu.Await(Condition(f, &flag)); ... // arbitrary code B mu.Unlock(); mu.LockWhen(Condition(f, &flag)); ... // arbitrary code C mu.Unlock();
-
absl::CondVar
条件变量的作用与条件临界区相同;它们是一种阻塞线程直到满足某些条件的方法。通常情况下,条件临界区更容易使用,但条件变量可能对程序员更熟悉,因为它们包含在POSIX标准和Java语言中。
// 用于等待某个条件C的线程,由互斥锁mu保护: void acquire() { mu.Lock(); while (!C) { cv->Wait(&mu); } // releases and reacquires mu // C holds; process data mu.Unlock(); } // 使C条件满足 void release() { mu.Lock(); // process data, possibly establishing C if (C) { cv->Signal(); } mu.Unlock(); }
-
absl::mutex 读写锁
读-写(共享-排他)锁有两种锁定模式。如果锁不是空闲的,它可以被一个线程以写(即独占)模式持有,也可以被一个或多个线程以读(即共享)模式持有。使用读写锁来保护经常读取但不经常修改的资源或数据结构。修改受保护状态的临界区必须以写的方式获得锁,而只读的临界区可以以读的方式获得锁。
absl::mutex mutex; mutex.ReaderLock(); // do read data mutex.ReaderUnlock(); mutex.WriterLock(); // do write data mutex.WriterUnlock();
-
线程注释
互斥量的主要缺点是任何互斥量类型的缺点:必须记得在进入临界区之前锁定它,必须记得在离开临界区时解锁它,并且必须避免死锁。
为了帮助解决这些问题,Abseil提供了线程安全注释(在base/thread_annotations.h中),以指定哪些变量由哪些互斥锁保护,调用哪些函数时应该保持哪些互斥锁,应该以何种顺序获取互斥锁,等等。然后在编译时检查这些约束,尽管这种机制不是万无一失的,但它确实捕获了许多常见的互斥锁使用错误。除了作为代码文档的一部分之外,编译器或分析工具还可以使用注释来识别和警告潜在的线程安全问题。
// 每个需要互斥锁保护的数据对象(无论是命名空间作用域中的全局变量还是类作用域中的数据成员)都应该有一个注释GUARDED_BY,表示哪个互斥锁保护它: int accesses_ GUARDED_BY(mu_); // count of accesses // 每个互斥锁都应该有一个补充注释,指出它保护哪些变量以及任何不明显的不变量: Mutex mu_; // protects accesses_, list_, count_ // invariant: count_ == number of elements in linked-list list_ // 当一个线程可以同时持有两个互斥锁时,其中一个互斥锁(或两个互斥锁)应该用ACQUIRED_BEFORE或ACQUIRED_AFTER进行注释,以指示必须先获取哪个互斥锁: Mutex mu0_ ACQUIRED_BEFORE(mu1_); // protects foo_ // 注释EXCLUSIVE_LOCKS_REQUIRED、SHARED_LOCKS_REQUIRED和LOCKS_EXCLUDED用于记录这些信息。它们只能应用于函数声明,而不能应用于定义 // Function declaration with an annotation void CountAccesses() LOCKS_EXCLUDED(mu_); // Function definition void CountAccesses() { this->mu_.Lock(); this->accesses_++; this->mu_.Unlock(); }
-
死锁
死锁常见的2种情况:
-
最简单死锁是自死锁.
mu.Lock(); mu.Lock(); // BUG: deadlock: thread already holds mu
-
涉及两个资源的死锁也很容易生成,当线程T0在持有M0时试图获取M1,同时线程T1在持有M1时试图获取M0时,会导致双互斥锁死锁;每个线程将无限期地等待另一个线程。
此外,absl::Mutex API提供了额外的死锁检测。只有当应用程序在调试模式下编译且标志-synch_deadlock_detection为非零时,才启用这种检测。启用后,API会检测到另外两种死锁情况:
- 互斥锁的获取顺序不一致。死锁检测器为进程中的互斥锁维护一个预先获取的图。如果在该图中检测到潜在的死锁(一个循环),则会生成一个错误。
- 互斥锁是由不持有互斥锁的线程释放的。
-
absl::call_once(absl/base/call_once.h)
它允许一个函数在所有线程上完全执行一次。
void MyClass::init()
{
absl::call_once(m_once, &MyClass::initT, this);
}
void MyClass::initT()
{
iCount++;
std::cout << "the count:" << iCount << std::endl;
}
absl::Notification (absl/synchronization/notification.h)
通知线程接收单个事件通知。线程使用一个WaitForNotification成员函数来注册通知。Notification::Notify()用于通知事件发生的等待线程,并且只能通知一次。
// Create the notification
Notification notification_;
// Client waits for notification
void Foo() {
notification_.WaitForNotification();
// Do something based on that notification
}
//
void Bar() {
// 将此通知的“已通知”状态设置为“true”并唤醒等待的线程。注意:不要在同一个' Notification '上多次调用' Notify() ';对同一个通知多次调用' Notify() '会导致未定义的行为。
// Do a bunch of stuff that needs to be done before notification
notification_.Notify();
}
absl::Barrier (absl/synchronization/barrier.h)
Barrier会阻塞线程,直到预先指定的线程阈值利用该Barrier为止。线程通过调用Barrier上的Block()来利用Barrier, Block()将阻塞该线程;没有对Block()的调用将返回,直到指定数量的线程调用了它。
Barrier *barrier_ = new Barrier(num_active_threads);
void Foo() {
// 等待num_active_threads个线程执行了Block,其中一个线程返回true,其他线程返回false。返回后都同时往后执行
if (barrier_->Block()) {
delete barrier_; // This thread is responsible for destroying barrier_;
}
// Do something now that the Barrier has been reached.
}
absl::BlockingCounter (absl/synchronization/blocking_counter.h)
absl::BlockingCounter为预先指定数量的操作阻塞所有线程。线程调用阻塞计数器上的Wait()来阻塞直到发生指定数量的事件;工作线程在完成工作后调用计数器上的DecrementCount()。一旦计数器的内部“计数”达到零,阻塞的线程就会解除阻塞。
void Foo() {
BlockingCounter things_to_process(things.size());
Process(&things_to_process)
// 等待计数器清零后继续往后执行
things_to_process.Wait();
}
void Process(BlockingCounter* things) {
// 计数器减去1
things->DecrementCount();
return;
}