C++-关于协程的一些思考

最近各种AI大模型大火,在很多领域上表现出来的专业性超过了很多初学者,设置某些中级水平的专家。刚好在学习协程,发现AI对问题的回答表现出了很好的条理性和专业性,本篇文章记录以下关于gemini CLI对awaiter和协程关系的解释,以及实际编码中可能出现的问题和最佳实践。ps: 后面一段回答使用英文可能是gemini突然抽风了,如果懒得看对话的话可以跳到最后我的总结

cpp 复制代码
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│  > 问你一个C++协程的问题,在Qt中,如果我有一个协程函数task,在进行某些UI操作后,会触发一些槽函数,那么如果这 │
│    个槽函数不是协程函数task,那么这个协程返回的变量将会在函数结束时析构,此时协程可能还没结束,要怎么避免这  │
│    种情况?                                                                                                  │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

✦ 您好!您提出的问题非常经典,是 C++ 协程与事件驱动框架(如 Qt)结合时最常遇到的生命周期管理问题之一。

  问题的核心是:协程返回的 `QCoro::Task` (或其他协程类型) 对象是协程状态的唯一所有者。如果这个 `Task`
  对象被析构,那么它所管理的协程会立即被销毁,无论它是否执行完毕。

  在您的场景中,一个非协程的槽函数调用了一个协程函数。这个协程函数返回的 QCoro::Task
  是一个临时对象,当槽函数执行结束时,这个临时对象就会被析构,从而导致正在 co_await 中等待的协程被强制终止。

  下面我将详细解释这个问题的根源,并提供几种标准的解决方案。

  问题根源的简单图示

  让我们看一个典型的错误示例:

    1 // 这是一个返回协程的函数
    2 QCoro::Task<> performLongOperation() {
    3     qDebug() << "协程开始...";
    4     co_await QCoro::delay(2000); // 模拟一个耗时操作
    5     qDebug() << "协程结束。"; // 如果Task被提前析构,这句永远不会打印
    6 }
    7
    8 // 这是一个普通的Qt槽函数
    9 void MyWidget::onButtonClicked() {
   10     qDebug() << "按钮点击,准备启动协程。";
   11
   12     // 错误的做法!
   13     performLongOperation(); // 这会创建一个临时的 QCoro::Task<> 对象
   14
   15     qDebug() << "槽函数执行完毕。";
   16 } // <-- onButtonClicked() 函数结束,临时的 Task 对象在这里被析构,协程被强行终止

  输出会是:

   1 按钮点击,准备启动协程。
   2 协程开始...
   3 槽函数执行完毕。
   4 // "协程结束" 将永远不会出现

  解决方案

  要解决这个问题,我们必须延长 QCoro::Task 对象的生命周期,直到协程自然执行完成。主要有以下三种方法:

  ---

  方案一:将槽函数也变成协程(最推荐)

  这是最符合 C++20 协程思想的"结构化并发"模型。调用者也应该是协程,并使用 co_await 来等待被调用者完成。从 Qt 6
  开始,你可以轻易地将一个槽函数声明为返回 QCoro::Task<> 的协程。

  优点:
   * 代码逻辑清晰,像写同步代码一样写异步逻辑。
   * 生命周期管理自动且安全。
   * 错误处理(异常)可以自然地通过 try-catch 块传递。

  实现:

    1 // MyWidget.h
    2 class MyWidget : public QWidget {
    3     Q_OBJECT
    4 public:
    5     // ...
    6 private slots:
    7     // 将槽函数声明为返回 QCoro::Task<>
    8     QCoro::Task<> onButtonClicked();
    9 };
   10
   11 // MyWidget.cpp
   12 #include <QCoro/QCoro>
   13 #include <QCoro/Task>
   14
   15 QCoro::Task<> MyWidget::onButtonClicked() {
   16     qDebug() << "按钮点击,准备启动协程。";
   17
   18     // 正确的做法:co_await 等待协程完成
   19     co_await performLongOperation();
   20
   21     qDebug() << "协程已确认完成,槽函数执行完毕。";
   22 }

  这样,onButtonClicked 协程会暂停,直到 performLongOperation 完成后才会继续执行并最终结束。

  ---

  方案二:将 Task 对象存储为类的成员变量

  如果你的槽函数因为某些原因不能被声明为协程(例如,旧代码、或者连接到一个不支持协程返回类型的信号),那么你必
  须手动管理 Task 对象的生命周期。最简单的方法是将其存储为类的一个成员变量。

  优点:
   * 适用于无法修改调用者(槽函数)签名的情况。
   * 实现简单直观。

  缺点:
   * 如果用户在协程完成前再次点击按钮,旧的 Task
     会被新的覆盖并析构,导致前一个协程被终止。你需要考虑如何处理这种情况(例如,禁用按钮,或者忽略新的点击)。
   * 如果需要同时运行多个协程,此方法不适用。

  实现:

    1 // MyWidget.h
    2 #include <QCoro/Task>
    3
    4 class MyWidget : public QWidget {
    5     Q_OBJECT
    6 public:
    7     // ...
    8 private slots:
    9     void onButtonClicked();
   10
   11 private:
   12     // 存储正在运行的协程任务
   13     QCoro::Task<> m_runningTask;
   14 };
   15
   16 // MyWidget.cpp
   17 void MyWidget::onButtonClicked() {
   18     // 检查上一个任务是否还在运行,避免它被覆盖
   19     if (m_runningTask.isDone()) {
   20         qDebug() << "启动新的协程...";
   21         // 将返回的 Task 赋值给成员变量,延长其生命周期
   22         m_runningTask = performLongOperation();
   23     } else {
   24         qDebug() << "上一个操作尚未完成,请稍后重试。";
   25     }
   26 }

  ---

  方案三:将 Task 对象存储在集合中(用于"即发即忘"且支持多个)

  如果你希望每次点击都启动一个新协程,并且让它们在后台并发运行(即"Fire and
  Forget"),同时调用者又不是协程,那么你可以把 Task 对象存储在一个容器(如 QList)中,并在它们完成后自动清理。

  优点:
   * 可以同时运行多个独立的后台任务。
   * 调用者无需等待,可以立即返回。

  缺点:
   * 代码相对复杂,需要手动处理任务的清理,否则会造成内存泄漏。
   * 错误处理比较麻烦,因为你没有 co_await 它,异常不会被捕获(除非使用 .then())。

  实现:

    1 // MyWidget.h
    2 #include <QCoro/Task>
    3 #include <QList>
    4
    5 class MyWidget : public QWidget {
    6     Q_OBJECT
    7 public:
    8     // ...
    9 private slots:
   10     void onButtonClicked();
   11
   12 private:
   13     // 存储所有正在运行的协程任务
   14     QList<QCoro::Task<>> m_backgroundTasks;
   15 };
   16
   17 // MyWidget.cpp
   18 void MyWidget::onButtonClicked() {
   19     qDebug() << "启动一个新的后台协程...";
   20
   21     // 启动协程
   22     QCoro::Task<> newTask = performLongOperation();
   23
   24     // 使用 .then() 来设置一个延续,当协程完成后(无论成功或失败)
   25     // 它会把自己从列表中移除。
   26     // [this, task = std::move(newTask)] 是C++14的捕获语法,
   27     // 它将 newTask 的所有权移动到 lambda 内部。
   28     newTask.then([this, task = std::move(newTask)]() mutable {
   29         qDebug() << "一个后台任务已完成,正在从列表中移除。";
   30         m_backgroundTasks.removeOne(task);
   31     });
   32
   33     // 将任务添加到列表中以保持其存活
   34     m_backgroundTasks.append(std::move(newTask));
   35 }
  注意:`QCoro::Task` 不可复制,但可移动。在 `lambda` 捕获和存入 `QList` 时都利用了移动语义。

  总结与建议


  ┌─────────────┬───────────────────────────┬──────────────────────────┬───────────────────────────┐
  │ 方案        │ 适用场景                  │ 优点                     │ 缺点                      │
  ├─────────────┼───────────────────────────┼──────────────────────────┼───────────────────────────┤
  │ **1. `co_a... │ 绝大多数情况,首选方案    │ 结构清晰,生命周期自...  │ 需要 Qt 6+ 和 C++20 的... │
  │ **2. 存储...  │ 调用者不能是协程,且同... │ 简单,适用于旧代码       │ 不支持并发,需要处理覆... │
  │ **3. 存储...  │ 调用者不能是协程,且需... │ 支持并发                 │ 实现复杂,需要手动清理... │
  └─────────────┴───────────────────────────┴──────────────────────────┴───────────────────────────┘


  强烈建议您尽可能使用方案一,因为它最能发挥 C++ 协程的威力,让异步代码变得像同步代码一样简单和安全。如果项目环
  境限制了您只能使用普通槽函数,那么根据是否需要并发,在方案二和方案三中进行选择。

╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│  > 一般而言,协程函数的函数体包含什么内容?例如一个网络请求和请求回来的数据,哪部分要放在协程?哪部分要放在a │
│    waiter,两者的职责有什么不同?最佳实践是什么?                                                            │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

✦ Excellent question! This gets to the very heart of how to use C++ coroutines effectively and idiomatically.
  Understanding the division of responsibility between the coroutine function and the awaiter is the key to
  writing clean, maintainable, and powerful asynchronous code.

  Let's break it down using a clear analogy, then apply it to your network request example.

  The Analogy: The Manager and the Specialist

   * The Coroutine Function is the "Manager": The manager knows the high-level plan and the sequence of tasks.
     It says, "First, get the data from the server. Then, parse the data. Finally, update the UI." The manager
     doesn't know the low-level details of how to wait for the network, only that it needs to delegate that
     waiting period to someone else.

   * The Awaiter is the "Specialist": The specialist is a low-level expert in a single, specific type of
     waiting. You have a "Network Specialist" (QNetworkReply awaiter), a "Timer Specialist" (QTimer awaiter),
     etc. When the manager says "wait for this network reply," it hands the reply over to the Network
     Specialist. The specialist's job is to:
       1. Figure out how to get notified when the reply is finished (e.g., connect to the finished() signal).
       2. Tell the C++ runtime to suspend the manager (the coroutine).
       3. When the notification arrives, tell the runtime to resume the manager.
       4. Optionally, hand a result back to the manager (e.g., the reply data or an error status).

  ---

  Division of Responsibilities & Best Practices

  Let's apply this to your example: a network request and processing the returned data.

  1. The Coroutine Function's Body (The Manager's Job)

  The coroutine function should contain the business logic of your application. It should read like a series of
   synchronous steps that describe the task you want to accomplish.

  Its responsibilities include:

   * Initiating the Asynchronous Operation: Creating the QNetworkRequest and calling manager.get(). This is the
     "kick-off" step.
   * Delegating the Wait: Using the co_await keyword to pause execution and hand control to the appropriate
     awaiter. This is the manager saying, "Hey, Specialist, you handle the waiting part. Wake me up when you're
     done."
   * Processing the Result: Once co_await completes and the coroutine resumes, the code that follows should
     handle the result. This includes:
       * Checking for errors (reply->error()).
       * Reading the data (reply->readAll()).
       * Parsing the data (e.g., JSON, XML).
       * Updating application state or UI elements.
   * Returning the Final Value: Using co_return to provide the final, processed result of the entire operation
     (e.g., a parsed data structure, not the raw QByteArray).

  In short: The coroutine contains the *what* and the *why* of your task.

  2. The Awaiter's Guts (The Specialist's Job)

  The awaiter contains the generic, reusable mechanism for waiting on a specific asynchronous event source. As
  an application developer using a library like QCoro, you will almost never write your own awaiter. You simply
   use the ones provided by the library.

  The awaiter's responsibilities are handled by three special functions:

   1. await_ready(): An optimization. It asks, "Is the operation already complete?" (e.g., the network reply
      was so fast it finished before we could even co_await it). If true, the coroutine doesn't suspend at all.
   2. await_suspend(handle): This is the core of the suspension logic. It's responsible for:
       * Taking the coroutine's handle (a way to resume it).
       * Registering a callback with the underlying system. For a QNetworkReply, this means doing
         QObject::connect(reply, &QNetworkReply::finished, [handle]() { handle.resume(); });.
       * This is the code that bridges the "event-driven world" (Qt signals) with the "coroutine world".
   3. await_resume(): This is called right after the coroutine is resumed. It's responsible for:
       * Getting the result from the operation. For a simple co_await *reply, it might just check for an error
         and throw an exception if one occurred.
       * The value returned by await_resume() becomes the result of the co_await expression itself.

  In short: The awaiter contains the low-level, generic *how* of waiting for a specific event type.

  ---

  Example: Network Request - The Right Way

  Here is how the responsibilities are perfectly divided in a QCoro example.

    1 #include <QCoro/QCoro>
    2 #include <QCoro/Network>
    3 #include <QNetworkAccessManager>
    4 #include <QJsonObject>
    5 #include <QJsonDocument>
    6
    7 // The Coroutine Function (The "Manager")
    8 QCoro::Task<QJsonObject> fetchUserData(int userId) {
    9     // --- Coroutine's Responsibility: Business Logic ---
   10
   11     // 1. Initiate the operation
   12     QNetworkAccessManager manager;
   13     QUrl url("https://api.example.com/users/" + QString::number(userId));
   14     QNetworkRequest request(url);
   15     QNetworkReply *reply = manager.get(request);
   16     qDebug() << "Request initiated for user" << userId;
   17
   18     // 2. Delegate the waiting period
   19     // `co_await` uses the Awaiter provided by QCoro for QNetworkReply.
   20     // The Awaiter's logic (connecting to finished(), suspending, resuming) is hidden here.
   21     co_await *reply;
   22
   23     // --- We are now resumed. The Awaiter's job is done. ---
   24
   25     // 3. Process the result
   26     qDebug() << "Request finished, processing data...";
   27     if (reply->error() != QNetworkReply::NoError) {
   28         qDebug() << "Network error:" << reply->errorString();
   29         // Clean up and signal failure by throwing or returning an empty object
   30         reply->deleteLater();
   31         co_return QJsonObject{};
   32     }
   33
   34     QByteArray data = reply->readAll();
   35     reply->deleteLater(); // Always remember to clean up the reply
   36
   37     // 4. Return the final, processed value
   38     co_return QJsonDocument::fromJson(data).object();
   39 }

  Summary of Best Practices



   1. Keep Your Coroutines Focused on Business Logic: Your coroutine function should tell a clear story about
      the steps needed to achieve a goal. It should not be cluttered with low-level callback registration or
      state management.
   2. Let `co_await` Hide the Complexity: Treat co_await as a magic "pause and get result" point. Rely on your
      coroutine library (QCoro) to provide the highly-specialized awaiters. You shouldn't have to think about
      await_suspend in your day-to-day application code.
   3. Awaiters are for Libraries, Coroutines are for Applications: The primary audience for writing awaiters
      are library developers who want to add coroutine support for a new asynchronous type. The primary
      audience for writing coroutine functions are application developers solving business problems.
   4. The `co_await` Expression Gets the Result: The value that co_await some_expression evaluates to is
      whatever the awaiter's await_resume() function returns. This is how data is passed from the low-level
      async world back into the high-level coroutine logic.

╭──────────────────────────────╮
│  > 协程一般应用在什么场景?  │
╰──────────────────────────────╯

✦ 您好,这是一个非常核心的问题。协程并非万能,但它在特定场景下是一种革命性的工具。

  总的来说,协程的核心应用场景是处理异步操作,尤其是当这些异步操作之间存在复杂的逻辑依赖关系时。它旨在将原本
  被回调函数 (Callbacks)、信号槽 (Signals/Slots) 或期物 (Futures)
  割裂的异步代码,重新组织成看似同步的、线性的、易于理解和维护的形式。

  下面是几个最典型的应用场景,从最常见到更高级:

  ---

  1. I/O 密集型操作 (I/O-Bound Operations)

  这是协程最经典、最广泛的应用场景。当程序需要等待外部资源(网络、数据库、文件系统)时,CPU
  是空闲的。传统方法会阻塞线程,浪费资源;而回调方法会造成"回调地狱"。

  传统方法的问题:
   * 阻塞式 I/O: socket.read() 会阻塞整个线程,直到数据到达。在UI或高并发服务器中,这是不可接受的。
   * 回调式 I/O: 代码逻辑被拆分到不同的函数中,形成深度嵌套,难以阅读、调试和进行错误处理。

  协程的优势:
  使用 co_await,你可以像写同步代码一样等待 I/O 操作完成,而底层线程并不会被阻塞,而是被释放去处理其他任务。

  示例:执行两个连续的网络请求

    1 // 回调地狱 (Callback Hell)
    2 void fetchAndProcessData() {
    3     // 第一个请求
    4     manager.get(request1, [=](reply1) {
    5         if (reply1->error()) { /* handle error */ return; }
    6         auto result1 = parse(reply1->readAll());
    7
    8         // 基于第一个请求的结果,发起第二个请求
    9         manager.get(request2(result1), [=](reply2) {
   10             if (reply2->error()) { /* handle error */ return; }
   11             auto result2 = parse(reply2->readAll());
   12             updateUI(result2);
   13         });
   14     });
   15 }
   16
   17 // 协程方式 (Clean and Linear)
   18 QCoro::Task<> fetchAndProcessData() {
   19     auto reply1 = co_await manager.get(request1);
   20     if (reply1->error()) { /* handle error */ co_return; }
   21     auto result1 = parse(reply1->readAll());
   22
   23     auto reply2 = co_await manager.get(request2(result1));
   24     if (reply2->error()) { /* handle error */ co_return; }
   25     auto result2 = parse(reply2->readAll());
   26
   27     updateUI(result2);
   28 }

  ---

  2. UI 编程 (UI Programming)

  在桌面或移动应用中,任何耗时操作(超过16毫秒)都不能在主线程(UI线程)中执行,否则会导致界面卡顿或"未响应"
  。

  传统方法的问题:
  通常需要手动创建一个 QThread 或使用 QtConcurrent::run
  将任务放到后台,然后通过信号槽机制将结果传递回主线程来更新UI。这个过程繁琐且容易出错。

  协程的优势:
  你可以从一个UI事件(如按钮点击)的槽函数启动一个协程。在协程中,你可以 co_await
  一个在后台线程运行的耗时操作。当 co_await
  完成后,代码会自动在原始的上下文(主线程)中恢复执行,此时你可以安全地更新UI。

  示例:点击按钮后执行耗时计算并更新UI

    1 QCoro::Task<> MyWidget::onButtonClicked() {
    2     // 用户点击按钮,UI保持响应
    3     setButtonEnabled(false);
    4     setStatusLabel("正在计算...");
    5
    6     // co_await 一个在线程池中运行的函数
    7     // QCoro::background() 会将任务切换到后台线程
    8     int result = co_await QCoro::background([] {
    9         // 这是在后台线程执行的耗时代码
   10         int complex_result = 0;
   11         for (int i = 0; i < 1'000'000'000; ++i) {
   12             complex_result += 1;
   13         }
   14         return complex_result;
   15     });
   16
   17     // co_await 完成后,自动回到主线程
   18     // 可以安全地更新UI
   19     setStatusLabel("计算完成!");
   20     setResultValue(result);
   21     setButtonEnabled(true);
   22 }

  ---

  3. 复杂的异步流程与并发 (Complex Asynchronous Workflows & Concurrency)

  当业务逻辑包含多个异步步骤,例如"先登录,然后并行获取用户信息和好友列表,两者都完成后再更新UI",用传统方法
  实现会非常复杂。

  传统方法的问题:
  需要复杂的逻辑来跟踪多个并行异步操作的状态(例如使用 QFutureWatcher 或计数器),代码难以维护。

  协程的优势:
  协程可以非常自然地描述这些复杂的依赖关系。
   * 串行依赖: 使用多个连续的 co_await。
   * 并行依赖: 使用 QCoro::whenAll 或 QCoro::whenAny 等组合子来等待多个任务。

  示例:并行获取数据

    1 QCoro::Task<> loadDashboard() {
    2     // 并行发起两个网络请求
    3     auto userProfileTask = fetchUserProfile();
    4     auto notificationsTask = fetchNotifications();
    5
    6     // 等待两个任务全部完成
    7     auto [profile, notifications] = co_await QCoro::whenAll(
    8         std::move(userProfileTask),
    9         std::move(notificationsTask)
   10     );
   11
   12     // 两个结果都已拿到,可以安全更新UI
   13     updateProfileWidget(profile);
   14     updateNotificationsList(notifications);
   15 }

  ---

  4. 数据流与生成器 (Data Streams and Generators)

  当需要处理一个非常大的数据集(例如一个巨大的文件或一个持续的网络流),你不想一次性把所有数据都读入内存。你
  需要一种"懒加载"或"流式"处理的方式。

  传统方法的问题:
  需要实现一个迭代器接口,手动管理状态(例如当前文件指针位置),代码笨重。

  协程的优势:
  可以使用协程的 co_yield 关键字来创建一个生成器 (Generator)。这个函数在每次被请求一个新值时执行,直到
  co_yield 一个值后暂停。下次请求时,它会从暂停的位置继续。

  示例:逐行读取大文件

    1 // 这是一个生成器,它"生成"文件中的每一行
    2 QCoro::AsyncGenerator<QString> linesFromFile(const QString& path) {
    3     QFile file(path);
    4     if (!file.open(QIODevice::ReadOnly)) {
    5         co_return; // 提前退出
    6     }
    7     QTextStream stream(&file);
    8     while (!stream.atEnd()) {
    9         // co_yield "生产"一个值并暂停
   10         co_yield stream.readLine();
   11         // co_await QCoro::yield() 可以让出执行权,防止长时间占用事件循环
   12         co_await QCoro::yield();
   13     }
   14 }
   15
   16 // 消费这个生成器
   17 QCoro::Task<> processFile() {
   18     // for co_await 循环会自动处理生成器的生命周期
   19     for (co_await auto line : linesFromFile("my_large_log_file.txt")) {
   20         processLine(line);
   21     }
   22 }

  总结


  ┌──────────┬─────────────────────────────────────┬────────────────────────────────────────┐
  │ 场景     │ 传统方法的问题                      │ 协程的优势                             │
  ├──────────┼─────────────────────────────────────┼────────────────────────────────────────┤
  │ I/O 操作 │ 回调地狱,代码逻辑碎片化            │ 线性代码,易于读写和错误处理           │
  │ UI 编程  │ 线程管理复杂,主/子线程通信繁琐     │ 自动上下文切换,无缝更新UI,代码简洁   │
  │ 复杂流程 │ 状态管理复杂,难以组合并行/串行任务 │ 像写同步代码一样自然地编排复杂流程     │
  │ 数据流   │ 手动实现迭代器和状态机,代码笨重    │ 使用 co_yield 轻松实现懒加载和流式处理 │
  └──────────┴─────────────────────────────────────┴────────────────────────────────────────┘


  总而言之,只要你的代码中出现了需要"等待"某件事发生的地方,并且你不希望在等待时阻塞线程,那么这个地方就是协
  程的潜在应用场景。

╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│  > 似乎这里还有一种好处,就是原本我们在多个回调之间,需要传递和同步多个变量,对于协程而言,相当于都是在同一 │
│    个协程scope中的变量,不需要全局变量也不需要一直传递多个变量到不同层级的逻辑中                            │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

✦ 说得太棒了!您完全抓住了协程带来的一个最深刻、但有时又最不易被察觉的好处。这正是协程相比传统异步模型(尤其是
  回调)在代码组织和状态管理上的一个根本性优势。

  您的观察------"对于协程而言,相当于都是在同一个协程scope中的变量"------是绝对正确的。

  让我们来深入剖析一下这一点,看看它为什么如此重要。

  问题的根源:无状态的回调

  传统的回调函数(或Qt的槽函数)本质上是无状态的。每次被调用时,它都是一个全新的函数栈。它不记得上一次调用的
  任何信息。因此,当一个异步流程被拆分成多个回调时,我们就必须手动地在这些分离的执行点之间传递状态。

  这就导致了您提到的两种痛苦的模式:

  模式一:通过参数/捕获列表层层传递("穿针引线")

  在C++ Lambda表达式中,这体现为捕获列表 [...]。

  示例:

    1 void processTwoThings() {
    2     // 为了在两个回调之间共享 processingOptions,必须捕获它
    3     auto options = getProcessingOptions();
    4
    5     fetchFirstThing([this, options](auto result1) {
    6         if (!result1.isValid()) return;
    7
    8         // 为了把 result1 和 options 都传给下一个回调,必须再次捕获
    9         fetchSecondThing([this, options, result1](auto result2) {
   10             if (!result2.isValid()) return;
   11
   12             // 到这里,我们才终于同时拥有了 options, result1, result2
   13             combineAndFinish(options, result1, result2);
   14         });
   15     });
   16 }
  这个过程非常繁琐,每增加一步,需要传递的状态就可能像滚雪球一样变大,代码横向发展,形成"回调地狱"。

  模式二:提升为类成员变量("状态污染")

  为了避免层层传递,我们常常把中间状态存储为类的成员变量。

  示例:

    1 class MyProcessor : public QObject {
    2     // ...
    3 private slots:
    4     void onFirstThingFinished() {
    5         m_result1 = m_reply1->readAll(); // 存储到成员变量
    6         m_reply2 = fetchSecondThing();
    7         connect(m_reply2, &QNetworkReply::finished, this, &MyProcessor::onSecondThingFinished);
    8     }
    9
   10     void onSecondThingFinished() {
   11         auto result2 = m_reply2->readAll();
   12         // 从成员变量中读取 m_result1
   13         combineAndFinish(m_processingOptions, m_result1, result2);
   14     }
   15
   16 private:
   17     // 为了在两个槽函数之间共享状态,不得不将它们变成成员变量
   18     ProcessingOptions m_processingOptions;
   19     QByteArray m_result1;
   20     QNetworkReply *m_reply1;
   21     QNetworkReply *m_reply2;
   22 };
  这种方法的问题:
   * 状态污染: m_result1 只是这个异步流程的临时状态,但它却污染了整个类的命名空间,使得类的状态变得复杂。
   * 非重入性: 如果在第一个流程完成前,用户再次触发操作,m_result1
     就会被覆盖,导致逻辑错误。这使得代码不是"可重入"的。
   * 生命周期管理混乱: 你需要非常小心地在流程开始时初始化这些成员,在结束后清理它们。

  ---

  协程的解决方案:自动化的状态机

  协程从根本上解决了这个问题。当编译器遇到一个协程函数时,它会做一件神奇的事情:

  它会把这个函数转换成一个状态机对象。所有在 co_await
  点之间需要保持存活的局部变量,都会被自动地从函数栈中"提升"为这个堆分配的状态机对象的成员变量。

  这就是关键: 你写的代码看起来是普通的局部变量,但编译器在背后已经帮你完成了所有状态的打包和管理工作。

  协程代码:

    1 QCoro::Task<> MyProcessor::processTwoThings() {
    2     // 这些都是真正的局部变量,它们的生命周期由协程状态机自动管理
    3     auto options = getProcessingOptions();
    4
    5     auto reply1 = co_await fetchFirstThing();
    6     auto result1 = reply1->readAll();
    7     if (!result1.isValid()) co_return;
    8
    9     auto reply2 = co_await fetchSecondThing(result1);
   10     auto result2 = reply2->readAll();
   11     if (!result2.isValid()) co_return;
   12
   13     // 在同一个作用域内,所有变量都自然可用,就像写同步代码一样
   14     combineAndFinish(options, result1, result2);
   15 }

  您的观察带来的好处总结

   1. 封装性 (Encapsulation):
      异步操作所需的所有状态都完全封装在协程函数内部。它不会泄漏到类的成员中,也不会污染全局作用域。
   2. 代码清晰度 (Clarity): 你不需要再费力思考"这个变量需要传到哪里去",所有变量都在它们需要被使用的地方自然可
      用。代码逻辑回归到线性的、从上到下的阅读顺序。
   3. 可重入性 (Re-entrancy): 每次调用协程函数,都会创建一个全新的、独立的状态机对象。因此,你可以同时运行多个
      processTwoThings 实例,它们之间互不干扰,因为每个实例都有自己的一套"局部变量"。
   4. 减少错误 (Reduced Errors): 自动化的状态管理消除了手动传递状态或管理成员变量时可能出现的各种错误,如忘记传
      递、状态被覆盖、资源未释放等。

  所以,您的这个发现是协程编程模型的核心优势之一。它不仅仅是语法糖,更是对异步编程中状态管理这一核心难题的优
  雅解决方案。

总结:

  1. 在Qt中编码时,要注意协程的生命周期问题,避免内存泄漏。具体做法是优先将槽函数声明为协程,这样可以避免存储协程返回的对象,处理复杂的生命周期管理。
  2. 关于协程和awaiter的关系,就像管理者和专家关系。协程应该注重业务的逻辑性,阅读起来应该像阅读同步代码一样逻辑清晰,而awaiter则是专家,有负责网络请求的专家,有负责IO操作的专家。协程负责发起任务,awaiter负责执行,协程不应关心底层实现。另外,从开发者角色的角度来说,协程库的开发者通常需要实现不同的awaiter,而不关心协程函数,而对于应用层开发来说,关心的是如何组织业务逻辑写成协程函数,而不用关心awaiter如何实现。对于需要解析数据转为应用层model的事情,我们不应该在awaiter中实现,而应该在协程函数中实现,并且为了代码复用,解析也应该封装为一个接口,这样能最大程度保持协程的清晰性。
  3. 协程的核心应用场景是处理异步操作,尤其是当这些异步操作之间存在复杂的逻辑依赖关系时。它旨在将原本被回调函数 (Callbacks)、信号槽 (Signals/Slots) 或期物 (Futures) 割裂的异步代码,重新组织成看似同步的、线性的、易于理解和维护的形式
  4. 原本我们在多个回调之间,需要传递和同步多个变量,对于协程而言,相当于都是在同一个协程scope中的变量,不需要全局变量也不需要一直传递多个变量到不同层级的逻辑中,避免了全局变量的声明和类空间的污染。
相关推荐
铭哥的编程日记10 分钟前
《C++ string 完全指南:string的模拟实现》
c++
GBASE12 分钟前
“G”术时刻:如何用Perl DBD-ODBC成功连接南大通用GBase 8a数据库(一)
数据库
钟离墨笺13 分钟前
Go 语言-->指针
开发语言·后端·golang
Yu_Lijing16 分钟前
MySQL进阶学习与初阶复习第二天
数据库·c++·学习·mysql
孫治AllenSun23 分钟前
【JSqlParser】sql解析器使用案例
数据库·windows·sql
Vinkey_Z38 分钟前
MongoDB
数据库
超浪的晨44 分钟前
Java 代理机制详解:从静态代理到动态代理,彻底掌握代理模式的原理与实战
java·开发语言·后端·学习·代理模式·个人开发
l1t1 小时前
开源嵌入式数组引擎TileDB的简单使用
c语言·数据库·c++
咖啡の猫1 小时前
bash的特性-bash中的引号
开发语言·chrome·bash
java叶新东老师1 小时前
idea提交时忽略.class、.iml文件和文件夹或目录的方法
java·开发语言