找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1863

积分

0

好友

263

主题
发表于 昨天 13:52 | 查看: 6| 回复: 0

在 C++ 并发编程领域,多线程一直是主流选择,但开发者常常为线程过多导致的内存占用飙升和上下文切换开销而苦恼。随着 C++20 标准的发布,协程作为一种轻量级并发机制正式成为标准的一部分,为解决这类问题提供了全新的思路。

许多初学者对协程的第一印象是抽象难懂,分不清它与线程的区别。本文将深入介绍 C++20 引入的协程机制,剖析这种全新的并发编程利器。

协程是什么?和线程有何不同?

1. 协程的核心定义

首先来了解协程的基本定义。协程本质上是一种可以暂停执行、保存当前状态,并且后续能够恢复执行的特殊函数。它的核心特点是 用户态调度,即协程的暂停与恢复由开发者通过代码控制,而非操作系统内核调度。

这里有个关键问题:暂停执行时,协程会保存哪些状态?答案是函数的局部变量、寄存器状态、程序计数器等关键信息,恢复时会精准还原这些状态,就像函数从未暂停过一样。

2. 协程与线程的核心区别

为了方便理解,我们用生活场景打个比方:

假设你是一家餐厅的老板,需要同时处理多个顾客的订单。如果使用线程的方式,你需要为每个顾客雇佣一个服务员,每个服务员独立处理订单。这种方式可以并行处理多个订单,但雇佣成本高,且服务员之间协调困难。

而如果使用协程的方式,你只需要雇佣一个服务员,他可以同时处理多个顾客的订单。当服务员处理一个订单需要等待厨师烹饪时,他可以暂停这个订单,转而处理其他订单。烹饪完成后,服务员再恢复处理之前的订单。这种方式节省了人力成本,也不存在协调问题。

两者的具体区别可总结为以下几点:

  • 调度层面:线程是内核态调度,由操作系统统一管理,调度开销大(涉及内核态与用户态切换);协程是用户态调度,由开发者控制,切换开销极小(仅保存/恢复函数状态)。
  • 资源占用:每个线程占用几 MB 栈空间(默认通常为 2 MB),创建上千个线程就可能耗尽内存;协程栈可动态调整,初始占用仅几十字节,支持创建上百万个。这使得协程在 C++ 并发编程 中成为管理大量并发任务的更优选择。
  • 并发粒度:线程是粗粒度并发,适合 CPU 密集型任务;协程是细粒度并发,适合 IO 密集型任务(如网络请求、文件读写)。
  • 执行特性:线程可并行执行(多 CPU 核心同时运行);协程本身是串行执行(同一时间只有一个协程运行,依赖切换实现并发),需结合线程池才能实现并行。

下表总结了协程和线程的核心差异:

特性 协程 线程
调度方式 用户态调度,由程序员控制 内核态调度,由操作系统控制
切换开销 非常小,只需要保存和恢复少量的寄存器值 较大,需要保存和恢复大量的寄存器值和栈指针
内存占用 非常小,只需要几 KB 的栈空间 较大,需要几 MB 的栈空间
并行性 不支持真正的并行性,只能在一个线程中串行执行 支持真正的并行性,可以在多个 CPU 核心上同时执行
适用场景 适用于 IO 密集型任务,如网络编程、文件操作等 适用于 CPU 密集型任务,如科学计算、图像处理等

从上面的特点我们可以看出,协程比较适合的场景是 IO 密集型任务。因为 CPU 密集型任务需要持续占用 CPU,协程的串行执行特性无法利用多核心,反而不如线程并行高效。

3. 执行差异实例

我们通过一个简单例子,直观感受协程和线程的执行差异。

先看线程的示例,创建两个线程分别打印数字:

#include<iostream>
#include<thread>
#include<chrono>
void print_num(int num)
{
   for (int i = 0; i < 3; ++i)
   {
       std::cout << "Thread " << num << ":" << i << std::endl;
       std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时操作
   }
}
int main()
{
   std::thread t1(print_num, 1);
   std::thread t2(print_num, 2);
   t1.join();
   t2.join();
   return 0;
}

运行结果是这样(每次结果可能不同):

多线程程序输出示例,线程1和线程2交替打印

可以看到输出结果存在重叠,这正是线程执行顺序的不确定性以及资源竞争导致的。

再看看通过协程来实现相同的打印功能。这里我们封装一个简单的协程类型,确保逻辑和线程示例一致,便于直观对比:

#include<iostream>
#include<coroutine> //c++20协程头文件
#include<chrono>
#include<thread>

// 协程承诺类型:控制协程暂停、恢复与状态管理
struct PrintPromise
{
    // 返回协程句柄,供外部控制执行
    std::coroutine_handle<PrintPromise> get_return_object()
    {
        return std::coroutine_handle<PrintPromise>::from_promise(*this);
    }
    // 初始不暂停,创建后立即执行
    std::suspend_never initial_suspend()
    {
        return {};
    }
    // 最终暂停,便于外部销毁协程
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    // 处理无返回值协程
    void return_void()
    {
    }
    // 异常处理:简单终止程序
    void unhandled_exception()
    {
        std::terminate();
    }
    // 处理协程内的延迟等待(模拟耗时操作)
    std::suspend_always await_transform(std::chrono::milliseconds delay)
    {
        // 模拟耗时,此处简化处理(实际开发可结合线程池)
        std::this_thread::sleep_for(delay);
        return {};
    }
};

// 协程返回类型:包装承诺类型与句柄
struct PrintCoroutine
{
    using promise_type = PrintPromise;
    std::coroutine_handle<PrintPromise> handle;
    // 构造函数:接收协程句柄
    PrintCoroutine(std::coroutine_handle<PrintPromise> h)
        : handle(h)
    {
    }
    // 析构函数:销毁协程,避免内存泄漏
    ~PrintCoroutine()
    {
        if (handle)
            handle.destroy();
    }
};

// 协程函数:对应线程的print_num功能
PrintCoroutine print_coro(int num)
{
    for (int i = 0; i < 3; ++i)
    {
        std::cout << "Coroutine " << num << ":" << i << std::endl;
        co_await std::chrono::milliseconds(100); // 协程暂停等待,替代线程睡眠
    }
}

int main()
{
    // 创建两个协程
    auto coro1 = print_coro(1);
    auto coro2 = print_coro(2);
    // 手动控制协程执行:顺序执行协程1和协程2,无内核切换
    coro1.handle.resume();
    coro2.handle.resume();
    coro1.handle.resume();
    coro2.handle.resume();
    coro1.handle.resume();
    coro2.handle.resume();
    return 0;
}

运行结果(顺序可控,不会出现不同结果):

协程程序输出示例,协程1和协程2严格按顺序打印

上述运行结果出现差异的核心原因在于调度方式不同:线程由操作系统内核随机调度,切换时机不可控,所以打印顺序混乱;协程由开发者通过代码控制恢复时机,这里我们按“协程1-协程2”的顺序交替恢复,执行顺序完全确定。

C++20协程的使用方法

再次明确一点,C++20的协程并非独立的语法元素,而是对函数的扩展。要实现协程,需满足两个核心条件:函数返回特定的“协程承诺类型”,且函数体内包含暂停点(如 co_awaitco_yieldco_return)。

先理解三个核心概念:

  • 协程函数:包含暂停点的函数,返回值为协程承诺类型对应的“协程句柄”或包装类型。
  • 承诺类型(Promise Type):协程的核心控制类,负责管理协程状态、结果存储、暂停/恢复逻辑,需实现一系列约定的成员函数(如 get_return_objectinitial_suspend 等)。
  • 协程句柄(coroutine_handle):用于控制协程的执行(恢复、销毁),是访问协程的唯一入口。

再看看实现协程的三个关键字:

  • co_await:用于暂停协程的执行,并等待一个异步操作完成。当协程执行到 co_await 时,它会暂停执行,并将控制权返回给调用者。当异步操作完成后,协程会恢复执行,并从 co_await 的下一行继续执行。
  • co_yield:用于暂停协程的执行,并返回一个值给调用者。当协程执行到 co_yield 时,它会暂停执行,并将当前的执行状态保存起来。当调用者再次调用协程时,协程会从暂停的位置继续执行,并返回下一个值。
  • co_return:用于结束协程的执行,并返回一个值给调用者。当协程执行到 co_return 时,它会结束执行,并将返回值传递给调用者。

下面这张图很好地展示了协程的执行流程:

C++20协程执行流程与生命周期示意图

我们通过代码来实现一个最基础的协程,仅完成暂停和恢复功能,以此来理解上面的流程:

#include<iostream>
#include<coroutine>

// 协程承诺类型
struct SimplePromise
{
    // 返回协程句柄,供外部控制协程
    std::coroutine_handle<SimplePromise> get_return_object()
    {
        return std::coroutine_handle<SimplePromise>::from_promise(*this);
    }
    // 初始暂停点:std::suspend_always表示协程创建后立即暂停
    std::suspend_always initial_suspend()
    {
        return {};
    }
    // 最终暂停点:协程结束后是否暂停
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    // 处理协程返回值(无返回值时实现)
    void return_void()
    {
    }
    // 处理协程异常
    void unhandled_exception()
    {
        std::terminate(); // 简单处理:直接终止程序
    }
};

// 协程返回类型(包装承诺类型,供函数返回)
struct SimpleCoroutine
{
    using promise_type = SimplePromise;
    std::coroutine_handle<SimplePromise> handle;
    // 构造函数:接收协程句柄
    SimpleCoroutine(std::coroutine_handle<SimplePromise> h)
        : handle(h)
    {
    }
    // 析构函数:销毁协程,避免内存泄漏
    ~SimpleCoroutine()
    {
        if (handle)
            handle.destroy();
    }
};

// 协程函数:包含暂停点co_await
SimpleCoroutine print_coroutine()
{
    std::cout << "Coroutine start" << std::endl;
    co_await std::suspend_always{}; // 暂停协程
    std::cout << "Coroutine resume" << std::endl;
    co_await std::suspend_always{}; // 再次暂停
    std::cout << "Coroutine end" << std::endl;
}

int main()
{
    auto coro = print_coroutine(); // 创建协程,此时协程因initial_suspend暂停
    std::cout << "After create coroutine" << std::endl;
    coro.handle.resume(); // 第一次恢复协程
    std::cout << "After first resume" << std::endl;
    coro.handle.resume(); // 第二次恢复协程
    std::cout << "After second resume" << std::endl;
    return 0;
}

运行结果:

基础协程执行流程输出示例

我们一步步拆解流程:

  1. 调用 print_coroutine() 创建协程,执行到 initial_suspend(),返回 std::suspend_always,协程立即暂停,此时还未执行函数体内的打印语句。
  2. main 函数打印 After create coroutine,随后调用 handle.resume() 恢复协程,协程从暂停点继续执行,打印 Coroutine start,接着执行 co_await std::suspend_always{},再次暂停。
  3. main 函数打印 After first resume,再次调用 resume(),协程恢复执行,打印 Coroutine resume,又一次暂停。
  4. main 函数打印 After second resume,第三次调用 resume(),协程恢复执行,打印 Coroutine end,随后执行到 final_suspend() 暂停,协程结束。

另外要了解,std::suspend_alwaysstd::suspend_never 是 C++20 提供的两个预定义暂停类型,前者表示总是暂停,后者表示从不暂停。initial_suspend 返回 std::suspend_never 时,协程创建后会立即执行,直到第一个 co_await 暂停点。

协程的使用流程

在 C++20 中,使用协程一般遵循以下几个步骤:

  1. 定义协程的返回类型:协程的返回类型必须是一个内部实现了 promise_type 结构体的类型。
  2. 实现 promise_type 结构体promise_type 结构体必须实现 get_return_objectinitial_suspendfinal_suspendreturn_voidreturn_valueunhandled_exception 等方法。
  3. 定义协程函数:协程函数是一个返回协程返回类型的函数,它可以使用 co_awaitco_yieldco_return 等关键字来实现协程的行为。
  4. 调用协程函数:调用协程函数会创建一个协程对象,然后可以使用协程对象来控制协程的执行,例如启动协程、暂停协程、恢复协程等。

我们通过协程方式来实现一个生成斐波那契数列的功能,看看协程的典型用法:

#include<iostream>
#include<coroutine>
#include<cstdint>

// 斐波那契协程的承诺类型
struct FibPromise
{
    using value_type = uint64_t;
    value_type current_value; // 存储当前生成的斐波那契数
    FibPromise()
        : current_value(0)
    {
    }
    // 返回协程句柄
    std::coroutine_handle<FibPromise> get_return_object()
    {
        return std::coroutine_handle<FibPromise>::from_promise(*this);
    }
    // 初始不暂停,创建后立即开始生成
    std::suspend_never initial_suspend()
    {
        return {};
    }
    // 最终暂停,供外部销毁协程
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    // 处理co_yield:返回当前值,并暂停
    std::suspend_always yield_value(value_type val)
    {
        current_value = val;
        return {};
    }
    void return_void()
    {
    }
    void unhandled_exception()
    {
        std::terminate();
    }
};

// 协程返回类型,实现迭代器接口,支持范围for循环
struct FibCoroutine
{
    using promise_type = FibPromise;
    std::coroutine_handle<FibPromise> handle;
    FibCoroutine(std::coroutine_handle<FibPromise> h)
        : handle(h)
    {
    }
    ~FibCoroutine()
    {
        if (handle)
            handle.destroy();
    }
    // 迭代器结构体
    struct iterator
    {
        std::coroutine_handle<FibPromise> handle;
        bool done; // 标记是否生成结束
        iterator(std::coroutine_handle<FibPromise> h, bool d)
            : handle(h), done(d)
        {
        }
        // 解引用:返回当前斐波那契数
        FibPromise::value_type operator*() const
        {
            return handle.promise().current_value;
        }
        // 自增:恢复协程生成下一个数
        iterator& operator++()
        {
            handle.resume();
            done = handle.done();
            return *this;
        }
        // 比较运算符:判断是否迭代结束
        bool operator!=(const iterator& other) const
        {
            return done != other.done;
        }
    };
    // 开始迭代器
    iterator begin()
    {
        if (!handle.done())
            return iterator(handle, false);
        else
            return iterator(handle, true);
    }
    // 结束迭代器
    iterator end()
    {
        return iterator(handle, true);
    }
};

// 斐波那契协程函数:生成不超过max_val的序列
FibCoroutine generate_fib(uint64_t max_val)
{
    uint64_t a = 0;
    uint64_t b = 1;
    while (a <= max_val)
    {
        co_yield a; // 返回a,并暂停
        uint64_t temp = a;
        a = b;
        b = temp + b;
    }
}

int main()
{
    std::cout << "Fibonacci sequence up to 100: ";
    // 范围for循环遍历协程生成的序列
    for (auto num : generate_fib(100))
    {
        std::cout << num << " ";
    }
    std::cout << std::endl;
    return 0;
}

这个示例的核心是通过 yield_value 成员函数处理 co_yield 语句,同时为协程返回类型实现迭代器接口,让协程支持范围 for 循环,使用起来和普通容器一样便捷。相比传统使用迭代器等方式,这种方法无需事先计算并存储整个数列,按需生成,既节省内存也提高了性能,这种思路也常见于 算法优化 场景。

协程的易错点和注意事项

C++20协程的语法和逻辑相对复杂,使用不当很容易造成难以排查的问题。下面总结几个常见的错误。

协程句柄未销毁,导致内存泄漏

创建协程后,仅调用 resume() 执行,未调用 handle.destroy() 销毁协程,会导致协程占用的内存(包括局部变量、状态信息)无法释放,造成内存泄漏。

错误示例

#include<iostream>
#include<coroutine>
struct BadPromise
{
    std::coroutine_handle<BadPromise> get_return_object()
    {
        return std::coroutine_handle<BadPromise>::from_promise(*this);
    }
    std::suspend_never initial_suspend()
    {
        return {};
    }
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    void return_void()
    {
    }
    void unhandled_exception()
    {
        std::terminate();
    }
};
struct BadCoroutine
{
    using promise_type = BadPromise;
    std::coroutine_handle<BadPromise> handle;
    BadCoroutine(std::coroutine_handle<BadPromise> h)
        : handle(h)
    {
    }
    // 错误:未实现析构函数销毁协程
};
BadCoroutine leak_coroutine()
{
    std::cout << "Leak coroutine" << std::endl;
    co_return;
}
int main()
{
    auto coro = leak_coroutine();
    // 未调用coro.handle.destroy()
    return 0;
}

解决方案:在协程返回类型的析构函数中调用 handle.destroy(),确保协程对象销毁时,协程资源也被释放。如前面的 SimpleCoroutineFibCoroutine 类,都在析构函数中处理了协程销毁。

协程暂停后未恢复,导致资源悬置

协程执行到 co_await 暂停后,忘记调用 resume() 恢复,会导致协程占用的资源一直悬置,无法释放,同时可能导致业务逻辑中断。

错误示例:

#include<iostream>
#include<coroutine>
struct SuspendPromise
{
    std::coroutine_handle<SuspendPromise> get_return_object()
    {
        return std::coroutine_handle<SuspendPromise>::from_promise(*this);
    }
    std::suspend_never initial_suspend()
    {
        return {};
    }
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    void return_void()
    {
    }
    void unhandled_exception()
    {
        std::terminate();
    }
};
struct SuspendCoroutine
{
    using promise_type = SuspendPromise;
    std::coroutine_handle<SuspendPromise> handle;
    SuspendCoroutine(std::coroutine_handle<SuspendPromise> h)
        : handle(h)
    {
    }
    ~SuspendCoroutine()
    {
        if (handle)
            handle.destroy();
    }
};
SuspendCoroutine forget_resume()
{
    std::cout << "Before suspend" << std::endl;
    co_await std::suspend_always{}; // 暂停后未被恢复
    std::cout << "After suspend" << std::endl; // 永远不会执行
}
int main()
{
    auto coro = forget_resume();
    // 忘记调用coro.handle.resume()
    return 0;
}

解决方案:确保每个协程的暂停都有对应的恢复逻辑。可通过线程池、事件循环等机制管理协程的生命周期,避免手动管理导致的遗漏。

协程中使用局部变量的引用,导致悬垂引用

协程暂停前返回局部变量的引用,恢复后使用该引用,此时局部变量已被销毁,导致悬垂引用。

#include<iostream>
#include<coroutine>
#include<string>
struct RefPromise
{
    const std::string& current_ref;
    std::coroutine_handle<RefPromise> get_return_object()
    {
        return std::coroutine_handle<RefPromise>::from_promise(*this);
    }
    std::suspend_never initial_suspend()
    {
        return {};
    }
    std::suspend_always final_suspend() noexcept
    {
        return {};
    }
    std::suspend_always yield_value(const std::string& ref)
    {
        current_ref = ref; // 存储局部变量的引用
        return {};
    }
    void return_void()
    {
    }
    void unhandled_exception()
    {
        std::terminate();
    }
};
struct RefCoroutine
{
    using promise_type = RefPromise;
    std::coroutine_handle<RefPromise> handle;
    RefCoroutine(std::coroutine_handle<RefPromise> h)
        : handle(h)
    {
    }
    ~RefCoroutine()
    {
        if (handle)
            handle.destroy();
    }
    const std::string& get_current()
    {
        return handle.promise().current_ref;
    }
};
RefCoroutine return_local_ref()
{
    std::string local_str = "Local string"; // 局部变量
    co_yield local_str; // 返回局部变量的引用,协程暂停
    // 暂停后,local_str被销毁
}
int main()
{
    auto coro = return_local_ref();
    const std::string& ref = coro.get_current();
    std::cout << ref << std::endl; // 未定义行为:悬垂引用
    return 0;
}

解决方案:协程中避免返回局部变量的引用或指针,应返回值拷贝,或使用动态分配的内存(需注意内存管理)。对于上面的例子,可将 yield_value 的参数改为值类型:

std::suspend_always yield_value(std::string val)
{
    current_val = std::move(val); // 存储值,而非引用
    return {};
}

混淆协程的暂停点行为

不清楚 co_awaitco_yieldco_return 的暂停时机,导致逻辑错误。例如,认为 co_return 会立即终止协程,不会执行 final_suspend()

记住以下结论

  • co_await:先执行右侧表达式,再根据结果决定是否暂停,暂停后恢复时从 co_await 之后的语句继续。
  • co_yield:等价于 co_await promise.yield_value(val),返回值后暂停。
  • co_return:调用 promise.return_void()return_value(),然后执行 final_suspend() 暂停,协程不会立即销毁,需调用 handle.destroy()

总结

C++20协程作为轻量级并发机制,为 IO 密集型任务提供了高效解决方案,其用户态调度、低资源占用、低切换开销的特点,有效弥补了线程在高并发 IO 场景下的不足。但协程的语法和生命周期管理相对复杂,需要掌握承诺类型、协程句柄、暂停点等核心概念,并小心避开内存泄漏、悬垂引用等易错点。

实际开发中,协程常与线程池、事件循环结合使用,既能发挥协程的轻量优势,又能利用多核心实现并行,这种模式是构建 高并发服务 的有效手段。随着 C++23、C++26 标准的推进,协程的高层封装会越来越完善,必将成为 C++ 并发编程工具箱中的重要一员。

如果你想了解更多关于 C++ 及其他技术话题的深度讨论和资源分享,欢迎访问 云栈社区 与更多开发者交流。




上一篇:BrowserUse 集成 AgentRun Sandbox:AI智能体浏览器自动化生产环境最佳实践
下一篇:Replit CEO访谈:编程与客服外多数Agent是玩具,透露转向Agent关键与核心模型
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-11 13:59 , Processed in 0.220972 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表