C++20 coroutine 探索II: coroutine_traits & await_transform 的用法,boost asio 中 coroutine 的用法

17 篇文章 11 订阅
12 篇文章 2 订阅

经过之前对 coroutine 的思路整理(额,实际并没有完成完整的一篇),我基本明白了一个事实(这个文章比我潦草本地笔记整理得好所以复习概念就看他了:C++20 协程初探 | Netcan on Programming, 不过主要的调用流程伪代码(约等于编译器生成代码)参考还是 co_await 流程协程调用流程,然后实际的细节指定就是看 cppreference  Coroutines (C++20) - cppreference.com 就行了)。


基本概念

  • Promise Type 实际是一个 implicit frame 来的,所有的资料都会通过计算后封到 promise 类型里然后丢到堆上。
  • 而 Task 即 Future Type 实际是用来实现交互的,由于协程并不能运行完在返回,而是调用一次到检查点就结束了,所以返回一个 Future 以便将来获取一个值。
  • await 是异步调用同步化,Awaitable 是转移控制权到另一个控制流,包括同步,阻塞什么的。
  • 澄清一下 future 和 promise 的区别吧, future 是一个将来能得到值的东西,他是一个 promise 的对外只读视图。而 promise 是一个沟通桥梁,一边读一边写。
  • worker/producer 工作在 promise 上面,一旦工作完成,就会 notify future,然后 comsumer 就可以从 future 上取值了。就这样而已。
  • 写 C# 的时候习惯 Task 的概念了,总之 Task 就是 Future,promise 在 C# 是 TaskCompletionSource。

查阅备用

R::promise_type

  • 必选成员函数:
R get_return_object();

某个Awaitable initial_suspend();

某个Awaitable  final_suspend() noexcept ;

void unhandled_exception() ;
  • 可选成员函数:
std::suspend_always yield_value(unsigned value)

void return_void() ;

void return_value(T);

Awaitable 定义

  • 什么这是?:Awaitable 是一个东西,这个东西能得到一个 awaiter,或者这个东西本身是一个 awaiter。这个东西能被 co_await 关键字调用(隐式转换成 awaiter 然后被调用,涉及 await_transform 的复杂一些)。
  • 编译允许:如果一个表达式能够被转换成 awaitable ,他会被转换为 awaitable 然后被转换成 awaiter。

Awaiter 定义

  • 什么这是?:Awaiter 是一个东西,which 能被  co_await, co_yield, co_return 这样的关键字调用。
  • 编译允许:Awaitable 是一个东西,which 必须有 3 个成员函数,他们会在这个 awaitable 的对象被 await 的时候被调用。
  • 成员函数:
bool await_ready()  noexcept ;

void/bool await_suspend(std::coroutine_handle<> h) ;

void await_resume()  noexcept;

T await_resume()  noexcept; (可选)

Coroutine 定义

  • 什么这是?:Coroutine 是一个 routine
  • 编译发现:Coroutine 里面里面使用了 co_await, co_yield, co_return 这样的关键字调用一个 awaitable 对象。
  • 签名返回值:协程的返回值必须是一个 R,which 有一个 R::promise_type 的结构体成员,which 有 3 个必须的成员函数以及一些可选的成员,他们会在协程运行的过程中被调用。编译时通过 C++20 concept requires 关键字进行检查(这个东西叫做 coroutine_traits, 和之前的 type traits 的原理差不多)如果没有就无法编译。
  • 实际返回值:一个 R,只要执行到检查点 co_await, co_yield, co_return )时就把 R 返回。
  • 参数:随便
  • 编译器干的活:编译器会在编译的时候会编译成把 coroutine 的栈放到堆上(可以提前算好避免动态分配)。编译器在执行他的时候会插入很多东西(就像析构、 copy constructor 等那些东西的调用一样)。

coroutine_handle<P> 定义

  • 什么是这?:这个东西是一个 current continuation,可以认为是一个某个点之后的程序控制流封装成的一个协程的闭包。也可以认为这个东西是一个 context switch 的一整套寄存器 frame 的存档。其实就是一个存档点。
  • PP 必须是一个协程的返回值的子类型 R::promise_type 或者 void. P 默认是 void。
  • 成员函数:
::promise(): 如果 P 不为 void,那么就能转换得到一个 P 类型。

::resume(): 把这个协程闭包从当前调用点调用(恢复执行),等价于一个 context switch。

::destroy(): 如果这个协程没有在运行(suspend 状态),那么删除他(清理堆上的数据结构)。

我接下来看 boost 是怎么用 C++20 这些“协程汇编”实现高级协程和异步编程的。

问题

首先我草草地用 coroutine 写了一个实验程序,生产者消费者,然后遇到了一些问题没有理解最好的做法。

// 非常幼稚的写法
#include <coroutine>
#include <iostream>
#include <thread>
#include <vector>
#include <memory>
#include <mutex>
#include <condition_variable>
#include <functional>

struct Pool {
  bool stoped{false};
  std::vector<int> fds_;
  std::mutex mx_;
  std::condition_variable cv_;
  int fetch() {
    std::unique_lock<std::mutex> lk(mx_);
    while (true) {
      if (stoped) {
        return -1;
      }
      if (fds_.empty()) {
        cv_.wait(lk);
      } else {
        break;
      }
    }
    auto ret = fds_.back();
    fds_.pop_back();
    return ret;
  }
  void stop() {
    stoped = true;
    cv_.notify_all();
  }
  void put(int i) {
    std::lock_guard<std::mutex> lk(mx_);
    fds_.push_back(i);
    cv_.notify_one();
  }
  Pool() : mx_{}, cv_{} { fds_.reserve(20); }
} fd_pools[4];

struct Task {
  struct promise_type {
    Task get_return_object() { return {}; }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
  };
};

struct async_opt_t {
  int pool_index_;
  int cache_;
  constexpr bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<Task::promise_type> h) {
    cache_ = fd_pools[pool_index_].fetch();
    h.resume();
  }
  constexpr void await_resume() const noexcept {}
  int await_resume() { return cache_; }
};

struct Acceptor {
  async_opt_t async_accept{3};
  Task EventLoop() {
    for (int i = 0; !fd_pools[3].stoped; i = (i + 1) % 3) {
      int fd = co_await async_accept;
      if (fd != -1) {
        std::cout << "accept a fd: " << fd << std::endl;
        fd_pools[i].put(fd);
      }
    }
    std::cout << "acceptor exit\n";
  }
  Acceptor() {}
};

struct Connector {
  int id;
  async_opt_t async_read;
  Task EventLoop() {
    for (; !fd_pools[id].stoped;) {
      int fd = co_await async_read;
      if (fd != -1) {
        std::cout << "client " << id << " read a fd: " << fd << std::endl;
      }
    }
    std::cout << "client " << id << " exit\n";
  }
  Connector(int i) : id(i), async_read{i} {}
};

int main() {
  Connector c0(0), c1(1), c2(2);
  Acceptor ac;
  std::jthread ths[4]{std::jthread(std::bind(&Connector::EventLoop, &c0)),
                      std::jthread(std::bind(&Connector::EventLoop, &c1)),
                      std::jthread(std::bind(&Connector::EventLoop, &c2)),
                      std::jthread(std::bind(&Acceptor::EventLoop, &ac))};
  for (int i = 0; i < 15; i += 3) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    fd_pools[3].put(i + 1);
    fd_pools[3].put(i + 2);
    fd_pools[3].put(i + 3);
  }
  std::cout << "end all\n";
  std::this_thread::sleep_for(std::chrono::seconds(4));
  fd_pools[0].stop();
  fd_pools[1].stop();
  fd_pools[2].stop();
  fd_pools[3].stop();
  // 因为没有捕获 handle,没有写 destroy(), 所以内存泄漏了。
  std::cout << "send stop\n";
  return 0;
}
  • 协程在哪里开始运行和恢复?目前我开了 jthread (jthread 会析构时 join)来做。
  • 怎么控制协程的状态,比如这里要做一个 descriptors pool 就算协程是一个对象,我也只能把 pool 放出去方便别人访问。但是既然整个帧实际是存在 promise_type 里面的,能不能直接在 Task 和 promise_type 里面做数据成员然后给暴露出来(实际也不是暴露,可能挂一些指针或者引用)。
  • await_suspend 到底要做什么??我目前是让 waitable 就真的 block 了,所以实际还是要做很多同步的东西。然后 block 结束之后返回。
  • 怎么停止,我现在的做法是直接让他 suspend 或者到达结束点(有 co_return 和 没有 co_return 效果是一样的),然后 destroy 他。

Boost 中的 Executor

首先是 executor 的概念,这个东西上次看到还是在 boost 里面,新的 C++ 提案就有 executor,他的意思是为了提供一个泛型的抽象(就像 stl container 抽象了容器接口分离实现那样)给异步执行控制流。至于这个异步是多线程还是单线程(前面讲过异步可以单线程实现也可以多线程,然后协程之间不断地跳转本质上可以等同于不用 preempt 的单核多线程的 time slicing),都抽象掉了。


coroutine_traits

既然协程的内容都保存在 promise 里面,而 Task(future) 又被绑定了 promise,所以实际为什么不把东西就存在这里面呢。我一开始想这样做,但是这样就发现必须要通过 co_await 才能捕获这个 Task/Promise。遂作罢,结果发现 boost 的确是这样用的:通过 co_await 调用 executor (一个空对象,他的行为定义在 awaitable 的 await_transform 里面)来捕获一个 Task(Promise),然后返回一个给这个协程用的 executor (即一个针对协程实现异步的 executor)。

// boost asio 代码库 example/cpp17/coroutines_ts/echo_server.cpp

awaitable<void> listener() {
  auto executor = co_await this_coro::executor;
  tcp::acceptor acceptor(executor, {tcp::v4(), 55555});
  for (;;) {
    tcp::socket socket = co_await acceptor.async_accept(use_awaitable);
    co_spawn(executor, echo(std::move(socket)), detached);
  }
}

这个先看看 boost 是怎么实现的他的这个 awaitable 的。这里涉及了修改了 coroutine_traits 所以首先要看 coroutine_traits 怎么修改,让 coroutine 的返回值不用是  Task 的。(在 echo server 的 demo 里面,一个协程可以返回一个 awaitable, boost 做成了一个 awaitable 可以是 the return type of a coroutine or asynchronous operation.)

其实很简单,根据 n4736 和 cppreference 的说法,只需要特化了 coroutine_traits 的成员 using promise_type 的指向就行了,看看重定向到一个有那些 init_suspend 成员的类型才行。

基本的 traits (possible impl):

// ill-formed
template<class, class...>
struct coroutine_traits {};

// default
template<class R, class... Args>
requires requires { typename R::promise_type; }
struct coroutine_traits<R, Args...> {
  using promise_type = typename R::promise_type;
};

可变参数模板是 parameter types of the coroutine, including the implicit object parameter if the coroutine is a non-static member function ,这样可以用来提取参数。所以 coroutine 可以返回一个 void 的,特化  struct coroutine_traits<void, T...> 即可。

boost 的 awaitable 特化是这样的:

// boost asio 代码库 include/boost/asio/impl/awaitable.hpp

template <typename T, typename Executor, typename... Args>
struct coroutine_traits<boost::asio::awaitable<T, Executor>, Args...>
{
  typedef boost::asio::detail::awaitable_frame<T, Executor> promise_type;
};

这里 awaitable 是 coroutine 的返回值,Executor 是用来 impl 一个异步执行控制流的接口的类。

比如这里的 echo 的写法:

awaitable<void> echo(tcp::socket socket) {

这样匹配的时候,T 和 Executor 都是 void,Args 则匹配到 tcp::socket

其他的用法 (std::coroutine_traits - cppreference.com )还有直接在 traits 里面定义一个 struct promise_type 这样就能非侵入地或者无声引入一个原来的类型让他也支持 coroutine 返回值了。


awiat_transform

然后吧 awiat_transform 给理解了先,这个东西是说,一般获取 Awaiter 之前要先获取 Awaitable,这个时候如果 promise 支持 transform ,就会通过调用 transform 来转化 expr,而不是直接运行 expr (当然这个 expr 实际是 co_await raw_expr 的 raw_expr 的求值的了)。

// 当执行 co_await 之前要用到的伪代码函数(可以认为是编译器生成这些东西)

template<typename P, typename T>
decltype(auto) get_awaitable(P& promise, T&& expr)
{
  if constexpr (has_any_await_transform_member_v<P>)
    return promise.await_transform(static_cast<T&&>(expr));
  else
    return static_cast<T&&>(expr);
}

template<typename Awaitable>
decltype(auto) get_awaiter(Awaitable&& awaitable)
{
  if constexpr (has_member_operator_co_await_v<Awaitable>)
    return static_cast<Awaitable&&>(awaitable).operator co_await();
  else if constexpr (has_non_member_operator_co_await_v<Awaitable&&>)
    return operator co_await(static_cast<Awaitable&&>(awaitable));
  else
    return static_cast<Awaitable&&>(awaitable);
}

(上面代码来自:co_await 流程

这样有一个好处是可以根据返回值 Task 的 promise 来隐式转换一个 await expr 的 expr 到一个 awaitable。而之后 awaitable 转为 awaiter 则是可以通过 co_await 运算符或者显式转换/隐式转换(比如定义一个 operator Awaitable 转换)到一个 awaiter(捕获 handle 3个接口的思实现者)。


然后我再回来看这个 executor_t 的 transform 是怎么 transform 成获取一个能给当前 coro 搞到一个 executor 的实现的。

捕获 Task, Promise

awaitable<void> listener() {
  auto executor = co_await this_coro::executor;

首先由于我们的目的是捕获一个 Task 或者 promise 的指针而已(解决上面问题中描述的:怎么控制协程的变量),所以这里不用阻塞,直接 ready 避免打包。然后把捕获到的东西(这里捕获了一个 promise !)放到 awaiter 的一个成员变量里返回 awaiter(这个时候 awaiter 就是一个指针指向这个捕获了的协程,即上面的 listener)。(重复一下前面的分析到的地方:通过 co_await 调用 executor (一个空对象,他的行为定义在 awaitable 的 await_transform 里面)来捕获一个 Task(Promise),然后返回一个给这个协程用的 executor (即一个针对协程实现异步的 executor)。

//  源码文件仍然是 awaitable.hpp:: awaitable_frame_base
// This await transformation obtains the associated executor of the thread of
// execution.
// 这个是 awaitable_frame_base 的成员函数,别忘了 awaitable 已经被偏特化为一个 Task 了, 而
// 他的 promise 是设定为 awaitable_frame 。
// 这个 awaitable_frame_base 就是承载 promise_type 即 awaitable_frame 的基类,实现了 
// initial_suspend() 那些 promise_type 的接口

  auto await_transform(this_coro::executor_t) noexcept {
    struct result {
      awaitable_frame_base* this_;
      bool await_ready() const noexcept{
        return true;
      }
      void await_suspend(coroutine_handle<void>) noexcept{}
      auto await_resume() const noexcept{
        return this_->attached_thread_->get_executor();
      }
    };
    return result{this};
  }

走完这段代码之后,根据流程的伪代码,下面复习一下整个 co_await expr 被调用的时候流程的伪代码先:

// https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
{
  auto&& value = <expr>;
  auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
  auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
  if (!awaiter.await_ready()){
    using handle_t = std::experimental::coroutine_handle<P>;
    using await_suspend_result_t =
      decltype(awaiter.await_suspend(handle_t::from_promise(p)));
    <suspend-coroutine>
    if constexpr (std::is_void_v<await_suspend_result_t>){
      awaiter.await_suspend(handle_t::from_promise(p));
      <return-to-caller-or-resumer>
    }
    else{
      static_assert(
         std::is_same_v<await_suspend_result_t, bool>,
         "await_suspend() must return 'void' or 'bool'.");
      if (awaiter.await_suspend(handle_t::from_promise(p))){
        <return-to-caller-or-resumer>
      }
    }
    <resume-point>
  }
  return awaiter.await_resume();
}

我们现在走到 awaiter 获取成功的那一行了,经过 await_transform 之后,我们得到了一个 awaiter,这个 awaiter 是:

    struct result {
      // 别忘了 awaitable 已经偏特化为一个 Task 了
      // 这时候这个成员已经捕获到了一个 Task 的 promise (awaitable_frame_base)了
      awaitable_frame_base* this_; 
      bool await_ready() const noexcept{
        return true;
      }
      void await_suspend(coroutine_handle<void>) noexcept{}
      auto await_resume() const noexcept{
        return this_->attached_thread_->get_executor();
      }
    };

之后他马上就会返回,然后就会获取到一个 executor,从而回到这里:

awaitable<void> listener() {
  auto executor = co_await this_coro::executor;

所以这个 attached_thread 和 executor 又是什么?


boost asio 的 await async_accept 实现

前面说了 executor 就是用来抽象一个异步控制流的。由于使用了协程,我们希望的是让线程不要空闲下来,当然不应该让线程阻塞,前面我让线程阻塞的做法根本没有利用上协程的精髓。

要实现这样的调度,首先想到的就是让 awaitable 本身也是一个协程,这样每次调用 co_await 的时候就会挂起当前协程,然后进入到某个 awaiter 的 await_suspend 成员去,然后 await_suspend 可以捕获一个 continuation,但是我们之前已经捕获到了 Task 了,而 handle 和 promise 可以互相转换。这里 boost 的全部 await_suspend (除了最后一层的 final_suspend ) 都没有捕获 continuation(handle),而是直接留空的。那么具体的流程到底是怎么执行的呢?

首先是 boost asio 的这个实现方法,他把 awaitable 就做成是协程的返回值,以及一些捕获工作。而具体的 async 操作都是通过 promise 的 await_transform 来做的。

// An awaitable_thread represents a thread-of-execution that is composed of one
// or more "stack frames", with each frame represented by an awaitable_frame.
// All execution occurs in the context of the awaitable_thread's executor. An
// awaitable_thread continues to "pump" the stack frames by repeatedly resuming
// the top stack frame until the stack is empty, or until ownership of the
// stack is transferred to another awaitable_thread object.
//
//                +------------------------------------+
//                | top_of_stack_                      |
//                |                                    V
// +--------------+---+                            +-----------------+
// |                  |                            |                 |
// | awaitable_thread |<---------------------------+ awaitable_frame |
// |                  |           attached_thread_ |                 |
// +--------------+---+           (Set only when   +---+-------------+
//                |               frames are being     |
//                |               actively pumped      | caller_
//                |               by a thread, and     |
//                |               then only for        V
//                |               the top frame.)  +-----------------+
//                |                                |                 |
//                |                                | awaitable_frame |
//                |                                |                 |
//                |                                +---+-------------+
//                |                                    |
//                |                                    | caller_
//                |                                    :
//                |                                    :
//                |                                    |
//                |                                    V
//                |                                +-----------------+
//                | bottom_of_stack_               |                 |
//                +------------------------------->| awaitable_frame |
//                                                 |                 |
//                                                 +-----------------+

首先看回 listener 的 async_accept 吧,这个是一个异步函数调用,

awaitable<void> listener() {
  auto executor = co_await this_coro::executor;
  tcp::acceptor acceptor(executor, {tcp::v4(), 55555});
  for (;;) {
    tcp::socket socket = co_await acceptor.async_accept(use_awaitable);

通过代码跟踪,首先这个 use_awaitable 是作为一个 accept handler 传给 async_accept 的,而 async_accept 是 boost asio 里面实现异步注册然后返回的一个东西。之后完成了就会调用这个 accept handler。那么这里传了一个 use_awaitable 进去是干什么呢?

我们定位到这个 asycn_accept 函数去一探究竟:

  async_accept(BOOST_ASIO_MOVE_ARG(MoveAcceptHandler) handler)
  {
    return async_initiate<MoveAcceptHandler,
      void (boost::system::error_code, typename Protocol::socket)>(
        initiate_async_move_accept(), handler, this,
        impl_.get_executor(), static_cast<endpoint_type*>(0),
        static_cast<typename Protocol::socket*>(0));
  }

(这里因为看的 boost asio 源码版本的问题出了差错,对于新版(1.74)的确是只是返回一个函数对象然后 transform 的,上面贴的代码是旧版的会直接运行到 AcceptEx)可以发现这个只是立马返回了一个 initiate 函数给调用者,意味着调用者(这里是 listen 里面的 await)保证将会自己调用这个异步初始化(我们知道异步初始化的工作无非就是创建一个请求,加入到 io_uring 的 SQ 或者 IOCP 的请求或者 asio 自己用 epoll 模拟的)。

前面讲到那个 await_transform 的作用,可以把任何一个不是 awaiter 的对象搞成 awaiter,对于这里的这个 assync_accept 返回值是一个函数, 他的 await_transform 是这样的:

  // This await transformation is used to run an async operation's initiation
  // function object after the coroutine has been suspended. This ensures that
  // immediate resumption of the coroutine in another thread does not cause a
  // race condition.
  template <typename Function>
  auto await_transform(Function f,
      typename enable_if<
        is_convertible<
          typename result_of<Function(awaitable_frame_base*)>::type,
          awaitable_thread<Executor>*
        >::value
      >::type* = 0)
  {
    struct result
    {
      Function function_;
      awaitable_frame_base* this_;

      bool await_ready() const noexcept
      {
        return false;
      }

      void await_suspend(coroutine_handle<void>) noexcept
      {
        function_(this_);
      }

      void await_resume() const noexcept
      {
      }
    };

    return result{std::move(f), this};
  }

可以看到,suspend 之后并没有捕获 handle(前面说过了,这个 handle 不重要,因为我们之前已经知道可以在   auto executor = co_await this_coro::executor; 的时候捕获到他的 promise (通过 this 指针,神奇!))。

所以关键点就是他马上转而去运行这个注册 IO 请求的事情。但是!他并没有调用 resume 哦!实际的 resume 还是等到 completion 才 resume。那么这个 resume 发生在哪里呢?很容易想到就是在上面说的 use_awaitable 里面。

所以我们去看这个东西吧。结果发现他又是空的,只是充当前面讲 POSAv2 的 ACT 模式里面的 Completion Token,并不是什么 callback handler。


/// A completion token that represents the currently executing coroutine.
/**
 * The @c use_awaitable_t class, with its value @c use_awaitable, is used to
 * represent the currently executing coroutine. This completion token may be
 * passed as a handler to an asynchronous operation. For example:
 *
 * @code awaitable<void> my_coroutine()
 * {
 *   std::size_t n = co_await my_socket.async_read_some(buffer, use_awaitable);
 *   ...
 * } @endcode
 *
 * When used with co_await, the initiating function (@c async_read_some in the
 * above example) suspends the current coroutine. The coroutine is resumed when
 * the asynchronous operation completes, and the result of the operation is
 * returned.
 */
template <typename Executor = any_io_executor>
struct use_awaitable_t
{

是我后面发现了 use_awaitable.hpp 里面还有一个

想到 pump 是不断地摘掉 frame 的 bottom,然后运行 resume (实际是为了保证 await 了里面又 await 了里面又 await 了(因为他把 task 也做成了 awaitable)),我猜测是这样的,async 事件 complete 的时候,会强制转换出一个 awaitable_handler 出来,然后就可以调用了。

对于 cancel 的事件,全部删除掉。然后运行 pump。

所以这个就是为了保证能够把所有的 awaitable (Task + awaiter)都能执行完整。

那么 boost asio 分析今天就先到这里了,具体到底怎么运转还要进一步分析,我去看看有没有讲 impl 的文档吧。

结论是,要让协程自己也是一个 awaitable / awaiter 就要自己实现栈和逐层 resume,这个实在是太难做了!

想起 C# 里面 Task 返回值本身也标注为 async (差不多也是 awaitable 的意思吧,里面用了 await)从而能实现一个管道化和流水线的 await,即 await 一个 async Task 本身里面也有一堆 await,没想到实现起来这么复杂。

这下体会到的确是协程的“汇编语言”了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值