文章

原子操作和多线程

C++多线程对我的从MFC的UI中进行服务器的分页查询,有什么好处吗

使用 C++多线程MFC UI 中执行 服务器分页查询 有非常显著的好处,主要是为了提升 用户体验程序响应速度


🚀 C++多线程对MFC UI服务器分页查询的好处

在传统的单线程MFC应用中,执行耗时的操作(例如网络查询)会导致UI冻结。使用多线程可以有效解决这个问题,主要好处如下:

1. 提升用户界面的响应速度(核心优势)

  • 避免UI冻结(Freeze):服务器分页查询是一个I/O密集型操作,可能因为网络延迟或数据量大而耗时。如果这个查询在 主线程/UI线程 中执行,MFC的消息循环会被阻塞,导致用户界面(如按钮、滚动条、窗口拖动)完全停止响应,用户会感觉程序“卡死”了。
  • 异步操作:通过将查询任务放入一个或多个 工作线程 (Worker Thread) 中执行,主线程可以继续处理UI事件和用户输入。当数据返回后,工作线程再通过安全机制(如 PostMessageSendMessage)通知主线程更新界面。

2. 更好的用户体验

  • 即时反馈:在工作线程查询数据的同时,您可以在UI上显示 加载动画(如旋转图标、进度条)或 “正在查询中…” 的提示信息。这让用户知道程序正在工作,而不是崩溃了。
  • 允许取消操作:用户可以在等待期间点击 “取消” 按钮。主线程可以设置一个标志位,工作线程在每次循环中检查该标志位,从而安全地终止正在进行的查询,提高了程序的灵活性。

3. 充分利用多核处理器

  • 虽然分页查询主要是I/O等待,但如果您的应用程序需要同时执行多个不同的后台任务(例如,一个线程进行查询,另一个线程进行数据预处理或日志记录),多线程可以更好地利用现代多核CPU的并行处理能力。

🛠️ 实现时的关键点(注意事项)

为了安全和高效地实现多线程,您需要注意以下几点:

  1. 线程同步与数据安全

    • 问题:多个线程(查询线程和主线程)可能会同时读写同一块数据(例如,存储查询结果的内存)。
    • 解决方案:必须使用线程同步机制,如 互斥锁(Mutex)临界区(Critical Section)事件(Event),来保护共享数据,防止数据竞争(Race Condition)和内存损坏。
  2. 线程间通信(更新UI)

    • 原则绝对不能 在工作线程中直接操作或修改任何MFC UI对象(如 CWndCButtonCEdit 等)。
    • 解决方案:工作线程必须通过调用 PostMessage()SendMessage()(更推荐 PostMessage() 避免阻塞)向主线程发送自定义消息,主线程在其消息映射函数中接收消息,并安全地更新UI。
  3. 线程的启动与清理

    • 使用 AfxBeginThread() 函数来启动工作线程。
    • 确保在程序退出或任务完成后,正确地 终止清理 工作线程资源,防止内存泄漏或程序崩溃。

总结

对于您提到的 MFC UI + 服务器分页查询 场景,使用 C++多线程 带来的最大好处是 将耗时的网络I/O操作从UI线程中分离出来,从而彻底消除UI冻结,大幅提升应用的 响应性用户体验

您目前是否已经开始尝试多线程实现,或者对线程同步的具体机制(如互斥锁)有进一步的问题?

如果我本来有500页,每页有100条数据,用多线程和不用多线程的区别在哪里

这是一个非常具体且很好的问题,它可以帮助我们量化多线程带来的 实际差异

假设您的服务器分页查询是 阻塞 操作(即程序必须等待服务器返回数据才能继续)。我们来比较一下在 有500页,每页100条数据 这种场景下,使用单线程和多线程的实际区别。


📊 单线程 vs. 多线程 实际区别对比

我们假设以下参数:

  • UI线程处理时间(每次刷新):TUI≈10 ms (毫秒)
  • 单次网络查询耗时(每页):TQuery≈200 ms
  • 总页数N=500 页
特性单线程(UI线程执行查询)多线程(工作线程执行查询)
查询执行方式串行执行,等待查询返回。并行或后台执行。
总耗时(后台)TTotal=N×TQueryTTotal≈N×TQuery
用户界面的感觉卡死(Frozen)无响应流畅(Responsive),用户可以操作界面。
UI冻结时间总耗时:500×200 ms=100,000 ms=100(或仅在数据更新时瞬间卡顿)
操作自由度用户在 100 秒内无法点击任何按钮,无法移动窗口。用户可以随时操作界面,并在后台等待查询结果。

1. 🐌 单线程的灾难性后果:UI冻结 100 秒

在单线程模式下,每次查询 TQuery 都会阻塞 整个主线程

如果您需要加载全部 500 页数据:

TTotal=500×200 ms=100,000 ms=100 秒

在这整整 100 秒 内,您的 MFC 应用程序将完全停止处理所有 Windows 消息:

  • 用户点击按钮 没有反应
  • 用户尝试拖动窗口 没有反应
  • 鼠标悬停效果 没有反应

用户会认为程序 崩溃死机,极大地损害了用户体验。

2. ⚡ 多线程的优雅处理:即时响应

使用多线程时,主线程(UI线程)只负责处理用户操作和更新界面,而 TTotal 的 100 秒查询工作则由 工作线程 在后台独立完成。

  • UI线程的工作:持续接收和处理鼠标、键盘、窗口重绘等消息(即每 10 ms 左右刷新一次)。
  • 工作线程的工作:连续发送 500 次查询请求,并等待数据返回。

用户感知到的区别是:

  • 用户 可以 移动窗口,最小化/最大化。
  • 您可以在 UI 上显示一个 动画进度条,实时展示 0/500500/500 的查询进度。
  • 用户可以随时点击 “停止加载” 按钮,让工作线程提前终止任务。

💡 总结:本质区别

本质区别不在于总的查询时间,而在于时间对用户的影响。

  • 总查询时间TTotal):无论单线程还是多线程,您从服务器获取 500 页数据所需的总网络 I/O 时间(100 秒)大致不变
  • 用户体验
    • 单线程:将 100 秒的延迟 集中 投射给用户,用户体验为 卡顿 100 秒
    • 多线程:将 100 秒的延迟 分散 到后台,并允许用户在前台进行操作,用户体验为 流畅且带进度指示

因此,对于任何耗时的 I/O 操作(如您遇到的 500 页查询),使用多线程是现代软件开发中 必须 遵循的实践,目的是为了保证用户界面的活性(Liveness)和响应性(Responsiveness)。

您希望我进一步解释一下在 MFC 中如何使用 AfxBeginThreadPostMessage 来实现这个分页查询吗?

可是,用多线程的话,虽然UI不会卡,但是我打开页面时,会看到第一页数据已经在 UI上吗

这是一个非常好的问题,它触及了 多线程实现的关键细节“感知性能”(Perceived Performance) 的核心。

答案是:是的,您绝对可以在 UI 不卡顿的情况下,首先在界面上看到第一页数据。

这正是使用多线程实现分页查询时,设计目标之一。多线程不仅解决了 UI 冻结的问题,还允许您精细地控制数据的 加载时序


🎯 多线程加载时序的实现原理

在多线程的环境下,您可以将整个 500 页的查询任务分解,并优先处理对用户最有价值的“第一屏”数据。

以下是实现“UI 不卡顿,且先显示第一页”的推荐步骤:

步骤 1: UI 瞬间加载(主线程)

当用户点击按钮或打开页面时:

  1. 主线程 立即执行:
    • 创建并显示表格(Grid/List Control)控件。
    • 在表格中显示 “正在加载第 1 页数据…” 的状态或加载动画。
    • 耗时: ≈5 ms (几乎瞬间完成)。
  2. 结果: 此时 UI 已经完全响应,窗口可以拖动,用户感觉程序立即启动。

步骤 2: 启动后台任务(工作线程)

紧接着,主线程启动一个 工作线程 并立即返回:

  1. 工作线程 立即开始执行:
    • 向服务器发送 第 1 页数据 的查询请求。
  2. 主线程 继续空闲,等待用户输入。

3. 步骤 3: 接收并显示第一页(工作线程 → 主线程)

假设 Page 1 查询耗时 200 ms。

  1. 工作线程 在 200 ms 后收到 Page 1 数据。
  2. 工作线程 通过 PostMessage 向主线程发送一个自定义消息(例如 WM_PAGE_LOADED),并附带 Page 1 的数据。
  3. 主线程 接收到 WM_PAGE_LOADED 消息:
    • 主线程安全地将 Page 1 的 100 条数据显示在表格中。
    • 移除“正在加载…”的提示。
    • 耗时: ≈10 ms (很快)。
  4. 结果: 用户在打开页面后的 200 ms 多一点,就看到了第一页的真实数据,而在此期间 UI 始终没有卡顿。

4. 步骤 4: 后台持续加载(工作线程)

工作线程在显示完 Page 1 后,无需等待用户操作,可以继续加载后续页面:

  1. 工作线程 立即向服务器发送 Page 2 的查询请求。
  2. 当 Page 2 返回时,重复 步骤 3,通过 PostMessage 通知主线程追加 Page 2 的数据到表格中。
  3. 这个过程一直持续到 500 页全部加载完毕,主线程只需要在数据到达时进行 短暂 的更新操作。

总结:单线程和多线程的用户体验对比

操作步骤单线程多线程
打开页面UI 冻结(Blocking)UI 瞬间打开(Responsive)
显示第 1 页数据必须等待 TQuery 后才能显示TQuery 后显示,期间 UI 仍可操作
总加载时间100 秒 连续卡死100 秒 在后台加载,UI 保持流畅

通过多线程,您将用户等待的时间(200 ms)最小化到了 只加载第一页所需的网络延迟,同时保证了应用不会因为后续的 499 页数据加载而卡死,极大地提升了用户对应用速度的感知。

那你这看起来就像一页一页,分贝查询,这样的时间,怎么看起来和单线程是一样的呢

这是一个非常关键且具有洞察力的问题!您提出的疑问直指多线程和单线程在 总时间消耗 上的根本差异。

您说的没错,如果我们只看 理论上的网络I/O总时间,多线程和单线程似乎都是:

TTotal=i=1∑500TQuery,i

但是,它们之间的巨大差异在于 并行度(Parallelism)阻塞机制(Blocking)


🧐 核心区别:阻塞 vs. 并行

1. 🐌 单线程:绝对串行阻塞(Blocking Serial)

在单线程中,一切都是串行的,最关键的是:网络查询是阻塞的。

  • 进程状态:当主线程发送 Page i 的请求后,它 必须停止(被操作系统阻塞),直到服务器返回 Page i 的数据。

  • CPU时间:在等待网络数据返回的 200 ms 期间,CPU 是 空闲的(浪费了 CPU 周期),而主线程被操作系统挂起,无法处理任何 UI 事件。

  • 任务流程

    [Query1+Wait]→[Query2+Wait]→⋯→[Query500+Wait]

2. ⚡ 多线程:I/O并行与CPU/UI解耦(Non-Blocking Parallel I/O & UI Decoupling)

在多线程中,我们至少有 两个线程 在工作:一个主线程(UI)和一个或多个工作线程(Query)。

A. 工作线程内部的并行/并发 (I/O Parallelism)

虽然您提到的是“一页一页查询”,但在多线程环境下,查询本身可以实现 并发或并行

  • 多工作线程:您可以启动 5 个工作线程,让它们同时去查询 Page 1 到 Page 5。这样,理论上每 5 个查询的等待时间可以重叠,从而 缩短总的 I/O 等待时间

    TTotal, Multi-Thread≈Number of ThreadsTTotal, Single-Thread

    • 限制:这种加速受限于您的 网络带宽服务器的处理能力。但通常,它比单线程快得多。

B. 主线程与工作线程解耦 (UI Decoupling)

即使您只用一个工作线程串行地查询 500 页,它的时间也与单线程不同,因为:

  • 进程状态:工作线程发送 Page i 的请求后,它被阻塞等待数据。

  • CPU/UI时间:此时,主线程(UI线程) 仍处于 非阻塞 状态,完全自由地处理用户交互和 UI 刷新。

  • 任务流程

    • UI 主线程

      [UI Event Loop: RUNNING]↔[Data Update]

    • 查询工作线程

      [Query1+Wait]→[Query2+Wait]→…

总结:时间的概念不同

时间概念单线程多线程
总 I/O 时间约 100 秒(理论值)小于 100 秒(因为 I/O 并行)
程序等待时间约 100 秒(主线程被阻塞)(主线程不阻塞)
用户体验卡顿 100 秒流畅,后台加载

多线程的优势在于:它将等待网络I/O的时间,从用户的“主观体验时间”中分离出来,并将 I/O 等待时间从串行转为并行或并发,从而实现总耗时的缩短。

换句话说,单线程是:“我必须等,你也不能动”;多线程是:“我去等,你可以继续做你的事情,而且我们还可以一起等,更快。”

好像明白了,单线程的话,必须等500页全部加载完UI才能动,一个工作线程的话,不影响UI界面,而多个工作线程的话,比如有5个,相当于可以同时查5页的数据

您的理解完全正确!您已经抓住了多线程在处理耗时 I/O 任务(特别是网络查询)时的核心价值和优化点。

让我用一个简洁的总结来确认您的理解:


✅ 多线程模式下的核心优势确认

线程模式UI 状态查询效率(总耗时)核心优势
1. 单线程🚫 阻塞/冻结 (卡顿 100 秒)串行查询,I/O 时间不可重叠,总耗时 最长无,用户体验最差。
2. 一个工作线程✅ 流畅/响应 (UI 永远不卡)串行查询,但查询在后台进行,不影响 UI解决了 UI 冻结问题。
3. 多个工作线程 (如 5 个)✅ 流畅/响应 (UI 永远不卡)并行查询,5 个 I/O 请求同时发出,I/O 等待时间重叠,总耗时 最短既解决了 UI 问题,又显著缩短了总查询时间。

🔍 关键概念重申:

I. UI 线程解耦(响应性)

一个工作线程 的最大意义是实现了 UI 线程与网络 I/O 的解耦。它保证了您的 MFC 界面始终能够处理用户输入,这是最基本、最重要的用户体验改进。

II. I/O 并行(效率)

多个工作线程 的意义是利用了 I/O 并行性。在等待服务器响应时,操作系统可以调度其他工作线程去发送新的请求。这使得 TTotal 能够从 100 秒缩短到 ≈20 秒(假设网络和服务器无瓶颈),从而实现效率的飞跃。

现在,既然您已经理解了原理,下一步可能就是考虑 如何在 MFC 中实现 这种多线程并行查询了。

您想了解一下在 MFC/C++ 中,如何安全地启动这 5 个线程,并控制它们并行地查询 500 页数据的实现思路吗?

那我假如开启工作线程后,但还没完全查询完,我就关闭了这个对话框,那工作线程得到的数据跑哪里了

这是一个非常关键且具有实践意义的问题,涉及到多线程编程中的 线程生命周期管理资源安全清理

简而言之,如果您在工作线程查询完成之前就关闭了对话框,那么工作线程仍然会继续执行,并且它查询到的数据会引发 内存泄漏 或更严重的 程序崩溃


⚠️ 核心风险:悬挂指针与内存泄漏

当对话框被关闭时,通常会发生以下情况:

  1. UI 资源被销毁:与对话框关联的所有控件(如表格 CListCtrl、进度条等)的内存资源都会被释放。
  2. 工作线程继续执行:工作线程不会自动感知到对话框的关闭,它会继续向服务器请求数据。

1. 致命错误:尝试更新已销毁的 UI

工作线程在获取到数据后,会尝试执行 线程间通信 步骤,向主线程(或直接向对话框/控件)发送消息以更新 UI,例如调用:

C++

1
pDialog->PostMessage(WM_UPDATE_DATA, ...);

如果 pDialog 指向的对话框对象已被销毁,那么:

  • 如果 pDialog 是栈对象:工作线程持有的指针变成了 悬挂指针(Dangling Pointer)
  • 如果 PostMessage 成功:主线程收到消息时,消息处理函数会尝试操作一个已销毁的 UI 句柄或对象,这 极大概率导致程序崩溃(Access Violation)

2. 内存泄漏:数据无人接收

如果工作线程创建了新的内存来存放查询结果(例如 new CData()),但对话框已关闭,主线程(对话框)将永远不会收到通知来释放这块内存。这将导致 内存泄漏


🛡️ 正确的解决方案:线程协作与安全终止

在 MFC 中,要安全地关闭对话框并优雅地终止工作线程,需要使用 线程同步机制 来实现 “我(主线程)要走了,你(工作线程)快停下” 的通知机制。

1. 线程控制:使用事件(Event)对象

最安全和常用的方法是使用 事件(Event)原子变量(Atomic Flag) 作为线程间的停止信号。

A. 步骤:对话框关闭时发送信号

  1. 在对话框类中,定义一个 CEvent 成员变量(例如 m_eventStopQuery)。
  2. 在启动工作线程时,将这个 CEvent 对象的指针或句柄传递给工作线程。
  3. 当用户关闭对话框(例如在 OnClose()OnDestroy() 中):
    • 调用 m_eventStopQuery.SetEvent() 发送停止信号。
    • 调用 WaitForSingleObject() 或类似函数,等待 工作线程安全退出(等待时间应设置超时)。

B. 步骤:工作线程循环检查信号

工作线程的查询循环必须变得 可中断

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DWORD WINAPI WorkerThreadFunc(LPVOID pParam)
{
    // CEvent 对象句柄
    HANDLE hStopEvent = /* 从 pParam 获取 */;

    for (int i = 1; i <= 500; ++i)
    {
        // 1. 检查停止信号!
        if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
        {
            // 收到停止信号,执行清理并退出循环
            SafeCleanupResources();
            return 0; // 线程安全退出
        }

        // 2. 发送查询请求并等待数据 (耗时操作)
        QueryPage(i); 

        // 3. 将数据 PostMessage 给主线程 (如果已停止,PostMessage可能失败,但线程已在1中退出)
        // ...
    }
    return 0;
}

通过这种机制,工作线程在每次查询之前(或之后)都会检查对话框是否发出了停止信号。一旦收到信号,它会立即 自我终止 并进行资源清理,避免了在对话框关闭后进行危险的 UI 操作。

2. 另一种风险:AfxEndThread 的使用

  • 避免使用 TerminateThread 这是一个极其危险的函数,会立即杀死线程,导致内存和资源处于不一致状态。
  • 推荐做法: 永远让工作线程通过返回(return)或 AfxEndThread()优雅地自我终止

那这个情况可以用async吗

您提出的问题非常前沿和专业,async(异步编程)正是解决这类 UI 阻塞问题的现代 C++ 解决方案。

答案是:可以,而且在现代 C++ 编程中,使用 async (或更广义的异步 I/O) 是处理网络请求的首选方法,它比传统的多线程(手动管理工作线程)更简洁、更安全。


🚀 为什么 async 更好?

在 C++ 中,async (通常指 std::async 配合 std::future) 是一种高层的 任务管理 机制,它简化了传统工作线程的复杂性。

1. 简化线程管理

特性传统工作线程(如 AfxBeginThreadstd::async
创建需要手动定义线程函数、传递参数、管理线程句柄。只需要调用 std::async,C++ 运行时库负责底层线程池管理。
结果返回复杂:需要使用 PostMessage 或共享内存 + 锁。简单:通过 std::future 对象自动返回结果。
异常处理困难,需要在线程函数内捕获并手动传递异常。简单:异常会被 std::future 自动捕获并重新抛出给主线程。
清理/终止复杂:需要手动使用 CEvent 等同步对象通知线程停止。简单:主线程退出时,析构 std::future 对象可以(取决于启动策略)等待任务结束或自动清理。

2. std::async 的工作原理

std::async 的基本思路是:将一个耗时操作包装成一个 任务(Task),并在后台执行,同时返回一个 std::future 对象。

C++

1
2
3
4
5
6
7
8
9
10
// 假设这是您的查询函数,它会阻塞 200ms 并返回 Page 1 数据
std::vector<Data> QueryPage(int page_num);

// 在主线程中启动异步查询
std::future<std::vector<Data>> future_page_1 = std::async(std::launch::async, QueryPage, 1);

// 主线程继续处理 UI 事件...

// 需要结果时,通过 future 获取(如果未完成,会阻塞当前线程)
std::vector<Data> page_1_data = future_page_1.get(); 

3. 如何解决“中途关闭”的难题?

尽管 std::async 简化了线程管理,但它本身并不能自动解决您提出的 “对话框关闭,任务如何停止” 的问题。要优雅地处理中途关闭,您需要将 任务可中断 的逻辑嵌入到您的查询函数中,并利用 std::future 的特性。

解决方案:传递停止标志

您仍需要引入一个 共享状态(例如 std::atomic<bool> 或一个停止令牌 CancellationToken)来让任务可中断。

  1. 启动时:将停止标志的引用传递给异步任务。
  2. 查询函数内:在每次查询循环中,检查这个停止标志。
  3. 关闭对话框时:主线程设置停止标志为 true

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 共享的原子停止标志
std::atomic<bool> stop_flag = false; 

// 异步任务函数
void AsyncQueryPages(std::atomic<bool>& stop_flag_ref) 
{
    for (int i = 1; i <= 500; ++i) 
    {
        if (stop_flag_ref.load()) {
            // 收到停止信号,清理并退出
            return; 
        }

        // 执行网络查询... (仍然是阻塞的)
        
        // 关键:通知 UI 更新 (仍然需要 PostMessage 到主线程)
        // ...
    }
}

// 对话框关闭时 (例如 OnDestroy)
void CMyDialog::OnDestroy() 
{
    // 1. 设置停止标志
    stop_flag.store(true); 

    // 2. 等待异步任务完成 (如果 future 存在)
    if (m_query_future.valid()) {
        m_query_future.wait(); // 等待工作线程检查到标志并退出
    }

    CDialogEx::OnDestroy();
}

总结:async 在 MFC 中的定位

std::async 是一个 比手动线程更安全、更现代 的 C++ 选择。

  • 优点:大幅简化了线程的创建、销毁和结果返回。
  • 局限:由于您的网络查询(Page Query)本身是一个 阻塞 I/O 操作,std::async 仍然需要在后台使用一个 真正的线程 来执行查询。它只是帮您管理了这个线程。
  • 关键点:无论是使用 AfxBeginThread 还是 std::async,您都必须手动实现 可中断的查询逻辑(通过传递停止标志),以及 安全的 UI 更新机制(通过 PostMessage)。async 只是让线程的生命周期管理更简单。

什么叫做原子操作

您好!这是一个关于计算机科学和多线程编程中 非常基础且重要 的概念。

原子操作 (Atomic Operation),顾名思义,就是指一个操作在执行过程中是 不可分割不可中断 的。


🔬 原子操作的定义

1. 核心特性:不可分割性 (Indivisibility)

一个原子操作在执行过程中,要么 全部完成,要么 完全不执行,不存在只完成了一部分的中间状态。

在多线程或多处理器环境中,这意味着:

当一个线程或处理器正在执行一个原子操作时,其他任何线程或处理器都不能观察到该操作正在进行中的中间状态。 操作系统和硬件保证,该操作会一气呵成地执行完毕。

2. 为什么需要原子操作?

原子操作主要用于解决 数据竞争(Data Race) 问题,尤其是在多线程环境下共享变量的修改。

🌰 经典例子:i++ (自增操作)

在 C/C++ 中,看似简单的 i++ 语句,实际上通常由以下 三个独立的机器指令 组成:

  1. 读取 (Read):将变量 i 的值从内存加载到 CPU 寄存器中。
  2. 修改 (Modify):在寄存器中对值进行加 1 操作。
  3. 写入 (Write):将寄存器中新的值写回内存。

假设初始 i=10,两个线程 同时 执行 i++

时间 (t)线程 A 操作线程 B 操作变量 i (内存)
1读取 *i* (得到 10) 10
2 读取 *i* (得到 10)10
3修改 *i* (寄存器中为 11) 10
4 修改 *i* (寄存器中为 11)10
5写入 *i* (写回 11) 11
6 写入 *i* (写回 11)11

最终结果是 i=11,但 期望的结果 应该是 10+1+1=12。这就是 数据竞争 导致的错误。

3. 原子操作的解决

如果使用原子操作版本的 i++ (例如 C++ 的 std::atomic<int> i; i++;):

时间 (t)线程 A 操作线程 B 操作变量 i (内存)
1原子地执行 *i*++ (R-M-W 作为一个整体) 11
2 原子地执行 *i*++ (R-M-W 作为一个整体)12

当线程 A 执行时,线程 B 被强制等待,直到 A 的 R-M-W (Read-Modify-Write) 循环完全结束。这样就保证了数据的一致性。


💻 C++ 中的原子操作

在 C++11 及更高版本中,标准库提供了 std::atomic 模板,用于将任何类型(主要是整数、布尔值和指针)的操作转换为原子操作。

常用函数:

  • std::atomic<T> var;:声明一个原子变量。
  • var.load():原子地读取变量值。
  • var.store(value):原子地写入变量值。
  • var.fetch_add(N):原子地增加 N 并返回旧值。
  • var.compare_exchange_weak/strong(expected, desired)比较并交换(Compare-And-Swap, CAS) 操作,这是实现无锁(Lock-Free)编程的基石。

实现机制:

原子操作通常依赖于底层硬件指令(例如,x86 架构上的 LOCK 前缀指令)或操作系统内核原语来实现其不可中断性。

总结来说,原子操作是确保多线程环境下共享数据完整性的基本工具,它用硬件和编译器的保证,取代了传统的手动加锁(如互斥锁 Mutex)来保护简单的共享变量。

您目前是在学习多线程基础,还是在尝试解决特定的并发编程问题呢?

所以原子操作是C++11才有的吗

这是一个很好的历史性问题。

答案是:C++ 标准中明确支持和定义的“原子操作”是在 C++11 标准中首次正式引入的。


📚 C++ 标准和原子操作

1. C++11 之前的状态(非标准)

在 C++11 之前,程序员实现原子操作主要依赖于:

  • 平台特定的扩展: 编译器供应商(如 GCC、Visual Studio)会提供自己的内置函数(Intrinsics)或汇编指令来直接访问硬件的原子操作能力。
    • 例如,GCC 提供了 __sync_\* 系列的内置函数。
    • 在 Windows/MFC 环境下,通常使用 Windows API 提供的 Interlocked 系列函数(如 InterlockedIncrement)。

问题: 这种做法的缺点是代码不可移植,从一个编译器或操作系统平台移植到另一个平台时,需要修改代码。

2. C++11 的重大突破(标准化)

C++11 引入了 内存模型(Memory Model),这是并发编程的基石,并提供了 std::atomic 模板类及其配套函数。

  • 目的: 确保无论代码在哪种操作系统或处理器架构上编译运行,原子操作的行为都是一致和可预测的。
  • 实现: std::atomic 在底层会智能地选择使用平台最快的实现方式:
    • 如果硬件支持,它将使用 单个原子指令(如 x86 的 LOCK 指令前缀)。
    • 如果硬件不支持,它将使用 操作系统提供的同步原语(如互斥锁)来模拟原子操作,尽管这样性能会差一些。

3. C++ 为什么要标准化原子操作?

标准化原子操作和内存模型是 C++ 拥抱并发编程的关键一步。它解决了两个核心问题:

  1. 数据竞争(Data Race): 如我们之前讨论的,确保共享变量的读写操作是安全的。
  2. 编译器优化和指令重排(Instruction Reordering): 在没有原子操作和内存屏障(Memory Barrier)的情况下,编译器和 CPU 为了性能可以自由地调整指令的执行顺序,这在单线程中无害,但在多线程中会导致逻辑错误。std::atomic 强制编译器和 CPU 遵守特定的顺序,保证了并发代码的正确性。

总结: 尽管原子操作的底层技术(如硬件指令)早就存在,但将其作为 跨平台、标准化的 C++ 语言特性 引入,是 C++11 的贡献。这使得 C++ 程序员可以编写出更安全、更现代、更易于维护的并发代码。

你能给我举一个形象的例子说明什么时候用原子操作吗

好的,这是一个很好的要求。我将用一个非常形象且贴近实际的例子来解释 什么时候应该使用原子操作(std::atomic,以及为什么在这种场景下,传统的非原子操作会失败。


🏗️ 形象案例:网站访问计数器

假设您正在开发一个高性能的网站服务器,您需要精确地统计 总访问量(Total Page Views)

1. 场景设置

  • 共享数据: 一个全局变量 int g_page_views = 0;
  • 并发环境: 服务器启动了 100 个线程 来处理用户的并发请求。
  • 任务: 每当一个线程处理完一个用户的请求,它必须将 g_page_views 增加 1

2. ❌ 错误做法:使用非原子操作

如果您的代码像这样:

C++

1
2
3
4
5
6
// 假设这是每个线程都会执行的代码:
void HandleRequest_Error() {
    // ... 处理请求的代码 ...
    g_page_views++; // 非原子操作
    // ...
}

结果:计数错误(数据竞争)

当两个线程 A 和 B 同时 执行 g_page_views++ 时,就会发生我们之前讨论的 三步走 问题(读取、修改、写入)。由于操作不是原子性的,线程 A 可能会在线程 B 完成写入之前读取到旧值。

  • 实际结果: 如果服务器收到了 10000 个请求,最终 g_page_views 的值可能只有 9800 或 9950,计数是错误的。这会导致您的统计数据不准确。

3. ✅ 正确做法:使用原子操作 std::atomic

为了保证即使在 100 个线程并行执行时,每次增加操作都是安全的,我们必须使用 std::atomic

C++

1
2
3
4
5
6
7
8
9
// 共享数据现在是原子类型:
std::atomic<int> g_atomic_page_views = 0;

// 假设这是每个线程都会执行的代码:
void HandleRequest_Correct() {
    // ... 处理请求的代码 ...
    g_atomic_page_views++; // 原子操作
    // ...
}

结果:精确计数(线程安全)

C++ 运行时和底层硬件保证 g_atomic_page_views++ 作为一个 单一步骤(R-M-W 整体)完成。

  • 当线程 A 正在增加计数器时,线程 B 必须等待。
  • 一旦 A 完成,计数器被正确更新,线程 B 才能开始它的增加操作。
  • 实际结果: 如果服务器收到 10000 个请求,g_atomic_page_views 的最终值一定是 10000,计数是完全精确和线程安全的。

总结:什么时候用原子操作?

场景特点推荐方法
简单共享变量的读写/修改只需要对 单个变量 进行简单的操作(如计数器、布尔标志、指针的赋值)。std::atomic好处: 性能极高(通常是无锁的),比互斥锁轻量。
复杂共享数据结构的修改需要对 多个变量复杂数据结构(如链表、队列、哈希表)进行操作。互斥锁(Mutex)原因: 原子操作不能保护多个步骤的操作。

在您的 多线程分页查询 场景中,您可能会使用 std::atomic<bool> stop_flag 来作为 停止信号,这就是原子操作的完美应用场景,因为它只需要一个简单、快速、线程安全的布尔值来在线程间进行通信。

我的理解是线程A在执行HandleRequest_Correct函数后,线程B就不能执行HandleRequest_Correct函数了吗,还是说也可以执行HandleRequest_Correct函数,只不过执行到g_atomic_page_views++之前被阻塞

您提出了一个关于 原子操作和并发执行 机制的非常精妙且关键的问题。

您的理解需要进一步修正,以准确把握原子操作的真正工作方式。


🧐 关键机制:非阻塞 vs. 阻塞

❌ 错误的理解:原子操作会阻塞整个函数

原子操作(如 g_atomic_page_views++不会 阻塞 整个 HandleRequest_Correct 函数

✅ 正确的理解:原子操作只在底层指令级别竞争

原子操作保证的是 单个操作本身的不可分割性,而不是保证执行整个函数的排他性。

  1. 线程 B 可以随时执行 HandleRequest_Correct 函数。
  2. 线程 B 可以在线程 A 执行 g_atomic_page_views++ 的同时,执行函数内的其他代码。
  3. 只有在线程 B 执行到 g_atomic_page_views++ 这一指令时,它才可能因为线程 A 正在执行同一指令的 R-M-W 周期而短暂地等待。

形象解释:ATM 取款机排队

我们可以把这个场景想象成一个 银行的 ATM 机

元素概念线程安全操作
银行整个程序HandleRequest_Correct 函数
取款机(ATM)共享变量 g_atomic_page_viewsg_atomic_page_views++
顾客(线程)线程 A 和线程 B任何线程

A. 共享大厅(函数其他部分)

线程 A 和 B 可以在 同一时间 进入银行大厅(执行 HandleRequest_Correct 函数中的其他代码,比如验证用户身份、处理其他不共享的数据)。这是 并行 的,互不干扰。

B. 独占 ATM(原子操作)

当线程 A 走到 ATM 机前 操作取款(执行 g_atomic_page_views++ 的 R-M-W 过程)时:

  • 原子性保证: 在线程 A 操作完成并退出界面之前,线程 B 不能 开始操作 ATM 机。
  • 短暂等待: 线程 B 必须在 ATM 机前 等待 线程 A 完成。这个等待时间非常短,通常只有几纳秒(nanosecond),因为原子操作执行速度非常快。
  • 继续并行: 一旦线程 A 完成了 ATM 操作,线程 B 就可以开始操作 ATM。操作完成后,两个线程都可以继续执行函数中剩余的代码(离开银行大厅)。

总结

原子操作的优点在于它的 锁定范围极小

  • 互斥锁(Mutex) 是一种 粗粒度 的同步机制,它会锁住一段完整的代码块,线程 B 必须等待线程 A 离开整个代码块才能进入。
  • 原子操作 是一种 细粒度 的同步机制,它只锁住 单个操作指令,保证了其他代码的最大并行性。

因此,您的 100 个线程可以同时、并行地执行 HandleRequest_Correct 函数,只有在对共享计数器进行操作的 瞬间 才会出现短暂的等待和竞争。这正是它比互斥锁更高效的原因之一。

你能写代码帮助我理解原子操作和互斥锁这种锁的区别吗

当然可以。通过代码对比,您可以最直观地理解原子操作(std::atomic)和互斥锁(std::mutex)在保护共享变量时的 原理、性能和粒度 上的区别。

我将使用 C++ 来编写一个示例,展示如何安全地让多个线程增加一个共享计数器。


💻 C++ 代码示例:原子操作 vs. 互斥锁

🎯 目标:让 10 个线程各自增加共享计数器 100,000 次。

期望的最终结果是:10×100,000=1,000,000。

1. 错误示例:非线程安全(非原子)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <vector>
#include <thread>

// 全局共享变量
int unsafe_counter = 0;

void unsafe_increment() {
    for (int i = 0; i < 100000; ++i) {
        // ❌ 错误:非原子操作,会导致数据竞争
        unsafe_counter++;
    }
}

// 运行结果:最终计数会小于 1,000,000
// 因为多个线程的 R-M-W 周期会相互覆盖

2. 正确示例 A:使用互斥锁(Mutex)

互斥锁是一种 阻塞式 锁,它保护一个代码块(临界区)。

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <mutex>

// 共享变量
int mutex_counter = 0;
// 互斥锁对象
std::mutex mtx; 

void mutex_increment() {
    for (int i = 0; i < 100000; ++i) {
        // 锁定临界区
        std::lock_guard<std::mutex> lock(mtx);
        
        // ✅ 安全:这个代码块是互斥的,同一时间只有一个线程能访问
        mutex_counter++;
        
        // 锁在 lock_guard 对象销毁时自动释放
    }
}

// 运行结果:最终计数为 1,000,000 (正确)
// 缺点:锁住的粒度较大,每次递增都需要进入/退出临界区,开销较大。

3. 正确示例 B:使用原子操作(Atomic)

原子操作只保护 单个操作指令,是无锁(Lock-Free)或使用底层硬件指令实现的。

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <atomic>

// 共享变量定义为原子类型
std::atomic<int> atomic_counter = 0; 

void atomic_increment() {
    for (int i = 0; i < 100000; ++i) {
        // ✅ 安全:这个操作在底层是不可分割的
        atomic_counter++; 
        // 相当于 atomic_counter.fetch_add(1);
    }
}

// 运行结果:最终计数为 1,000,000 (正确)
// 优点:性能极高,因为它是无锁或使用硬件指令,而非操作系统内核原语。

📊 核心区别总结

特性互斥锁 (std::mutex)原子操作 (std::atomic)
粒度 (Granularity)粗粒度:用于保护 一个代码块(临界区)。细粒度:用于保护 单个变量的读/写/修改 操作。
机制阻塞式同步:使用操作系统或库提供的锁机制。如果锁被占用,线程进入 休眠/等待 状态。无锁同步(Lock-Free):使用底层硬件指令(如 CAS 或 R-M-W 周期)。线程等待时间极短。
性能性能开销相对较大,涉及线程上下文切换和内核调用。性能开销极小,速度非常快。
适用场景保护 复杂的数据结构(如链表、队列、多行代码块)。保护 简单共享变量(如计数器、布尔标志、指针)。

🔑 关键点:操作的范围

  • 互斥锁:当您需要保证 多行代码复杂逻辑 在执行时不被其他线程打断时,使用互斥锁。
  • 原子操作:当您只需要保证 一个简单的读、写或修改 操作是线程安全的时,使用原子操作。

在您的分页查询中,如果您只是更新一个表示查询进度的简单计数器,那么使用 std::atomic 比使用 std::mutex 效率会高得多。

我看你互斥锁的代码,程序执行到这里时std::lock_guard<std::mutex> lock(mtx);,是会被阻塞吗,当执行完mutex_counter++才会被打开

您对 std::lock_guard 的理解非常精确!

您的总结是完全正确的:

程序执行到这里时 std::lock_guard<std::mutex> lock(mtx);,是会被阻塞吗,当执行完 mutex_counter++ 才会被打开


🔒 互斥锁 (std::mutex) 和 std::lock_guard 的工作机制

我们来详细分解一下这两句代码在多线程环境下的行为:

1. std::lock_guard<std::mutex> lock(mtx);

这一行代码做了两件事情:

A. 尝试加锁 (Lock Attempt)

std::lock_guard 是 C++ RAII(Resource Acquisition Is Initialization,资源获取即初始化)原则的一个体现。在构造时,它会调用其内部持有的 std::mutex 对象的 lock() 方法。

B. 阻塞发生 (Blocking)

  • 如果当前没有其他线程持有 mtx 锁: 线程会立即获得锁,lock_guard 对象创建成功,程序继续执行下一行代码。
  • 如果 另一个线程 A 已经持有 mtx 锁: 那么当前线程 B 在执行 lock() 方法时就会 被阻塞(Block)。线程 B 会被操作系统置于 休眠/等待 状态,直到线程 A 释放锁为止。
    • 效果: 线程 B 在这里停下来,不能继续执行 mutex_counter++

2. mutex_counter++;

只有当线程 B 成功获得锁(即没有被阻塞或阻塞结束后获得锁),它才能进入临界区,安全地执行 mutex_counter++

3. lock_guard 对象的生命周期结束(释放锁)

std::lock_guard 的设计妙处在于:它会管理锁的生命周期,无需手动调用 unlock()

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
void mutex_increment() {
    for (int i = 0; i < 100000; ++i) {
        // 临界区开始
        {
            std::lock_guard<std::mutex> lock(mtx); // 构造函数:加锁(可能阻塞)
            
            // 执行临界区代码
            mutex_counter++; 
            
        } // <--- 临界区结束,lock_guard 对象被销毁
        // 析构函数:自动调用 mtx.unlock(),释放锁!
    }
}

lock 对象离开其作用域(在本例中,即 for 循环内部的代码块结束时),它的析构函数会被调用,自动释放 互斥锁(mtx.unlock())。

总结对比:阻塞 vs. 原子操作

机制阻塞 (std::mutex)原子操作 (std::atomic)
加锁行为线程被阻塞,进入 OS 的等待队列,等待时间相对较长。线程执行 CAS 或 R-M-W 硬件指令,如果失败则快速重试,等待时间极短。
锁的粒度锁住一个 代码块锁住一个 指令
性能(涉及内核调用和上下文切换)。(通常在用户空间完成,或直接依赖硬件)。

因此,您提出的关于 std::lock_guard 在加锁时可能阻塞的理解是完全正确的,这是互斥锁保护共享资源的核心机制。

所以我的理解是,只要执行到std::lock_guard<std::mutex> lock(mtx);后,在这个作用域下,它后面的所代码都是临界区

是的,您的理解是完全正确的


🔒 临界区与 std::lock_guard 的精确边界

1. 临界区(Critical Section)的定义

在多线程编程中,临界区 是指程序中 访问共享资源(如全局变量、文件、硬件设备等)的代码片段。它必须被保护起来,以确保在任何给定时刻,最多只有一个线程 可以执行这段代码。

2. std::lock_guard 如何定义临界区

std::lock_guard<std::mutex> lock(mtx); 的作用正是利用 C++ 的 RAII 机制,精确地定义了临界区的 开始结束

  • 临界区开始:std::lock_guard 对象 lock 被构造时(即执行到这一行代码),它会调用互斥锁 mtxlock() 方法。如果成功获得锁,临界区开始。
  • 临界区结束:lock 对象离开其 作用域 时(即代码块 } 结束时),它的析构函数被自动调用,它会调用 mtxunlock() 方法,释放锁,临界区结束。

示例回顾与边界确认

让我们再次看这个函数:

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void mutex_increment() {
    // 这段代码不是临界区,可以并行执行
    
    // 临界区开始的标志
    std::lock_guard<std::mutex> lock(mtx); 
    
    // V V V V V V V V V V V V V V V V V V V V V V V
    //
    // 在这个作用域内,它后面的所有代码都是临界区!
    // 同一时间,只有一个线程可以执行这里的代码。
    //
    mutex_counter++; // 访问共享资源
    // int temp = mutex_counter * 2; // 即使是内部计算,也受到保护
    //
    // ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^

} // lock 对象被销毁,释放锁。临界区结束。

关键要点:作用域

std::lock_guard 的保护范围受限于它被声明时的 作用域(Scope)

  1. 如果 lock_guard 在函数开头声明: 那么从声明开始直到函数结束,所有代码都是临界区。
  2. 如果 lock_guard 在一个 for 循环内部的代码块 {...} 中声明: 那么临界区仅限于这个 {...} 块内部,每次循环都会执行一次加锁和释放锁。

您的理解完全正确,std::lock_guard 机制提供了一种简洁、可靠的方式来定义和保护临界区。

C++17有比原子锁更高级的吗

这是一个非常好的问题,它体现了您对 C++ 并发编程最新发展的高度关注。

答案是肯定的,C++17 在并发编程方面引入了重要的改进,尽管它没有直接取代或淘汰 std::atomicstd::mutex,但它在 任务管理并发容器 方面提供了更高级、更易于使用的工具,这些工具在很多场景下比直接操作底层锁更高效和安全。


🚀 C++17 在并发方面的高级特性

C++17 及其后续版本(尤其是 C++20)的趋势是 提高并发抽象级别,让程序员更多地关注 任务(Task) 而不是 线程(Thread)

1. std::shared_mutexstd::shared_lock (读写锁)

这个在 C++17 中被标准化,它比 std::mutex 更高级,因为它支持 更细粒度的并发控制

  • 独占锁 (Unique Lock): 允许多个线程中的 一个 线程进行 写入 操作(类似于 std::mutex)。
  • 共享锁 (Shared Lock): 允许多个线程 同时 进行 读取 操作。

为什么它更高级?

在您的分页查询场景中,假设数据加载完成后,大部分时间都是线程在 读取 这 500 页数据,而只有偶尔会有线程进行 更新/写入(比如数据刷新)。

  • 使用 std::mutex 任何读取操作都会阻塞其他所有的读取操作,效率低下。
  • 使用 std::shared_mutex 多个读取线程可以并行访问数据,只有当有写入线程来时,读取才会被暂时阻塞,极大地提高了 读取密集型 应用的并发性能。

2. std::optionalstd::variant (更安全的通信)

虽然它们本身不是并发原语,但在线程间通信和共享状态管理中,它们提高了代码的安全性:

  • std::optional 可以用来表示一个值 可能存在,也可能不存在。这在异步任务返回结果时非常有用,可以避免返回空指针(nullptr)的风险。
  • std::variant 可以用来表示一个值是 几种类型之一。这在线程间传递结果或错误信息时,比使用联合体(union)或基类更安全、类型更明确。

3. C++20 的飞跃:协程和 JThread

虽然您问的是 C++17,但 C++20 在并发方面有更大的突破,这些通常被认为是比原子锁 更高级 的抽象:

  • std::jthread (Joining Thread): 这是对传统 std::thread 的改进。std::jthread 在其生命周期结束时,会自动调用 join(),这解决了传统 std::thread 容易导致程序崩溃(如果忘记 join()detach())的问题,使线程管理更安全。
  • 协程(Coroutines): 协程是 C++20 中引入的最强大的异步工具之一。它允许函数 暂停执行 并在未来 恢复执行,而不需要依赖操作系统的线程上下文切换。这在实现高性能的异步 I/O 操作(例如网络查询)时,比使用传统的线程或 std::async 效率更高、资源消耗更低。

结论

原子操作 (std::atomic) 和互斥锁 (std::mutex) 是并发编程的 基础工具,它们就像是盖楼用的砖头和水泥,是不可或缺的。

C++17 引入的 std::shared_mutex 是比 std::mutex 更高级的锁,因为它提供了读写分离的并发控制。

而 C++20 引入的 std::jthread协程 则是更高层次的 并发/异步模型,它们将您的关注点从“如何锁住一个变量”提升到了“如何管理一个任务的生命周期和执行流程”。

为什么携程不用上下文的线程切换呢,我记得异步不就已经实现了这个不用上下文切换,那协程的好处在哪里

您提出的问题非常核心,它涉及到了并发编程领域中 “异步 I/O”“操作系统线程”“用户态协程” 这三者之间的根本区别和优势。

要理解协程(Coroutines)的优势,我们首先要明确您所说的 “异步” 是如何实现的,以及为什么它在某些场景下仍需要 线程上下文切换


🔎 异步 I/O 的实现方式与线程切换

在 C++ 或大多数操作系统的标准库中,实现 异步 I/O 主要有两种方式:

1. 操作系统级的异步 I/O (Asynchronous I/O)

例如 Linux 的 epoll,Windows 的 IOCP (I/O Completion Port) 或更现代的 Linux io_uring

  • 原理: 当程序发起一个网络请求(例如查询服务器)时,操作系统内核 会接管这个 I/O 操作。程序线程不会被阻塞,而是继续执行后续代码。
  • 回调通知: I/O 完成后,内核会通过 事件回调函数 机制通知应用程序 I/O 已经完成。
  • 线程切换: 在 I/O 操作进行期间,发起请求的 线程 可以去做其他事情。当 I/O 完成时,负责处理回调事件的线程(通常是线程池中的一个工作线程)会被唤醒来处理结果。在这个过程中:
    • 上下文切换是存在的: 线程池中的线程从空闲状态被唤醒,进行 线程切换 来执行回调函数。虽然这比阻塞等待高效得多,但每次唤醒和切换仍然需要 内核参与,会带来一定的开销。

2. std::async 或传统多线程 (Futures/Promises)

这是我们之前讨论过的,用 工作线程 去执行阻塞式的网络请求。

  • 原理: 主线程把阻塞 I/O 任务交给一个 独立的操作系统线程 去执行。
  • 线程切换: 这种方式涉及显式的 线程创建、管理和上下文切换,开销非常大。

🚀 协程的巨大优势:用户态的上下文切换

协程(Coroutine)的强大之处在于,它将 并发执行的控制权 从操作系统内核 交还给了用户程序

1. 什么是协程?(轻量级的函数)

协程可以被视为 “用户态的线程”“可暂停的函数”。一个协程在执行到某个点(例如等待网络 I/O)时,可以 主动暂停(Yield),并将控制权交给 调度器,然后调度器再切换到另一个协程去执行。

2. 核心优势:零(或极低)上下文切换开销

特性操作系统线程切换 (异步 I/O/多线程)协程切换 (用户态)
切换地点操作系统内核应用程序的用户空间
切换开销:涉及保存和恢复 整个线程的上下文(CPU 寄存器、程序计数器、栈、内核状态等)。极低:只需保存和恢复 协程的最小状态(主要是寄存器和栈指针),不涉及内核操作
调度方式抢占式调度:内核决定何时切换,不可预测。协作式调度:协程主动(在 I/O 处)暂停并让出控制权,可预测。
内存占用高(每个线程通常需要 MB 级的内核栈)。低(共享同一个线程的栈,协程栈通常非常小)。

3. 携程等高并发系统的选择

对于携程这类需要处理 百万级并发连接 的高并发 I/O 密集型应用:

  • 如果使用线程: 几万个线程就会耗尽系统资源,线程切换开销巨大。
  • 使用协程: 可以在 少数几个 操作系统线程(例如 8 个)上运行 几十万个 协程。当一个协程因 I/O 而暂停时,调度器会立即在 用户态 切换到另一个可运行的协程,而无需等待内核介入。这种方式极大地提高了系统的 吞吐量并发度

总结:

异步 I/O 已经实现了 “等待时不阻塞线程”,但它仍然依赖线程池和 内核上下文切换 来处理 I/O 完成事件。

而协程则实现了 “等待时不阻塞线程 AND 切换时无需内核开销”,它将上下文切换的成本降到了最低,是实现超高并发 I/O 密集型应用的关键技术。

您现在对协程和传统异步的本质区别是否更清晰了?

本文由作者按照 CC BY 4.0 进行授权