c++提供的可有效分配对象空间的运算符是_C++协程:理解 co_await 运算符

本文是我们翻译的 C++ 协程相关的第二篇文章,原作者是 Lewis Baker。我们建议你在阅读本文前首先阅读我们已经翻译和发表的同一作者的第一篇文章《C++协程原理》,以便更好地理解本文中协程、协程帧、堆栈帧、堆内存分配等相关的概念或问题。本文的原文请参见:https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await。

在上一篇文章《协程原理》中,我描述了函数和协程之间在较高层次上的差异,但是没有像 C++ 协程技术规范(N4680)一样详细描述协程的语法和语义。

协程技术规范(Coroutines TS)向 C++ 语言添加的关键新功能是挂起协程,并允许它们稍后被恢复执行。技术规范为此提供的机制是新的 co_await 运算符。

理解 co_await 运算符的工作方式,将有助于我们揭开协程的神秘面纱,以及理清它们如何被挂起和恢复。在这篇文章中,我将解释 co_await 运算符的机制,并引入与之相关的 AwaitableAwaiter 两个接口类型。

不过,在我详细介绍 co_await 之前,我想首先对协程技术规范做一个预览,以便提供一些背景信息。

协程技术规范给我们带来了什么

  • 三个新的语言关键字:co_await、co_yield 和 co_return。
  • 位于 std::experimental 命名空间中的几个新类型:coroutine_handle

    ,coroutine_traits ,suspend_always,suspend_never。

  • 一种通用机制,库开发者可以用来与协程进行交互,并自定义它们的行为。
  • 一种语言工具,使得编写异步代码变得非常容易。

协程技术规范为 C++ 提供的语言特性可以被认为是用于协程的低级汇编语言。这些特性很难以安全的方式直接使用,主要是供库开发者用于构建可以被应用开发者安全使用的高级别抽象。

计划中是将这些新的低级特性提交给即将到来的语言标准(希望是 C++20),同时在标准库中提供一组更高级别的类型,它们将会封装这些低级组件,从而让应用开发者更容易、 更安全地使用协程。【译注:原文发表于 2017 年 11 月,当时协程还没有被接收进 C++ 标准。 如今,协程已被纳入 C++20 标准中。在这几年里,技术规范和语言特性等已经发生了一些调整, 比如命名空间已经从 std::experimental 改为 std。】

编译器与库的交互

有意思的是,技术规范并没有定义协程的语义。没有定义如何产生返回给调用方的值。 没有定义如何处理传递给 co_return 语句的返回值,或如何处理从协程抛出的异常。也没有定义协程应当在哪一个线程上恢复执行。

相反,它为库代码制定了一种通用的机制,以便库可以通过实现满足特定接口要求的一组类型来自定义协程的行为。然后,编译器会负责生成代码,以调用这些类型的对象实例方法。这种方式跟库开发者通过实现 begin() 和 end() 方法以及一个 iterator 类型来自定义基于范围(range-based)的 for 循环类似。

技术规范没有为协程机制规定任何特殊的语义,这使得它成为一种强大的工具。它允许库开发者为满足各种不同需求而定义许多不同类别的协程。

比如,你可以定义这样的一个协程,它能够异步地产生单个值,或者以延迟的方式生成一系列的值,或者在遇到 nullopt 值时提前退出,以便简化基于 optional 的控制流。

协程技术规范定义了两类接口:Promise 接口和 Awaitable 接口。

Promise 接口提供的方法用于自定义协程本身的行为。库开发者能够自定义协程被调用时会发生什么,在协程返回(正常返回或异常)时要发生什么,以及自定义协程内 co_await 或 co_yield 表达式的行为。【译注:协程的 promise 与 std::promise 是不同的,这将在第三篇文章中进行详细解释。因此,请不要理解为 std::promise。】

Awaitable 接口提供的方法则用于控制 co_await 表达式的语义。当一个值被 co_await 时,源代码会被翻译为对可等待对象(awaitable)方法的一系列调用,这些调用使它可以明确指示:是否挂起当前协程,是否在挂起后执行一些逻辑以便安排协程在稍后恢复执行,以及是否在协程恢复后执行一组逻辑以便生成 co_await 表达式的结果。

我将在后续文章中详细介绍 Promise 接口,现在我们先来看 Awaitable 接口。

Awaiters 和 Awaitables:解释 co_await 运算符

co_await 是一个新的一元运算符,可被应用于一个值。比如:co_await someValue。

co_await 运算符只能在协程上下文中使用。这有点像重言式(tautology),因为按照定义,任何包含了 co_await 运算符的函数体都会被编译为协程。

如果一个类型支持 co_await 运算符,那么它就是一个 Awaitable 类型。

请注意,是否能够将 co_await 运算符应用于一个类型取决于 co_await 表达式所在的 上下文。控制协程行为的 promise 类型可能会通过它的 await_transform 方法更改 co_await 表达式的含义(稍后详细解释)。

为明确起见,当协程的 promise 没有定义 await_transform 方法时,在此类协程上下 文中支持 co_await 运算符的对象类型,使用 Normally Awaitable 来表示。当协程的 promise 定义了 await_transform 方法时,在这类协程上下文中支持 co_await 运算符的对象类型,使用 Contextually Awaitable 来表示。(我愿意接受这两个名称的更好的建议...)

一个 Awaiter 类型是指实现了三个特殊方法的类型:await_ready、await_suspend和 await_resume,对这三个方法的调用是 co_await 表达式的一部分。

请注意,我无耻地(shamelessly)从 C# 异步关键字机制中“借用”了“Awaiter”这一术语。 C# 的异步机制是通过 GetAwaiter()方法返回一个接口对象实现的,而这个接口跟 C++ 中 Awaiter 的概念极为相似。有关 C# awaiters 的更多信息请参见这篇文章【译注: https://weblogs.asp.net/dixin/understanding-c-sharp-async-await-2-awaitable-awaiter-pattern】。

需要注意的是,一个类型可以同时是 Awaitable 类型和 Awaiter 类型。【译注:根据下文的描述和我们的实践,如果我们没有提供自定义的 co_await 运算符重载,那么一个 awaitable 对象就是一个 awaiter 对象。这能够满足许多应用场景的需要。不过,这也可能让人误以为所谓的 awaitable 对象就是实现了 await_ready、await_suspend 和 await_resume 三个方法的对象,因为人们确实可能会直接在 Awaitable 类型上定义这三个方法。在阅读中理解这一点,有助于我们理清作者的思路,以及明确 AwaitableAwaiter 的不同。】

当编译器遇到一个 co_await 表达式时,根据相关类型的不同,源代码会如何被翻译,存在许多种可能性。

获取 Awaiter 对象

编译器要完成的第一件事就是,为被等待的值生成获取 Awaiter 对象的代码。要获取 awaiter 对象,需要完成许多个步骤,这些步骤定义在 N4680 的 5.3.8(3) 中。

假设等待协程的 promise 对象的类型为 P,变量 promise 是对当前协程 promise 对象的一个左值引用。

如果类型 P 上有一个方法 await_transform,那么 首先会被传递给对 promise.await_transform() 的调用,以便能够获取 Awaitable 的值,此值使用变量 awaitable 表示。如果 P 上没有 await_transform 方法,那么直接将 的计算结果作为 Awaitable 使用,同样使用变量 awaitable 表示。【译注:在没有阅读下一篇文章之前, 对此可能不太容易理解。我们先举一个简单例子。通过提供一个 await_transform 方法,我们可以将 std::chrono::duration 或一个 int 转换为一个 awaitable 对象,从而让应用开发者可以用这样的代码实现异步等待 30 秒(30 秒超时计时器):co_await 30s;。与初始化计时器、 启动和停止计时器、分配和管理计时器内存等一系列操作相比,这条语句显然更容易理解,更容易维护,甚至可以不用进行堆内存分配,从而性能也有可能会更好。】

接着,如果 Awaitable 对象(也就是变量 awaitable)上有一个可用的 co_await() 运算符的重载,那么调用此运算符获取 Awaiter 对象。否则,直接将 awaitable 作为 awaiter 对象。

如果我们将以上规则编写为 get_awaitable() 和 get_awaiter() 函数,那么它们看上去可能像这样:

ff6cc5e53a90fe4dad8cc9f162a11742.png
ea5de8c3b1a02d4dffd23b2bbbd17830.png

等待 Awaiter 对象

假设我们已经使用上述函数封装了把 的结果转换为 Awaiter 对象的逻辑,那么 co_await 的语义大体上可以如下翻译:【译注:原文中此部分代码没有注释,下图中的注释是我们“擅自”添加的注释,这段代码是理解 co_await 运算符的最关键的代码。】

b5c67c1de568db42e992daf47c694b02.png

对于 void 版本的 await_suspend,当从 await_suspend() 返回时,执行会被无条件地 转移回调用方或恢复程序。对于 bool 版本的 await_suspend,会有条件地立即恢复协程,而不是返回到调用方或恢复程序。

当异步操作有可能同步完成时,bool 版 await_suspend 就变得非常有用。如果操作是同步完成的,那么 await_suspend() 可以通过返回 false 指示协程应该立即恢复执行。

在 位置处,编译器会生成一些代码来保存协程的当前状态,为其将来恢复执行做准备。具体工作包括保存 的位置,以及将所有寄存器的值保存到协程帧中。

当 处的操作完成以后,当前协程被认为进入挂起状态。此时,你能够观察到被挂起协程的第一个点位于 await_suspend() 方法内部。一旦协程被挂起,接下来它就可以被恢复或销毁。

await_suspend() 方法负责安排协程在未来某时刻异步操作完成后恢复(或销毁)。请注意,从 await_suspend() 返回 false 被看作是安排协程在当前线程上立即恢复。

如果事先已知操作会同步完成,而不需要挂起协程,那么可以通过 await_ready() 方 法来避免执行 所带来的开销。

在 点处,执行会被转回给调用方或恢复程序,本地堆栈帧会被弹出,而协程帧将继续保留。

当(或如果)协程最终恢复时,执行会在 处恢复,也就是在调用 await_resume() 获取操作结果之前的位置。

await_resume() 方法的返回值会成为 co_await 表达式的结果。await_resume() 方法也可以抛出异常,此时异常将被传播到 co_await 表达式之外。

请注意,如果 await_suspend() 抛出了异常,那么协程会被自动恢复,同时异常会被 传播到 co_await 表达式之外,但不会执行 await_resume()。

协程句柄

你可能已经注意到了 coroutine_handle

类型的实例被传递给了 await_suspend() 方法。

该类型表示了一个不由协程帧所有的句柄,可被用于恢复协程的执行,或销毁协程帧。 还可以用于访问协程的 promise 对象。

coroutine_handle 具有如下的接口定义(已精简):

1e31f95d8fd520af61084173408e1775.png

在实现 Awaitable 类型时,你主要使用的协程句柄方法是 .resume()。当异步操作完成,并且你希望恢复协程的执行时,需要调用此方法。调用协程句柄上的 .resume() 方法会在 点处重新激活已挂起的协程 。当协程下一次执行到 时,对 .resume()的调用才会返回。

.destroy() 方法则会销毁协程帧,析构作用域内每一个变量,释放协程帧占用的内存。 除非你是实现协程 promise 类型的库开发者,否则通常不需要(实际上应该避免)调用 .destroy()。一般的,协程帧会被某个 RAII 类型所有,这个 RAII 是在调用协程时返回的。 因此,在没有与 RAII 对象协作的情况下调用 .destroy() 会导致重复释放的错误。

.promise() 方法返回协程帧 promise 对象的一个引用。同样的,跟 .destroy() 一样,一般只有实现 promise 类型的库开发者才会使用此方法。你应当将协程的 promise 对象看作是协程的内部实现细节。对于大部分的 Normally Awaitable,你应当使用 coroutine_handle 作为 await_suspend() 方法的参数类型,而不是使用 coroutine_handle。

coroutine_handle

::from_promise(P& promise) 函数能够让我们从协程 promise 对象引用中重新构造协程句柄。请注意,你必须确保类型 P 严格匹配协程帧实际使用的 promise 类型。如果实际使用的 promise 类型是 Derived,但却试图构造一个 coroutine_handle 对象,就会导致未定义的行为。

.address() 和 from_address() 这两个函数则允许我们在协程句柄和 void* 指针之间相互转换。这主要是为了可以将协程句柄作为“上下文”参数传递给 C API,在某些情况下,你可能会发现这对实现 Awaitable 类型非常有帮助。然而,我发现大多数情况下都需要在传递给回调函数的这个“上下文”参数中包含其他的额外信息。因此,我一般都是将协程句柄保存在一个结构中,然后在“上下文”参数中传递该结构的一个指针,而不是使用 .address() 方法的返回值。

无需同步的异步代码

这样设计 co_await 运算符带来的强大功能之一是,在协程被挂起之后,而执行回到调用方或恢复程序之前,我们能够执行一些代码。【译注:在 await_suspend(handle) 方法中。】

这就让 Awaiter 对象在确保协程已经挂起的情况下才会初始化一个异步操作,并将已挂起协程的句柄传递给该异步操作。这样,在异步操作完成(可能在另一个线程上)时,协程可以被安全地恢复,而不需要提供额外的同步机制。

例如,在协程已经挂起的情况下,通过在 await_suspend() 内部启动一个异步读取操作,我们就可以在异步读取完成时简单恢复协程的执行即可,而不需要在启动读取的线程和完成读取的线程之间进行线程同步工作。【译注:关于下面两张图,第一张图是作者原文截图,第二张图是我们翻译调整后的图。两张图除了中英文不同之外,时序上也有差异。原图可能会让人误以为线程 1 的后半部分是在线程 2 所有工作完成后才开始,实际上这只是可能性之一,作者接下来的三段文字也做了特别强调。请将我们附加的图示看作是对其他可能性的补充。不过原图更清晰地表明了协程的恢复和销毁可能发生在从 await_suspend() 返回之前,从而提醒我们一旦启动了异步操作,那么在 await_suspend() 内访问一些资源是不安全的。】

85ed0a8d08651eac084c76b28a110892.png
9774771bd194fd61d1e8d6e98f9667f1.png

在使用这种方式时需要特别注意的是,一旦你在启动异步操作时将协程句柄传递给了其他线程,那么另一个线程就可以在 await_suspend() 返回之前在该线程上恢复目标协程,并且恢复后的协程可以与 await_suspend() 的剩余部分并行地执行。

协程恢复后的第一件事情就是调用 await_resume() 获取结果,接着通常会立即析构销毁 Awaiter 对象(也就是 await_suspend() 的 this 指针)。然后协程结束运行,销毁协程和 promise 对象,而这一切都有可能发生在 await_suspend() 返回之前。

因此,在 await_suspend() 内部,一旦协程有可能在另外一个线程上并行地恢复,那么你就必须确保避免访问 this 指针以及协程的 .promise() 对象,因为这两个对象都有可能已经被销毁。通常,在异步操作启动且协程已被安排恢复之后,唯一可以安全访问的内容是 await_suspend() 内部的局部变量。

与有栈协程的对比

我想快速拐个小弯,就协程技术规范定义的无栈协程在挂起后执行附加逻辑的能力,与一些常用的有栈协程工具做个对比,比如 Win32 fibers 或 boost::context。

在许多有栈协程框架中,一个协程的挂起操作会与另一个协程的恢复操作合并到一个“上下文切换”操作中。通常,在这种上下文切换操作中,我们没有机会在当前协程挂起之后,而另一个协程恢复之前执行附加逻辑。

这就意味着,如果我们想要在有栈协程上实现一个类似的异步文件读取操作,就必须在协程挂起之前启动该操作。这就可能造成在协程挂起和具备恢复条件之前,异步操作就已经在另一个线程上完成。异步操作在其他线程上完成,与当前协程挂起操作之间潜在的竞争关系,就需要我们提供某种线程同步机制来进行仲裁,并决定获胜者。

可以使用蹦床上下文(trampoline context)来解决此问题,在初始上下文被挂起后,蹦床上下文可以代表初始上下文启动异步操作。然而,这需要额外的基础设施和额外的一次上下文切换才能正常工作。这种方式带来的开销可能大于其试图避免的同步开销。

避免内存分配

异步操作通常需要保存一些与操作相关的状态,以便能够跟踪操作的进度。这些状态一般会在整个操作过程中保持有效,且仅应当在操作完成后才能释放。

例如,在调用异步 Win32 I/O 函数时,你需要分配并传递一个指针给一个 OVERLAPPED 结构。调用方负责确保该指针在操作结束前一直有效。

在传统基于回调函数的 API 中,一般会在堆上为操作状态分配内存,以确保其具有恰当的生命周期。如果需要执行大量的操作,那么就必须为每一个操作分配和释放状态。如果性能是需要操心的事儿,那么可以使用一个自定义的内存分配器从内存池中分配这些状态对象。

而当我们使用协程时,可以充分利用协程帧内的局部变量在协程挂起期间会一直留存这一事实,来避免在堆上为操作状态分配存储空间。

通过将操作状态保存到 Awaiter 对象中,我们就可以在 co_await 表达式持续期间,有效地从协程帧“借用”内存来保存操作状态。一旦操作完成,协程恢复执行,Awaiter 对象被销毁,这部分协程帧内存就可以释放,以供其他局部变量使用。

协程帧终究仍然有可能在堆上分配。然而,一旦被分配,这个通过单次堆分配创建的协程帧就可以被用于执行许多次异步操作。

仔细想一下,你会发现协程帧就像一种真正高性能的运动场(arena)内存分配器。编译器在编译时计算出所有局部变量所需要的总的内存大小,然后在零开销的情况下按需将内存分配给局部变量。试着用自定义的牛逼分配器击败它吧:)

示例:实现一个简单的线程同步原语

既然我们已经学习了 co_await 运算符的许多机制,我想通过实现一个基础的可等待同步原语——一个异步手动重置事件(manual-reset event)——来介绍如何将这些知识应用于实践当中。

对该事件的基本需求是:它应当可被多个并行执行的协程等待,所以要实现 Awaitable 接口,并且一旦被某个协程等待,该协程就要被挂起,直至某个线程调用了 .set() 方法,此时正在等待该事件的所有协程都将恢复执行。如果某一个线程此前已经调用了 .set() 方法,那么协程应该继续执行而不是被挂起。

最好,我们希望它是 noexcept 的,不需要从堆上分配内存的,并且是无锁实现的。

修订于 2017-11-23:添加了 async_manual_reset_event 的用法示例。

其用法看上去应如下所示:

43f19a5b53cb8149ffe12d8c0903f893.png

我们首先来考察此事件可能的状态:“未设置(not set)”和“已设置(set)”。

当事件处在“未设置”状态时,一个列表(可能为空)中的多个等待协程正在等待该事件进入“已设置”状态。

当事件处在“已设置”状态时,不会有任何协程等待它,因此 co_await 该事件的所有协程都会继续执行而不是挂起。

这两种状态可通过单个 std::atomic 实现。

  • 使用一个特殊的指针值表示“已设置”状态。在这里我们使用事件的 this 指针作为这个特殊值,因为我们知道这个值与等待协程列表中的任何一个协程项的地址都不同。
  • 除此之外,事件处在“未设置”状态,此时 std::atomic 的值是所有等待协程数据结构构成的链表头节点的指针。

通过将链表节点保存在位于协程帧的“awaiter”对象中,我们就可以避免额外在堆中为链表节点分配内存。

让我们从定义如下所示的类接口开始:

f69890e78d29ec8a9cdc963bf71571e1.png

这是一个相当简洁明了的接口。此时此刻,最需要关注的部分是,该接口有一个 co_await() 运算符方法,该方法返回一个我们还没有定义的类型:awaiter。

现在,我们来定义这个 awaiter 类型。

定义 Awaiter

首先,它需要知道哪一个 async_manual_reset_event 对象即将被等待,所以 awaiter 需要一个对事件对象的引用,并通过一个构造函数来初始化它。

其次,awaiter 还是链表中的节点,因此,它需要有一个指向链表中下一个 awaiter 对象的指针。

然后,awaiter 还需要保存执行了 co_await 表达式的等待协程的句柄,以便在事件进入“已设置”状态时能够恢复协程。我们不关心协程的 promise 是何种类型,因此我们仅使用 coroutine_handle<>(coroutine_handle 的简写)来表示协程句柄。

最后,它需要实现 Awaiter 接口,也就是它要实现三个特殊方法:await_ready,await_suspend 和 await_resume。我们不需要从 co_await 表达式返回值,因此 await_resume 只需要返回 void 即可。

我们把这些组合在一起后,基本的 awaiter 接口就长成如下的样子:

84e7f924faf047de1d913d51c05c1f40.png

现在,当我们 co_await 一个事件时,如果事件已经被设置,那么我们的协程不应该被挂起。因此,在事件已经被设置的情况下,我们让 await_ready 返回 true。

de596965e2ba9a8e27acf54b2827e5b5.png

接下来,我们开始实现 await_suspend 方法。此方法通常汇聚了一个 awaitable 类型的主要魔法。

首先,它需要将等待协程的句柄保存到 m_awaitingCoroutine 成员变量中,以便事件接下来能够调用句柄上的 .resume() 方法。

然后,一旦完成上述工作,我们就需要尝试原子地将 awaiter 对象入队到链表中。如果我们成功地将 awaiter 添加到了链表队列中,那么就返回 true,表明我们不希望立即恢复协程。否则,如果我们发现事件已经被并行地转变为“已设置”状态,那么就返回 false,表明协程应该立即恢复执行。

4d5147d6ef5576b03d81d469814451be.png

请注意,在加载旧状态时我们使用了“获取”(acquire)这一内存顺序,这样,如果我们读取的是“已设置”状态值,那么我们就能够注意到在调用“set()”之前发生的写入操作。

如果 compare-exchange 操作成功,为了让后续对“set()”的调用能够注意到我们对 m_awaitingCoroutine 的写入以及先前对协程状态的写入,我们需要使用“释放”(release)语义。

实现事件类的其余部分

现在,我们已经定义了 awaiter 类型,让我们回头看看对 async_manual_reset_event 上方法的实现。

首先是构造函数。它要么使用一个空的等待列表(即 nullptr)将事件初始化为“未设置”状态,要么使用 this 指针将事件初始化为“已设置”状态。

621290dc5274aebfa687ee10b62f39f0.png

接下来是 is_set()方法,此方法的实现比较简单直接,如果状态值为特殊值this,那么状态就是“已设置”。

247dfd7387dc6b2a363850bc02e5de15.png

再接下来是 reset() 方法。如果当前状态是“已设置”,那么就转换为空列表表示的“未设置”状态,否则保持不变。

454198ec2d86b41b618cd229207029be.png

对于 set() 方法,我们希望通过将当前状态与特殊值 this 进行交换来将事件转换为“已设置”状态,然后检查交换前的原始值。如果存在正在等待的协程,那么在从 set() 返回前,依次恢复每一个协程。

33ecf2ec164ecaac90eb127fc8b28b6c.png

最后,我们需要实现 co_await() 运算符方法。只需要构造并返回一个 awaiter 对象即可。

2ff9d70278423d0feb386d2aa5e18c9d.png

大功告成。一个可等待的、无锁的、无需分配内存的、noexcept 的异步手工重置事件就实现完成了。

如果你想试用此代码,或查看其在 MSVC 和 Clang 编译器下的编译结果,请查看 godbolt 上的源代码。【译注:https://godbolt.org/g/Ad47tH】

你还可以在 cppcoro 库中找到此类的实现,该库还提供了许多其他有用的可等待类型,例如 async_mutex 和 async_auto_reset_event。

结语

这篇文章探讨了如何根据 Awaitable 和 Awaiter 的概念来实现和定义 co_await 运算符。

同时还演练了如何实现一个可等待的异步线程同步原语,该原语充分利用在协程帧上分配 awaiter 对象这一事实,避免了额外的堆内存分配。

我希望这篇文章能够帮助你揭开 co_await 运算符的神秘面纱。

在下一篇文章中,我将探索 Promise 的概念,以及协程库开发者如何自定义协程的行为。

致谢

我要特别感谢 Gor Nishanov 在过去几年里耐心而热情地回答了我许多有关协程的问题。

同时请 Eric Niebler 对这篇文章的早期草稿进行审查并提供反馈。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值