15.实现高效定时器:Timestamp、Timer 类与 TimerQueue 容器的设计与分析

目录

1.Timestamp类

2.Timer定时器类

3.定时函数的选择:使用timerfd_* 系列函数

3.1.timerfd_* 系列函数的使用

4.TimerQueue定时器容器类

4.1 对定时器操作的一些要求

4.2容器的选择

4.3其类的一些成员函数的实现

5.总结


前面的章节已经把大体框架弄好了,这一节我们添加定时器的功能。

说到定时器,那就一定会和时间有关联,我们使用c++11的std::chrono去获取当前时间,这个时间的精度是可以到纳秒的,我们就只取到微妙级别就足以应付了。

1.Timestamp类

关于时间的调用,封装在一个叫做Timestamp的类中。Timestamp类是对时间戳的封装,内部有个成员变量micro_seconds_since_epoch_,是其微妙数。

class Timestamp {  
public:  
    // 默认构造函数,初始化为无效时间戳  
    Timestamp();  

    // 使用微秒级别的时间戳初始化  
    explicit Timestamp(int64_t microSecondsSinceEpoch);  

    // 获取当前的时间戳  
    static Timestamp now();  

    // 返回一个无效的时间戳,通常表示时间未初始化  
    static Timestamp invalid() { return Timestamp(); }  

    // 将时间戳转换为可读的字符串格式(“年月日 时:分:秒”)  
    std::string toString() const;  

    // 将时间戳转换为格式化字符串,可以选择是否显示微秒  
    std::string toFormattedString(bool showMicroseconds = true) const;  

    // 检查时间戳是否有效  
    bool valid() const { return micro_seconds_since_epoch_ > 0; }  

    // 获取自 epoch(1970年1月1日)以来的微秒数  
    int64_t microSecondsSinceEpoch() const { return micro_seconds_since_epoch_; }  

    // 微秒到秒的转换常量  
    static const int kMicroSecondsPerSecond = 1000 * 1000;  

private:  
    // 存储自 epoch(1970年1月1日)以来的微秒数  
    int64_t micro_seconds_since_epoch_;   
}; 

这个类比较简单,就不多说了。

2.Timer定时器类

时间类解决了,那么我们还需要定义一个定时器类,用于表明该定时器会在何时做何事。起个名字叫做Timer。

class Timer {  
public:  
    // 定义一个定时器回调类型,用于存储定时器超时后的回调函数  
    using TimerCallback = std::function<void()>;  

    // 构造函数,初始化定时器的相关参数  
    Timer(TimerCallback cb, Timestamp when, double interval)  
        : callback_(std::move(cb)) // 将回调函数移动到成员中  
        , expiration_(when)         // 设置定时器的超时时间  
        , interval_(interval)       // 设置定时器的时间间隔  
        , repeat_(interval > 0.0)   // 判断定时器是否为重复执行的  
        , sequence_(num_created_++)  // 创建一个唯一的序列号来标识每个定时器  
    {}  

    // 执行定时器的回调函数  
    void run() const { callback_(); }  

    // 获取定时器的超时时间  
    Timestamp expiration() const { return expiration_; }  

    // 判断定时器是否为重复  
    bool repeat() const { return repeat_; }  

    // 获取定时器的唯一序列号  
    int64_t sequence() const { return sequence_; }  

    // 重启定时器,设置下一个执行的时间  
    void restart(Timestamp now) {  
        if (repeat_) // 若该定时器是重复的,就改写时间到下一个执行的时刻  
            expiration_ = addTime(now, interval_);  
        else  
            expiration_ = Timestamp::invalid(); // 一次性定时器,设置为无效  
    }  

private:  
    const TimerCallback callback_; // 定时器执行的回调函数  
    Timestamp expiration_;          // 定时器的执行时间,即超时事件  
    const double interval_;         // 定期重发的时间间隔(单位:秒)  
    const bool repeat_;            // 是否为重复定时器的标识  
    const int64_t sequence_;       // 唯一的标识符,用于区分不同的定时器  

    // 使用原子整型来管理创建定时器的计数,确保多线程环境下安全  
    static std::atomic_int64_t num_created_; // 统计已创建的定时器数量  
};  

该Timer类也很容易理解,该类定义的定时器的执行时间,执行事件,是否是重复的等等,并用sequence_作为辨别定时器的唯一标识。

Timer类就是对应一个超时任务,保存了超时时刻Timestamp,超时回调函数,以及超时任务类型(一次 or 周期)。

3.定时函数的选择:使用timerfd_* 系列函数

定时函数,可用于让程序等待一段时间或安排计划任务

而timerfd_* 入选的原因:

  1. sleep / alarm / usleep 在实现时有可能用了信号 SIGALRM,在多线程程序中处理信号是个相当麻烦的事情,应当尽量避免。
  2. nanosleep 和 clock_nanosleep 是线程安全的,但是在非阻塞网络编程中,绝对不能用让线程挂起的方式来等待一段时间,程序会失去响应。正确的做法是注册一个时间回调函数。
  3. getitimer 和 timer_create 也是用信号来 deliver 超时,在多线程程序中也会有麻烦。timer_create 可以指定信号的接收方是进程还是线程,算是一个进步,不过在信号处理函数(signal handler)能做的事情实在很受限。
  4. timerfd_create 把时间变成了一个文件描述符,该“文件”在定时器超时的那一刻变得可读,这样就能很方便地融入到 select/poll 框架中,用统一的方式来处理 IO 事件和超时事件,这也正是 Reactor 模式的长处。
  5. 传统的 Reactor 利用select/poll/epoll的timeout来实现定时功能,但 poll 和 epoll 的定时精度只有毫秒,远低于 timerfd_settime 的定时精度。
3.1.timerfd_* 系列函数的使用
timerfd 3个接口: timerfd_create,timerfd_settime,timerfd_gettime。

这里需要注意下如何设置重复的定时器。

#include <sys/timerfd.h>  

// 创建一个定时器对象.  
// 返回与之关联的文件描述符 (fd),与创建 socket 的方式相似.  
// 参数 clockid 用于指定使用的时钟类型,  
// 可以选择 CLOCK_REALTIME(系统范围时钟)或 CLOCK_MONOTONIC(不可调整的时钟,不能手动修改)  
// 参数 flags 可指定为 TFD_NONBLOCK(设置 fd 为非阻塞模式)或 TFD_CLOEXEC(在 exec 时关闭 fd)。  
int timerfd_create(int clockid, int flags);  

// 启动或停止与指定 fd 关联的定时器。  
// 参数 flags 可以指定为 0:启动一个相对定时器,由 new_value->it_value 指定相对超时时间;  
// 或使用 TFD_TIMER_ABSTIME 启动一个绝对定时器,由 new_value->it_value 指定绝对超时时间。  
// 参数 old_value 用于保存旧的定时值,以便后续需要时查看或复原。  
// 函数返回 0 表示设置成功。  
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);  

// 如果定时器不需要重复触发,可以将 interval 设置为 0。  
struct timespec {  
    time_t tv_sec;  // 秒数  
    long tv_nsec;   // 纳秒数  
};  

// 定时器规格结构体,用于指定定时器的间隔时间和初始过期时间。  
struct itimerspec {  
    struct timespec it_interval; // 定时器触发的间隔时间  
    struct timespec it_value;    // 定时器首次到期的时间  
};  

// 获取 fd 关联的定时器的当前时间值。  
// 函数返回 0 表示成功,curr_value 将填充当前定时器的时间状态。  
int timerfd_gettime(int fd, struct itimerspec *curr_value);  

我们可以让创建的timerfd去绑定一个channel,这就和其他socketfd一样的,并设置好回调函数,接着交由epoll去监听该channel,该tiemrfd的定时时间到后就会触发epoll_wait(),那就可以在回调函数中执行定时任务了。

4.TimerQueue定时器容器类

我们有可能想设置多个定时器任务,那这时就需要去管理这么多个定时器,才不会混乱。那我们可以定义一个定时器容器类,用于管理这多个定时器,该类叫做TimerQueue。

4.1 对定时器操作的一些要求

我们想要的是能高效添加定时器,也能高效删除定时器。能快速地根据当前时间找到已经到期的定时器。

4.2容器的选择

那最简单的就是已按到期时间排好序的线性表作为数据结构,那其复杂度是O(N),其性能不符合我们的要求。

那另一种常用的方法就是优先队列了,这是一种二叉堆结构,但是不能高效删除堆中的某个元素,这不符合我们的高效删除要求。

想要满足高效删除的需求,那就还有二叉搜索树(例如std::set,std::map),其插入删除复杂度都是O(logN)。

若是简单的把Timer按照到期时间先后拍序,那使用std::map<Timestamp,Timer*>容器,但这不行,因为这样无法处理两个Timer到期时间相同的情况。仅仅用Timestamp作为key是无法进行区分,我们也可以使用mulitimap。但我们还是不是用mulitimap/mulitiset,可以把pair<Timestamp,Timer*>作为key,这样即使两个Timer的到期时间相同,那它们的地址也必定不同的。

那也不需要使用std::map<pair<Timestamp,Timer*>,Timer*>,可以就使用std::set<pair<Timestamp,Timer*>>,这样会简单点。

经过上面的分析,来看看TimerQueue的数据成员。

class TimerQueue {  
public:  
    // 构造函数,接受一个 EventLoop 指针,用于定时器队列的管理  
    explicit TimerQueue(EventLoop* loop);  

    // 析构函数,负责清理定时器队列资源  
    ~TimerQueue();  

    // 添加一个定时器,返回定时器的唯一标识符  
    int64_t addTimer(Timer::TimerCallback cb, Timestamp when, double interval);  

    // 取消一个已添加的定时器,使用其唯一标识符进行识别  
    void cancel(int64_t timerId);  

private:  
    // 定义一个条目类型,包含时间戳和定时器指针的对  
    using Entry = std::pair<Timestamp, Timer*>;  

    // 定义一个定时器列表,使用 std::set 存储按时间戳排序的定时器条目  
    using TimerList = std::set<Entry>;  

    // 处理读取定时器超时事件的函数,在此函数内部执行定时器的任务  
    void handleRead();  

    // 获取所有超时的定时器条目,返回一个条目向量  
    std::vector<Entry> getExpired(Timestamp now);  

    // 插入定时器到定时器列表中  
    bool insert(Timer* timer);  

    EventLoop* loop_;          // 关联的事件循环  
    const int timerfd_;       // 通过 timerfd_create 创建的文件描述符  
    Channel timerfd_channel_;  // 用于监听定时器超时事件的 Channel 对象  
    TimerList timers_;         // 存储当前活动的定时器列表  
};  

该类内部也使用了一个channel去绑定timerfd_,和其他socketfd一样的。内部也有一个EventLoop变量,用来表示该定时器队列是在哪个IO线程调用的。那么就会牵扯到runInLoop()函数的啦。

该类可以给外部使用的函数接口就两个:删除定时器,添加定时器

4.3其类的一些成员函数的实现

构造函数

TimerQueue::TimerQueue(EventLoop* loop)  
    : loop_(loop)  // 初始化成员变量 loop_,记录关联的事件循环  
    , timerfd_(timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC))  // 创建定时器文件描述符  
    , timerfd_channel_(loop, timerfd_)  // 初始化 Channel 对象,绑定到 timerfd_  
{  
    // 设置读取回调函数,类似于其他 socket 文件描述符  
    // 该回调会在定时器到期时被调用,执行 handleRead 函数  
    timerfd_channel_.SetReadCallback([this]() { handleRead(); });  

    // 启用对 timerfd 的读事件监听  
    timerfd_channel_.enableReading();  
}  

来看看添加定时器的函数

int64_t TimerQueue::addTimer(Timer::TimerCallback cb, Timestamp when, double interval) {  
    // 创建一个新的定时器对象,使用给定的回调、到期时间和间隔时间  
    auto timer = new Timer(std::move(cb), when, interval);  
    
    // 将添加定时器的请求转发到所属的事件循环线程中执行,确保线程安全  
    loop_->runInLoop([this, &timer]() { addTimerInLoop(timer); });  

    // 返回新创建定时器的唯一标识符(序列号)  
    return timer->sequence();   
}  

void TimerQueue::addTimerInLoop(Timer* timer) {  
    // 将定时器插入定时器列表,判断插入的定时器的到期时间是否小于当前最快到期时间。  
    // 如果是,则需要重置最快的到期时间。  
    bool earliestChanged = insert(timer);  
    
    // 如果插入的定时器是当前最快到期的定时器,则重置 timerfd 的到期时间  
    if (earliestChanged)  
        resetTimerfd(timerfd_, timer->expiration()); // 重新设置定时器到期时间  
}  

addTimer函数使用了runInLoop()函数,这是为了可以在不是该所属的loop线程也能进行添加。

把定时器insert()插入到timers_容器中,并判断是否需要resetTimerfd()重置到期时间。这两个函数的实现后面会仔细解说的,先不着急,先知道增加定时器的流程。

接着说说删除定时器的函数。

在现有的条件下要删除定时器就麻烦了。在增加定时器的时候,该函数通过返回唯一的标识符timerId来标识该定时器。

那要删除定时器的时候,我们也是给删除函数传递该标识符timerId,这只是int64_t。而我们的容器timers_是std::set<pair<Timestamp,Timer*>,这样我们就不能通过timerId作为key来高效的找到该定时器了

要是按照这样的,就只能线性地找到每个定时器来进行比较起标识符,这个性能就比较差了。

所以,为了可以方便高效地进行删除我们想要删除的定时器,那可以再添加一个新容器来进行删除了。那我们就可以添加一个以定时器标识符为key的容器,那可以选择std::unordered_map<int64_t,Timer*>

std::unordered_map是c++11新增的。 unordered_map 容器和 map 容器仅有一点不同,即 map 容器中存储的数据是有序的,而 unordered_map 容器中是无序的。std::unordered_map的底部实现是哈希表,其查询和增删的复杂度是O(1),这方面性能很好。

在TimerQueue类中添加成员

class TimerQueue {  
private:  
    TimerList timers_;  // 存储当前活动的定时器列表,按时间戳自动排序,确保按到期时间管理定时器  

    // 新添加的容器,用于以唯一标识符 int64_t 作为键,快速进行定时器的删除操作  
    std::unordered_map<int64_t, Timer*> active_timers_;  
};  

那么这时就有两个容器了。那么这两个容器的元素和容量大小都要相等才行,也就是说要增加定时器和要删除定时器的时候,都要在这两个容器中操作。同删同减

那接着要在前面的增加定时器函数中添加 在active_timers_中添加定时器

新添加active_timers_后的删除函数

void TimerQueue::cancel(int64_t timerId) {  
    // 将取消定时器的请求转发到所属的事件循环线程中执行,以确保线程安全  
    loop_->runInLoop([this, timerId]() { cancelInLoop(timerId); });  
}  

void TimerQueue::cancelInLoop(int64_t timerId) {  
    // 在定时器队列的事件循环中执行的取消定时器操作  
    auto it = active_timers_.find(timerId);  // 在活动定时器映射中查找指定的定时器  

    if (it != active_timers_.end()) {  // 如果找到了定时器  
        // 从定时器列表中移除定时器条目  
        timers_.erase(Entry(it->second->expiration(), it->second));  

        // 释放定时器对象的内存  
        delete it->second;  

        // 从活动定时器映射中移除该定时器的条目  
        active_timers_.erase(it);  
    }  

    // 确保定时器列表和活动定时器映射的大小一致  
    assert(timers_.size() == active_timers_.size());  
}  

那接下来来看看增加定时器函数的内部实现,inset()函数和resetTimerfd()函数。

void resetTimerfd(int timerfd, Timestamp expiration) {  
    struct itimerspec new_value; // 定义新的定时器值结构  

    memset(&new_value, 0, sizeof(new_value)); // 清零结构体  
    new_value.it_value = howMuchTimeFronNow(expiration); // 计算从现在到指定过期时间的间隔  

    // 启动定时器,设置新的定时器值  
    int ret = ::timerfd_settime(timerfd, 0, &new_value, nullptr);  
}  

bool TimerQueue::insert(Timer* timer) {  
    bool earliestChanged = false; // 标记是否需要重置到期时间  
    auto when = timer->expiration(); // 获取定时器的到期时间  
    auto it = timers_.begin(); // 获取定时器列表中最早的到期时间  

    // 判断是否需要更新最早到期时间  
    // 若容器中没有元素,或者要添加的定时器的到期时间小于当前最早的到期时间  
    if (it == timers_.end() || when < it->first) {  
        earliestChanged = true; // 需要重置最早的到期时间  
    }  

    // 在两个容器中都添加定时器  
    timers_.emplace(Entry(when, timer)); // 将定时器添加到 timers_ 容器中  
    active_timers_.emplace(timer->sequence(), timer); // 将定时器添加到 active_timers_ 容器中  

    // 断言确保两个容器的大小相等  
    assert(timers_.size() == active_timers_.size());  

    return earliestChanged; // 返回是否更新了最早的到期时间  
}  

增加删除基本讲完了,那就到了定时器任务的执行

前面也讲了其是通过epoll来进行监听,到了指定的时间,就会触发epoll_wait(),那就会触发设置好的回调函数handleRead()。我们就可以在该回调函数中执行定时器任务。

// 在这个函数内部执行定时器的任务  
void TimerQueue::handleRead() {   
    Timestamp now(Timestamp::now()); // 获取当前时间戳  

    readTimerfd(timerfd_, now); // 读取 timerfd,清除超时事件。如果不调用,会不停触发 EPOLLIN  

    // 获取所有已超时的定时器,因为超时的定时器可能不止一个  
    auto expired = getExpired(now);  

    // 遍历已超时的定时器,执行它们的回调函数  
    for (const auto& it : expired) {  
        it.second->run(); // 执行定时器的任务  
    }  

    // 处理完到期的定时器后,重置最早的到期时间  
    reset(expired, now);  
}  

// 得到所有的超时定时器  
std::vector<TimerQueue::Entry> TimerQueue::getExpired(Timestamp now) {  
    Entry sentry(now, reinterpret_cast<Timer*>(UINTPTR_MAX)); // 创建一个哨兵条目,以当前时间为到期时间  

    // 查找第一个未到期的定时器  
    auto end = timers_.lower_bound(sentry); // 使用 lower_bound 找到第一个未到期的定时器  

    // 把 [timers_.begin(), end) 范围内的已超时定时器拷贝到 expired 向量中  
    std::vector<Entry> expired(timers_.begin(), end);  
    
    // 从 timers_ 容器中删除这些已超时的定时器  
    timers_.erase(timers_.begin(), end);   

    // 遍历已超时的定时器,移除它们在 active_timers_ 中的记录  
    for (const auto& it : expired) {  
        active_timers_.erase(it.second->sequence()); // 在 active_timers_ 中删除  
    }  

    // 确保 timers_ 和 active_timers_ 的大小一致  
    assert(timers_.size() == active_timers_.size());  

    return expired; // 返回所有已超时的定时器  
} 

到这里基本完成的不错了,但是在删除的时候还是会有问题。想象一个场景:假如我们想要删除的一个定时器,这定时器是重复循环的。

该定时器正在执行,那就说明在容器timers_和active_timers_中已经没有了该定时器。而这时我们想要删除该定时器却又找不到了,无从下手删除。而等到该定时器结束了,又会再次添加回到容器中,因为是重复的,那它到了时间就又会执行,这就不符合我们的删除情况了。

原因就出在想要删除的定时器却正在执行那我们可以针对定时器正在执行的时候做一些特别的操作。

那我们可以用一个标识来表示定时器正在执行。正在执行我们想要删除的定时器时,就可以把该定时器放置在另一个容器(cancelingTimers_)中。等到定时器执行完毕后,需要重置最新的到期时间的时候,就检查该定时器是否在容器cancelingTimers_中,若是在该容器中,就算其是重复的定时器,也要把其删除

那么在删除定时器的函数cancelInLoop和重置最新的到期时间函数reset需要修改。

而这也是我没有在讲完getExpired函数后就讲解reset函数的原因,只有解决了想要删除的定时器却正在执行的问题,reset函数才好实现

先来看看TimerQueue类中需要新添加的成员

这时TimerQueue类就有3个容器,需要分清楚哈。

class TimerQueue {  
private:  
    TimerList timers_;  // 用于存储当前活动的定时器,便于按到期时间查找超时的定时器  

    // 为了方便以唯一标识符 int64_t 作为键进行快速删除  
    std::unordered_map<int64_t, Timer*> active_timers_;  

    // 新添加的容器,用于解决定时器正在执行时想要删除该定时器的情况  
    std::unordered_map<int64_t, Timer*> cancelingTimers_;  

    // 标识定时器是否在执行  
    std::atomic_bool callingExpiredTimers_;  
};  

后来添加的,在这新添的容器可以更加的简化,使用std::unordered_set<int64_t>就行啦。不需要std::unordered_map<int64_t, Timer*>。因为就只是通过定时器的sequeue去判断该定时器是否需要删除而已嘛。这个就留读者先去实现吧,也是很容易的呢。

先来看看怎样标识定时器正在执行,在handleRead()中添加callingExpiredTimers_。

void TimerQueue::handleRead() {  
    // 省略部分 ... (例如,获取当前时间戳和读取定时器文件描述符)  

    // 获取所有已超时的定时器  
    auto expired = getExpired(now);  

    // 1. 将标志设为 true,表示当前正在处理超时定时器  
    callingExpiredTimers_ = true;   

    // 2. 清空正在取消的定时器列表  
    cancelingTimers_.clear();   

    // 遍历已超时的定时器,执行它们的回调函数  
    for (const auto& it : expired) {  
        it.second->run(); // 执行定时器的任务  
    }  

    // 3. 将标志设为 false,表示超时定时器的处理已完成  
    callingExpiredTimers_ = false;   

    // 处理完后,重置最早的到期时间  
    reset(expired, now);  
}  

来看看删除的函数

void TimerQueue::cancelInLoop(int64_t timerId) {  
    auto it = active_timers_.find(timerId); // 查找指定的定时器  

    if (it != active_timers_.end()) {  
        // 省略......(这里执行取消定时器的逻辑,比如从 timers_ 中移除定时器、删除它的内存等)  
    }  
    // 这个 else if 是新添加的,是为了解决定时器正在执行,却想删除该定时器的问题  
    else if (callingExpiredTimers_) {  
        // 如果当前正在执行超时定时器的回调  
        cancelingTimers_.emplace(timerId, it->second); // 将要取消的定时器放入 cancelingTimers_ 中  
    }  
}  

还有最后的重置最新的超时时间reset函数

void TimerQueue::reset(const std::vector<Entry>& expired, Timestamp now) {  
    // 遍历所有已超时的定时器  
    for (const auto& it : expired) {  
        // 如果该定时器是重复的且没有在 cancelingTimers_ 容器中找到该定时器  
        if (it.second->repeat() &&   
            cancelingTimers_.find(it.second->sequence()) == cancelingTimers_.end()) {  
            it.second->restart(now); // 重启定时器,设置下次到期时间  
            insert(it.second); // 将重启后的定时器插入到 timers_ 中  
        } else {  
            delete it.second; // 如果不是重复定时器或已在取消队列中,删除定时器  
        }  
    }  

    // 检查 timers_ 是否为空  
    if (!timers_.empty()) {  
        // 获取下一个超时时间  
        Timestamp nextExpire = timers_.begin()->second->expiration(); // 获取最早的超时时间  

        // 如果时间有效,则重置最早的超时时间  
        if (nextExpire.valid()) {  
            resetTimerfd(timerfd_, nextExpire); // 重置定时器  
        }  
    }  
}  

到这里就基本可以了哈,这一节的代码还不能去执行。

这还不是结束,我们调用增加/删除的函数还没实现,是通过EventLoop去调用增删定时器的函数,这就留到下一节再去解决。这一节要好好理解在TimerQueue类中的各种情况以及解决的方式

5.总结

我们这一节添加了Timestamp类,该类是对时间戳的封装,也是为了方便定时器使用有关时间的函数。

添加了Timer类,Timer类就是对应一个超时任务,保存了超时时刻Timestamp,超时回调函数,以及超时任务类型(一次 or 周期)。

接着讲解了如何在linux中实现定时,选择的定时函数,timerfd_* 系列函数的使用。

最后到了管理多个定时的类TimerQueue。讲解了选择哪种数据结构来进行管理定时器。

该类需要解决比较多问题,其类内部有三个容器,一定要清楚哪个容器是解决了哪个问题的,这是一一对应的。这个很重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

榆榆欸

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

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

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

打赏作者

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

抵扣说明:

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

余额充值