UE5的线程同步机制
- Mutex
- [Critical Section](#Critical Section)
- Semaphore
- [Memory Order](#Memory Order)
- [Spin Lock](#Spin Lock)
- [Scoped Lock](#Scoped Lock)
- [Reentrant Lock](#Reentrant Lock)
- Reference
线程的等待一般有3种实现方式,一是轮询(poll):
c++
while(!CheckCondition())
{
...
}
...
二是阻塞(block),线程将会进入睡眠状态,当条件满足时,内核再去唤醒线程,系统中有若干函数都可以起到block线程的作用:
- 打开文件,如
fopen
函数; - 主动睡眠,如
pthread_sleep
函数; - 等待其他线程结束,如
pthread_join
函数; - 等待互斥锁,如
pthread_mutex_wait
函数。
三是让渡(yield),与轮询类似,只是每次循环会选择让出线程剩余的时间片,而不是忙等待:
c++
while(!CheckCondition())
{
...
pthread_yield(nullptr);
}
...
基于以上,我们来讨论一下UE5中形形色色的线程同步机制。
Mutex
系统库提供的mutex实现是比较耗的,如pthread_mutex_t
,STL中的std::mutex
,在大多数操作系统中,互斥锁可以在进程之间共享。因此,它是由内核在内部管理的数据结构。这意味着对互斥锁执行的所有操作都涉及内核调用,因此需要在 CPU 上进行上下文切换到受保护模式。即使没有其他线程在争夺锁,互斥锁也相对比较昂贵。所以UE并没有使用系统库来实现mutex,而是使用无锁的方式进行实现。"无锁"是指在等待资源变得可用时防止线程进入睡眠状态的做法。换句话说,在无锁编程中,线程永远也不会阻塞。
Critical Section
有部分操作系统,提供了临界区这个概念。临界区是一个轻量级的锁机制,比Mutex开销要低,UE5提供了两种类型的临界区,FCriticalSection
和FSystemWideCriticalSection
,分别表示用户模式下的临界区和系统范围的临界区,两者从设计上的区别如下:
类型 | 优点 | 缺点 |
---|---|---|
FCriticalSection | 效率高,不需要上下文切换 | 不能用于进程间同步,只能进程内线程间同步 |
FSystemWideCriticalSection | 既可用于进程间同步,也可用于进程内线程间同步 | 效率低,需要上下文切换 |
以Windows平台为例,FCriticalSection
是利用Windows自带的CRITICAL_SECTION
实现的,比较简单:
c++
/**
* This is the Windows version of a critical section. It uses an aggregate
* CRITICAL_SECTION to implement its locking.
*/
class FWindowsCriticalSection
{
public:
FWindowsCriticalSection(const FWindowsCriticalSection&) = delete;
FWindowsCriticalSection& operator=(const FWindowsCriticalSection&) = delete;
FORCEINLINE FWindowsCriticalSection()
{
Windows::InitializeCriticalSection(&CriticalSection);
Windows::SetCriticalSectionSpinCount(&CriticalSection,4000);
}
FORCEINLINE ~FWindowsCriticalSection()
{
Windows::DeleteCriticalSection(&CriticalSection);
}
FORCEINLINE void Lock()
{
Windows::EnterCriticalSection(&CriticalSection);
}
FORCEINLINE bool TryLock()
{
if (Windows::TryEnterCriticalSection(&CriticalSection))
{
return true;
}
return false;
}
FORCEINLINE void Unlock()
{
Windows::LeaveCriticalSection(&CriticalSection);
}
private:
Windows::CRITICAL_SECTION CriticalSection;
};
这里拷贝构造和赋值操作都设置为了delete,并且SetCriticalSectionSpinCount
传入了4000,这表示线程在进入等待之前先自旋4000次,如果在自旋途中可以进入临界区,就无需要进入等待状态了。
而FSystemWideCriticalSection
内部实现使用的是mutex,类的实现遵守RAII,在构造时创建mutex并且持有,只有持有成功mutex才有效;在析构时检查mutex,如果有效释放并销毁它。
c++
FWindowsSystemWideCriticalSection::FWindowsSystemWideCriticalSection(const FString& InName, FTimespan InTimeout)
{
// Attempt to create and take ownership of a named mutex
Mutex = CreateMutex(NULL, true, MutexName);
if (Mutex != NULL && GetLastError() == ERROR_ALREADY_EXISTS)
{
// CreateMutex returned a valid handle but we didn't get ownership because another process/thread has already created it
bool bMutexOwned = false;
if (InTimeout != FTimespan::Zero())
{
// We have a handle already so try waiting for it to be released by the current owner
DWORD WaitResult = WaitForSingleObject(Mutex, FMath::TruncToInt((float)InTimeout.GetTotalMilliseconds()));
// WAIT_OBJECT_0 = we got ownership when the previous owner released it
// WAIT_ABANDONED = we got ownership when the previous owner exited WITHOUT releasing the mutex gracefully (we own it now but the state of any shared resource could be corrupted!)
if (WaitResult == WAIT_ABANDONED || WaitResult == WAIT_OBJECT_0)
{
bMutexOwned = true;
}
}
if (!bMutexOwned)
{
// We failed to gain ownership by waiting so close the handle to avoid leaking it.
CloseHandle(Mutex);
Mutex = NULL;
}
}
}
FWindowsSystemWideCriticalSection::~FWindowsSystemWideCriticalSection()
{
Release();
}
bool FWindowsSystemWideCriticalSection::IsValid() const
{
return Mutex != NULL;
}
void FWindowsSystemWideCriticalSection::Release()
{
if (IsValid())
{
// Release ownership
ReleaseMutex(Mutex);
// Also release the handle so it isn't leaked
CloseHandle(Mutex);
Mutex = NULL;
}
}
如果已经存在同名的mutex,CreateMutex
会返回该mutex,同时GetLastError
返回的是ERROR_ALREADY_EXISTS
。如果在指定时间内未能持有,就直接放弃。
Semaphore
信号量和互斥锁比较类似,不同之处在于,其一信号量可以计数,也就是说它允许多个线程同时持有同一个信号量,只要信号量的值不低于0;其二,互斥锁的释放只能由获取的那个线程执行,而二值信号量(初始值为1的信号量),它可以被任何线程所signal。Windows平台使用CreateSemaphore
创建信号量,WaitForSingleObject
获取信号量,ReleaseSemaphore
释放信号量:
c++
class FWindowsSemaphore
{
public:
UE_NONCOPYABLE(FWindowsSemaphore);
FWindowsSemaphore(int32 InitialCount, int32 MaxCount)
: Semaphore(CreateSemaphore(nullptr, InitialCount, MaxCount, nullptr))
{
checkfSlow(Semaphore, TEXT("CreateSemaphore failed: %u"), GetLastError());
}
~FWindowsSemaphore()
{
CloseHandle(Semaphore);
}
void Acquire()
{
DWORD Res = WaitForSingleObject(Semaphore, INFINITE);
checkfSlow(Res == WAIT_OBJECT_0, TEXT("Acquiring semaphore failed: %d (%u)"), Res, GetLastError());
}
bool TryAcquire(FTimespan Timeout = FTimespan::Zero())
{
DWORD Res = WaitForSingleObject(Semaphore, (DWORD)Timeout.GetTotalMilliseconds());
checkfSlow(Res == WAIT_OBJECT_0 || Res == WAIT_TIMEOUT, TEXT("Acquiring semaphore failed: %d (%u)"), Res, GetLastError());
return Res == WAIT_OBJECT_0;
}
void Release(int32 Count = 1)
{
checkfSlow(Count > 0, TEXT("Releasing semaphore with Count = %d, that should be greater than 0"), Count);
bool bRes = ReleaseSemaphore(Semaphore, Count, nullptr);
checkfSlow(bRes, TEXT("Releasing semaphore for %d failed: %u"), Count, GetLastError());
}
private:
HANDLE Semaphore;
};
UE_NONCOPYABLE
是一个宏,表示该类的左值/右值拷贝构造函数不被定义,左值/右值赋值操作不被定义。通过这个宏可以禁止同类型的拷贝和赋值。
Memory Order
看下面的代码:
c++
int32_t g_data = 0;
int32_t g_ready = 0;
void ProducerThread()
{
// produce some data
g_data = 42;
// inform the consumer
g_ready = 1;
}
void ConsumerThread()
{
// wait for the data to be ready
while (!g_ready)
PAUSE();
// consume the data
ASSERT(g_data == 42);
}
CPU可能会对指令进行重新排序,导致排序的结果和代码的顺序不一致。也就是说,在ProducerThread
线程中,g_ready的赋值操作可能在g_data之前,而在ConsumerThread
线程中,g_data的读取操作也可能发生在g_ready之前,从而导致意想不到的结果。
step | Producer | Consumer |
---|---|---|
1 | 写入g_ready | |
2 | 读取g_data | |
3 | 读取g_ready | |
4 | 写入g_data |
除了CPU指令重排外,内存的操作顺序可能也和代码不一致。比如在ProducerThread
线程中,g_ready可能要比g_data更早写入cache,导致ConsumerThread
线程先看到了g_ready的最新值,再看到g_ready的最新值。内存顺序不一致的情况可以分为如下4种:
- 读操作越过了读操作
- 读操作越过了写操作
- 写操作越过了读操作
- 写操作越过了写操作
针对以上每一种情况,都可以提出3种内存屏障策略,单向,反向以及双向。这里的方向指的是代码顺序,单向表示内存屏障前的内存操作必须要在屏障前完成;反向表示内存屏障后的内存操作必须要在屏障后完成,双向就是单向+反向都要满足。那么严格来说就有4*3=12种内存屏障,但实际使用中并不需要那么多,C++提供了如下6种内存屏障:
memory_order_relaxed
没有顺序一致性的要求,也没有内存同步的要求,唯一能保证的就是操作的原子性。
c++
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
x.store(true, std::memory_order_relaxed); // 1
y.store(true, std::memory_order_relaxed); // 2
}
// thread B
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed)) { // 3
}
if (x.load(std::memory_order_relaxed)) { // 4
++z;
}
}
如上述代码,由于内存序为relaxed,那么A线程中y的写入可能发生在x之前,相应地B线程中x的读取也可能发生在y之前。所以B线程中,就算y的结果为true,x的结果也可能还是false,也就导致z的值为0。
step | thread A | thread B |
---|---|---|
1 | store y | |
2 | load x | |
3 | load y | |
4 | store x |
memory_order_release
memory_order_release保证本线程中,所有之前的读写操作完成后才能执行本条原子操作。memory_order_release通常用于写操作:
c++
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
x.store(true, std::memory_order_relaxed); // 1
y.store(true, std::memory_order_release); // 2
}
// thread B
void read_y_then_x() {
while (!y.load(std::memory_order_relaxed)) { // 3
}
if (x.load(std::memory_order_relaxed)) { // 4
++z;
}
}
step | thread A | thread B |
---|---|---|
1 | load x | |
2 | store x | |
3 | store y | |
4 | load y |
上述代码中线程A的y使用了memory_order_release,会使得x的store一定发生在y的store之前,也就是说对于线程A,y为true时x一定为true,但这个事情放到线程B上就不一定成立了,线程B依旧可能先读x再读y,导致z的值为0。
memory_order_acquire
与release配对的memory_order_acquire,它保证所有之后的读写操作都要在本条原子操作执行之后才能完成。memory_order_acquire通常用于读操作,如果有其他线程使用memory_order_release对相同原子变量写入,那么memory_order_acquire保证一定能读取到其他线程所有写入的最新值。
c++
std::atomic<bool> x = false;
std::atomic<bool> y = false;
int z = 0;
// thread A
void write_x_then_y() {
x.store(true, std::memory_order_relaxed); // 1
y.store(true, std::memory_order_release); // 2
}
// thread B
void read_y_then_x() {
while (!y.load(std::memory_order_acquire)) { // 3
}
if (x.load(std::memory_order_relaxed)) { // 4
++z;
}
}
上述代码中线程B的y使用了memory_order_acquire,会使得x的load一定发生在y的load之后,并且y在load到最新值时,也会刷新x的最新值。配合线程A的逻辑,y最新值为true时x最新值一定也为true,那么当线程B读到y为true时,说明此时的顺序为store x => store y => load y => load x,x的值必定为true(x的cache在读取y最新值的时候已经被刷新),此时z的值一定为1。
step | thread A | thread B |
---|---|---|
1 | store x | |
2 | store y | |
3 | load y | |
4 | load x |
memory_order_consume
与memory_order_acquire类似,memory_order_consume也通常用于读操作,但是它只保证有依赖的读写操作在本条原子操作执行之后才能完成。而且,只有依赖的变量能保证读取到的是最新值。什么是依赖呢?来看下面这个例子:
c++
int a = 0;
std::atomic<std::string*> ptr;
void Write() {
std::string* p = new std::string("Hello World.");
a = 30;
ptr.store(p, std::memory_order_release);
}
void Read() {
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_consume)));
assert(*p2 == "Hello World.");
delete p2;
assert(a == 30);
}
首先来看Write线程,ptr使用了memory_order_release内存序,那么p和a的写入必然在ptr写入之前;再看Read线程,ptr使用了memory_order_consume内存序,那么只有和ptr有关联的变量读写,才会保证发生在ptr读取之后。这里,p2的值取决于ptr,换言之p2依赖于ptr,所以p2的读写一定发生在ptr读取之后,而且在读取到最新的ptr时,p2的最新值也会随之刷新。而a的值与ptr无关,那么memory_order_consume内存序并不能保证a的读取一定发生在ptr之后,也不能保证a读取到的值是最新值(可能还是cache的旧值),从而可能导致a == 30
断言失败。
memory_order_acq_rel
memory_order_acquire+memory_order_release的合体,如果原子操作是读操作,那么使用memory_order_acquire内存序,如果原子操作是写操作,那么使用memory_order_release内存序,如果原子操作是read-modify-write这种既有读又有写的,那么acquire和release内存序同时生效。
memory_order_seq_cst
memory_order_acq_rel内存序的加强版,它提供了更加严格的顺序保证,保证所有的线程都能看到最新的数据,这是其他内存序所不具备的,例如下面这个例子:
c++
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};
// thread A
void write_x()
{
x.store(true, std::memory_order_seq_cst);
}
// thread B
void write_y()
{
y.store(true, std::memory_order_seq_cst);
}
// thread C
void read_x_then_y()
{
while (!x.load(std::memory_order_seq_cst))
;
if (y.load(std::memory_order_seq_cst))
++z;
}
// thread D
void read_y_then_x()
{
while (!y.load(std::memory_order_seq_cst))
;
if (x.load(std::memory_order_seq_cst))
++z;
}
如果将上述代码中的memory_order_seq_cst都替换为memory_order_acq_rel,z的值依旧可能为0。由于x和y的写入在两个线程中进行,所以它们不存在绝对的有序关系。这意味着,对于线程C,虽然memory_order_acq_rel保证x一定在y之前读取到最新的值,但它不能保证x读取到最新值时,y也能读取到最新值,也就是存在x为true而y为false的可能;对于线程D,则不能保证y读取到最新值时,x也能读取到最新值,即有可能出现y为true而x为false的情况。这就是这两件事可以同时发生的原因。
而memory_order_seq_cst可以保证全局的唯一有序性,这意味着,对于线程C和D,它们读取到的x和y一定都是最新的值,也就是说从全局来看,线程A对x的写入要么发生在线程B对y的写入之前,要么之后。那么线程C和线程D必定有一个可以通过while和if的逻辑,z的值一定不会为0。
Spin Lock
UE中实现自旋锁就使用上述提到的内存序,避免全部使用memory_order_seq_cst可以提高程序运行的性能。
c++
class FSpinLock
{
public:
UE_NONCOPYABLE(FSpinLock);
FSpinLock() = default;
void Lock()
{
while (true)
{
if (!bFlag.exchange(true, std::memory_order_acquire))
{
break;
}
while (bFlag.load(std::memory_order_relaxed))
{
FPlatformProcess::Yield();
}
}
}
bool TryLock()
{
return !bFlag.exchange(true, std::memory_order_acquire);
}
void Unlock()
{
bFlag.store(false, std::memory_order_release);
}
private:
std::atomic<bool> bFlag{ false };
}
exchange
是一个read-modify-write操作,在Lock中使用memory_order_acquire可以保证,exchange
一定发生在load
之前,并且能同步到Unlock中使用memory_order_release的最新值。exchange
如果返回false,说明当前线程成功获取到锁,否则就得进入到后续的while循环一直yield。这里Lock用的是两层while,是为了避免每次循环都动用memory_order_acquire,如果第一次判断拿不到锁,后面就使用memory_order_relaxed听天由命,虽然yield的次数潜在增加了,但是同步的代价减少了。
Scoped Lock
主要是防止lock之后忘记unlock而设计的,其实就是RAII的思想,借助C++局部变量离开作用域就会自动析构的逻辑实现的。
c++
template<typename MutexType>
class TScopeLock
{
public:
UE_NONCOPYABLE(TScopeLock);
UE_NODISCARD_CTOR TScopeLock(MutexType& InMutex)
: Mutex(&InMutex)
{
check(Mutex);
Mutex->Lock();
}
~TScopeLock()
{
Unlock();
}
void Unlock()
{
if (Mutex)
{
Mutex->Unlock();
Mutex = nullptr;
}
}
private:
MutexType* Mutex;
};
Reentrant Lock
可重入的锁,就是支持递归调用的锁。一般的锁不支持递归调用,如果两个函数持有了同一个锁,很可能导致死锁:
c++
FSpinLock g_lock;
void A()
{
TScopeLock<FSpinLock> lock(g_lock);
// do some work
}
void B()
{
TScopeLock<FSpinLock> lock(g_lock);
A(); // deadlock
}
UE的RecursiveMutex
就是可重入的锁,它用到了两个原子变量,一个是uint32的union,包含3个字段,另一个是表示当前持有锁的线程ID。
c++
union FRecursiveMutex::FState
{
struct
{
uint32 bIsLocked : 1;
uint32 bHasWaitingThreads : 1;
uint32 RecurseCount : 30;
};
uint32 Value = 0;
constexpr FState() = default;
constexpr explicit FState(uint32 State)
: Value(State)
{
}
constexpr bool operator==(const FState& Other) const { return Value == Other.Value; }
};
std::atomic<uint32> State = 0;
std::atomic<uint32> ThreadId = 0;
bIsLocked
表示锁是否被持有,bHasWaitingThreads
表示当前锁是否有其他线程在等待,RecurseCount
表示同一线程递归调用的次数。那么Lock函数需要处理3种情况:
锁未被持有
这种最简单,使用无锁原子操作compare_exchange_weak
修改原子变量State
:
c++
FState CurrentState(State.load(std::memory_order_acquire));
// Try to acquire the lock if it was unlocked, even if there are waiting threads.
// Acquiring the lock despite the waiting threads means that this lock is not FIFO and thus not fair.
if (LIKELY(!CurrentState.bIsLocked))
{
FState NewState = CurrentState;
NewState.bIsLocked = true;
if (LIKELY(State.compare_exchange_weak(CurrentState.Value, NewState.Value, std::memory_order_acquire)))
{
checkSlow(ThreadId.load(std::memory_order_relaxed) == 0 && CurrentState.RecurseCount == 0);
ThreadId.store(CurrentThreadId, std::memory_order_relaxed);
return;
}
}
注意这里使用了两个memory_order_acquire
,保证State
一定是先load再compare_exchange_weak,且ThreadId
的load和store一定在State
内存操作之后。同时,虽然CurrentState
读取到的未必是最新的值(也即实际已经被Lock了,但线程还认为是Unlock),但是后续的compare_exchange_weak
会保证读取到最新的State
值,如果比较下来发现不同,则认为加锁失败,说明当前已经被Lock了。compare_exchange_weak也存在误判,即使两者值相等,也有可能返回false,只不过这里返回false只会认为加锁失败,从而走到后续逻辑,没有太大影响。使用weak而不是strong,可以提高程序运行的性能。如果加锁成功,则把当前线程ID写入到ThreadId
中,这里使用memory_order_relaxed
的原因也是出于性能考虑,如果其他线程读到的值是旧值,也只是会被认为尝试加锁的线程和持有锁的线程不同而已,也会走到对应的处理逻辑,没有太大影响。
锁被同一线程持有
通过线程ID,就可以实现递归加锁,即把调用次数+1即可,这样就不会死锁了:
C++
// Lock recursively if this is the thread that holds the lock.
FState AddState;
AddState.RecurseCount = 1;
State.fetch_add(AddState.Value, std::memory_order_relaxed);
fetch_add
会保证State
读取到最新的值。
锁被不同线程持有
这个时候线程会根据bHasWaitingThreads
的值,采取不同的等待策略,如果当前有其他线程在等待,则会进入到UE维护的线程队列,直到State
发生改变(锁被释放,锁没有其他线程等待)才会被唤醒。如果只有自己在等待,则先尝试自旋一定次数,如果在自旋期间未能成功获取到锁,则修改bHasWaitingThreads
的值为true,然后进入到UE维护的线程队列,直到被唤醒。
类似地,Unlock函数也需要对3种情况进行处理:
锁被同一线程持有
说明相同线程中还有其他函数持有锁,那么只需要把调用次数-1即可:
c++
// When locked recursively, decrement the count and return.
FState SubState;
SubState.RecurseCount = 1;
State.fetch_sub(SubState.Value, std::memory_order_relaxed);
fetch_sub
同理,也保证State
读取到最新的值。
没有其他线程在等待锁
此时需要把ThreadId
和State
清空,并且要保证清空的顺序一定要是先ThreadId
再State
,所以这里compare_exchange_strong
在值相等时使用的是memory_order_release
内存序。使用strong的原因是这里如果比较失败,只可能是此时有别的线程尝试加锁失败,进入了等待队列,需要在Unlock结束时唤醒。
c++
ThreadId.store(0, std::memory_order_relaxed);
// Unlocking with no waiting threads only requires resetting the state.
if (LIKELY(!CurrentState.bHasWaitingThreads))
{
FState NewState;
if (LIKELY(State.compare_exchange_strong(CurrentState.Value, NewState.Value, std::memory_order_release, std::memory_order_relaxed)))
{
return;
}
}
有其他线程在等待锁
在Unlock结束时唤醒其中一个线程即可。
Readers-Writer Lock
读写锁分为读锁和写锁,允许多个线程同时持有读锁,或者一个线程持有写锁。Windows平台上,UE使用系统自带的SRWLOCK
实现读写锁。
c++
FORCEINLINE void ReadLock()
{
Windows::AcquireSRWLockShared(&Mutex);
}
FORCEINLINE void WriteLock()
{
Windows::AcquireSRWLockExclusive(&Mutex);
}
FORCEINLINE bool TryReadLock()
{
return !!Windows::TryAcquireSRWLockShared(&Mutex);
}
FORCEINLINE bool TryWriteLock()
{
return !!Windows::TryAcquireSRWLockExclusive(&Mutex);
}
FORCEINLINE void ReadUnlock()
{
Windows::ReleaseSRWLockShared(&Mutex);
}
FORCEINLINE void WriteUnlock()
{
Windows::ReleaseSRWLockExclusive(&Mutex);
}
Reference
[1] C/C++ 中内存对齐--结构体
[3] C++ struct 位域
[4] Memory model synchronization modes
[5] What do each memory_order mean?
[7] UE4原子操作与无锁编程
[8] C语言宏的特殊用法和几个坑
[9] SetCriticalSectionSpinCount function
[10] Using Mutex Objects
[12] std::memory_order
[13] C++11标准原子库内存顺序memory_order_consume与memory_order_acquire的差异示例
[14] How do memory_order_seq_cst and memory_order_acq_rel differ?
[16] std::memory_order_relaxed example in cppreference.com
[17] Concurrency: Atomic and volatile in C++11 memory model
[18] Can't relaxed atomic fetch_add reorder with later loads on x86, like store can?