UE多线程基础

发布于 21 天前  37 次阅读


UE4/UE5的TaskGraph - quabqi的文章 - 知乎
https://zhuanlan.zhihu.com/p/408012121

阻塞

UE的源码里叫做Stall,源码很多地方都会出现这个单词,可能其他支持多线程的语言或代码会叫做Blocking(Java)。本质是程序主动让出CPU的执行权,让CPU可以有机会去执行别的线程或者休息。UE中的锁,事件,Sleep函数等都会产生阻塞。

LockFree操作一般都是写在一个循环中,最后CAS根据结果来决定是否回滚,全程是没有让出CPU的,如果并发量大,严重的数据竞争会导致CPU使用率暴涨,尤其是移动平台很容易出现发热降频等问题。和LockFree的失败回滚机制不同的是,阻塞发生后当前线程就不占用CPU了,反而CPU可以得到休息或者去执行别的线程,这里是需要注意的一点。

写UE业务代码时也有个大致的原则:GameThread要尽量避免写有阻塞的代码防止掉帧,而其他异步执行的工作线程要多考虑用阻塞的方式,尽量避免写有循环并通过CAS回滚的代码,防止机器发热降频。

锁主要是操作系统提供的一种机制,可以让在锁的作用范围内的代码,在同一时间只能有一个线程执行,其他线程会阻塞等待,直到正在访问的线程离开作用域才可以继续执行。UE提供的锁,主要有FScopeLock,FScopeUnlock,FScopeTryLock,FWriteLock,FReadLock,FRWLock等,前面3个是互斥锁,后面3个是读写锁。这些都是以C++的class方式提供的,利用了C++的RAII机制保证在生命期内加锁或解锁成对出现(构造函数加锁,析构函数解锁)。

互斥锁:

在Windows上是通过临界区实现的,而在其他平台是通过pthread提供的mutex实现的。临界区本身细节这里不细说,本质是操作系统提供的一对API(EnterCriticalSection/LeaveCriticalSection),保证这对API之间的代码同一时刻只有一个线程能访问。另外临界区本身不是系统的内核对象,从性能上对比,比系统的内核对象mutex要好很多。当然也有封装系统内核对象级别的互斥锁,UE封装成了FSystemWideCriticalSection。这里比较重要的一点是,UE提供的锁是同一线程可重入的,也就是说,递归加同一个锁是不会出现死锁的,会在最外层的锁释放时,锁才会真正释放。

读写锁:

和互斥锁差不多,互斥锁的问题是无论是否修改数据,都只能有一个线程访问,但假如只读数据,不改数据,即使多线程访问也不会有问题,如果用互斥锁性能就会很差,为了提升一些加锁并行度就有了读写锁。具体就是这样的规则:

  1. 允许多个线程同时占有读锁,只允许一个线程占有写锁
  2. 一个线程占有写锁的时候,其他线程不能占有读锁
  3. 一个线程占有读锁的时候,其他线程不能占有写锁
  4. 写锁释放后,写期间更新的数据对所有线程生效

事件

事件也是操作系统提供的机制,和锁做的事情是差不多的,都是阻塞唤醒线程,区别在于不同的控制方法。锁是一个线程访问到了加锁区域后,其他线程如果也进入这一区域就会被阻塞,当这个线程离开加锁区域其他线程会被唤醒继续执行。而事件是让业务程序可以主动的阻塞当前线程,或者主动的去唤醒其他线程,而不用考虑是否进入了某段区域。UE封装成了FEvent对象,对象上有两个函数,Wait函数会阻塞,Trigger会唤醒。

当然Wait也有几个重载版本,可以指定需要阻塞多长时间自动唤醒,不指定时间会无限阻塞,直到手动调用Trigger才会唤醒。

在Windows上封装的Event内核对象,Wait实际是调用WaitForSingleObject来阻塞,而Trigger是调用SetEvent来唤醒的。在其他平台是通过pthread库提供的mutex和cond组合模拟出来的,直接用mutex阻塞,通过broadcast/signal来唤醒,效果和windows上的基本一致,这里不过多介绍细节了,有需要可以自行搜索。

可见性

在写代码时如果用到了锁,本身感觉好像只是保证了锁的区域内数据在出了多线程范围,就会同步给其他线程,但实际情况是周围的一些没被锁保护起来的变量,虽然值只在本地有改写,但也有可能被同步到了所有线程。如下图所示

线程A加锁前的代码块执行的结果,对线程B不可见,数据可能还在寄存器上。线程A加锁结束的时候,线程B可以看到代码块内加锁前的结果(不一定是这样,如果没发生编译器或CPU的代码重排,或者数据在同一个缓存行上肯定就会一起同步过来)。

UE也有提供对应的API:FPlatformMisc::MemoryBarrier()让程序可以利用这一个机制,防止CPU或编译器做指令重排。一定保证在屏障之前的代码都执行完,再执行屏障之后的代码。会把屏障代码之前计算的结果同步到内存。这个API本身性能要比锁好很多,源码中很多地方都能看到使用。

原子变量

UE4的原子变量TAtomic,本身也支持更细粒度的控制数据同步。这里只简单说一下Relaxed就是程序完全不关心内存是否同步,而SequentiallyConsistent会严格保证顺序

而UE5直接废弃掉了原子变量TAtomic,直接使用std::atomic。