C++20新特新——02特性的补充

        虽然上节我们介绍了不少关于协程的特点,但是大家可能对协程还是不是很了解,没关系,这里我们再对其进行补充,详细讲解一下;

一、协程函数与普通函数的区别

这里我们再回归到问题:普通函数和协程在这方面的区别是什么?

  • 普通函数是线程相关的,函数的状态和线程紧密相关!
  • 但是协程的状态和和线程无关!

接下来我们对这方面进行解释说明:

        假设当前我们有Foo这个函数,我们需要调用Foo这个函数,此时在对应的的线程的栈上会记录这个函数的状态(参数、局部变量等),也就是函数栈帧!

        这里是通过移动函数的栈顶指针和栈底指针来实现的;

        详细的大家可以参考我之前写的一篇博客:

如上图所示,此时我们调用FOO普通函数:

这里地址2和地址3分别对应我们的栈顶指针(低地址)和栈底指针!(高地址)

此时如果我们再调用Bar()函数:

  • 这里地址3到地址2是给FOO函数调用使用的;
  • 地址2到地址1是给Bar()函数调用使用的;
  • 如果同时调用两个函数,此时栈顶指针指向地址1;
  • 当bar()销毁的时候,此时栈顶指针从地址1回到地址2;

因此这里可以发现,函数栈帧中存放的函数的状态完全依赖于线程栈!

如果线程栈被销毁了,此时函数的状态也就被销毁掉了;

但是协程不一样,此时如果我们假设Bar()是一个协程:

        此时,协程的状态信息是存放在堆上的!与线程的函数栈帧分开!

        传递给协程的参数都会复制到状态当中,局部变量会直接再协程的状态中进行直接创建!

        但是实际上,调用Bar()的时候,本质上还是一个函数调用,所以栈顶指针也会往下移动,在栈上给执行 Bar() 所需的状态分配空间,其中会有一个引用指向在堆上的状态,这样一来, Bar() 就可以像一个普通函数那样执行了,线程也可以访问到堆上的协程的状态。

        如果协程需要暂停,那么当前执行的代码的位置就会记录到堆的状态当中!

        此时栈上的执行状态会被直接销毁!栈顶指针移动到回收空间;

        而在下一次恢复执行时,堆状态中记录的暂停位置会读取出来,从这个位置接着执行,从而实现一个可暂停和恢复的函数!

二、协程相比于线程函数的优点

        协程的主要优点体现于:其可以优化异步逻辑的代码,与进程相比,尤其是在多进程方面,使得代码的逻辑更简单!

        接下来我们举一个具体的例子:假设我们有一个组件叫 IntReader ,它的功能是从一个访问速度很慢的设备上读取一个整数值,因此它提供的接口是异步的,如下所示:

class IntReader {
public:
    void BeginRead() {

        std::thread thread([]() {

            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            int value = std::rand();
        });

        thread.detach();
    }
};

这里BeginRead相当于启动1个显得线程用来读取一个随机数;

关于异步的线程接口的使用可以参考我的上一篇博客:

C++20新特新——01协程的入门及一些语法知识-CSDN博客

这里相当于BeginRead为主线程,然后新启动一个线程生成一个随机数,然后主线程和子线程实现线程分离;

  • 调用.join的时候此时主线程会进行同步阻塞;
  • 调用.detch的时候此时主线程和子线程会进行异步分离;

问题:如果我想要获取IntReader的结果,我应该怎么实现?

即此时问题就是一个线程要获取另一个线程的返回值,此时有两种解决方法:回调函数和async;

使用async解决问题

在上一篇博客当中,我提到过async与thread相比,可以获取到线程的返回值!

所以这里我们将上面的代码进行修改:

#include <future>
#include <cstdlib>
#include <ctime>

class IntReader {
public:
    std::future<int> BeginRead() {
        // 使用 std::async 启动异步任务,返回 future<int>
        return std::async(std::launch::async, []() {
            // 生成随机数(需确保线程安全)
            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            return std::rand();
        });
    }
};
int main() {
    IntReader reader;
    std::future<int> future = reader.BeginRead(); // 启动异步任务
    
    // 执行其他操作...
    
    int value = future.get(); // 阻塞等待结果
    std::cout << "生成的随机数: " << value << std::endl;
    
    return 0;
}

        上面的这里我们返回的std::rand()实际上是一个int类型,然后这个int类型会隐式的转化为std::future类型进行返回;

        而在主函数当中,这里我们定义了future变量,此时就会执行对应的异步代码;然后通过调用get函数会返回生成的结果;

使用回调函数解决问题

class IntReader {
public:
    void BeginRead(const std::function<void(int)>& callback) {

        std::thread thread([callback]() {

            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            int value = std::rand();

            callback(value);
        });

        thread.detach();
    }
};

void PrintInt() {

    IntReader reader;
    reader.BeginRead([](int result) {

        std::cout << result < std::endl;
    });
}

这种方式本质就是:

  • 当我们调用BeginRead函数的时候,向其中传入一个回调函数用来接收回收值;
  •  在BeginRead中,这里我们将随机值传递到了回调函数当中;
  • 在main函数,这里我们向其中传入回调函数,形参result用来接收value,然后函数体对其打印即可;

        假如我们需要调用多个 IntReader ,把它们的结果加起来再输出,那么基于回调的代码就会很难看了:

void PrintInt() {

    IntReader reader1;
    reader1.BeginRead([](int result1) {

        int total = result1;

        IntReader reader2;
        reader2.BeginRead([total](int result2) {

            total += result2;

            IntReader reader3;
            reader3.BeginRead([total](int result3) {
            
                total += result3;
                std::cout << total << std::endl;
            });
        });
    });
}

        需要注意的是:这里的代码逻辑实际上是一个线程执行完再执行下一个线程,不会出现同时并行执行的效果,是按照串行执行进行的;

        但是这里的代码逻辑很乱,很难整理清楚;

        但是如果我们使用协程就不一样了:

Task PrintInt() {

    IntReader reader1;
    int total = co_await reader1;

    IntReader reader2;
    total += co_await reader2;

    IntReader reader3;
    total += co_await reader3;

    std::cout << total << std::endl;
}

这里每个等待体可以获取到对应的随机值,然后进行返回计算;

整体的逻辑清晰了不少;

三、如何实现一个完整的协程

在第一节,我们已经介绍了对应的协程体的等待体和返回值等,大家可以参考上节博客,这里我们只对协程进行一些补充;

1. 协程的返回类型和promise_type

  • C++对协程的返回类型只有一个要求:包含名为 promise_type 的内嵌类型。
  • 跟上文介绍的 等待体一样, promise_type 需要符合C++规定的协议规范,也就是要定义几个特定的函数。
  • promise_type 是协程的一部分,当协程被调用,在堆上为其状态分配空间的时候,同时也会在其中创建一个对应的 promise_type 对象。
  • 通过在它上面定义的函数,我们可以与协程进行数据交互,以及控制协程的行为。 

2. 协程的返回值和co_return

        协程的返回值取决于我们的需求!例如上面我们所示的例子中,这里PrintInfo函数只是与上面的函数体内进行交互,而不需要返回实际的值给调用者当中;

        普通的线程函数可以通过回调函数或者通过异步接口std:async这样获取到返回值,那么协程如何获取到返回值呢?

        这里协程中提供了一个co_return的关键字,例如下面假如我们不是打印对应的消息,而是要获取对应的信息GetInfo,那么此时我们可以对该协程函数进行修改:

Task GetInt() {

    IntReader reader1;
    int total = co_await reader1;

    IntReader reader2;
    total += co_await reader2;

    IntReader reader3;
    total += co_await reader3;

    co_return total;
}

        这里的co_return total 这个表达式等价于 promise_type.return_value(total) ,也就是说,返回的数据会通过 return_value() 函数传递给 promise_type 对象, promise_type 要实现这个函数才能接收到数据;

        这里我们要区分co_return的本质:实际上是上total的值设置到了promise_type的对象当中!而不是类似普通的线程函数中的return那种;

        这里的total的值是返回到了promise_type当中,所以对应的协程的返回值如果想要从Task当中获取到promise_type当中的value,这里我们可以让Task和promise_type两者共享一份数据!

例如下面所示的协程代码例子:

#define  _CRT_SECURE_NO_WARNINGS 1
#include <coroutine>
#include <iostream>
#include <thread>

// 定义一个等待体
class IntReader {
public:
    // 协程挂起
    bool await_ready() {
        return false;
    }

    void await_suspend(std::coroutine_handle<> handle) {

        // 挂起后创建一个子线程,将随机数赋值给value_
        std::thread thread([this, handle]() {

            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            value_ = std::rand();

            handle.resume();
            });
        // 父线程和子线程异步
        thread.detach();
    }

    int await_resume() {
        return value_;
    }

private:
    int value_{};
};

class Task {
public:
    class promise_type {
    public:
        // 由于promise_type的构造函数调用了
        promise_type() : value_(std::make_shared<int>()) {

        }

        Task get_return_object() {
            return Task{ value_ };
        }
        // co_return 实际上调用了该函数
        // 将传入的参数value保存到value_当中
        void return_value(int value) {
            *value_ = value;
        }

        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void unhandled_exception() {}

    private:
        std::shared_ptr<int> value_;
    };

public:
    // 初始化的时候需要传入共享指针
    Task(const std::shared_ptr<int>& value) : value_(value) {

    }

    int GetValue() const {
        return *value_;
    }

private:
    // 通过共享指针管理value
    std::shared_ptr<int> value_;
};

Task GetInt() {

    IntReader reader1;
    int total = co_await reader1;

    IntReader reader2;
    total += co_await reader2;

    IntReader reader3;
    total += co_await reader3;

    co_return total;
}

int main() {

    auto task = GetInt();

    std::string line;
    while (std::cin >> line) {
        std::cout << task.GetValue() << std::endl;
    }
    return 0;
}

问题:这里是如何实现Task和promise_type共享同一个变量value的?

首先我们看这里的promise_type的构造函数:

        // 由于promise_type的构造函数调用了
        promise_type() : value_(std::make_shared<int>()) {

        }

        这里我们是让primise_type的value_指向0所示的共享指针,从而进行初始化;

        而当进行返回一个返回值的时候,此时会用value_构造一个Task,这里需要注意的是:因为该成员函数发生在promise_type的内部,所以此时value_采用的是promise_type的value!

Task get_return_object() {
    return Task{ value_ }; // 将 promise_type 的 value_ 传递给 Task
}

        所以此时Task会用这个value_对Task里面value_进行初始化!

        接下来这里我们再看返回值,当我们调用co_return的时候:

        此时会调用下面的函数:

void return_value(int value) {
    *value_ = value; // 将值写入共享指针指向的内存
}

这里是将形参value写入到value_当中,但是我们需要注意的是:

  • 这个函数发生在promise_type内部,但是由于此时promise_type和Task共享同一个value_;
  • 所以此时对promise_type里面的value_发生改变,那么Task里面的value_也会发生改变;

所以如果我们想要获取到对应的value,只需要在Task里面定义一个接口即可:

    int GetValue() const {
        return *value_;
    }

此时即可获取到对应的value的值!

注意点:

  • 跟普通的 return 一样, co_return 也可以不带任何参数,这时候协程以不带数据的方式返回,相当于调用了 promise_type.return_void() , promise_type 需要定义这个函数以支持不带数据的返回;
  • 如果我们在协程结束的时候没有调用任何 co_return ,那么编译器会隐式地加上一个不带参数的 co_return 调用

        这里我们再重点提醒一下,co_return和传统的return不一样,相当于将值存放到promise_type里面当中!

问题:除了上面所示的共享指针,还有没有其他的方法可以使Task获取到promise_type的成员变量?

        其实有一个特别简单的方法,也就是通过协程句柄:coroutine_handle获取到对应的promise对象,例如我们可以对上面的代码进行修改:

#include <coroutine>
#include <iostream>

class Task {
public:
    class promise_type {
    public:
        // 直接存储数据,而非共享指针
        int value_ = 0;

        // 返回 Task 对象时,传入协程句柄
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }

        // 协程完成后挂起,保持协程帧存活
        std::suspend_always final_suspend() noexcept { return {}; }

        // 其他必要接口
        std::suspend_never initial_suspend() { return {}; }
        void unhandled_exception() {}
        void return_value(int value) { value_ = value; } // co_return 赋值
    };

public:
    // 保存协程句柄
    explicit Task(std::coroutine_handle<promise_type> h) : coro_handle(h) {}

    // 析构时销毁协程帧
    ~Task() {
        if (coro_handle) coro_handle.destroy();
    }

    // 禁止拷贝,允许移动(避免重复销毁)
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;
    Task(Task&& other) noexcept : coro_handle(other.coro_handle) {
        other.coro_handle = nullptr;
    }

    // 通过协程句柄直接访问 promise_type 的数据
    int GetValue() const {
        return coro_handle.promise().value_;
    }

private:
    std::coroutine_handle<promise_type> coro_handle;
};

// 示例协程
Task MyCoroutine() {
    co_return 42; // 调用 return_value(42)
}

int main() {
    Task task = MyCoroutine();
    std::cout << task.GetValue(); // 输出 42
}

在Task内部:

// 通过协程句柄直接访问 promise_type 的数据
    int GetValue() const {
        return coro_handle.promise().value_;
    }

        这里我们可以直接通过协程句柄获取到promise对象,然后再获取到对应的value的值;

        这种方法理解更为简单; 

3. 协程的关键字co_yield

问题:什么时候我们需要使用co_yield?

        当协程调用了 co_return ,意味着协程结束了,就跟我们在普通函数中用 return 结束函数一样。这时候,与这个协程实例有关的内存都会被释放掉,它不能再执行了

        但是如果需要在协程中多次返回数据而不结束协程的话,可以使用 co_yield 操作符!

        co_yield 的作用是,返回一个数据,并且让协程暂停,然后等下一次机会恢复执行;

        co_yield value 这个表达式等价于 co_await promise_type.yield_value(value) , co_yield 的参数会传递给 promise_type 的 yield_value() 函数,再把这个函数的返回值传给 co_await ;(这里该函数的返回值是一个等待体类型的!);

        在这里就可以使用预定义的 std::supsend_never 或 std::suspend_always ,通常会使用后者来让协程每次调用 co_yield 的时候都暂停;

例如下面这个例子:

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

// 定义等待体
class IntReader {
public:
    // 将协程挂起
    bool await_ready() {
        return false;
    }

    // 切换到另一个线程
    void await_suspend(std::coroutine_handle<> handle) {

        std::thread thread([this, handle]() {
            // 定义不被销毁的静态变量
            static int seed = 0;
            value_ = ++seed;

            handle.resume();
            });
        // 主线程和子线程异步
        thread.detach();
    }

    int await_resume() {
        return value_;
    }

private:
    // 这里实际上是调用统一的列表初始化
    // 给value_一个默认值为0
    int value_{};
};

class Task {
public:
    class promise_type {
    public:
        Task get_return_object() {
            return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        // 调用co_yield的时候,调用该函数;
        // 返回值是一个等待体类型 --- 让传递返回值后总是挂起
        std::suspend_always yield_value(int value) {
            value_ = value;
            return {};
        }

        void return_void() { }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void unhandled_exception() {}

        int GetValue() const {
            return value_;
        }

    private:
        int value_{};
    };

public:
    Task(std::coroutine_handle<promise_type> handle) : coroutine_handle_(handle) {

    }

    int GetValue() const {
        return coroutine_handle_.promise().GetValue();
    }

    void Next() {
        coroutine_handle_.resume();
    }

private:
    std::coroutine_handle<promise_type> coroutine_handle_;
};

Task GetInt() {

    while (true) {

        IntReader reader;
        int value = co_await reader;
        co_yield value;
    }
}

int main() {

    auto task = GetInt();

    std::string line;
    while (std::cin >> line) {

        std::cout << task.GetValue() << std::endl;
        task.Next();
    }
    return 0;
}

上面的代码相比于之前我们写的确实改动了不少,但是这里我们可以逐个进行分析:

  • 整体的代码框架依然是定义一个等待体、一个协程的返回值、一个协程函数和我们对应的主函数;

这里我们先分析等待体:

    // 切换到另一个线程
    void await_suspend(std::coroutine_handle<> handle) {

        std::thread thread([this, handle]() {
            // 定义不被销毁的静态变量
            static int seed = 0;
            value_ = ++seed;

            handle.resume();
            });
        // 主线程和子线程异步
        thread.detach();
    }

这里等待体从之前的生成随机数变为递增的整数;

除此之外,当我们调用co_yield的时候,协程保存到对应的数据后可能会挂起,所以我们在返回值Task提供协程的恢复函数:

    void Next() {
        coroutine_handle_.resume();
    }

需要注意的是,由于恢复协程需要用到协程句柄,所以我们需要在Tsak里面声明一个协程句柄:

private:
    std::coroutine_handle<promise_type> coroutine_handle_;

那么此时我们我们通过promise_type返回Task对象的时候,就需要向其传入一个协程句柄用来初始化;

问题:那么在promise_type的内部,我们怎么获取到Task的协程句柄呢?

实际上,promise_type作为连接协程内外的桥梁,这里其提供了一个静态的接口函数

template <class _Promise>
struct coroutine_handle {
    static coroutine_handle from_promise(_Promise& _Prom) noexcept {
      ...
    }
}

这里我们向其传入一个promise_type等待体的对象,然后值是一个协程句柄;

所以在promise_type返回一个协程对象的时候,这时候我们就可以通过下面这种方式传入协程句柄:

Task get_return_object() {
    return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}

promise_type内可以通过该静态函数获取到协程体对象;

与此相对应的,Task内部也可以通过协程句柄.promise()获取到对应的promise_type对象!

    int GetValue() const {
        return coroutine_handle_.promise().GetValue();
    }

问题:coroutine_handle有coroutine_handle<>和coroutine_handle<romise_type>,这两个有什么区别?

这里我们可以发现在我们定义等待体的时候:

    void await_suspend(std::coroutine_handle<> handle) {

        // 挂起后创建一个子线程,将随机数赋值给value_
        std::thread thread([this, handle]() {

            std::srand(static_cast<unsigned int>(std::time(nullptr)));
            value_ = std::rand();

            handle.resume();
            });
        // 父线程和子线程异步
        thread.detach();
    }

例如这里的await_suspend,这里我们传入的协程句柄是coroutine_handle<>类型!

而当我们在Task定义协程句柄的时候:

private:
    std::coroutine_handle<promise_type> coroutine_handle_;

类型为:coroutine_handle<romise_type>!

        它们的区别类似于指针 void* 和 promise_type* 的区别,前者是无类型的,后者是强类型的!

        两种类型的协程句柄本质上是相同的东西,它们可以有相同的值,指向同一个协程实例,而且也都可以恢复协程执行。

        但是这里需要注意的是只有强类型的 std::coroutine_handle<promise_type> 才能调用 from_promise() 获取到 promise_type 对象!

        除此之外。这里我们还把协程函数改为无限循环的类型:

Task GetInt() {

    while (true) {

        IntReader reader;
        int value = co_await reader;
        co_yield value;
    }
}

我们可以再看之前的使用co_return的协程函数:

Task GetInt() {

    IntReader reader1;
    int total = co_await reader1;

    IntReader reader2;
    total += co_await reader2;

    IntReader reader3;
    total += co_await reader3;

    co_return total;
}

        其实在协程中使用无限循环是很常见的,因为当我们调用co_yield的时候,此时返回值保存到value当中,并且协程会挂起!不会一直死循环执行!当我们恢复协程时,其执行完工作又继续挂起,和传统的死循环是不一样的!

四、协程的生命周期

        在一开始调用协程的时候,C++会在堆上为协程的状态分配内存,这块内存必须在适当的时机来释放,否则就会造成内存泄漏。释放协程的内存有两种方式:自动释放和手动释放。

        当协程结束的时候,如果我们不做任何干预,那么协程的内存就会被自动释放。调用了 co_return 语句之后,协程就会结束,下面两个协程是自动释放的例子:

Task GetInt() {

    IntReader reader;
    int value = co_await reader;

    co_return value;
}


Task PrintInt() {

    IntReader reader1;
    int value = co_await reader;

    std::cout << value << std::endl;
}

        PrintInt() 没有出现 co_return 语句,编译器会在末尾隐式地加上 co_return !
        自动释放的方式有时候并不是我们想要的,参考下面这个例子: 

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

class Task {
public:
    class promise_type {
    public:
        Task get_return_object() {
            return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        void return_value(int value) {
            value_ = value;
        }

        int GetValue() const {
            return value_;
        }

        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void unhandled_exception() {}

    private:
        int value_{};
    };

public:
    Task(std::coroutine_handle<promise_type> handle) : coroutine_handle_(handle) {

    }

    int GetValue() const {
        return coroutine_handle_.promise().GetValue();
    }

private:
    std::coroutine_handle<promise_type> coroutine_handle_;
};

Task GetInt() {

    co_return 1024;
}

int main() {

    auto task = GetInt();

    std::string line;
    while (std::cin >> line) {
        std::cout << task.GetValue() << std::endl;
    }
    return 0;
}

打印的结果如下所示: 

会发现打印的是一些随机值!

        造成这个现象的原因是,协程在返回1024之后就被自动释放了, promise_type 也跟着被一起释放了此时在 Task 内部持有的协程句柄已经变成了野指针,指向一块已经被释放的内存。所以访问这个协程句柄的任何行为都会造成不确定的后果!

解决方法:

        修改 promise_type 中 final_supsend() 函数的返回类型,从 std::suspend_never 改成 std::suspend_always ;协程在结束的时候,会调用 final_suspend() 来决定是否暂停,如果这个函数返回了要暂停,那么协程不会自动释放,此时协程句柄还是有效的,可以安全访问它内部的数据;

        不过,这时候释放协程就变成我们的责任了,我们必须在适当的时机调用协程句柄上的 destroy() 函数来手动释放这个协程!

    ~Task() {
        coroutine_handle_.destroy();
    }

修改后此时我们再次运行我们的程序:

此时发现可以正常打印值!

五、协程的异常处理

        协程的异常处理机制与普通函数有所不同,主要依赖于 promise_type 中的 unhandled_exception 方法!接下来我们对其进行解释说明:

#include <exception> // for std::current_exception

class Task {
public:
    class promise_type {
    public:
        // 存储异常
        std::exception_ptr exception_;

        // 当协程抛出未捕获的异常时调用
        void unhandled_exception() {
            exception_ = std::current_exception(); // 捕获异常指针
        }

        // 其他必要方法(initial_suspend, final_suspend, get_return_object 等)
    };

private:
    std::coroutine_handle<promise_type> coro_;
};

        promise_type 的 unhandled_exception() 函数会被调用,我们可以在这个函数里面做对应的异常处理!

        而在我们的实际协程的代码框架中,我们可以采用下面的框架伪代码:

try {

    co_await promise_type.initial_suspend();

    //协程函数体的代码...
}
catch (...) {

    promise_type.unhandled_exception();
}

co_await promise_type.final_suspend();

首先这里我们先执行:

co_await promise_type.initial_suspend();
  • 看协程是立刻挂起,还是执行到对于的co_await再挂起;
  •  接下来填写的是协程的主逻辑框架;
  • 如果出现异常,此时会交给对应的promise_type.unhandle_exception进行处理!
  • 最后在调用final_suspend()看协程结束后是否需要挂起;

        调用了 unhandled_exception() 之后,协程就结束了,接下来会继续调用 final_suspend() ,与正常结束协程的流程一样;

        C++规定 final_suspend() 必须定义成 noexcept ,也就是说它不允许抛出任何异常!

至此,我们对协程的学习就更进一步了;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一道秘制的小菜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值