C++任务调度器实现:从线程池到工作窃取的高性能并发编程

📅 2026/7/16 4:34:36 👁️ 阅读次数 📝 编程学习
C++任务调度器实现:从线程池到工作窃取的高性能并发编程

1. 项目概述:为什么C++需要高效的任务调度?

在C++的世界里,尤其是当你开始涉足高性能计算、游戏引擎、服务器后端或者任何对实时性、吞吐量有要求的领域时,“任务调度”这个词会频繁地出现在你的视野里。它听起来可能有点学术,但本质上,它解决的是一个非常实际的问题:如何让一堆需要执行的“活儿”(任务),在有限的CPU核心上,有条不紊、高效地跑起来,并且尽可能地压榨硬件的每一分性能。

我自己在开发一个实时数据处理框架时,就曾深陷泥潭。最初,我简单地用std::thread创建了一堆线程,每个线程处理一个数据流。结果呢?当任务数量激增,线程上下文切换的开销变得巨大,CPU缓存命中率惨不忍睹,性能不升反降。更头疼的是,有些任务是I/O密集型的(比如读写文件、网络请求),它们大部分时间在等待,却白白占着一个宝贵的硬件线程。这时候我才明白,粗暴的“一个任务一个线程”模型,在复杂场景下根本行不通。我们需要一个更聪明的“管家”——任务调度器。

一个高效的任务调度器,其核心价值在于抽象与自动化。它将“做什么”(任务逻辑)和“何时、在哪个核心上做”(调度策略)分离开。作为开发者,你只需要关注如何定义清晰、无状态(或状态可控)的任务单元;调度器则负责管理线程池、任务队列、负载均衡、任务依赖和优先级。这带来的好处是显而易见的:更高的CPU利用率(避免线程空转)、更低的延迟(任务能更快地被分派执行)、更好的可伸缩性(任务数量增加时,性能能线性或近线性增长),以及更简洁的并发代码(你不再需要手动管理线程的生命周期和同步)。

C++标准库虽然提供了std::asyncstd::thread,但它们更像是并发编程的“原材料”。要构建一个健壮、高效的系统,我们往往需要在其上封装,或者直接使用更强大的库,比如Intel的TBB(Threading Building Blocks)或微软的PPL(Parallel Patterns Library,即并发运行时)。本文将带你从零开始,理解任务调度的核心思想,并动手实现一个简易但五脏俱全的C++任务调度器,让你不仅能“会用”高级库,更能“懂得”其背后的原理,从而在关键时刻能自己定制或优化。

2. 核心概念与设计思路拆解

在动手写代码之前,我们必须把几个关键概念和设计选择想清楚。任务调度不是一个可以随意堆砌的功能,它的设计直接决定了最终系统的性能和可维护性。

2.1 任务(Task)的本质是什么?

首先,我们必须定义什么是“任务”。在最简化的模型里,一个任务就是一个可调用的对象——函数、Lambda表达式、函数对象,或者绑定好的std::function。但一个可用于调度的任务,通常需要包含更多元数据:

  • 可执行体:即那段具体的代码。
  • 状态:是否已准备、正在执行、已完成、被取消。
  • 依赖关系:本任务执行前,需要等待哪些其他任务完成。
  • 优先级:用于决定在任务队列中谁先被取出执行。
  • 结果(可选):如果任务有返回值,我们需要一个地方来存储或传递它。

在C++中,我们可以用一个基类Task来抽象这些共性,具体的任务逻辑则由派生类重写execute()方法来实现。更现代、更轻量的做法是,直接使用std::packaged_taskstd::function来包装可调用对象,再搭配std::future来获取结果,而将依赖、优先级等作为外部管理的信息。

2.2 线程池(Thread Pool):劳动力的容器

任务调度器几乎总是基于线程池的。线程池维护一组预先创建好的工作线程(Workers),它们处于睡眠或等待状态,一旦有任务到来,就被唤醒去执行。这避免了频繁创建和销毁线程的巨大开销。 设计线程池时,要考虑几个关键点:

  1. 线程数量:设多少合适?一个经典的启发式规则是std::thread::hardware_concurrency(),它返回硬件支持的并发线程数(通常是CPU核心数)。对于CPU密集型任务,线程数约等于核心数可以最大化利用CPU而不至于因过多线程切换导致开销。对于I/O密集型任务,可以适当增加线程数,以在部分线程阻塞时,仍有其他线程可以执行CPU任务。
  2. 任务队列(Task Queue):任务提交后,在被执行前存放在哪里?这就是任务队列。它的数据结构选择和同步机制至关重要。
    • 数据结构:通常使用std::queuestd::deque。如果需要优先级调度,则需要std::priority_queue
    • 同步:因为多个生产者(提交任务的线程)和多个消费者(工作线程)会同时访问队列,所以必须加锁。std::mutex配合std::condition_variable是最常见的组合。条件变量用于在队列空时让工作线程等待,有任务时通知它们。
  3. 工作线程的生命周期:线程启动后,通常在一个循环中不断尝试从任务队列获取任务并执行。循环退出的条件一般是接收到一个特殊的“停止”信号。

2.3 调度策略:决定谁先来的规则

这是调度器的“大脑”。最简单的策略是FIFO(先进先出),所有任务丢进一个队列,工作线程按顺序取。但这显然不够智能。 更实用的策略包括:

  • 优先级调度:每个任务附带一个优先级数值。任务队列使用优先队列(大顶堆或小顶堆),保证高优先级任务先被执行。这在实时系统中很常见。
  • 工作窃取(Work-Stealing):这是现代高性能调度器(如TBB、Go scheduler)的核心技术。每个工作线程拥有自己的本地任务队列。当线程自己的队列为空时,它不会闲着,而是随机去“偷”其他线程队列尾部的任务来执行。这极大地减少了全局队列的争用,提高了并行度和缓存局部性。
  • 依赖感知调度:任务A必须在任务B完成后才能开始。调度器需要维护一个任务依赖图(DAG),并跟踪每个任务的未完成前置任务计数。当一个任务完成时,它将其后继任务的计数减一,当计数为零时,该后继任务才被放入可执行队列。

我们的第一个实践版本将从最简单的FIFO全局队列开始,逐步引入优先级和依赖感知,而工作窃取因其复杂性,将作为高级话题讨论其设计思路。

2.4 与现有库(TBB, PPL)的关系

你可能会问,既然有TBB、PPL这些成熟的库,为什么还要自己实现?原因有三:

  1. 理解原理:自己动手实现是理解其精妙之处的最佳途径。你会遇到所有经典并发问题,并学会如何解决它们。
  2. 轻量与定制:大型库可能带来额外的依赖和开销。如果你的需求非常特定,一个几百行代码的自定义调度器可能更合适。
  3. 学习与面试:深入理解任务调度是高级C++工程师的必备技能,在面试中也是高频话题。

我们的实现会借鉴这些工业级库的思想,但保持代码的简洁和可读性。

3. 基础实现:一个简单的FIFO线程池调度器

让我们从最基础的开始:一个固定大小线程池,一个全局的FIFO任务队列。

3.1 核心组件定义

首先,我们定义任务。这里我们使用std::function<void()>,因为它能包装任何可调用对象,且无需返回值(通过Lambda捕获或参数传递结果)。

#include <functional> #include <future> #include <vector> #include <thread> #include <queue> #include <mutex> #include <condition_variable> #include <atomic> class ThreadPool { public: using Task = std::function<void()>; explicit ThreadPool(size_t num_threads) : stop_(false) { workers_.reserve(num_threads); for (size_t i = 0; i < num_threads; ++i) { // 每个工作线程执行一个循环,不断从队列取任务 workers_.emplace_back([this] { while (true) { Task task; { // 这个花括号限定了锁的作用域,非常重要! std::unique_lock<std::mutex> lock(queue_mutex_); // 等待条件成立:池子停止或有任务可执行 condition_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); // 如果池子已停止且任务队列为空,线程结束 if (stop_ && tasks_.empty()) { return; } // 取出任务 task = std::move(tasks_.front()); tasks_.pop(); } // 锁在这里自动释放,其他线程可以操作队列了 // 执行任务(在锁外执行,避免长时间持有锁) task(); } }); } }

关键点解析

  1. condition_.wait(lock, predicate):这是标准的生产者-消费者模式。predicate是一个lambda,检查是否满足继续执行的条件(有任务或停止)。wait会在等待时自动释放锁,被唤醒后重新获取锁,并再次检查predicate。这避免了虚假唤醒。
  2. 锁的作用域:我们只在访问共享数据tasks_队列和stop_标志时加锁。一旦任务从队列取出,立即释放锁,然后再执行任务。这保证了任务执行期间,其他线程依然可以提交任务或获取任务,极大提高了并发度。
  3. std::move(tasks_.front()):使用移动语义将任务移出队列,避免不必要的拷贝。

3.2 任务提交接口

接下来,我们需要一个向线程池提交任务的接口。我们希望这个接口是异步的,并且能方便地获取任务结果。这里我们使用std::future

template<typename F, typename... Args> auto enqueue(F&& f, Args&&... args) -> std::future<decltype(f(args...))> { // 推导任务返回类型 using return_type = decltype(f(args...)); // 将任务和其参数打包成一个 packaged_task,它能提供 future auto task = std::make_shared<std::packaged_task<return_type()>>( std::bind(std::forward<F>(f), std::forward<Args>(args)...) ); // 获取与该任务关联的 future std::future<return_type> res = task->get_future(); { std::unique_lock<std::mutex> lock(queue_mutex_); if (stop_) { throw std::runtime_error("enqueue on stopped ThreadPool"); } // 将 packaged_task 包装成一个 void() 类型的任务,放入队列 tasks_.emplace([task]() { (*task)(); }); } // 通知一个等待的工作线程 condition_.notify_one(); return res; }

关键点解析

  1. std::packaged_task:这是一个高级工具,它将一个可调用对象包装起来,允许异步获取其结果(通过std::future)。我们使用std::shared_ptr来管理它,因为Lambda捕获需要可复制构造,而packaged_task本身不可复制。
  2. std::bind和完美转发:std::bind将函数和参数绑定成一个无参的可调用对象。std::forward用于保持参数的左值/右值引用属性,实现完美转发,避免不必要的拷贝。
  3. condition_.notify_one():当新任务入队后,我们通知一个正在等待的工作线程。如果使用notify_all()会唤醒所有线程,但只有一个能抢到任务,其他会再次休眠,造成不必要的开销。notify_one在多数情况下更高效。

3.3 析构与资源清理

线程池的析构必须确保所有已提交的任务被执行完(或明确丢弃),并让所有工作线程安全退出。

~ThreadPool() { { std::unique_lock<std::mutex> lock(queue_mutex_); stop_ = true; } // 通知所有线程,让它们检查 stop_ 条件 condition_.notify_all(); // 等待所有线程执行完毕 for (std::thread &worker : workers_) { if (worker.joinable()) { worker.join(); } } } private: std::vector<std::thread> workers_; std::queue<Task> tasks_; std::mutex queue_mutex_; std::condition_variable condition_; std::atomic<bool> stop_; };

3.4 基础版本的使用示例与性能初探

现在,我们可以使用这个基础的线程池了。

int main() { ThreadPool pool(4); // 创建一个4线程的池子 std::vector<std::future<int>> results; // 提交10个任务 for (int i = 0; i < 10; ++i) { results.emplace_back(pool.enqueue([i] { std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << "Task " << i << " executed by thread " << std::this_thread::get_id() << std::endl; return i * i; })); } // 获取结果 for (auto &&result : results) { std::cout << "Result: " << result.get() << std::endl; } return 0; // pool 析构时会自动等待所有任务完成 }

这个简单的调度器已经具备了基本功能。但在高并发压力测试下,你会发现它的瓶颈很明显:那个全局的queue_mutex_。所有线程(生产者和消费者)都要竞争这一把锁,当线程数很多时,锁争用会严重限制扩展性。这就是我们需要更高级策略的原因。

4. 进阶实现:支持优先级与依赖感知

基础FIFO调度器解决了有无问题,但离“高效”还有距离。接下来我们为其添加优先级和简单的依赖管理。

4.1 实现优先级调度

要实现优先级,我们需要改变任务队列的数据结构,并使用std::priority_queue。同时,我们需要为任务定义一个优先级。

struct PrioritizedTask { std::function<void()> task; int priority; // 数值越大,优先级越高 // 重载 < 运算符,用于 priority_queue(默认是大顶堆) bool operator<(const PrioritizedTask& other) const { // 注意:priority_queue 默认使用 std::less,但它是大顶堆。 // 我们希望优先级高的先出队,所以这里用 `>` 来比较。 return priority < other.priority; // 这样是优先级数字小的先出?不对! // 正确应该是:return priority < other.priority; // 对于大顶堆,我们需要“小于”比较时,实际是优先级高的在前。 // 更清晰的写法是使用自定义比较器。 } }; // 更好的方式:使用自定义比较器 struct ComparePriority { bool operator()(const PrioritizedTask& a, const PrioritizedTask& b) { // 我们希望优先级数值大的先执行 return a.priority < b.priority; // 注意:这是 priority_queue 需要的“小于”比较,但队列是最大堆,所以这样写是对的。 // 对于 std::priority_queue<T, Container, Compare>,Compare 需要提供“严格弱序”,当 Compare(a,b) 为 true 时,a 的优先级低于 b。 // 所以 a.priority < b.priority 为 true 时,a 的优先级低于 b,因此 b(优先级更高)会在队列顶部。 } }; class PriorityThreadPool { private: using Task = std::function<void()>; std::priority_queue<PrioritizedTask, std::vector<PrioritizedTask>, ComparePriority> tasks_; // ... 其他成员与基础 ThreadPool 类似 };

然后,修改enqueue函数,接受一个优先级参数。工作线程的循环逻辑几乎不变,只是队列类型换了。当从优先队列中pop时,自动取出优先级最高的任务。

实操心得: 使用std::priority_queue时,要特别注意其比较逻辑。它默认使用std::less<T>,但表现是最大堆(即“小于”比较时,大的元素在顶)。自定义比较器时,要明确你定义的“小于”关系意味着“优先级更低”。一个简单的记忆方法是:priority_queue顶部的元素,是使得比较器返回false的那个元素。在上面的ComparePriority中,如果a.priority < b.prioritytrue,说明a的优先级低于b,那么b应该在顶部。

4.2 实现简单的依赖感知(DAG调度)

依赖感知更复杂一些。我们需要将任务组织成有向无环图(DAG)。每个任务需要知道:

  1. 它依赖哪些前置任务(或它的后继任务是谁)。
  2. 还有多少个前置任务未完成。

一种常见的实现方式是使用“计数器”和“完成时通知”机制。

class DAGTask { public: using Ptr = std::shared_ptr<DAGTask>; std::function<void()> workload; std::atomic<int> unfinished_prerequisites{0}; std::vector<Ptr> successors; // 后继任务列表 std::mutex mutex; // 保护 successors 的修改(如果动态添加) void addSuccessor(Ptr succ) { std::lock_guard<std::mutex> lock(mutex); successors.push_back(succ); succ->unfinished_prerequisites.fetch_add(1, std::memory_order_relaxed); } void operator()() { workload(); // 执行实际工作 // 当前任务完成,通知所有后继任务 for (auto& succ : successors) { // 将后继任务的未完成前置计数减1 int old = succ->unfinished_prerequisites.fetch_sub(1, std::memory_order_acq_rel); if (old == 1) { // 如果减1后变为0,说明该后继任务的所有依赖都完成了 // 将这个后继任务提交到线程池的可执行队列 // 这里需要访问线程池的提交接口,通常需要将线程池的引用或提交函数传递给DAGTask // 例如:pool_->enqueueReadyTask(succ); } } } };

调度器需要维护一个“就绪队列”,里面存放所有unfinished_prerequisites为0的任务。当一个任务完成时,它遍历自己的后继,递减它们的计数器。如果某个后继的计数器变为0,就将其加入就绪队列。

注意事项

  1. 线程安全unfinished_prerequisites的修改必须是原子的,因为多个前置任务可能同时完成并尝试修改同一个后继任务的计数器。我们使用std::atomicfetch_sub
  2. 内存序memory_order_acq_rel确保了递减操作前的所有内存写入(在workload()中)对获取到old == 1的那个线程是可见的。这保证了任务完成的“副作用”对后继任务是可见的。
  3. 循环依赖检测:这是DAG调度器的难点。你需要在添加依赖时检查是否形成了环,否则计数器永远无法归零,导致任务死锁。一种简单的方法是在构建DAG时进行拓扑排序检测。
  4. 性能:每个任务完成时都需要遍历后继列表并操作原子计数器。如果后继很多,开销不小。工业级调度器会使用更高效的数据结构,如使用无锁队列来收集完成的任务,由专门的调度线程来更新依赖关系。

将优先级和依赖感知结合起来,一个任务只有在依赖全部满足(计数器为0)且优先级最高时,才会被调度执行。这需要调度器维护两个结构:一个等待图(存储所有任务及其依赖),一个就绪优先队列(存储所有可执行的任务)。

5. 高级话题:迈向无锁与工作窃取

当线程数量很多(比如几十上百)时,之前基于互斥锁的全局队列会成为严重的性能瓶颈。为了突破这个瓶颈,我们需要减少锁的争用。工作窃取(Work-Stealing)是当前最主流的解决方案。

5.1 工作窃取调度器设计思路

其核心思想是:每个工作线程拥有一个本地任务队列(通常是双端队列,deque)

  • 生产者(任务提交者):可以将任务提交到全局队列,或者根据某种策略(如轮询)提交到某个工作线程的本地队列。
  • 消费者(工作线程)
    1. 首选:从自己的本地队列的头部(或尾部,取决于设计)快速取出任务执行。因为只有自己访问,所以通常可以无锁操作(或使用更轻量的锁)。
    2. 次选:如果自己的队列为空,它变成一个“窃贼”(Thief),随机选择另一个工作线程,尝试从它的本地队列的尾部“偷”一个任务。从尾部偷取可以减少与队列所有者(从头部取)的冲突。
    3. 最后:如果偷取也失败了,再去查看全局队列。

这种设计极大地减少了线程间对共享数据的竞争。大部分时间,线程都在操作自己的本地队列。

5.2 C++实现工作窃取的挑战与简易示例

完全无锁、正确的工作窃取队列实现非常复杂,涉及精细的内存管理和原子操作。一个相对简单的实现可以使用带锁的双端队列,但锁的粒度更小。

class WorkStealingQueue { private: using Task = std::function<void()>; std::deque<Task> deque_; mutable std::mutex mutex_; public: void pushFront(Task task) { // 所有者从头部推入和弹出 std::lock_guard<std::mutex> lock(mutex_); deque_.push_front(std::move(task)); } bool tryPopFront(Task& task) { // 所有者尝试从头部弹出 std::lock_guard<std::mutex> lock(mutex_); if (deque_.empty()) return false; task = std::move(deque_.front()); deque_.pop_front(); return true; } bool trySteal(Task& task) { // 窃贼从尾部偷取 std::lock_guard<std::mutex> lock(mutex_); if (deque_.empty()) return false; task = std::move(deque_.back()); deque_.pop_back(); return true; } bool empty() const { std::lock_guard<std::mutex> lock(mutex_); return deque_.empty(); } };

然后,每个工作线程持有一个WorkStealingQueue实例。线程的主循环逻辑变为:

void workerThread(WorkStealingQueue* my_queue, std::vector<WorkStealingQueue*>* all_queues) { Task task; while (!stopped) { // 1. 先尝试从自己的队列取 if (my_queue->tryPopFront(task)) { task(); continue; } // 2. 尝试从其他随机队列偷 bool stolen = false; if (all_queues) { // 随机打乱索引,避免总是偷同一个线程 std::vector<size_t> indices(all_queues->size()); std::iota(indices.begin(), indices.end(), 0); std::shuffle(indices.begin(), indices.end(), std::mt19937{std::random_device{}()}); for (auto idx : indices) { if ((*all_queues)[idx] == my_queue) continue; // 跳过自己 if ((*all_queues)[idx]->trySteal(task)) { stolen = true; break; } } } if (stolen) { task(); continue; } // 3. 如果都偷不到,可以yield或sleep一小会儿,避免空转消耗CPU std::this_thread::yield(); } }

避坑技巧

  1. 偷取策略:随机选择目标线程可以避免所有空闲线程同时去偷同一个忙碌线程的队列,造成“惊群效应”。
  2. 空转处理:当所有队列都空时,线程会不断尝试偷取和yield,导致CPU空转。一个优化是引入一个全局的“休眠”计数器或使用条件变量,让线程在无任务时真正休眠,当有新任务提交时再被唤醒。
  3. 任务提交:任务提交到哪个队列?可以提交到全局队列,也可以使用一种策略(如轮询、基于负载)提交到某个工作线程的本地队列。提交到本地队列能更好地利用缓存局部性。

真正的工业级实现(如TBB)会使用无锁算法来实现本地队列的pushpop操作,进一步减少同步开销,其代码复杂度远高于此示例。但对于大多数应用,一个基于锁的、设计良好的工作窃取线程池已经能带来巨大的性能提升。

6. 性能调优、问题排查与实战建议

实现了一个调度器后,如何评估其性能?又会在实际使用中遇到哪些坑?

6.1 性能评估关键指标

  1. 吞吐量:单位时间内完成的任务数量。使用高频率的计数器(如std::chrono::high_resolution_clock)来测量。
  2. 延迟:从任务提交到开始执行的时间。这反映了调度器的响应速度。
  3. 可伸缩性:随着线程数(不超过物理核心数)增加,吞吐量是否线性增长。理想情况是线性增长,但锁争用、内存带宽、缓存一致性等问题会导致增长曲线趋于平缓甚至下降。
  4. CPU利用率:使用系统工具(如top,perf)观察,在任务持续提交时,所有核心是否都处于高利用率状态。频繁的yield或锁竞争会导致利用率不足。

简易测试方法:创建大量计算密集型小任务(例如计算斐波那契数列的某个小值),分别用直接循环、基础线程池、工作窃取线程池来执行,统计总耗时。你会发现,当任务数量远大于线程数时,工作窃取的优势会非常明显。

6.2 常见问题与排查技巧

  1. 死锁
    • 场景:任务A等待任务B的结果,任务B又等待任务A的结果(循环依赖)。或者在任务执行函数内部,又去同步等待(如调用.get())另一个由同一调度器提交的任务的未来结果,如果线程池线程数不足,可能导致所有线程都在等待,没有线程去执行那个被等待的任务。
    • 排查:检查任务依赖图是否有环。避免在任务内部同步等待同一调度器派发的其他任务,改用.then()式的延续(continuation)模式,或者确保线程池有足够的空闲线程来处理这种嵌套等待。
  2. 任务饿死
    • 场景:低优先级任务永远得不到执行,因为一直有高优先级任务加入。
    • 解决:实现“老化”机制。随着任务在队列中等待时间变长,逐渐提高其优先级。或者,可以采用多级反馈队列等更复杂的调度算法。
  3. 负载不均
    • 场景:某些线程忙死,某些线程闲死。在基础全局队列模型中,由于锁争用,这种现象可能不明显但整体吞吐量低。在简单的本地队列模型中,如果任务提交不均匀,就会导致。
    • 解决:工作窃取是解决此问题的银弹。确保实现了有效的偷取策略。
  4. 异常处理
    • 问题:任务在执行中抛出异常。如果异常未被捕获,会终止整个线程,导致线程池线程数减少。
    • 最佳实践:在工作线程的循环最外层进行try-catch(...),捕获所有异常,记录日志,并确保线程继续运行。同时,将异常通过std::promise/std::future机制传递回任务提交者,使其能在调用.get()时接收到异常。
    // 在工作线程循环内 try { task(); } catch (...) { // 记录异常日志 // 如果任务关联了 promise,需要在这里设置异常 // 例如:promise.set_exception(std::current_exception()); }
  5. 资源泄漏
    • 问题:任务中分配了资源(如内存、文件句柄)但未释放。
    • 建议:鼓励使用RAII(资源获取即初始化)管理所有资源。确保任务抛异常时,局部对象的析构函数会被调用。

6.3 实战建议与选型指南

  • 何时自己造轮子?
    • 学习、研究、面试准备。
    • 项目有极其特殊、定制化的调度需求,现有库无法满足。
    • 对二进制大小和依赖有极端要求。
  • 何时用现有库?
    • 绝大多数生产环境。直接使用Intel TBB微软PPL。它们经过千锤百炼,实现了无锁工作窃取,性能极高,且API丰富(并行算法、流图等)。
    • C++17及以上:可以关注std::execution并行算法和**执行器(Executor)**提案,这是标准库向并发抽象迈出的重要一步,未来可能会成为统一接口。
    • 计算图/流水线:如果需要复杂的DAG调度,可以考虑OpenCVcv::gapi或专门的任务图库。
  • 设计原则
    • 任务粒度:任务不能太细(否则调度开销占比太大),也不能太粗(否则无法有效并行)。需要通过性能剖析来确定合适的粒度。
    • 避免阻塞:任务中应尽量避免同步I/O、锁等待等阻塞操作。如果必须要有,考虑使用异步I/O,或者使用专门的I/O线程池,与计算线程池分离。
    • 测量,不要猜测:并发性能优化非常反直觉。任何优化前后,一定要用真实负载进行基准测试(Benchmark)。

最后,记住任务调度的终极目标:让正确的任务,在正确的时间,在正确的核心上执行。理解了这个目标,你就能更好地理解各种策略背后的权衡,并设计出适合自己应用场景的高效调度器。从简单的全局队列开始,逐步引入优先级、依赖、工作窃取,这个过程本身就是对并发编程精髓的一次深刻探索。