【转载】UE4 异步编程专题 - 线程池 FQueuedThreadPool(三)

580 阅读9分钟

原作者:Madokakaroto
原文链接:UE4 异步编程专题 - 线程池 FQueuedThreadPool

专题的第三篇,我们详细聊聊由 FRunnableFRunnableThread 实现的 FQueuedThreadPool.

1. FQueuedThreadPool & IQueuedWork

FQueuedThreadPool 是 UE4 中抽象出的线程池。线程池有若干个 Worker 线程,和一个同步队列构成。 UE4 把同步队列执行的任务抽象为 IQueuedWork . 线程池的同步队列,就是一个 IQueuedWork 的队列了。借用 wiki 上线程池的图, UE4 的 FQueuedThreadPool 也是如图中所示的结构:

  • Thread pool

生产者生产 IQueuedWork 的实例对象。线程池会向生产者提供入队的接口。线程池中的 Worker 线程都是消费者,会不停地从队列中取出 IQueuedWork,并执行 work.

下面的代码就是 FQueuedThreadPool 给用户使用的接口:

class CORE_API FQueuedThreadPool
{
public:

    // 创建线程池,指定线程数,还有每个 worker 栈大小及优先级
    virtual bool Create( 
        uint32 InNumQueuedThreads, 
        uint32 StackSize, 
        EThreadPriority ThreadPriority
    ) = 0;

    // 销毁线程池,对 Task Queue 和每个 worker 线程执行清理操作
    virtual void Destroy() = 0;

    // 生产者使用这个接口,向同步队列添加 IQueuedWork 
    virtual void AddQueuedWork(IQueuedWork* InQueuedWork) = 0;

    // 生产者使用这个接口,尝试删除一个 IQueuedWork 
    virtual bool RetractQueuedWork(IQueuedWork* InQueuedWork) = 0;

    // 获取线程池中 worker 线程的数目
    virtual int32 GetNumThreads() const = 0;
};

需要提及的是,RetractQueuedWork 接口只能尝试去删除或取消一个 work 对象。如果 work 不在队列当中,或者请求删除时已经在执行和执行完成,都无法取消

IQueuedWork 是同步队列中,任务对象的抽象。代码如下:

class IQueuedWork
{
public:
    virtual void DoThreadedWork() = 0;
    virtual void Abandon() = 0;
};

IQueuedWork 的接口很简单,我们只需要实现代码中的两个接口,分别是 任务的执行流程废弃当前任务 的接口。

2. FQueuedThread

FQueuedThread 就是线程池 worker 线程的实现了。它是一个 FRunnable 的实现类,并内聚了一个 FRunnableThread 的实例对象。

class FQueuedThread : public FRunnable
{
protected:
    FRunnableThread* Thread;
    virtual uint32 Run() override;
};

FQueuedThread 实现的 Run 函数,就是类似上一篇我们实现的 MyRunnable 的空闲等待的流程。我们回顾一下,实现所需的部件:

  1. 一个原子布尔变量作为循环的标识位
  2. 一个 FEvent 用来让线程在无任务可做时挂起,而不占用系统资源;

按照上面的思路,我们继续补完代码:

class FQueuedThread : public FRunnable
{
protected:
    FEvent*             DoWorkEvent;
    TAtomic<bool>       TimeToDie;
    FRunnableThread*    Thread;

    virtual uint32 Run() override
    {
        while(TimeToDie.Load(EMemoryOrder::Relaxed))
        {
            DoWorkEvent->Wait();
            // TODO ... do work
        }
    }
};

这样的实现有很严重的缺陷。无穷时间的等待,线程被挂起后,UE4 无法获取这些线程的状态了。因此,UE4 采用的是等待 10 ms,再 check 是否继续等待。

while(TimeToDie.Load(EMemoryOrder::Relaxed))
{
    bool bContinueWaiting = true; 
    while(bContinueWaiting)
    {
        DECLARE_SCOPE_CYCLE_COUNTER(...); // record status
        bContinueWaiting = !DoWorkEvent->Wait( 10 );
    }

    // TODO ... do work
}

被唤醒后意味着两种情况:

  1. 新的任务分配下来,有活干了;
  2. 线程池发出清理指令,线程即将退出;

把执行 Work 的代码加入,如下所示:

class FQueuedThread : public FRunnable
{
protected:
  FEvent*                 DoWorkEvent;
  TAtomic<bool>           TimeToDie;
  FRunnableThread*        Thread;
  IQueuedWork* volatile   QueuedWork;

  virtual uint32 Run() override
  {
    while(TimeToDie.Load(EMemoryOrder::Relaxed))
    {
      bool bContinueWaiting = true; 
      while(bContinueWaiting)
      {
        DECLARE_SCOPE_CYCLE_COUNTER(...); // record status
        bContinueWaiting = !DoWorkEvent->Wait( 10 );
      }

      IQueuedWork* LocalQueuedWork = QueuedWork;
      QueuedWork = nullptr;

      FPlatformMisc::MemoryBarrier();

      check(LocalQueuedWork || TimeToDie.Load(EMemoryOrder::Relaxed));

      while (LocalQueuedWork)
      {
        LocalQueuedWork->DoThreadedWork();
        LocalQueuedWork = OwningThreadPool->ReturnToPoolOrGetNextJob(this);
      } 
    }
    return 0;
  }
};

QueuedWork 就是需要执行的 work 对象的指针,它被 volatile 修饰,说明还有其他的线程会修改这个指针防止编译器生成直接从缓存中读取代码的优化。 check 方法,明显地指明了被唤醒时只有前面提及的两种情况。如果 Work 不为空,则调用 IQueuedWorkDoThreadedWork 接口。任务完成后的下一行代码,就是向所属线程池的同步队列再申请一个任务。如果队列中有任务,则继续执行新的任务。若队列已经为空,则将线程归还到线程池。线程池有一个 QueuedThreads 成员,记录线程池中的空闲的线程。

个人觉得 UE4 在 check 之后的实现略有不妥。在同时有 Work 要执行和 TimeToDietrue 时,UE4 选择了继续执行完 Work 再退出。笔者认为 TimeToDietrue 时,应该放弃执行当前的work,直接退出。当然,这里不同的策略差别也不大,也不重要。

还有一个重要的函数,就是 FQueuedThread::DoWork. 它是由生产者调用线程池的 AddQueuedWork,线程池对象在进行调度的时候调用的。DoWork 函数代码如下:

void FQueuedThread::DoWork(IQueuedWork* InQueuedWork)
{
    // ...
    QueuedWork = InQueuedWork;
    FPlatformMisc::MemoryBarrier();
    // Tell the thread to wake up and do its job
    DoWorkEvent->Trigger();
}

值得提及的是两个函数中的内存屏障代码,FPlatformMisc::MemoryBarrier(). DoWork 中会对 QueuedWork 进行操作,而在 Run 函数中会对 QueuedWork 进行操作,而且DoWork与Run发生在不同的线程,这样就产生了竞争条件(race condition). 一般的情况是上一个 mutex lock,而 UE4 却没有,只使用了内存屏障。原因是这个竞争条件发生的时候,有且仅有一个线程写,有且仅有一个线程读;并且 DoWork 中的 DoWorkEvent->Trigger(),发出一个事件告知已经准备好一个 IQueuedWork,一定发生在 Run 函数中读取 IQueuedWork 之前。所以 UE4 使用内存屏障来保证顺序一致性,让 Run 函数从另外一个线程读取 IQueuedWork 时,能够读取到已经同步过后的值。关于无锁编程,大家感兴趣可以上purecpp相关专题一起讨论。

3. FQueuedThreadPoolBase

再来看看线程池的实现类。FQueuedThreadPool 的实现类只有一个,就是 FQueuedThreadPoolBase 类。我们从它的数据成员,可以很清晰地可以看出,该线程池的结构与第一节的所示的线程池的结构图是基本吻合的:

class FQueuedThreadPoolBase : public FQueuedThreadPool
{
protected:

    /** The work queue to pull from. */
    TArray<IQueuedWork*> QueuedWork;

    /** The thread pool to dole work out to. */
    TArray<FQueuedThread*> QueuedThreads;

    /** All threads in the pool. */
    TArray<FQueuedThread*> AllThreads;

    /** The synchronization object used to protect access to the queued work. */
    FCriticalSection* SynchQueue;

    /** If true, indicates the destruction process has taken place. */
    bool TimeToDie;

// ....
}

数组 QueuedWork 和互斥锁 SynchQueue ,组成了一个线程安全的同步队列。AllThreads 管理着全部的 worker 线程。 TimeToDie 是标识线程池生命状态,如果置为 true,线程池的清理工作正在进行,或者已经进行完毕了。还有一个 QueuedThreads 成员,它管理着空闲的线程,也就是上一节 FQueuedThread 归还自己到线程池的空闲队列。

线程池的创建,会依次创建每个 worker 线程。线程池销毁的时候,会依次向每个 worker 线程发出销毁的命令,并等待线程退出。线程池的销毁会放弃还未执行的 work. 创建和销毁的流程较为简单,就不详细展开了。后文着重讨论生产者向线程池添加 work 的流程。

生产者创建了一个 IQueuedWork 实现对象后,会调用第一节提及的 AddQueuedWork 接口,向线程池添加要执行的 work . UE4 控制线程池添加 work 的流程,实现的较为精细。它将线程池的状态分成了两类,来分别处理。这两种状态分别为:

  1. 线程池中还有空闲线程,即 QueuedThreads 不为空,并且 QueuedWork 一定为空;
  2. 线程池中已经没有空闲的线程,即 QueuedThreads 为空;

第一个情景的处理策略是从空闲线程数组中,取一个线程,并直接唤醒该线程执行由生产者当前传递进来的 work . 第二个情景,较为简单,由于没有空闲线程可用,就直接将 work 入队即可。

void FQueuedThreadPoolBase::AddQueuedWork(
    IQueuedWork* InQueuedWork) /*override*/
{
    // ....

    FQueuedThread* Thread = nullptr;
    {
        FScopeLock sl(SynchQueue);
        const int32 AvailableThreadCount = QueuedThreads.Num();
        if (AvailableThreadCount == 0)
        {
            // situation 2:
            QueuedWork.Add(InQueuedWork);
            return;
        }

        // situation 1:
        const int32 ThreadIndex = AvailableThreadCount - 1;
        Thread = QueuedThreads[ThreadIndex];
        QueuedThreads.RemoveAt(ThreadIndex, 1, false);
    }

    Thread->DoWork(InQueuedWork);
}

UE4 处理情景一的实现,有两个优点

第一,UE4 并不是简单地让每个线程抢占任务队列中 work. 而是在当有空闲线程的时候,小心地获取一个空闲线程,指定 work 并唤醒这一个线程。这样做的好处,是不会出现惊群效应,而让 CPU 浪费时间做无用的线程调度。

第二,从代码中可以看出,UE4 每次获取空闲线程都是取数组的最末尾的空闲线程,也就是最近归还的 work 线程。这样做的好处是,最近归还的线程意味着它相比其他空闲线程是更近期使用过的。它有更大的概率,操作系统还未对它进行 context 切换,或者它的context 数据还留存在缓存当中。优先使用该线程,就有更大的概率获取较为低廉的线程切换开销。

最后,线程池为 worker 线程提供的,从线程池获取下一个可用的 work 和归还空闲线程的接口,ReturnToPoolOrGetNextJob 函数:

IQueuedWork* FQueuedThreadPoolBase::ReturnToPoolOrGetNextJob(
    FQueuedThread* InQueuedThread) /*override*/
{
    // ... omitted codes
    IQueuedWork* Work = nullptr;

    FScopeLock sl(SynchQueue);
    // ... omitted codes
    if (QueuedWork.Num() > 0)
    {
        Work = QueuedWork[0];
        QueuedWork.RemoveAt(0, 1, false);
    }

    if (!Work)
        QueuedThreads.Add(InQueuedThread);

    return Work;
}

当任务队列中还有 work 时,就从队列头部取出一个,是一个 FIFO 的同步队列。当任务队列为空,无法取出新的任务时,线程就将自己归还给到线程池中,标记为空闲队列。UE4 这里实现的不太妥当的就是 QueuedWork 是一个 TArray<IQueuedWork*> 数组。数组对非尾部元素的 Remove 操作,是会对数组元素进行移动的。虽然移动指针并不是很昂贵,而且 UE4 也禁止了 Remove 导致的 shrink 操作,但开销依然是存在的。这里最好的方案是使用一个可以扩容的环状队列。

4. 小结

本文讨论了 UE4 中线程池的实现细节。线程池 FQueuedThreadPool 的实现是由一个元素为 IQueuedWork *的同步队列,及若干个 worker 线程所组成。UE4 中的线程池,将 IQueuedWork 队列化,并用 FIFO 的调度策略。线程池为 IQueuedWork 的生产者提供了入队接口,并为 worker 线程(消费者)提供了获取出队接口。UE4 对线程池的性能优化也做了不少的工作。例如避免线程池抢占 IQueuedWork 时可能会发生的惊群现象,以及取最近使用的线程,还有无锁编程等。

专题的下一篇,我们将讨论 UE4 中的 AsyncTask . 这也是 UE4 迈向现代 C++ 设计的有力步伐。