最近在翻 UE 的源码,看到一个单例类还蛮有意思的。 TThreadSingleton,如字面意思,是一个支持每个线程创建一个实例的单例结构。
具体看实现,定义是一个类模板,继承自 FTlsAutoCleanup
template<class T>
class TThreadSingleton : public FTlsAutoCleanup
获取单例的方法,主要是几种 Get() 方法,核心内容就是如何能够保证单例线程唯一。
static T& Get();
static T& Get(TFunctionRef<FTlsAutoCleanup*()> CreateInstance);
static T* TryGet();
继续看无参 Get() 方法,使用了内部类 FThreadSingletonInitializer 进行封装,需要传入 TFunctionRef 和 uint32 的 id :
static FTlsAutoCleanup * Get
(
TFunctionRef< FTlsAutoCleanup *()> CreateInstance, uint32 & TlsSlot
)
TFunctionRef 可以理解为 UE 版本的 Lambda 表达式,根据文档描述,定义为TFunctionRef<返回类型(形参列表)>
举例:接受字符串和浮点数作为参数并返回
int32的函数。 参数名称是可选的。TFunctionRef<int32 (const FString& Name, float Scale)>
因此 Get() 的第一个参数接收一个返回类型为 FTlsAutoCleanup* 的无参 TFunctionRef,而 TThreadSingleton 在调用时传递的实现中,是直接 new 一个模板类 T,因此使用时通常直接继承 TThreadSingleton,从而成为 FTlsAutoCleanup 的子类。
[](){return (FTlsAutoCleanup*)new T();}
继续看 Get() 内部的实现,为了能够实现线程唯一,内部使用了动态 TLS (线程局部存储)结构进行管理。下面这张图可以阐述清楚动态 TLS 的结构:
一个进程持有一个通常大小为 64 的标记数组,用于标记每个位置的状态是闲置或使用。进程中的所有线程都有一个等大的数组用于存储与线程相关联的数据,对应的操作开放为四个方法:
- TlsAlloc
在使用数组存储数据之前,需要使用 TlsAlloc 由标记数组确定一个可以使用的位置 slotIndex ,当一个位置标记为使用后,所有线程都可以使用对应位置进行存储,但每个线程访问的都是自己独有的内存空间,避免了多线程读写问题。当没有闲置位置可分配时会返回 TLS_OUT_OF_INDEXES,通常被定义为 0xFFFFFFFF。
- TlsSetValue
当需要存储数据时,需要使用 slotIndex 向指定位置进行存储。需要保证传入的索引值在标记数组中被标记为使用,因为 TLS 实现过程中为了效率放弃了错误检测。
- TlsGetValue
当需要读取数据时,需要使用 slotIndex 向指定位置进行读取。
- TlsFree
当不再需要指定数据时,需要将对应 slotIndex 通知标记数组重置为闲置,进程会将所有线程该位置数据清空。
回到代码,Get() 的第二个参数传的 TlsSlot 就是 0xFFFFFFFF,因此会先调用 TlsAlloc 获取有效索引。这里为了避免多线程产生多次申请索引的情况,使用线程互斥的交换函数 InterlockedCompareExchange 将索引值存储到 TlsSlot 中,从而使得只有一个有效的 slotIndex 被写入 TlsSlot 中,其余额外生成的 slotIndex 都会被 FreeTlsSlot 释放掉。
// 第一个参数与第三个参数进行比较,如果相等则用第二个参数进行替换,返回值是第一个参数的初始值.
static int32 InterlockedCompareExchange( volatile int32* Dest, int32 Exchange, int32 Comparand );
有了 slotIndex 后,使用 GetTlsValue 查询是否线程内已经创建过该单例的指针,未创建则调用传入的 TFunctionRef 进行创建,将指针通过 SetTlsValue 进行存储。后续再进行调用时,就能够通过正确的 TlsSlot 直接获得单例指针。
整体逻辑梳理完成后,再关注一个问题,就是创建的实例会被强转成 FTlsAutoCleanup 类型,并且需要调用 Register() 方法:
FTlsAutoCleanup* ThreadSingleton = (FTlsAutoCleanup*)FPlatformTLS::GetTlsValue( TlsSlot );
if( !ThreadSingleton )
{
ThreadSingleton = CreateInstance();
ThreadSingleton->Register();
FPlatformTLS::SetTlsValue( TlsSlot, ThreadSingleton );
}
继续看 FTlsAutoCleanup 中 Register() 的实现,调用了 FRunnableThread 获取当前线程,将单例加入线程的 TlsInstances 数组中,等待线程销毁时统一销毁。当前线程的指针,同样也是使用 Tls 进行存储的,通过预先申请的一个 slotIndex 进行获取。
void FTlsAutoCleanup::Register()
{
FRunnableThread* RunnableThread = FRunnableThread::GetRunnableThread();
if( RunnableThread )
{
RunnableThread->TlsInstances.Add( this );
}
}
分析到这里算是粗浅的结束了,FRunnableThread 需要再开一篇好好分析一下了。