i007.cc

i007.cc

优先队列-降维打击

05.价值资料

协程到底解决的是什么问题?适用那些场景?一个best practice

一句话:协程解决的是”等待”期间如何不浪费线程的问题。

要理解这句话,先看没有协程时会发生什么。


问题的根源:I/O 比 CPU 慢一万倍

操作                耗时
L1 缓存读取         ~1 ns
内存读取            ~100 ns
SSD 随机读          ~100,000 ns   (0.1 ms)
网络请求(同城)     ~500,000 ns   (0.5 ms)
数据库查询          ~1,000,000 ns  (1 ms)

 

一个线程在等待网络响应的 1ms 里,能执行大约 100 万条 CPU 指令。这些 CPU 时间全部浪费掉了。


三种应对方案,各有代价

方案一:同步阻塞

cpp
string result = http_get("/api/data");  // 线程在这里睡觉
process(result);

 

最简单,但线程在 I/O 期间完全被占住。如果有 1 万个并发请求,就需要 1 万个线程——内存爆炸(每个线程默认 8MB 栈),上下文切换开销极大。

方案二:回调地狱

cpp
http_get("/api/user", [](User user) {
    http_get("/api/orders?id=" + user.id, [user](Orders orders) {
        db_query("INSERT ...", [](Result r) {
            // 嵌套第四层...
            // 错误处理在哪?
            // 循环怎么写?
        });
    });
});

 

线程不阻塞了,但代码失去了线性结构,错误处理、循环、早退都变得极其麻烦。这就是著名的”回调地狱”。

方案三:协程

cpp
User user     = co_await http_get("/api/user");
Orders orders = co_await http_get("/api/orders?id=" + user.id);
Result r      = co_await db_query("INSERT ...");

 

代码写起来像同步,执行起来像异步。co_await 的含义是:”挂起我这个协程,让出线程去干别的事,等结果好了再回来继续”。

这才是协程的本质价值:用同步的写法,得到异步的性能。


协程 vs 线程的根本区别

线程的切换由操作系统决定,是抢占式的,切换成本高(保存/恢复寄存器、TLB 刷新、内核态切换,约 1-10 μs)。

协程的切换由程序员(或运行时)决定,是协作式的,切换成本极低(只需保存少量寄存器,约 10-100 ns),因为切换点是明确的 co_await,不需要保存完整的 CPU 状态。

10,000 个并发连接:

线程模型:10,000 个线程 × 8MB 栈 = 80GB 内存(不可能)
协程模型:10,000 个协程 × ~200B 帧 = 2MB 内存(没问题)

 


适用场景

核心判断标准:你的瓶颈是 I/O 还是 CPU?

协程只对 I/O 密集型有效。CPU 密集型(图像处理、加密、压缩)用协程没有意义,应该用线程或进程并行。

最适合的场景

网络服务器 / API 网关:每个请求需要调用多个下游服务,大量时间在等网络。这是协程最主流的用武之地——Nginx、Node.js、Go 的 HTTP 服务器本质都是这个模型。

数据库访问层:查询要等数据库,连接池配合协程可以用少量线程服务大量并发查询。

爬虫和批量 HTTP 请求:同时发出几千个请求,等响应回来再处理,比多线程高效得多。

实时通信系统(WebSocket、长连接):每个连接大部分时间是空闲的,等用户发消息,协程可以让一个线程维护数万个连接。

游戏逻辑脚本:技能释放、剧情对话这类”等待几秒再执行下一步”的逻辑,用协程比状态机清晰得多。

不适合的场景

  • 纯 CPU 计算(协程帮不了你,用线程/SIMD)
  • 代码必须运行在不支持堆分配的硬件上(协程帧在堆上)
  • 简单的单次操作(没有并发需求时引入协程只增加复杂度)

Best Practice:一个完整可运行的例子

用 Asio 写一个并发 HTTP 请求聚合器——同时发出多个请求,等所有结果回来后合并,这是最典型的协程使用模式。

cpp
// 依赖:asio (standalone,不需要 Boost)
// 编译:g++ -std=c++20 -o demo demo.cpp -I/path/to/asio/include

#include <asio.hpp>
#include <asio/co_spawn.hpp>
#include <asio/detached.hpp>
#include <asio/use_awaitable.hpp>
#include <chrono>
#include <iostream>
#include <vector>

using namespace asio;
using namespace std::chrono_literals;

// -------------------------------------------------------
// 模拟一次异步操作(真实场景换成 async_connect/async_read)
// -------------------------------------------------------
awaitable<std::string> fetch(io_context& ctx, std::string name, int delay_ms) {
    // 用 timer 模拟网络延迟
    steady_timer timer(ctx);
    timer.expires_after(std::chrono::milliseconds(delay_ms));
    co_await timer.async_wait(use_awaitable);
    co_return "result_from_" + name;
}

// -------------------------------------------------------
// 串行:一个接一个等
// -------------------------------------------------------
awaitable<void> serial_requests(io_context& ctx) {
    auto t0 = std::chrono::steady_clock::now();

    // 三个请求依次等待,总时间 = 100 + 200 + 150 = 450ms
    auto r1 = co_await fetch(ctx, "service_A", 100);
    auto r2 = co_await fetch(ctx, "service_B", 200);
    auto r3 = co_await fetch(ctx, "service_C", 150);

    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - t0).count();

    std::cout << "[Serial] " << r1 << ", " << r2 << ", " << r3
              << " — " << elapsed << "ms\n";
}

// -------------------------------------------------------
// 并发:同时发出,等最慢的那个
// -------------------------------------------------------
awaitable<void> concurrent_requests(io_context& ctx) {
    auto t0 = std::chrono::steady_clock::now();

    // experimental::make_parallel_group 或直接用 co_spawn + channel
    // 这里用最简洁的 awaitable<> 组合方式:
    // 同时启动三个协程,使用 awaitable_operators 等待全部完成
    using asio::experimental::awaitable_operators::operator&&;

    auto [r1, r2, r3] = co_await (
        fetch(ctx, "service_A", 100) &&
        fetch(ctx, "service_B", 200) &&
        fetch(ctx, "service_C", 150)
    );
    // 总时间 ≈ max(100, 200, 150) = 200ms

    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - t0).count();

    std::cout << "[Concurrent] " << r1 << ", " << r2 << ", " << r3
              << " — " << elapsed << "ms\n";
}

// -------------------------------------------------------
// 带超时:任意一个先完成就继续
// -------------------------------------------------------
awaitable<void> with_timeout(io_context& ctx) {
    using asio::experimental::awaitable_operators::operator||;

    steady_timer timeout_timer(ctx);
    timeout_timer.expires_after(120ms);

    // fetch 和 timeout 竞争,谁先完成用谁的结果
    auto result = co_await (
        fetch(ctx, "slow_service", 300) ||
        timeout_timer.async_wait(use_awaitable)
    );

    if (result.index() == 0)
        std::cout << "[Timeout] Got: " << std::get<0>(result) << "\n";
    else
        std::cout << "[Timeout] Timed out after 120ms\n";
}

// -------------------------------------------------------
// 带错误处理的协程
// -------------------------------------------------------
awaitable<void> with_error_handling(io_context& ctx) {
    try {
        auto r = co_await fetch(ctx, "flaky_service", 50);
        std::cout << "[ErrorHandling] Success: " << r << "\n";
    } catch (const std::exception& e) {
        // co_await 抛出异常就像同步代码一样自然
        std::cout << "[ErrorHandling] Failed: " << e.what() << "\n";
    }
}

// -------------------------------------------------------
// 带重试的协程——注意这里循环写起来多自然
// -------------------------------------------------------
awaitable<std::string> fetch_with_retry(io_context& ctx,
                                        std::string name,
                                        int max_retries) {
    for (int i = 0; i < max_retries; i++) {
        try {
            co_return co_await fetch(ctx, name, 50);
        } catch (...) {
            if (i == max_retries - 1) throw;  // 最后一次失败就抛出
            std::cout << "  retry " << i + 1 << "...\n";
            steady_timer backoff(ctx);
            backoff.expires_after(std::chrono::milliseconds(100 * (i + 1)));
            co_await backoff.async_wait(use_awaitable);
        }
    }
    throw std::runtime_error("unreachable");
}

// -------------------------------------------------------
// 主入口
// -------------------------------------------------------
int main() {
    io_context ctx;

    // 注意:所有协程都跑在同一个线程上
    // 如果需要多线程,用 ctx.run() 在多个线程里同时调用
    co_spawn(ctx, serial_requests(ctx),     detached);
    co_spawn(ctx, concurrent_requests(ctx), detached);
    co_spawn(ctx, with_timeout(ctx),        detached);
    co_spawn(ctx, with_error_handling(ctx), detached);

    ctx.run();  // 事件循环,直到所有协程完成
    return 0;
}

 

运行结果大致是:

[Serial]     result_from_service_A, result_from_service_B, result_from_service_C — 450ms
[Concurrent] result_from_service_A, result_from_service_B, result_from_service_C — 200ms
[Timeout]    Timed out after 120ms
[ErrorHandling] Success: result_from_flaky_service

 

串行 450ms,并发 200ms——同样是三个请求,时间缩短了一半多,而且只用了一个线程


几条真正的 Best Practice

1. 永远不要在协程里调用同步阻塞操作。
std::this_thread::sleep_for()、同步 read()mutex::lock() 会阻塞整个线程,其他协程全部卡死。所有阻塞操作必须换成异步版本。

2. 用 && 并发,不要串行 co_await
如果两个操作没有依赖关系,永远用并发模式。串行 co_await 是最常见的性能陷阱。

3. 错误处理用 try/catch,不要用回调式 error_code。
协程最大的人体工学优势就是可以用 try/catch 写异常处理,不要放弃这个优势。

4. 取消和超时是必须考虑的,不是可选的。
永远不要写没有超时的网络请求,用 || 竞争一个 timer 是最简单的超时模式。

5. 保持协程函数短小,单一职责。
协程的生命周期管理比普通函数复杂,函数越长越难推理。把复杂逻辑拆成多个 co_await 的小协程。

6. 注意对象生命周期。
协程在 co_await 点挂起期间,调用者可能已经析构了你捕获的引用。捕获引用时一定要确认对象生命周期长于协程。


最后一张图

时间轴 →
线程 1   [请求A处理] [等待A的I/O......................] [处理A结果]
         ↑ 传统阻塞:线程一直被占用

线程 1   [请求A发出] [请求B发出] [请求C发出] [A回来处理] [B回来处理] [C回来处理]
         ↑ 协程:I/O期间线程去处理其他事,吞吐量大幅提升

 

协程不让你的代码跑得更快,而是让同样的硬件同时做更多事。这是它的本质价值,也是它的边界。

发表回复