Skip to content

线程局部调度上下文 ​

打算稍微说说 Job System 中的 thread_local 声明的变量的用途

C++
constexpr uint32_t kExternalWorkerIndex = UINT32_MAX;

thread_local void* g_scheduler = nullptr;
thread_local uint32_t g_workerIndex = kExternalWorkerIndex;
thread_local JobHandle g_currentJob;
  • g_scheduler, 记录当前线程归属运行在哪个 JobSystem (Impl) 的上下文中.
  • g_workerIndex, 记录是当前 JobSystem 的哪个 worker 运行的;
  • g_currentJob, 记录当前执行的任务 handle

或许使用一个 struct ThreadLocalContext 来包裹可能会更清晰一些.

假设我们使用了普通函数参数记录, 在什么 Wait, Help, Execute 等方法中进行传递, 但是这样显得繁琐. 于是直接使用一个 thread_local 进行实现.

初始化 ​

我们在初始化一个 JobSystem 的时候, 会创建若干 thread 类型的 worker 来运行 WorkerMain, 而创建的时候就是它们生命周期的开始 ——

C++
void WorkerMain(uint32_t workerIndex)
{
    g_scheduler = this;
    g_workerIndex = workerIndex;

    Profiler::ProfilerSession::Get()
        .SetCurrentThreadName(
            "Job Worker " + std::to_string(workerIndex)
        );

    while (!m_stopRequested.load(std::memory_order_acquire))
    {
        JobHandle handle;

        if (TryTakeJob(workerIndex, handle))
        {
            Execute(handle);
            continue;
        }

        ...
    }

    g_currentJob = JobHandle::Invalid();
    g_workerIndex = kExternalWorkerIndex;
    g_scheduler = nullptr;
}

这个也非常好理解 —— 在成为 Worker, 拥有线程身份的时候, 同步的让 g_scheduler 和 g_workerIndex 记录 Worker 身份.

当前 engine 下, g_scheduler 只会出现两个数值 —— this 指针指向唯一的 JobSystem (其实是 Impl), 或者 nullptr

表示执行当前线程的上下文属于这个 JobSystem 还是其他的东西(比如 nullptr 表示没有 Scheduler( 像是普通线程 )). 乍一看没什么用, 一个 bool 判断即可. 不过我们并没有让 JobSystem 是单例, 所以实际上可以有多个 JobSystem 存在于 Engine 中. 那么 g_scheduler 的记录身份就比较重要了.

然后对于其他普通线程, 在其生命周期内没有类似 WorkerMain 这样的赋值操作, 所以就是初始值 nullptr. (不过会在 Execute 的时候暂时设置成 JobSystem Impl, 并且在结束的时候恢复)

不过这里也有一个小巧思就是使用 void* 做了一次类型擦除, 也许我们直接使用 Impl 类型即可, 但是这样会是的代码依赖私有 Impl 类型, 而使用 void* 只需要进行一次身份判断即可, 减少依赖.

然后 g_workerIndex 记录了当前 Scheduler 下哪个 worker 执行任务, 一方面是确定身份, 另一方面可以借此定位到 localQueue

记录原因 ​

上文说到一点, 在当前线程记录在哪个 JobSystem 中运行, 哪个 worker 工作, 正在执行什么工作. 然后可以使用这些信息参与各种设计.

Scheduler ​

主要还是上文说的记录 Scheduler 身份吧. 可以查看 Wait 那篇来获取使用说明.

这边比较妙的一点是, 由于 thread_local 是在每一个 thread 有一个自己的实例. 在 JobSystem 的 Worker 中, 它们在初始化的时候就赋值给了 this (当前的 Impl 实现).

那么对于普通的线程, 它的 g_scheduler = nullptr(注意, 每个线程都有自己的实例), 然后在它自己的线程调用 Wait 的时候, 内部检查 g_scheduler == this 的时候, 就非常巧妙的不需要查看或者传入当前 thread ID, 这个 g_scheduler 就是当前这个普通的线程信息了. 而同时如果当前线程是 JobSystem A 中的 Worker01, 它调用了 JobSystem B 下的 Wait 方法, 那么也不会参与 Help.

CurrentJob ​

g_currentJob, 一个很重要的作用是防止直接的 self-wait 而产生的死锁 —— 我们看 Wait 方法, 如果传入的 Handle 就是当前正在工作的 Handle, 那么就是无限期的自己等待自己. 发生死锁.

那么借由我们已经记录了当前线程正在执行的 JobHandle 是什么, 就可以做防御性判断, 避免这种情况发生

C++
JobHandle self;

self = jobs.Schedule(
    "SelfWait",
    [&]
    {
        jobs.Wait(self);
    }
);

比如这种情况发生.(不过如果间接的逻辑上承环的话, 依旧无法判断出来, 比如 A->B->A 这种情况)

另外的, 这个信息在JobShutdownPolicy::Drain模式下关闭 JobSystem 的时候也很有用 —— 先看 Shutdown

C++
while (m_outstandingJobs.load(...) != 0)
{
	...
}

我们需要在 Shutdown 前把工作全部做完, 如果简单粗暴的不允许往里面添加 Job 的话(一刀切), 已有的任务往 JobSystem 中添加任务这件事情就是非法的, 可能导致那个任务失败/达不成预期效果 (比如 nested 的 Job)

那么记录了 CurrentJobHandle, 就可以在 ScheduleInternal 的时候做特判

C++
const bool internalSubmission = g_scheduler == this && g_currentJob.IsValid();

internalSubmission &&
m_acceptingWorkerSubmissions.load(std::memory_order_acquire)

Released under the MIT License.