【翻译】多线程并非免费:性能陷阱可视化

30 阅读20分钟

原文链接:www.callstack.com/blog/multit…

作者:Mariusz Pasiński

欢迎回来!在本期内容中,我将继续我的专题系列。正如在2025年2月发布的首期结尾所承诺的,我们将聚焦于多线程应用程序。

“测验”答案(第一部分)

但在深入主题前,请允许我快速回顾前述文章中的最后一个问题:

PS:再留个谜题:除了执行循环,我们最初的函数还做了什么?它似乎有70%的时间在处理其他任务……有线索吗?

剧透预警: 这部分时间都耗费在内存分配和复制上!我必须承认,函数参数列表中省略的&符号确系刻意为之。仅一个字符竟能造成如此巨大的性能影响。令人惊讶的是,这种情况并不罕见:Meta仅通过在auto关键字后添加单个&符号,就实现了每年约15,000台服务器的容量节省。

引言

今年初,我有幸为多位C++高手进行了多轮技术面试。必须承认,这个过程变得有些复杂——你永远无法确定对方是否使用了人工智能助手,不过这又是另一个故事了。真正令我惊讶的是,当我深入追问时,候选人给出的答案往往要么是他们自己都不完全相信的,要么是无法自圆其说的。多线程编程正是其中典型领域。

当被要求提升程序运行速度时,人们通常首先想到多线程——当然,前提是算法复杂度已无法进一步优化。这种思路完全合理,毕竟多线程能实现多任务并行处理。抱歉,本文不会深入探讨指令级并行数据级并行(SIMD)。但我强烈建议大家深入研究这个领域,因为它能带来800%至3200%的性能提升!

话虽如此,多线程并非"免费"。它带来了一系列需要考虑的挑战。其中之一是确保正确的同步机制以避免数据竞争。我面试过的几乎所有候选人都会立即提到锁和互斥量作为解决方案。虽然这是合理的"默认"答案,但绝非唯一选择!更何况,锁机制还会引入其他问题,如饥饿、死锁和活锁。

因此,本文计划向各位展示如何对多线程应用程序进行性能分析,以及分析过程中需要关注的要点。开篇即提供性能分析环节的指导性问题:

  • 我们是否有效利用了线程?
  • 工作是否在各线程间均匀分配?
  • 哪些线程正在等待锁?
  • 上下文切换如何影响吞吐量?

虽然我很想深入探讨所有这些话题,但恐怕这篇博文会变成一本小书——甚至一场研讨会。我会尽量穿插一些参考文献或至少提及相关名称,以便各位自行研究这些主题。若您需要更全面的指南,请随时告知!😉

你想要什么?

首先,你需要确定目标——这是优化和性能分析的第一步。这个问题的答案将决定你需要收集哪些指标。

让我们用一个比喻来说明。假设你需要购买一台新的洗衣机和干衣机,且不考虑价格因素。如果两种方案完成任务所需的时间相同,你会选择分别购买两台设备,还是选择一台能同时完成两项任务的组合机?

设备运行时间
洗衣机1h 15min
烘干机45min
洗衣烘干机2h

由于“运行时间”相同,您可能更倾向于第二种方案,因为它占用的空间可能更少。但若需要进行三轮洗衣呢?显然,组合机将耗时n × (tw + td) = 3 × 2h = 6h(其中n为轮次数,tw为洗涤时间,td为烘干时间),那么若分开购买呢?有趣之处正在于此。

Time0min1h 15min2h 30min3h 45min4h 30min
洗衣浅色深色毛巾--
烘干-浅色深色毛巾-

这需要 n × max(td, tw) + min(td, tw) = 3 × 75m + 45m = 225m + 45m = 270m,仅需四个半小时!我们成功实现了洗涤与烘干阶段的重叠处理!

这就是流水线处理的威力!当然,你仍受制于耗时最长的环节——洗涤阶段,这是必须牢记的关键点。

冷知识:你的CPU在处理汇编指令时也在使用流水线技术!若想了解相关知识,不妨从指令流水线技术入门。

选项吞吐量 [轮次 / 小时]
组合机3 轮 / 6h = 0.5
单体机3 轮 / 4,5h = 0.3(3)

因此,独立机器的吞吐量更高!这引出了另一个重要概念:延迟,即完成单项任务所需的时间(例如完成一轮洗衣),而这两种情况下的延迟都固定为2小时。

线程模式

首先,我将重构第一部分中处理图像数据的合成基准测试之一。为简化操作,我已将缓冲区分配移出函数。

// Single-threaded version
void ImageTweaker::syntheticBenchmarkBaseline(
        std::vector<uint8_t> &pixels, // inout 
        size_t width = 4032, 
        size_t height = 3024
) { 
  const size_t pitch = 4 * width; // assume 4 bytes per pixel
  const size_t numBytes = pitch * height;
  assert(pixels.size() == numBytes);
  
  ZoneScoped;
  for (size_t retry = 0; retry < 100; ++retry) {
    ZoneScopedN("SyntheticTransform2D_Baseline");
    for (size_t y = 0; y < height; ++y) { // from top to bottom 
      for (size_t x = 0; x < pitch; ++x) { // from left to right
        const size_t i = y * pitch + x;
        pixels[i] = 2 * pixels[i] + 1;
      }
    }
  }
}

直觉反应是将外层循环拆分成更小的块,然后让每个块在独立线程中运行,对吧?这种做法可行,因为迭代之间不存在数据依赖性,使得任务具有极高的并行化潜力!我们可以先将每行数据卸载到专属线程中。

最初(出于偷懒),我曾考虑在示例中使用OpenMP——毕竟它被主流编译器广泛支持——但最终决定避免让非C++开发者感到困惑。因此我放弃了预处理指令,转而开发了自己的函数实现。

// First (naive) multi-threaded version
void ImageTweaker::syntheticBenchmarkThreadedRows(
    std::vector<uint8_t> &pixels, 
    size_t width = 4032, 
    size_t height = 3024
) {
  const size_t pitch = 4 * width; // assume 4 bytes per pixel
  const size_t numBytes = pitch * height;
  assert(pixels.size() == numBytes);
  
  ZoneScoped;
  for (size_t retry = 0; retry < 100; ++retry) {
    ZoneScopedN("SyntheticTransform2D_ThreadPerRow");
    parallel_for (0, height, [](y) => { // y=0; y <= height
      for (size_t x = 0; x < pitch; ++x) {
        const size_t i = y * pitch + x;
        pixels[i] = 2 * pixels[i] + 1;
      }
    }, height); // number of threads = number of rows
  }
}

我尽量保持与原始代码一致。以下是我实现的 parallel_for

template <typename Func>
void parallel_for(
    size_t start, size_t end,
    Func body,
    size_t numThreads
) {
  assert(start <= end);
  const size_t size = end - start;
  numThreads = std::min(numThreads, size);
  const size_t chunkSize = (size + numThreads - 1) / numThreads;
  
  std::vector<std::thread> threads;
  threads.reserve(numThreads);
  
  // Create and run threads (a.k.a. "fork")
  for (size_t i = 0; i < numThreads; ++i) {
    const size_t chunkStart = i * chunkSize + start;
    const size_t chunkEnd = std::min(chunkStart + chunkSize, end); 
    threads.emplace_back([chunkStart, chunkEnd, &body]() { 
      for (size_t ci = chunkStart; ci < chunkEnd; ++ci) {
        body(ci); 
      } 
    }); 
  }
  
  // Wait for all threads to finish (a.k.a. "join")
  for (auto& t : threads) { 
    if (t.joinable()) t.join();
  }
}

这种多线程模式被称为分叉-合并fork-join:在执行工作负载前创建所有线程,运行后等待所有任务完成。虽然效率不高,但目前尚可应付。我的实现额外暴露了一个可调整的参数——线程数量。

请注意,每个线程被分配的工作量恰好为1个单位。

在此我需要暂停思考:究竟该创建多少个线程?这是否重要?最糟糕的答案是敷衍了事,而最简单的做法是为每行数据单独创建线程(上文我刻意采用了这种方式)。我已尽职完成测试,分别测量了单线程基准版本与"每行线程"方案的执行时间,结果如下:

基准测试耗时(运行100次)平均耗时(单次)加速比线程数
syntheticBenchmarkBaseline2 292,99 ms22,93 ms1,00 x1
syntheticBenchmarkThreadedRows9 529,34 ms95,29 ms0,24 x3024

哇,这招不管用!我们用3024个线程来处理这个"问题",结果速度反而慢了四倍?!我特意为你做了额外作业(这样稍后论证时更有说服力)。我尝试了不同数量的线程,并跳过了枯燥的表格展示,直接用图表呈现结果: 一张图表展示了不同线程数如何影响相对于单线程应用程序的加速比

这难道不有趣吗?我们应当立即忽略所有Y值低于1的线程数!此外,似乎存在一个约4.5倍的上限。

首先,多线程性能极少呈线性增长——我们受限于CPU核心数量,更受阿姆达尔定律约束,这点我们或许能直观理解。增加人手处理单一问题的效果存在上限,上图清晰印证了这一点。

还能发现什么?实际上,若大幅减少线程数(至少降至前四分之一),应用反而运行更快!我们还能发现性能峰值出现在200线程以下。为此我制作了另一张图表(向精通数学的读者致歉,因苹果Numbers软件无法设置对数刻度,我将继续采用线性刻度):

一张图表展示了不同线程数如何影响相对于单线程应用程序的加速效果;该图绘制了2至32范围内所有可能的线程数量

糟糕的是:如果我告诉你,运行这些基准测试的机器拥有8个CPU核心呢?上述现象本质上是超额订阅——创建的线程数超过了并发执行能力,操作系统不得不频繁在它们之间切换,而这种切换本身也存在开销。你可以修改我们之前的源代码,用std::thread::hardware_concurrency()替代硬编码的线程数!

我之前也提到过,fork-join模型并非最优解——或许在等待当前任务完成时,我们可以启动另一批任务进行处理。此外,创建和销毁线程本身也需要时间开销,这点可以通过多种方式进行实测。更何况当工作负载过小时,创建线程的成本甚至可能超过实际计算开销。

线程万岁!

我们应当尝试摊销创建线程的成本,理想方案是建立专属的工作线程池!预先创建最优数量的线程,让它们等待任务执行。很简单对吧?

理论上——没错。但实践中这会引发任务分配"问题"。目前我们把图像分割成"条带",条带高度取决于"硬件线程"数量。若图像有3024行,则3024除以8个核心得到每批378行。这个过度简化的示例可能掩盖了细微问题。现实中,工作负载极少具有均匀尺寸。

即便忽略尺寸差异,当负载规模相近时,部分线程仍可能提前完成,进而陷入等待"下一批次"的状态。购物中心排队现象正是绝佳的现实例证! 四张收银台示意图,每张台前均有顾客排队(黄色圆圈);红色圆圈代表负责接待顾客的店员

想象你采购完毕正走向收银台。上图展示了你抵达时的场景。这家商店设有四个收银台(灰色矩形代表传送带,即硬件线程),但仅有三名收银员(红色圆圈,即工作线程)在处理顾客(黄色圆圈)。通常部分顾客仅购买少量商品,另一些则推着满载的购物车。当然,每个人的消费习惯不同,收银员处理速度也各异。你会选择哪个收银台?

我假设你会选择在第四个柜台"排队"——这也是我的做法。要求增设柜台显然极其愚蠢。但这假设你拥有自由意志。现在设想你受雇于这家商店(成为蓝色圆圈),需要将顾客(来自主队列)分配到各个通道。你会如何操作?你的启发式策略是什么?

展示四个柜台及顾客主队列(黄色圆圈);蓝色圆圈负责将每位顾客分配至独立柜台 为简化起见,所有顾客均采用相同处理方式,不支持优先级任务。

所谓的循环调度法会依次将顾客分配至下一个可用柜台。因此当所有柜台开放时,第四位顾客前往第四个柜台,第五位顾客从第一个柜台开始依次分配。这虽是不错的起点且基本符合我们的实现方案,但若每第四位顾客都推着满载购物车呢?他们最终都将涌向同一个柜台!但愿该收银员手速极快(致敬利德超市的德国收银员们😄)。

如果你是网页开发者,"循环调度"这个词想必听得耳熟能详。这确实是负载均衡器面临的相同难题。

若想推卸责任,不妨采用随机分配机制。虽不确定其正式名称,我称之为"俄罗斯轮盘调度法"。当愤怒或不耐烦的客户投诉时,你总能辩解为运气不佳。其优点在于难以被"利用"——例如找出规律将所有"大客户"分配到同一通道。

说到这,若存在效率极高的收银员团队,他们能否主动邀请顾客加入?若允许,应从主队列还是其他柜台调配?具体哪个柜台?若ATM故障怎么办?若放任不管,顾客将陷入"无限等待"。此时应继续排队还是转至其他队列?若转队,该去哪个队列?

在此我将向您介绍两种调度方法:工作请求式与工作窃取式。有趣的是,欧洲部分大型超市采用的是"缓冲式"工作请求机制。我更倾向于工作窃取式——当主队列清空时,闲置工位可主动邀请超负荷工位的顾客加入。虽然这种方式成本稍高(需更换传送带并重新装卸货物),但总体运行效果良好。

再插一句:虽然一次性分配线程池并让其在应用运行全程保持活跃很诱人,这种做法在游戏和服务器应用中效果极佳,但我们——移动应用开发者——还需优化电池续航!每个应用情况不同,因此建议实现灵活的线程池,使其能根据工作负载动态扩展或缩减。

实现时间

让我们将上述讨论内容具体化。我起草了一个简单的调度器,始终牢记两个目标:

  1. 我们希望维护一个工作线程池,而非频繁创建和销毁线程。
  2. 存在一个全局任务队列,工作线程应能从中获取任务。这最终将突破单线程仅处理1批任务的限制。
class WorkSharingPool {
public:
    using Task = std::function<void()>;
    
    WorkSharingPool(size_t numThreads = std::thread::hardware_concurrency())
        : m_isQuitting(false)
        , m_syncRequested(false)
    {
        ZoneScoped;
        // Create worker threads that will run `workOnTasks()`
        m_workers.reserve(numThreads);
        for (size_t i = 1; i <= numThreads; ++i) {
            m_workers.emplace_back([this, i] { workOnTasks(i); });
        }
    }
    
    ~WorkSharingPool()
    {
        quit();
    };
    
    void add(Task&& task)
    {
        ZoneScoped;
        std::lock_guard<LockableBase(std::mutex)> lock(m_queueLock);
        m_mainQueue.emplace(task);
        m_cv.notify_all();
    }
    
    void waitUntilFinished()
    {
        ZoneScoped;
        m_syncRequested.store(true);
        m_cv.notify_all();
        {
            std::unique_lock<LockableBase(std::mutex)> lock(m_queueLock);
            m_cv.wait(lock, [this] { return m_isQuitting.load() || (m_mainQueue.empty() && m_idleWorkers == poolSize()); });
        }
    }
    
    void quit()
    {
        m_isQuitting.store(true);
        m_syncRequested.store(true);
        m_cv.notify_all();
        
        // Wait for the threads to finish
        for (std::thread &t : m_workers) {
            if (t.joinable())
                t.join();
        }
    }
    
    inline size_t poolSize() const { return m_workers.size(); }
    
private:
    std::vector<std::thread> m_workers;     // implements the clerks (red circles)
    TracyLockable(std::mutex, m_queueLock); // to avoid race-conditions on the main queue
    std::condition_variable_any m_cv;       // "wake up" a sleeping worker when tasks get updated
    std::queue<Task> m_mainQueue;           // our customers waiting for processing
    std::atomic<bool> m_isQuitting;         // whether threads should keep working or quit
    std::atomic<bool> m_syncRequested;      // whether we want to synchronize with our workers
    std::atomic<size_t> m_idleWorkers;
    
    std::optional<Task> tryGetTask(bool &isIdling)
    {
        ZoneScoped;
        std::optional<Task> task;

        // Wait for tasks to be available.
        // No need to constantly yell "next please" to an empty space...
        std::unique_lock<LockableBase(std::mutex)> lock(m_queueLock);
        m_cv.wait(lock, [this] { return m_syncRequested.load() || !m_mainQueue.empty(); });
        
        // Pop the task from the global queue
        if (!m_mainQueue.empty()) {
            task = std::move(m_mainQueue.front());
            m_mainQueue.pop();
            
            if (isIdling) {
                --m_idleWorkers;
                isIdling = false;
            }
        } else if (!isIdling) {
            ++m_idleWorkers;
            isIdling = true;
            m_cv.notify_one();
        }

        return task;
    }
    
    void workOnTasks(size_t workerIndex)
    {
#if TRACY_ENABLE
        ZoneScoped;
        char name[16];
        snprintf(name, sizeof name, "Worker #%zu", workerIndex);
        tracy::SetThreadName(name);
#endif
        bool isIdling = false;

        while (m_isQuitting.load() == false) {
            std::optional<Task> task = tryGetTask(isIdling);
            
            // Execute the task, then rinse, repeat
            if (task.has_value()) {
                ZoneScopedN("Execute task");
                (*task)();
            }
        }
    }
};

然后,我重载了 parallel_for() 函数,使其能够与这个新的调度器配合使用:

template <typename Func>
void parallel_for(
    size_t start, size_t end,
    Func body,
    WorkSharingPool &pool
)
{
  assert(start <= end);
  const size_t size = end - start;
  const numChunks = pool.poolSize();
  const size_t chunkSize = (size + numChunks - 1) / numChunks;

  // Distribute workload as evenly as possible
  for (size_t i = 0; i < numChunks; ++i) {
    const size_t chunkStart = i * chunkSize + start;
    const size_t chunkEnd = std::min(chunkStart + chunkSize, end);
    pool.add([chunkStart, chunkEnd, &body]() {
      for (size_t ci = chunkStart; ci < chunkEnd; ++ci) {
        body(ci);
      }
    });
  }

  // Wait for all threads to finish (a.k.a. "join")
  pool.waitUntilFinished();
}

最后是我们的基准测试功能:

// Second (naive) multi-threaded version
void syntheticBenchmarkWorkSharedBands(
    std::vector<uint8_t> &pixels,
    size_t width = 4032,
    size_t height = 3024
)
{
  const size_t pitch = 4 * width; // assume 4 bytes per pixel
  const size_t numBytes = pitch * height;
  assert(pixels.size() == numBytes);

  // Create the thread pool
  const size_t numWorkers = std::thread::hardware_concurrency();
  WorkSharingPool pool(numWorkers);

  ZoneScoped;
  for (size_t retry = 0; retry < 100; ++retry) {
    ZoneScopedN("SyntheticTransform2D_WorkSharedBands");
    parallel_for(0, height, [pitch, &pixels](size_t y) { // y=0; y <= height
      for (size_t x = 0; x < pitch; ++x) {
        const size_t i = y * pitch + x;
        pixels[i] = 2 * pixels[i] + 1;
      }
    }, pool);
  }
}

表现如何?天哪,看看这张图表: 图表显示了两种实现方案的相对性能提升(相对于单线程应用):通过使用线程池并打破"1个线程处理1个批次"的限制(绿色曲线),无论计算任务数量多少,我们都实现了显著的加速效果。

本质上,无论任务数量如何变化,我们的表现都远超预期。是的——任务数量——因为我们上述的工作共享调度器维护着固定大小的线程池(我的MacBook上为八个线程),而最初的"线程化行"实现严格遵循单任务单线程模式。值得深思这种性能差距!

别碰这个(锁)

不过别太兴奋……我们才刚开始,还没真正用上Tracy呢。

文章开头提过锁对吧?并发机制最讨厌锁(在热点路径上),但这不意味着更多锁会引发更多问题。如果你仔细浏览过我的调度器,应该注意到我用了些Tracy宏对吧?Tracy,你能帮帮我吗? Tracy生成的截图展示了每个线程的火焰图及锁使用情况;每个线程火焰图底部的红色矩形标记着互斥锁被锁定导致线程等待的时刻

在每条"线程轨迹"下方,火焰图中可见该线程正在获取的锁(黄色)或正在等待的锁(红色)。遗憾的是(但也在预料之中),大量红色标记出现,这预示着严重问题。这种可怕现象被称为线程争用。左下角的"锁信息"窗口甚至显示:"锁持有时间:94.86毫秒"和"锁等待时间:214.89毫秒"!没错,我们实际等待的时间竟超过了"弹出"任务所需的时间——足足两倍有余!

这就是为什么我对add()tryGetTask()方法,以及工作者循环中的"执行任务"区域进行了性能监控。这使我能够直观展示线程争用现象,并有助于评估问题的严重程度。由于我们需要最大化吞吐量,应尽可能延长工作者执行任务的时间,同时尽量缩短tryGetTask()方法的耗时。

如果用购物比喻来说明,想象每次店员处理完一位顾客后,都得打电话叫你过去接下一位顾客。但你每次只能和一位店员通话;其他店员只能等着你接听他们的电话。这种情况每次处理完顾客后都会发生——天哪!

我们可以尝试复用之前奏效的策略:通过批量获取顾客来摊销"获取成本"。传送带很长,我们可以尝试排队6位顾客(顺便说一句,这个数字是我刚掷骰子决定的)。我们可以在每个线程中添加一个小随机数。虽然这个想法不错,能促进数据局部性,但只是把问题藏在毯子底下。我们能否在锁机制上做点什么?

角色划分明确

我们的队列采用何种内存访问模式?系统中各角色如何分工?"混乱"或许是个答案,但我更关注生产者消费者的角色定位。目前仅主线程向调度器提交任务,因此主线程是系统中唯一的生产者。至于谁在消费这些任务,则是所有工作线程共同承担,因此我们面对的是单生产者多消费者(SPMC)架构。

无需深入技术细节(尽管强烈推荐阅读Dmitry Vyukov的博客文章以了解更多),我们可选用(或自行实现)多种无锁无等待的SPMC队列。或许"队列"并非最精确的表述,因为我们关注的是双端队列。这类"猛兽"无需同步生产者和消费者,队列的两端是"独立"的。

本文选取经典方案:出自《动态循环工作窃取双端队列》论文的Chase-Lev SPMC双端队列(我早说过对工作窃取机制有偏爱)。该类队列成员主要包含三项操作:专属于所有者的push_back()pop_back(),以及面向其他参与者的steal()。顾名思义,所有者遵循后进先出(LIFO)顺序,而窃取者则遵循先进先出(FIFO)顺序。

我们是否要用这样的“双端队列”替换主队列?或许我们应该考虑彻底移除主队列?这是否可行?又如何能减少线程争用?

等等,就这些?

我得先把这篇文章搁笔了。想给各位留点时间消化上述内容,动手实践,或许还能琢磨出减少线程竞争的方法。本系列下一篇文章将继续探讨工作窃取机制,届时我会展示如何通过增加更多纤流来提升吞吐量😄

本文深入探讨了让程序充分利用多线程执行的技术。最后再次抛出几个关键问题供思考:

  • 我们是否有效利用了线程?
  • 工作负载在线程间是否均衡分配?
  • 哪些线程正处于锁等待状态?
  • 上下文切换如何影响吞吐量?

或许此刻你已明白其中多数指标的重要性,甚至已构思出可靠的测量与可视化方案。带监控功能的分析器将成为不可或缺的利器,助你解答那些迫切的问题,而Tracy确实是款卓越的工具。关于线程争用(以及Tracy如何可视化呈现)我仅做了浅尝辄止的探讨,但请耐心等待下期内容——我们将继续推进"假设导向编程"的实践,绝对值得期待 🙂 与此同时,我邀请您亲自尝试探索工作分配与负载均衡的实践!