C++20 协程(coroutine)入门

文章目录

  • C++20 协程(coroutine)入门
    • 什么是协程
      • 无栈协程和有栈协程
      • 有栈协程的例子
        • 例 1
        • 例 2
      • 对称协程与非对称协程
      • 无栈协程的模型
      • 无栈协程的调度器
        • 朴素的单线程调度器
        • 让协程学会等待
        • Python 中的异步函数
        • 可等待对象
        • M:N 调度器——C# 中的异步函数
      • 小结
    • C++20 中的协程对象
      • (未完待续)

在阅读下面的内容之前,建议先入门至少三门除 C++ 外的其他编程语言,最好还支持协程。

可以参考:渡劫 C++ 协程(0):前言 | Benny Huo

C++20 协程(coroutine)入门

什么是协程

可以参考:初识协程 | 楚权的世界 (chuquan.me)

老生常谈,协程的核心思想是允许放弃执行当前函数,转而执行其他函数,但之后还能恢复之前函数的执行状态。学过 Python 的人很快就能想到,这不就是生成器吗?

程序 1(Python):生成器
def my_range(to): # 是一个生成器。
    for i in range(1, to + 1):
        yield i # 1. 放弃执行当前函数。

if __name__ == "__main__":
    for i in my_range(3): # 3. 恢复之前函数的执行状态。
        print(i) # 2. 转而执行其他函数。

但这个和什么所谓的“亿级别流量洪峰”有什么关系,怎么做到让数亿协程“宏观并行”(即表现出并发特征)?如果没有新的线程被创建,网络调用仍然只能在主线程执行,这个矛盾怎么解决?我相信即使你不会 Python,看不懂上面的代码,在看别人对协程的介绍时也能想到这些问题。

我们一步一步来,先巩固协程相关的基本概念,再来回答以上刁钻的问题。

无栈协程和有栈协程

可以参考:浅谈有栈协程与无栈协程 - 知乎 (zhihu.com)。

可以参考:协程和纤程的区别是什么? - tearshark的回答 - 知乎。

可以参考:有栈协程与无栈协程 (mthli.xyz)

协程(coroutine),也就是协作(co-)的过程(routine),离不开过程二字,也就是说协程也是一个函数(function, method, routine, etc.)。同时可以顾名思义,互相“协作”的“过程”生来就是用于解决并发问题的。

我们都知道,一般的线程一定存在一个函数调用栈,记录着函数之间的调用关系、局部变量、返回地址等等。那对于协程来说,它和我们熟知的那个栈有什么关系呢?

有什么关系,其实取决于“协作”的具体实现。不同的实现会与我们熟知的那个栈产生不同的联系。大体上可以分为两类:

  1. 有栈协程(stackful)。

    创建一个有栈协程时,运行时(runtime)会申请一片内存空间,作为协程的栈空间。之后,该协程都将这片空间视为自己的栈空间。如果已经开始执行该协程的代码,这个协程就好像在一个新的线程上运行一样。

    但创建一个有栈协程并不会创建一个内核态的线程,如何使得协程具有并发特征?其实关键还是在于让协程自己放弃当前的执行权。

  2. 无栈协程(stackless)。

    创建一个无栈协程时,运行时会申请一片内存空间,保存协程的栈帧。之后,该协程仍然在某个线程的栈空间上运行,只不过协程可以选择保存当前栈帧后放弃执行权,再之后还可以恢复到此前的状态继续执行。

简单地说,这两类协程可以描述为(不一定准确,主要是为了方便理解):

  1. 有栈协程就是不由操作系统内核调度的“线程”。取决于具体实现,可能没有线程本地存储(Thread Local Storage, TLS),等等,总而言之只是长得像线程。
  2. 无栈协程就是一个可以断断续续执行的函数。

Python 的生成器可以看作是无栈协程,C++20 提供的协程也是无栈协程。

有栈协程的例子

介绍以上分类,其实对理解协程提供并发能力并没有任何帮助。一方面,一开始提到的 Python 的生成器也是无栈协程,但我们(可能)并没有见过用生成器解决并发问题的场景,所以之前的提问一个也没有被解答。

为了更直观地看到协程如何解决并发问题,我们来看几个有栈协程的例子。

例 1

程序 2(C 语言):Windows 中的纤程(fiber),是有栈协程的一种实现,在单线程中实现并发
#include 
#include 

#include 

PVOID fiber_main;
PVOID fiber_anothers[2];

void inner(int id) {
	printf("Task %d\n", id);
	// Note:放弃当前纤程执行权,转换到其他纤程。
	SwitchToFiber(fiber_main);
}

void WINAPI another(LPVOID param) {
	while (true) {
		inner((int)param);
	}
}

int main() {
	// 将当前的线程转换为纤程,允许参与纤程的调度。
	fiber_main = ConvertThreadToFiber(NULL);
	// 创建纤程,但不执行。
	for (unsigned i = 0; i < 2; i++) {
		// 参数 1 是栈空间,0 表示取默认值。
		fiber_anothers[i] = CreateFiber(0, another, (LPVOID)(i + 1));
	}

	printf("Fiber demo started\n");
	for (unsigned i = 0; i < 3; i++) {
		for (unsigned j = 0; j < 2; j++) {
			// Note:放弃当前纤程执行权,转换到其他纤程。
			SwitchToFiber(fiber_anothers[j]);
		}
	}
	printf("Done!\n");

	// 回收资源。
	for (unsigned i = 0; i < 2; i++) {
		// 即使两个任务是死循环,也因为放弃执行权而没有运行。
		// 由于纤程是我们自己调度,所以可以安全地删除它们。
		DeleteFiber(fiber_anothers[i]);
	}
	ConvertFiberToThread();
}

运行结果:

Fiber demo started
Task 1
Task 2
Task 1
Task 2
Task 1
Task 2
Done!

程序 1 的 another 函数是一个典型的协程。它运行时可以表现出并发的特征,前提是我们需要自己放弃当前协程的执行权(SwitchToFiber 函数)。即使是在协程调用的子函数中(inner 函数),也可以主动放弃当前协程的执行权,所以 Windows 中的纤程是有栈协程的一种实现。

例 2

程序 3(Go 语言):goroutine 是有栈协程的一种实现,通过运行时调度器实现并发
package main

import (
	"fmt"
	"time"
)

func inner(id int) {
	fmt.Println("Task", id)
	// Note: 放弃当前 goroutine 执行权,转换到其他 goroutine。
	time.Sleep(100 * time.Millisecond)
	// Note: 运行时会帮助我们尽可能在 100 毫秒后重新取得执行权。
}

func another(id int) {
	for true {
		inner(id)
	}
}

func main() {
	fmt.Println("goroutine demo started")
	for i := 0; i < 2; i++ {
		// 创建 goroutine,是否立即开始在其他线程中执行取决于运行时。
		go another(i)
	}
	// Note: 放弃当前 goroutine 执行权,转换到其他 goroutine。
	time.Sleep(300 * time.Millisecond)
	fmt.Println("Done!")
	// Note: 主 goroutine 被销毁后,其他 goroutine 也被销毁。
}

可能的运行结果:

goroutine demo started
Task 0
Task 1
Task 0
Task 1
Task 1
Task 0
Done!

通过这两个例子,我们大致看到了有栈协程在实现并发时不可或缺的东西:调度(schedule)。例 1 中,调度完全由手工实现(SwitchToFiber 函数),费时费力;而例 2 中,调度由 Go 语言的**调度器(scheduler)**实现,写程序时只用自然地让当前 goroutine 睡眠即可(time.Sleep 函数)。

为了实现 M:N 模型,Go 语言运行时提供的调度器颇为复杂,但使用 Go 语言时就不用考虑这么多了,就程序 3 而言,把 goroutine 看作一个线程也无妨。Go 语言的调度器让有栈协程具有了很多类似线程的功能,从而可以像线程一样使用 goroutine,同时让创建 goroutine 的代价很低,也就实现了高并发。

从 Go 语言可以看出,如果一个语言支持有栈协程,那么把原有的线程函数迁移为协程函数并不会太复杂,因为它们长得挺像。但对于无栈协程来说,没有长得像一说,所以代码迁移可能会花更多时间。但无栈协程所占空间明显小于有栈协程,这是无栈协程特有的优势。

对称协程与非对称协程

可以参考:协程学习(对称和非对称) - 知乎 (zhihu.com)

可以参考:一文彻底弄懂C++开源协程库libco——原理及应用 - 知乎 (zhihu.com)

程序 3 中,goroutine 通过 go 语句被创建后,就好像一个单独的线程一样,被创建的协程只能自己选择放弃(yield)执行权,至于放弃之后谁执行,只由调度器决定,不由协程的创建者决定,这种就是对称协程(symmetric coroutine)。对称协程之间不存在明显的从属关系,大家都是平等的。

程序 2 中,我们完全自己调度纤程。如果规定在放弃执行权时只能回到纤程的创建者,则可以形成纤程的调用关系链。这种具有明显调用关系的协程就是非对称协程(asymmetric coroutine)。

由此可以注意到,无栈与有栈、对称与非对称是两个不同的概念。Python 的生成器可以看作是非对称协程,C++20 提供的协程也是非对称协程。

应该没有无栈对称协程……

无栈协程的模型

我们终于来到与 C++20 有关的东西了:无栈非对称协程。如果它不能表现得类似于一个线程,又有什么用,该怎么用?

图 1:程序 1 的大致执行流程

图 1 中,main() 表示主流程,是一个普通的函数(不妨把 Python 的主过程看成一个函数),my_range() 是生成器,也就是一个协程。图中,黑点表示可以进入的点,普通函数只有开头一个,而无栈非对称协程则可以有任意多个,每个对应 Python 中的 yield 语句。

因此,可以把协程看作一个状态机,图 1 中,协程内的黑点就对应一个状态,协程内的箭头就对应状态的转移。需要注意的是,这个状态机还有大量隐藏的状态以局部变量的形式存在于协程中,随图中可见状态的转移而转移。

无栈协程在逻辑上总是可以用闭包的形式实现,但实际上很难写,甚至可能会写不出来。尽管如此,尝试将无栈协程和闭包相互转换,对理解无栈协程的工作原理会很有帮助。

程序 4(C++):使用闭包实现一个简单的无栈协程
#include 

auto my_range() {
	// 每一个 lambda 表达式都对应图 1 协程中的一个黑点。
	int value = 0;
	// 通过按值捕获变量,将局部变量作为状态保存在闭包中。
	return [=]() mutable {
		std::cout << ++value << std::endl;
		// 通过按引用捕获变量,模拟局部变量的状态转移。
		return [&]() {
			std::cout << ++value << std::endl;
			return [&]() {
				std::cout << ++value << std::endl;
				return [&]() -> void {
					// 没有返回值。
				};
			};
		};
	};
}

int main() {
	// 类似于 Python 中的生成器对象,状态均保存在名为 coroutine 的对象中。
	auto coroutine = my_range();
	// resume_point_* 不保存变量状态,只保存执行位置。
	const auto resume_point_1 = coroutine();
	const auto resume_point_2 = resume_point_1();
	resume_point_2();
}

运行结果:

1
2
3

程序 4 对应程序 1 和图 1,是使用 C++ 中的闭包模拟无栈协程的结果。从中可以感受到,如果编译器不支持协程相关的语法,只用闭包模拟无栈协程会有相当多的困难:

  1. 协程中的状态点越多,闭包的层数就越深。

    如果尝试将闭包作为回调函数,复杂逻辑就会导致很深的闭包,称为回调地狱(callback hell)。如果能把程序 4 转换成程序 1 那样,回掉地狱问题就解决了。

    # 更接近程序 4 模拟无栈协程的 Python 生成器。
    def my_range():
        value = 0
        # 没有回调地狱!
        yield (value := value + 1)
        yield (value := value + 1)
        yield (value := value + 1)
    

    可以参考:Java如何实现一个回调地狱(Callback Hell)? - 掘金 (juejin.cn)

    通过诉诸协程解决回调地狱,靠的是扩展处理器的日常使用方法:过去我们只想到函数调用、中断,现在还可以通过自己保存栈帧来实现协程。除了向计算机底层寻求方法,还可以向更抽象的

  2. 协程中的局部变量作为内部状态,很难正确地处理。

    比如,程序 4 中一会儿按值捕获,一会儿按引用捕获,很难弄清楚,特别是有更多零散的局部变量时。

  3. 如果有复杂的结构,例如循环结构,很难、甚至不能用闭包实现。

    比如程序 4 就没有写出程序 1 中的循环结构。

  4. 闭包无法实现协程中的数据传递。

现在,我们大致明白了使用协程实现并发的方法(关键在于存在一个调度器),也知道了无栈协程的状态机模型。但我们仍然不知道如何用无栈协程实现并发,这是因为我们不知道无栈协程应该有怎样的调度器。

无栈协程的调度器

可以参考:万字好文:从无栈协程到C++异步框架! - 腾讯云技术社区 - SegmentFault 思否

可以参考:python中的yield、yield from、async/await中的区别与联系 - 简书 (jianshu.com)

可以参考:await 运算符 - 异步等待任务完成 | Microsoft Learn

可以参考:【译】图与例解读Async/Await - 知乎 (zhihu.com)

作为入门教程,我们当然不讨论无栈协程的调度器具体该怎么写,但是我们必须至少弄清楚无栈协程的调度器长什么样,不然怎么知道如何用它实现并发,怎么发挥协程的优势?

朴素的单线程调度器

很容易想到,可以让调度器变成一个死循环,不断轮流执行尚未完成的所有协程就可以了。

程序 5(Python):最朴素的想法
def my_range(to):
    for i in range(1, to + 1):
        yield i

if __name__ == "__main__":
    coroutines = [my_range(3) for _ in range(4)]
    # 如果不是所有协程都已经结束,就继续执行。
    while not all(coroutine.gi_frame is None for coroutine in coroutines):
        # 轮流执行每个协程。
        for coroutine in coroutines:
            try:
                print(next(coroutine))
            except StopIteration:
                pass

运行结果:

1
1
1
1
2
2
2
2
3
3
3
3
图 2:最朴素的想法

虽然程序 5 似乎没啥用,但是我们得知了:

  1. 调度器一定是一个普通函数,而不是协程。因为我们讨论的是非对称协程,所以这些协程放弃执行权后会自动回到调度器上次执行的位置,对调度器而言执行协程就好比执行函数一样。

    这意味着当我们希望协程表现出并发的特征时,首先需要调用一个调度器函数。

  2. 这种最朴素的调度器并不调度协程内创建的协程。比如程序 5 中,my_range 里面创建了 range,它也是一个协程,但 main 调度器看不见也管不着它。

    这意味着要想有栈协程那样允许在任意子调用中放弃执行权会很困难。

  3. 这种最朴素的调度器没有办法处理协程之间的依赖关系。比如程序 5 中,各个 my_range 产生的结果都是无关的。

    这意味着想要使用另一个协程的运行结果会很困难。

对于后两个问题,如果像程序 5 中 my_range 使用 range 那样,让协程 my_range 自己调度另一个协程 range,并且又希望使用另一个协程的最后运行结果(因为我们通常更关心函数的返回值),代码就会变得很繁琐。请看下面的 Python 程序。

程序 6(Python):最失败的 man
def my_complex_task(id):
    for i in range(3):
        print(f"Task {id}")
        yield
    # 需要拿到这个结果。
    yield id + 1

def my_print(id):
    inner_coroutine = my_complex_task(id)
    # 繁琐:怎么拿到协程的返回值?
    last_yield = None
    for result in inner_coroutine:
        last_yield = result
        # 繁琐:我自己调度,怎么知道什么时候自己该 yield?
        yield
    # 繁琐:如果这个协程也只是返回结果,然后在 main 里才进行输出,是不是以上繁琐还要再来一次?
    print(f"Result of {id}: {last_yield}")

if __name__ == "__main__":
    coroutines = [my_print(i + 1) for i in range(2)]
    # 如果不是所有协程都已经结束,就继续执行。
    while not all(coroutine.gi_frame is None for coroutine in coroutines):
        # 轮流执行每个协程。
        for coroutine in coroutines:
            try:
                next(coroutine)
            except StopIteration:
                pass

运行结果:

Task 1
Task 2
Task 1
Task 2
Task 1
Task 2
Result of 1: 2
Result of 2: 3

程序 6 确实让 my_print 协程用到了 my_complex_task 协程的结果,并且成功表现出了并发的特征,但写出来实在是太繁琐了。如果 my_print 要用到 my_complex_task 的结果,怎么做更优美?

让协程学会等待

既然 my_print 要用到 my_complex_task 的结果,那就等 my_complex_task 结束吧。

图 3:如果协程学会等待

事实上,“学会等待”是无栈协程的基本操作,因为这样就可以实现栈式的函数调用,同时保留了并发能力。在编程语言中,等待(await)就会导致协程被挂起(suspend),直到通知恢复(assume),协程才能继续被调度。用于并发操作的无栈协程本身常被称为异步(async)函数。

Python 中的异步函数

Python 的生成器虽然是无栈协程,但实际上不会用于并发场景,原因可以见程序 6。用于并发场景的无栈协程,也就是异步函数,在 Python 中的基本使用方法如下所示。

程序 7(Python):异步函数
import asyncio

# async 关键字表示这是一个协程。
async def my_complex_task(id):
    for i in range(3):
        print(f"Task {id}")
        # 主动放弃执行权。
        await asyncio.sleep(0)
    # 需要拿到这个结果。
    return id + 1
	# 结束,通知调用方(my_print),使其恢复。

async def my_print(id):
    # 声称自己要等。等到结果后才会被继续调度。
    result = await my_complex_task(id)
    print(f"Result of {id}: {result}")

if __name__ == "__main__":
    # 直接“调用”协程将会得到一个协程对象,并没有开始执行。
    tasks = [my_print(i) for i in range(3)]
    # 创建调度器。
    loop = asyncio.new_event_loop()
    # 调用调度器函数。
    loop.run_until_complete(asyncio.wait(tasks))
    # 回收调度器。
    loop.close()

运行结果:

Task 2
Task 1
Task 0
Task 2
Task 1
Task 0
Task 2
Task 1
Task 0
Result of 2: 3
Result of 1: 2
Result of 0: 1

程序 7 和程序 6 的功能一样,在单个线程中具有并发能力。但程序 7 的编写比程序 6 简单许多,正是“等待”使得无栈协程可以在调用其他协程的同时保持并发能力。缺点是,所有被调用的协程都需要用 async 关键字修饰,称这种现象为 async 传染。

图 3 说,await 会使新的协程被加入调度器,但这一点似乎从程序 7 中看不明白。事实上,要看透这一点,必须深入协程调度器的具体实现,所以这个问题需要留到讲解 C++20 的协程库时才能解决。

可等待对象

图 4:如果协程学会抽象的等待

图 4 的意思是,协程必须等待的是另一个协程吗?只要等待的对象能够恢复(resume)调用方协程、能提供运行的结果,那就可以拿来等!这种对象就称为可等待对象(awaitable object)。

虽然可等待对象可以不是协程,但一般都是协程。图 4 中的 my_task 也有可能是协程吗?事实上是可能的,只要 main_task 在首次恢复时不被调度器指派到主线程上即可。

M:N 调度器——C# 中的异步函数

可以参考:await 运算符 - 异步等待任务完成 | Microsoft Learn

至此为止,我们只实现了并发,还没有实现并行。很容易想到,要让协程拥有并行的能力,只需要让调度器支持创建多个内核态线程就好了。

实现并行的关键是在恢复协程时为它分配到另一个线程上。我们直接看看 C# 的一个例子。

程序 8(C#):异步函数(修改自官网的例子)
public class AwaitOperator
{
    public static async Task Main()
    {
        Task<int> downloading = DownloadDocsMainPageAsync(); // 立即开始执行,直到 await。返回值是 Task。
        Console.WriteLine($"{nameof(Main)}: Launched downloading. (on {Thread.CurrentThread.ManagedThreadId})");

        int bytesLoaded = await downloading;
        Console.WriteLine($"{nameof(Main)}: Downloaded {bytesLoaded} bytes. (on {Thread.CurrentThread.ManagedThreadId})");
    }

    private static async Task<int> DownloadDocsMainPageAsync()
    {
        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: About to start downloading. (on {Thread.CurrentThread.ManagedThreadId})");

        var client = new HttpClient();
        byte[] content = await client.GetByteArrayAsync("https://learn.microsoft.com/en-us/");

        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: Finished downloading. (on {Thread.CurrentThread.ManagedThreadId})");
        return content.Length;
    }
}

可能的运行结果:

DownloadDocsMainPageAsync: About to start downloading. (on 1)
Main: Launched downloading. (on 1)
DownloadDocsMainPageAsync: Finished downloading. (on 7)
Main: Downloaded 39995 bytes. (on 7)

程序 8 告诉我们:

  1. C# 可以在后台自动运行一个调度器,并且是 M:N 调度器。
  2. 调度器的调度工作在 await 语句处发生。协程挂起后,再次恢复时在哪个线程上由调度器决定。

小结

C++20 的协程是无栈非对称协程。无栈协程可以用于生成器,也可以用于并发场景。用于并发场景的协程也被称为异步函数。并发场景下,协程的调度器不可或缺。

无栈协程可以抽象为一个状态机,也可以用闭包模拟简单的无栈协程。使无栈协程并发的关键是 await 语句,可以等待对象返回结果后再接受调度。使无栈协程并行的关键是调度器,调度器可以在协程恢复运行时指派线程。不同编程语言实现的调度器各不相同,不同场景下所需的调度器也不相同,使用前需要充分调研所用调度器的特征。

C++20 中的协程对象

前面举了这么多例子,只是为了说明协程的功能。C++20 中的协程具体是怎样的?很遗憾,C++20 根本没提供协程的调度器,一切都需要自己写,所以大家才说 C++20 的协程是为库开发者准备的。

但如果学习了 C++20 中的协程,便可以说了解了协程的底层原理,处理其他语言中的协程也就游刃有余了。

(未完待续)

on 7)


程序 8 告诉我们:

1. C# 可以在后台自动运行一个调度器,并且是 M:N 调度器。
2. 调度器的调度工作在 `await` 语句处发生。协程挂起后,再次恢复时在哪个线程上由调度器决定。

### 小结

C++20 的协程是无栈非对称协程。无栈协程可以用于生成器,也可以用于并发场景。用于并发场景的协程也被称为异步函数。并发场景下,协程的调度器不可或缺。

无栈协程可以抽象为一个状态机,也可以用闭包模拟简单的无栈协程。使无栈协程并发的关键是 await 语句,可以等待对象返回结果后再接受调度。使无栈协程并行的关键是调度器,调度器可以在协程恢复运行时指派线程。不同编程语言实现的调度器各不相同,不同场景下所需的调度器也不相同,使用前需要充分调研所用调度器的特征。

## C++20 中的协程对象

前面举了这么多例子,只是为了说明协程的功能。C++20 中的协程具体是怎样的?很遗憾,C++20 根本没提供协程的调度器,一切都需要自己写,所以大家才说 C++20 的协程是为库开发者准备的。

但如果学习了 C++20 中的协程,便可以说了解了协程的底层原理,处理其他语言中的协程也就游刃有余了。

### (未完待续)

你可能感兴趣的:(C++,前沿语法,编程语言,c++20,开发语言,c++,协程)