🗓️ 2025-05-21 🎖️ 快速上手 "modern c++" 🗂️ C++笔记 🏷️ #coroutine #C++

协程快速上手

什么是协程?

协程就是一个可以暂停和恢复执行的函数。别的函数是一镜到底,协程是走走停停。停下(被挂起)的时候,就是说当前线程可以去执行别的任务了,完了还能再回来

为什么要有协程

首先看看线程的历史。为什么会有线程呢,线程的作用是什么呢?

是为了尽可能多的吃满CPU,发挥性能。当然,这是我们说的多线程并发的常用场景。但是最开始引入线程其实是为了解决一个异步IO的问题。

一开始的电脑,程序员编辑完一个文件,按下保存,然后就可以去玩了。因为只有一个线程,在保存的时候,就不能干别的了。但是这显然是不合理的,磁盘在那吭哧吭哧写,但是cpu却是闲着的。闲着的cpu是否应该找点活干呢?

后来有了多任务的概念(应该不是当前场景首创,较真的话需要深入考究),也就是多个线程。有了多个线程,保存的线程阻塞了,不影响编辑的线程。这就是多线程带来的异步,让IO进行的时候,CPU能空出来。

随着计算机的发展,异步写磁盘的情况已经是小菜一碟了,但是又有了新的挑战,比如经典的C10K问题,也就是服务器单机1w并发请求处理。单个线程处理1w个请求必然会卡死。创建1w个线程,每个线程4M的栈,这就是40G的内存,那显然也不行。看来用线程实现的异步IO不中用了。

那是时候引入单线程异步IO的概念了。用一个线程,就可以发起很多个IO操作。这需要:

  1. 一个IO多路复用的通知机制。一个接口,通知所有的IO时间。如果不是多路复用的,那必然就需要新的线程
  2. 非阻塞IO(只需有IO发起操作,无需等IO完成)。阻塞了当前这个唯一的线程就被挂起了

todo: 简化一下

深入理解协程

理解协程,最好的办法就是和线程进行类比:

  1. 从调度、挂起切换角度比较
    1. 线程由OS调度、切换,调用阻塞IO等会被挂起,IO就绪OS会将其恢复
    2. 协程由用户调度、切换,由用户指定什么时候挂起,什么时候恢复。怎么指定呢?——通过Awaiter指定,await_suspend()描述协程挂起时执行什么动作(比如发起一次非阻塞IO然后等待,比如注册fd到epoll然后等待);await_resume()描述协程恢复时执行什么动作(比如读取就绪数据/向就绪fd发送)。
  2. 从编码角度比较。我们更习惯的都是以同步的方式编码
    1. 线程调用阻塞IO,比如read,发起一次IO,然后当前线程被OS阻塞,IO就绪时恢复。
    2. 协程效用一个异步IO/非阻塞IO,发起一次任务。然后当前协程被阻塞,任务就绪时恢复。线程阻不阻塞OS知道,协程怎么知道哪个IO要阻塞呢?用co_await标记。所以加上co_await后,就相当于告诉协程,这是一个“阻塞”IO,要阻塞协程。
    3. 简单来说,阻塞IO阻塞整个线程,非阻塞IO阻塞协程(用co_await标记上:这对于协程来说是一个阻塞IO)
  3. 从结果传出的角度
    1. 线程需要使用一个std::promise,传递不是立马能获取到的结果
    2. 协程也需要这么一个promise,它就是协程的Task<T>,里面要求必须有一个promise_type,这就是协程的promise,用于获取不是立马得到的结果
  4. 从第2点也可以看出:协程必须要搭配真正的异步IO才叫协程。因为
    1. 调用同步IO,OS管你这那的,直接阻塞整个线程了,它眼里可没有协程这么回事。
    2. 创建线程实现异步IO,那不还是多线程等于?

类比完再想想,什么叫做,协程时由用户负责调度切换的协程这句话,有没有更清晰?

C++20协程核心概念

promise_type

template<typename T>
struct Task<T>::promise_type {
    T promised_value;
    std::exception_ptr exptr;
    T Get() {
        if(exptr) std::rethrow_excetion(exptr);
        return promised_value;
    }
    
    
    // 强制要求的函数:
    
    void get_return_object(){ return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; }
    
    auto initial_suspend() { return std::suspend_never{}; }
    
    auto final_suspend() noexcept { return std::suspend_always{}; }
    
    void unhandled_exception() { exptr = std::current_excetion(); }
    
    template<typename U = T>
    require(std::is_same_v<U, void>)
    void return_void() {}
    
    template<typename U = T>
    require(std::is_convertible_v<U, T>)
    void return_value(U v){ promised_value = std::move(v); }
    
    template<std::convertible_to<T> From>
    std::suspend_always yield_value(From v) { promised_value = std::move(v); return {}; }
};

那么一个完整的task应该长什么样呢?

template<typename T>
class Task{
public:
    struct promise_type;
    using HandleType = std::coroutine_handle<promise_type>;
    
    Task(HandleType handle): coro_handle_(handle){}
    Task(Task&&);
    Task& operator=(Task&&);
    ~Task(){ if(coro_handle_){ coro_handle_.destory(); coro_handle = nullptr; } }
    
    Get() { return coro_handle_.promise().Get(); }
    
private:
	HandleType coro_handle_;
};

Awatiable & Awaiter

Comment