首页 文章详情

面试官:C++20 协程有用过吗?

字节流动 | 149 2023-03-03 23:06 0 1 0
UniSMS (合一短信)

无栈协程成为 C++20 协程标准

协程分为无栈协程和有栈协程两种无栈指可挂起/恢复的函数,有栈协程则相当于用户态线程。

有栈协程切换的成本是用户态线程切换的成本,而无栈协程切换的成本则相当于函数调用的成本。

无栈协程和线程的区别:无栈协程只能被线程调用,本身并不抢占内核调度,而线程则可抢占内核调度。

协程函数与普通函数的区别:


(1)普通函数执行完返回,则结束。协程函数可以运行到一半,返回并保留上下文;下次唤醒时恢复上下文,可以接着执行。


协程与多线程:


(1)协程适合IO密集型程序,一个线程可以调度执行成千上万的协程,IO事件不会阻塞线程

(2)多线程适合CPU密集型场景,每个线程都负责cpu计算,cpu得到充分利用


协程与异步:


(1)都是不阻塞线程的编程方式,但是协程是用同步的方式编程、实现异步的目的,比较适合代码编写、阅读和理解

(2)异步编程通常使用callback函数实现,将一个功能拆分到不同的函数,相比协程编写和理解的成本更高。

C++20 为什么选择无栈协程?

有栈(stackful)协程通常的实现手段是在堆上提前分配一块较大的内存空间(比如 64K),也就是协程所谓的“栈”,参数、return address 等都可以存放在这个“栈”空间上。

如果需要协程切换,那么通过 swapcontext 一类的形式来让系统认为这个堆上空间就是普通的栈,这就实现了上下文的切换。

有栈协程最大的优势就是侵入性小,使用起来非常简便,已有的业务代码几乎不需要做什么修改,但是 C++20 最终还是选择了使用无栈协程,主要出于下面这几个方面的考虑。

  • 栈空间的限制

有栈协程的“栈”空间普遍是比较小的,在使用中有栈溢出的风险;而如果让“栈”空间变得很大,对内存空间又是很大的浪费。无栈协程则没有这些限制,既没有溢出的风险,也无需担心内存利用率的问题。

  • 性能

有栈协程在切换时确实比系统线程要轻量,但是和无栈协程相比仍然是偏重的,这一点虽然在我们目前的实际使用中影响没有那么大(异步系统的使用通常伴随了 IO,相比于切换开销多了几个数量级),但也决定了无栈协程可以用在一些更有意思的场景上。

举个例子,C++20 coroutines 提案的作者 Gor Nishanov 在 CppCon 2018 上演示了无栈协程能做到纳秒级的切换,并基于这个特点实现了减少 Cache Miss 的特性。

关于协程的储存空间

  1. C++ 的设计是无栈协程, 所有的局部状态都储存在堆上.

  2. 储存协程的状态需要分配空间. 分配 frame 的时候会先搜索 promise_type 有没有提供 operator new, 其次是搜索全局范围.

  3. 有分配就可能会有失败. 如果写了 get_return_object_on_allocation_failure() 函数, 那就是失败后的办法, 代替 get_return_object() 来完成工作. (需要 noexcept)

  4. 协程结束以后的释放空间也会先在 promise_type 里面搜索 operator delete, 其次搜索全局范围.

  5. 协程的储存空间只有在运行完 final_suspend 之后才会析构, 或者你得显式调用 coro.destroy(). 否则协程的存储空间就永远不会释放. 如果你在 final_suspend 那里停下了, 那么就得在包装函数里面手动调用 coro.destroy(), 不然就会漏内存.

  6. 如果已经运行完毕了 final_suspend, 或者已经被 coro.destroy() 给析构了, 那么协程的储存空间已经被释放了. 再次对 coro 做任何的操作都会导致 seg fault.

无栈协程是普通函数的泛化

无栈协程是一个可以暂停和恢复的函数,是函数调用的泛化

为什么?

我们知道一个函数的函数体(function body)是顺序执行的,执行完之后将结果返回给调用者,我们没办法挂起它并稍后恢复它,只能等待它结束。

而无栈协程则允许我们把函数挂起,然后在任意需要的时刻去恢复并执行函数体,相比普通函数,协程的函数体可以挂起并在任意时刻恢复执行。

所以,从这个角度来说,无栈协程是普通函数的泛化。

C++20 协程的“微言大义”

C++20 提供了三个新关键字(co_await、co_yield 和 co_return),如果一个函数中存在这三个关键字之一,那么它就是一个协程。

编译器会为协程生成许多代码以实现协程语义。会生成什么样的代码?我们怎么实现协程的语义?协程的创建是怎样的?co_await机制是怎样的?在探索这些问题之前,先来看看和 C++20 协程相关的一些基本概念。

协程相关的对象

协程帧(coroutine frame)

当 caller 调用一个协程的时候会先创建一个协程帧,协程帧会构建 promise 对象,再通过 promise 对象产生 return object。

协程帧中主要有这些内容:

  • 协程参数

  • 局部变量

  • promise 对象

这些内容在协程恢复运行的时候需要用到,caller 通过协程帧的句柄 std::coroutine_handle 来访问协程帧。

promise_type

promise_type 是 promise 对象的类型。promise_type 用于定义一类协程的行为,包括协程创建方式、协程初始化完成和结束时的行为、发生异常时的行为、如何生成 awaiter 的行为以及 co_return 的行为等等。promise 对象可以用于记录/存储一个协程实例的状态。每个协程桢与每个 promise 对象以及每个协程实例是一一对应的。

coroutine return object

它是promise.get_return_object()方法创建的,一种常见的实现手法会将 coroutine_handle 存储到 coroutine object 内,使得该 return object 获得访问协程的能力。

std::coroutine_handle

协程帧的句柄,主要用于访问底层的协程帧、恢复协程和释放协程帧。 
程序员可通过调用 std::coroutine_handle::resume() 唤醒协程。

co_await、awaiter、awaitable

  • co_await:一元操作符;

  • awaitable:支持 co_await 操作符的类型;

  • awaiter:定义了 await_ready、await_suspend 和 await_resume 方法的类型。

co_await expr 通常用于表示等待一个任务(可能是 lazy 的,也可能不是)完成。co_await expr 时,expr 的类型需要是一个 awaitable,而该 co_await表达式的具体语义取决于根据该 awaitable 生成的 awaiter。

看起来和协程相关的对象还不少,这正是协程复杂又灵活的地方,可以借助这些对象来实现对协程的完全控制,实现任何想法。但是,需要先要了解这些对象是如何协作的,把这个搞清楚了,协程的原理就掌握了,写协程应用也会游刃有余了。

一个简单的 C++20 协程例子

这个例子很简单,通过 co_await 把协程调度到一个线程中打印一下线程 id。

#include <coroutine>
#include <iostream>
#include <thread>

namespace Coroutine {
  struct task {
    struct promise_type {
      promise_type() {
        std::cout << "1.create promie object\n";
      }
      task get_return_object() {
        std::cout << "2.create coroutine return object, and the coroutine is created now\n";
        return {std::coroutine_handle<task::promise_type>::from_promise(*this)};
      }
      std::suspend_never initial_suspend() {
        std::cout << "3.do you want to susupend the current coroutine?\n";
        std::cout << "4.don't suspend because return std::suspend_never, so continue to execute coroutine body\n";
        return {};
      }
      std::suspend_never final_suspend() noexcept {
        std::cout << "13.coroutine body finished, do you want to susupend the current coroutine?\n";
        std::cout << "14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye\n";
        return {};
      }
      void return_void() {
        std::cout << "12.coroutine don't return value, so return_void is called\n";
      }
      void unhandled_exception() {}
    };

    std::coroutine_handle<task::promise_type> handle_;
  };

  struct awaiter {
    bool await_ready() {
      std::cout << "6.do you want to suspend current coroutine?\n";
      std::cout << "7.yes, suspend becase awaiter.await_ready() return false\n";
      return false;
    }
    void await_suspend(
      std::coroutine_handle<task::promise_type> handle)
 
{
      std::cout << "8.execute awaiter.await_suspend()\n";
      std::thread([handle]() mutable { handle(); }).detach();
      std::cout << "9.a new thread lauched, and will return back to caller\n";
    }
    void await_resume() {}
  };

  task test() {
    std::cout << "5.begin to execute coroutine body, the thread id=" << std::this_thread::get_id() << "\n";//#1
    co_await awaiter{};
    std::cout << "11.coroutine resumed, continue execcute coroutine body now, the thread id=" << std::this_thread::get_id() << "\n";//#3
  }
}// namespace Coroutine

int main() {
  Coroutine::test();
  std::cout << "10.come back to caller becuase of co_await awaiter\n";
  std::this_thread::sleep_for(std::chrono::seconds(1));

  return 0;
}

测试输出:

1.create promie object
2.create coroutine return object, and the coroutine is created now
3.do you want to susupend the current coroutine?
4.don't suspend because return std::suspend_never, so continue to execute coroutine body
5.begin to execute coroutine body, the thread id=0x10e1c1dc0
6.do you want to suspend current coroutine?
7.yes, suspend becase awaiter.await_ready() return false
8.execute awaiter.await_suspend()
9.new thread lauched, and will return back to caller
10.come back to caller becuase of co_await awaiter
11.coroutine resumed, continue execcute coroutine body now, the thread id=0x700001dc7000
12.coroutine don't return value, so return_void is called
13.coroutine body finished, do you want to susupend the current coroutine?
14.don'
suspend because return std::suspend_never, and the continue will be automatically destroyed, bye

从这个输出可以清晰的看到协程是如何创建的、co_await 等待线程结束、线程结束后协程返回值以及协程销毁的整个过程。

参考资料:

  • https://github.com/alibaba/async_simple

  • https://timsong-cpp.github.io/cppwp/n4868/

  • https://blog.panicsoftware.com/coroutines-introduction/

  • https://lewissbaker.github.io/

  • https://juejin.cn/post/6844903715099377672

  • https://wiki.tum.de/download/attachments/93291100/Kolb%20report%20-%20Coroutines%20in%20C%2B%2B20.pdf

作者:祁宇,Modern C++ 开源社区 purecpp.org 创始人,《深入应用 C++11》作者  
许传奇,阿里巴巴开发工程师, LLVM Committer, C++ 标准委员会成员  
韩垚,阿里巴巴工程师,目前从事搜索推荐引擎开发

原文链接: https://blog.csdn.net/csdnnews/article/details/124123024

推荐:

面试常问的 C/C++ 问题,你能答上来几个?

C++ 面试必问:深入理解虚函数表

很多人搞不清 C++ 中的 delete 和 delete[ ] 的区别

看懂别人的代码,总得懂点 C++ lambda 表达式吧

Java、C++ 内存模型都不知道,还敢说自己是高级工程师?

C++ std::thread 必须要熟悉的几个知识点

现代 C++ 并发编程基础

现代 C++ 智能指针使用入门

c++ thread join 和 detach 到底有什么区别?

C++ 面试八股文:list、vector、deque 比较

C++经典面试题(最全,面中率最高)

C++ STL deque 容器底层实现原理(深度剖析)

STL vector push_back 和 emplace_back 区别

了解 C++ 多态与虚函数表

C++ 面试被问到的“左值引用和右值引用”


good-icon 1
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter