高性能定时器1——最小堆实现

12 篇文章 4 订阅
2 篇文章 0 订阅

​ 在网络程序中我们通常要处理三种事件,网络I/O事件、信号以及定时事件,我们可以使用I/O复用系统调用(select、poll、epoll)将这三类事件进行统一处理。我们通常使用定时器来检测一个客户端的活动状态,服务器程序通常管理着众多定时事件,因此有效地组织这些定时事件,使之能在预期的时间点被触发且不影响服务器的主要逻辑,对于服务器的性能有着至关重要的影响。为此我们需要将每个定时事件分别封装为定时器,并使用某种容器类数据结构,比如:链表、排序链表、最小堆、红黑树以及时间轮等,将所有定时器串联起来,以实现对定时事件的统一管理。此处所说的定时器,确切的说应该是定时容器,定时器容器是容器类数据结构;定时器则是容器内容纳的一个个对象,它是对定时事件的封装,定时容器是用来管理定时器的。


在本文中将主要介绍使用最小堆来实现的定时容器。

1、I/O复用系统调用的超时参数

​ Linux下的3组I/O复用系统调用(select、poll、epoll)都带有定时参数,因此他们不仅能统一处理信号和I/O事件,也能统一处理定时事件。我们可以使用定时容器和I/O复用系统调用来共同实现定时器的触发。

这三个系统调用的定义如下:

#include <sys/select.h>
int select(int fds, fd_set *readfds, fd_set *writerfds, fd_set *exceptfds, struct timeval *timeout);
struct timeval
{
    long tv_sec;        //秒数
    long tv_usec;       //微妙数
}

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

这三个系统调用都有一个timeout的参数,当发生I/O事件时,这三个系统调用将会返回;当指定的时间到达时,如果没有I/O事件发生,这三个系统调用也会返回。

2、时间堆定时容器

​ 该定时器容器的思路是:将所有定时器中超时时间最小的一个定时器的超时值作为心搏间隔,这样,一旦心搏函数tick被调用,超时时间最小的定时器必然到期,我们就可以在tick函数中处理该定时器。然后,再从剩余的定时器中找到超时时间最小的一个,并将这段最小时间设置为下一次心搏间隔,如此反复,就实现了较为精确的定时。

​ 最小堆很适合处理这种定时方案。最小堆是指每个节点的值都小于或等于其子节点的值的完全二叉树。

​ 树的基本操作是插入和删除节点。对最小堆而言,它们都很简单。为了将一个元素X插入最小堆,我们可以在树的下一个空闲位置创建一个空穴,如果X可以放在空穴中而不破坏堆序,则插入完成。否则就执行上滤操作,即交换空穴和它的父节点上的元素,不断执行上述过程,直至X可以放入空穴,则插入操作完成。可以按照下图的步骤来操作,假设要在最小堆中插入一个元素14:

在这里插入图片描述

​ 最小堆的删除操作指的是删除其根节点上的元素,并且不破坏堆序性质。执行删除顶部元素操作时,我们需要先在根节点处创建一个空穴,由于堆现在少了一个元素,因此我们可以将堆的最后一个元素X移动到该堆的某个地方。如果X可以被放入空穴,则删除操作完成。否则就执行下滤操作,即交互空穴和它的两个儿子中的较小者。不断进行上述过程,直至X可以被放入空穴,则删除操作完成。比如我们对上图插入元素前的最小堆执行删除顶部元素操作:

在这里插入图片描述

​ 由于最小堆是一种完全二叉树,所以我们可以使用数组来组织其中的元素。对于数组中的任意一个位置i上的元素,其左儿子节点在位置2i+1上,右儿子在位置2i+2上,其父节点在位置[(i - 1) / 2](i>0)上。与用链表来表示堆相比,用数组不仅节省空间,而且更容易实现堆的插入、删除等操作。

最小堆实现的定时容器的代码实现如下:

​ 在timer_common.hpp中定义了Timer以及ITimerContainer两个类,Timer类为定时器类,ITimerContiner类为定时器容器的一个虚基类或者说是接口,后续将实现最小堆定时器、时间轮定时器以及红黑树定时器,这几个定时器都实现了ITimerContainer中的方法。
最小堆定时容器的几个接口介绍:

​ 1) tick :在tick函数中循环查找超时值最小的定时器,并调用其回调函数,直到找到的定时器的没有超时,就结束循环。

​ 2)addTimer::向容器中添加一个定时器,并返回定时器的指针。

​ 3)delTimer::根据传入的定时器指针删除容器中的一个定时器,并且销毁资源。

​ 4)resetTimer: 重置一个定时器。

​ 5)getMinExpire:获取容器中超时值最小的绝对时间;

timer_common.hpp:

#ifndef _LIB_SRC_TIMER_COMMON_H
#define _LIB_SRC_TIMER_COMMON_H

#include <stdio.h>
#include <sys/time.h>

// 获取时间戳 单位:毫秒
time_t getMSec()
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec * 1000 + tv.tv_usec / 1000;
}

// 定时器数据结构的定义
template <typename _User_Data>
class Timer
{
public:
    Timer() : _user_data(nullptr), _cb_func(nullptr) {};
    Timer(int msec) : _user_data(nullptr), _cb_func(nullptr)
    {
        this->_expire = getMSec() + msec;
    }

    ~Timer()
    {

    }

    void setTimeout(time_t timeout)
    {
        this->_expire = getMSec() + timeout;
    }

    time_t getExpire()
    {
        return _expire;
    }

    void setUserData(_User_Data *userData)
    {
        this->_user_data = userData;
    }

    void handleTimeOut()
    {
        if(_cb_func)
        {
            _cb_func(_user_data);
        }
        
    }

    using TimeOutCbFunc = void (*)(_User_Data *);
    void setCallBack(TimeOutCbFunc callBack)
    {
        this->_cb_func = callBack;
    }

private:
    time_t _expire;                    // 定时器生效的绝对时间            
    _User_Data *_user_data;            // 用户数据
    TimeOutCbFunc _cb_func;           // 超时时的回调函数
};



template <typename _UData>
class ITimerContainer 
{
public:
    ITimerContainer() = default;
    virtual ~ITimerContainer() = default;

public:
    virtual void tick() = 0;               
    virtual Timer<_UData> *addTimer(time_t timeout) = 0;
    virtual void delTimer(Timer<_UData> *timer) = 0;
    virtual void resetTimer(Timer<_UData> *timer, time_t timeout) = 0;
    virtual int getMinExpire() = 0;
};

#endif

heap_timer.hpp:

#ifndef _LIB_SRC_HEAP_TIMER_H_
#define _LIB_SRC_HEAP_TIMER_H_


#include <iostream>
#include "timer_common.hpp"


/*
 * @Author: MGH
 * @Date: 2021-09-29 13:01:04
 * @Last Modified by: Author
 * @Last Modified time: 2021-09-29 13:01:04
 * @Description: Heap Timer
*/

#define HEAP_DEFAULT_SIZE 128



// 定时器数据结构的定义
template <typename _User_Data>
class HeapTimer
{
public:
    HeapTimer() = default;
    HeapTimer(int msec)
    {
        timer.setTimeout(msec);
    }

    ~HeapTimer()
    {

    }

    void setTimeout(time_t timeout)
    {
        timer.setTimeout(timeout);
    }

    time_t getExpire()
    {
        return timer.getExpire();
    }

    void setUserData(_User_Data *userData)
    {
        timer.setUserData(userData);
    }

    int getPos()
    {
        return _pos;
    }

    void setPos(int pos)
    {
        this->_pos = pos;
    }

    void handleTimeOut()
    {
        timer.handleTimeOut();    
    }

    using TimeOutCbFunc = void (*)(_User_Data *);
    void setCallBack(TimeOutCbFunc callBack)
    {
        timer.setCallBack(callBack);
    }

public:
    Timer<_User_Data> timer;  

private:
      
    int _pos;                          // 保存该定时器在数组中的位置,以便查找删除操作            
};


// 定时容器,使用最小堆实现
template <typename _UData>
class HeapTimerContainer : public ITimerContainer<_UData> 
{
public:
    HeapTimerContainer();
    HeapTimerContainer(int capacity);
    HeapTimerContainer(HeapTimer<_UData> **initArray, int arrSize, int capacity);
    virtual ~HeapTimerContainer() override;

public:
    virtual void tick() override;               
    Timer<_UData> *addTimer(time_t timeout)  override;
    void delTimer(Timer<_UData> *timer)  override;
    void resetTimer(Timer<_UData> *timer, time_t timeout)  override;
    int getMinExpire() override;
    Timer<_UData> *top();
    void popTimer();

private:
    void percolateDown(int hole);
    void percolateUp(int hole);
    void resize();
    bool isEmpty();

private:
    HeapTimer<_UData> **_array;              // 堆数据
    int _capacity;                           // 堆数组的容量
    int _size;                               // 当前包含的元素
};


template <typename _UData>
HeapTimerContainer<_UData>::HeapTimerContainer() : HeapTimerContainer(HEAP_DEFAULT_SIZE)
{

}

// 初始化一个大小为cap的空堆
template <typename _UData>
HeapTimerContainer<_UData>::HeapTimerContainer(int capacity)
{
    this->_capacity = capacity;
    this->_size = 0;

    _array = new HeapTimer<_UData> *[capacity]{nullptr};
}

// 用已有数组来初始化堆 
template <typename _UData>
HeapTimerContainer<_UData>::HeapTimerContainer(HeapTimer<_UData> **initArray, int arrSize, int capacity) :
    _size(arrSize)
{
    if(capacity < arrSize) 
    {
        this->_capacity = capacity = 2 * arrSize;
    }

    _array = new HeapTimer<_UData> *[capacity];
    for (int i = 0; i < capacity; i++)
    {
        _array[i] = nullptr;
    }

    if(arrSize > 0) 
    {
        for (int i = 0; i < arrSize; i++)
        {
           _array[i] = initArray[i]; 
        }
        
        for(int i = (_size - 1) / 2; i >= 0; i--)
        {
            percolateDown(i);       //对数组中的第(_size - 1) / 2 ~ 0个元素执行下滤操作
        }
    }
    
}

template <typename _UData>
HeapTimerContainer<_UData>::~HeapTimerContainer()
{
    if(_array)
    {
        for(int i = 0; i < _size; i++) 
        {
            delete _array[i];
        }
        delete []_array;
    }
}

template <typename _UData>
void HeapTimerContainer<_UData>::tick()
{
    std::cout << "----------tick----------" << std::endl;
    HeapTimer<_UData> *tmp = _array[0];
    time_t cur = getMSec();
    // 循环处理到期的定时器
    while(!isEmpty())
    {
        if(!tmp)
        {
            break;
        }

        // 如果定时器没到期,则退出循环
        if(tmp->getExpire() > cur)
        {
            break;
        }

        tmp->handleTimeOut();
        // 将堆顶元素删除,同时生成新的堆顶定时器
        popTimer();
        tmp = _array[0];
    }
}

// 获取一个定时器
template <typename _UData>
Timer<_UData> *HeapTimerContainer<_UData>::addTimer(time_t timeout)
{
    if(_size >= _capacity)
    {
        this->resize();             //如果容量不够,则进行扩容
    }

    // hole是新建空穴的位置
    int hole = _size++;
    HeapTimer<_UData> *timer = new HeapTimer<_UData>(timeout);
    _array[hole] = timer;

    percolateUp(hole);

    return &timer->timer;
}

// 删除目标定时器
template <typename _UData>
void HeapTimerContainer<_UData>::delTimer(Timer<_UData> *timer)
{
    if(!timer) 
    {
        return ;
    }

    /* 仅仅将目标定时器的数据设置为空,延迟销毁
       等定时器超时再删除该定时器
     */
    timer->setCallBack(nullptr);
    timer->setUserData(nullptr);

}

// 重置一个定时器
template <typename _UData>
void HeapTimerContainer<_UData>::resetTimer(Timer<_UData> *timer, time_t timeout)
{
    // 类型强转
    HeapTimer<_UData> *htimer = reinterpret_cast< HeapTimer<_UData>* >(timer);

    // 找到该定时器在数组中的位置,将其与最后一个定时器的位置交换,然后先进行下滤操作,再进行上滤操作
    int pos = htimer->getPos();
    int lastPos = _size - 1;
    if(pos != lastPos)
    {
        HeapTimer<_UData> *temp = _array[pos];
        _array[pos] = _array[lastPos];
        _array[lastPos] = temp;
    }
    timer->setTimeout(timeout);

    // 下滤 上滤
    percolateDown(pos);
    percolateUp(lastPos);

}

// 获取容器中超时值最小的值
template <typename _UData>
int HeapTimerContainer<_UData>::getMinExpire()
{
    Timer<_UData> * timer = top();
    if(timer)
    {
        return timer->getExpire();
    }

    return -1;
}

// 获得顶部的定时器
template <typename _UData>
Timer<_UData> *HeapTimerContainer<_UData>::top()
{
    if(isEmpty())
    {
        return nullptr;
    }

    return &_array[0]->timer;
}

template <typename _UData>
void HeapTimerContainer<_UData>::popTimer()
{
    if(isEmpty())
    {
        return;
    }

    if(_array[0])
    {
        delete _array[0];
        // 将原来的堆顶元素替换为堆数组中最后一个元素
        _array[0] = _array[--_size];
        // 对新的堆顶元素执行下滤操作
        percolateDown(0);
    }
}

// 最小堆的下滤操作,它确保数组中以第hole个节点作为根的子树拥有最小堆性质
template <typename _UData>
void HeapTimerContainer<_UData>::percolateDown(int hole)
{
    if(_size == 0)
    {
        return ;
    }
    HeapTimer<_UData> *temp = _array[hole];
    int child = 0;
    for(; ((hole * 2 + 1) <= _size - 1); hole = child)
    {
        child = hole * 2 + 1;
        if((child < (_size - 1)) && (_array[child + 1]->getExpire() < _array[child]->getExpire()))
        {
            child++;
        }

        if(_array[child]->getExpire() < temp->getExpire())
        {
            _array[hole] = _array[child];
            _array[hole]->setPos(hole);             // 调整定时器的位置时,重新设置timer中pos保存的其在数组中的位置
        }
        else 
        {
            break;
        }
    }

    _array[hole] = temp;
    _array[hole]->setPos(hole);
}

template <typename _UData>
void HeapTimerContainer<_UData>::percolateUp(int hole)
{
    int parent = 0;
    HeapTimer<_UData> *temp = _array[hole];

    // 对从空穴到根节点的路径上的所有节点执行上滤操作
    for(; hole > 0; hole = parent)
    {
        parent = (hole - 1) / 2;
        // 将新插入节点的超时值与父节点比较,如果父节点的值小于等于该节点的值,那么就无需再调整了。否则将父节点下移,继续这个操作。
        if(_array[parent]->getExpire() <= temp->getExpire())   
        {
            break;
        }
        _array[hole] = _array[parent];
        _array[hole]->setPos(hole);
    }

    _array[hole] = temp;
    _array[hole]->setPos(hole);
    
}

// 将数组的容量扩大一倍
template <typename _UData>
void HeapTimerContainer<_UData>::resize()
{
    HeapTimer<_UData> **temp = new HeapTimer<_UData> *[2 * _capacity];
    _capacity = 2 * _capacity;
    

    for(int i = 0; i < _size; i++)
    {
        temp[i] = _array[i];
    }

    for(int i = _size; i < _capacity; i++)
    {
        temp[i] = nullptr;
    }

    delete []_array;
    _array = temp;
}

template <typename _UData>
bool HeapTimerContainer<_UData>::isEmpty()
{
    return _size == 0;
}

#endif

下面的测试代码为使用epoll实现的一个回射服务器,每个客户端连接服务端后会为每个连接设置一个定时器,超时时间为15秒,每次进行数据交互后就会重置连接对应的定时器,如果定时器超时就会被服务器踢掉。程序中使用epoll_wait来将I/O事件与定时事件进行统一处理,使用定时容器中最小的超时时间作为epoll_wait的超时时长。启动服务端,连接三个客户端进行测试。可以看到三个客户端在超时时间到的时候都被踢掉了。如果客户端在超时时间内发送数据,那么服务端就会重置相应客户端的定时器。

在这里插入图片描述

test_heap_timer.cpp:

#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <signal.h>
#include "heap_timer.hpp"


using std::cout;
using std::endl;

#define PORT 6666
#define MAX_EVENTS 1024
#define MAX_BUF_SIZE 1024

struct Event;

using readHandle = void(*)(Event *, ITimerContainer<Event> *);
using writeHandle = void(*)(Event *, ITimerContainer<Event> *);

// 自定义结构体,用来保存一个连接的相关数据
struct Event
{
    int fd;
    char ip[64];
    uint16_t port;
    epoll_event event; 

    void *timer;

    char buf[MAX_BUF_SIZE];
    int buf_size;

    readHandle read_cb;
    writeHandle write_cb;
};

int epfd;
int pipefd[2];

// 超时处理的回调函数
void timeout_handle(Event *cli)
{
    if(cli == nullptr)
    {
        return ;
    }
    
    cout << "Connection time out, fd:" << cli->fd << " ip:[" << cli->ip << ":" << cli->port << "]" << endl;

    epoll_ctl(epfd, EPOLL_CTL_DEL, cli->fd, &cli->event);

    close(cli->fd);

    delete cli;
}

void err_exit(const char *reason)
{
    cout << reason << ":" << strerror(errno) << endl;
    exit(1);
}

// 设置非阻塞
int setNonblcoking(int fd)
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);

    return old_option;
}

// 设置端口复用
void setReusedAddr(int fd)
{
    int reuse = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
}

// 初始化server socket
int socket_init(unsigned short port, bool reuseAddr)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd < 0)
    {
        err_exit("socket error");
    }

    if(reuseAddr)
    {
        setReusedAddr(fd);
    }

    struct sockaddr_in addr;
    bzero(&addr, 0);
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);

    int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret < 0)
    {
        err_exit("bind error");
    }

    setNonblcoking(fd);

    ret = listen(fd, 128);
    if(ret < 0)
    {
        err_exit("listen error");
    }

    return fd;
}

void readData(Event *ev, ITimerContainer<Event> *htc)
{
    ev->buf_size = read(ev->fd, ev->buf, MAX_BUF_SIZE - 1);
    if(ev->buf_size == 0)
    {
        close(ev->fd);
        htc->delTimer((Timer<Event> *)ev->timer);
        epoll_ctl(epfd, EPOLL_CTL_DEL, ev->fd, &ev->event);
        cout << "Remote Connection has been closed, fd:" << ev->fd << " ip:[" << ev->ip << ":" << ev->port << "]" << endl;
        delete ev;
        
        return;
    }

    ev->event.events = EPOLLOUT;
    epoll_ctl(epfd, EPOLL_CTL_MOD, ev->fd, &ev->event);
}

void writeData(Event *ev, ITimerContainer<Event> *htc)
{
    write(ev->fd, ev->buf, ev->buf_size);

    
    ev->event.events = EPOLLIN;
    epoll_ctl(epfd, EPOLL_CTL_MOD, ev->fd, &ev->event);

    // 重新设置定时器
    htc->resetTimer((Timer<Event> *)ev->timer, 15000);
}

// 接收连接回调函数
void acceptConn(Event *ev, ITimerContainer<Event> *htc)
{
    Event *cli = new Event;
    struct sockaddr_in cli_addr;
    socklen_t sock_len = sizeof(cli_addr);
    int cfd = accept(ev->fd, (struct sockaddr *)&cli_addr, &sock_len);
    if(cfd < 0)
    {
        cout << "accept error, reason:" << strerror(errno) << endl;
        return;
    } 
    setNonblcoking(cfd);

    cli->fd = cfd;
    cli->port = ntohs(cli_addr.sin_port);
    inet_ntop(AF_INET, &cli_addr.sin_addr, cli->ip, sock_len);
    cli->read_cb = readData;
    cli->write_cb = writeData;

    auto timer = htc->addTimer(15000);      //设置客户端超时值15秒
    timer->setUserData(cli);
    timer->setCallBack(timeout_handle);
    cli->timer = (void *)timer;

    cli->event.events = EPOLLIN;
    cli->event.data.ptr = (void *) cli;
    epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &cli->event);

    cout << "New Connection, ip:[" << cli->ip << ":" << cli->port << "]" << endl;
}

void sig_handler(int signum)
{
    char sig = (char) signum;
    write(pipefd[1], &sig, 1);
}

int add_sig(int signum)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = sig_handler;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);

    return sigaction(signum, &sa, nullptr);

}

int main(int argc, char *argv[])
{
    // 信号处理
    int ret = add_sig(SIGINT);
    if(ret < 0)
    {
        err_exit("add sig error");
    }
    ret = socketpair(AF_UNIX, SOCK_STREAM, 0, pipefd);
    if(ret < 0)
    {
        err_exit("socketpair error");
    }

    int fd = socket_init(PORT, true);
    Event server;
    Event sig_ev;
    server.fd = fd;
    sig_ev.fd = pipefd[0];
    
    epfd = epoll_create(MAX_EVENTS);
    if(epfd < 0)
    {
        err_exit("epoll create error");
    }

    sig_ev.event.events = EPOLLIN;
    sig_ev.event.data.ptr = (void *) &sig_ev;;

    server.event.events = EPOLLIN;
    server.event.data.ptr = (void *)&server;

    epoll_ctl(epfd, EPOLL_CTL_ADD, pipefd[0], &sig_ev.event);
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &server.event);

    cout << "------ Create TimerContainer ------" << endl;

    ITimerContainer<Event> *htc = new HeapTimerContainer<Event>;

    cout << "------ Create TimerContainer over ------" << endl;

    struct epoll_event events[MAX_EVENTS];
    int nready = 0;

    int timeout = 10000;      //设置超时值为10秒
    char buf[1024] = {0};
    bool running = true;
    while(running)
    {
        // 将定时容器中定时时间最短的时长作为epoll_wait的最大等待时间
        auto min_expire = htc->getMinExpire();
        timeout = (min_expire == -1) ? 10000 : min_expire - getMSec();
        
        nready = epoll_wait(epfd, events, MAX_EVENTS, timeout);
        if(nready < 0)
        {
            cout << "epoll wait error, reason:" << strerror(errno) << endl;
        } 
        else if(nready > 0)
        {
            // 接收新的连接
            for(int i = 0; i < nready; i++)
            {
                Event *ev =  (Event *) events[i].data.ptr;
                // 接受新的连接
                if(ev->fd == pipefd[0])
                {   
                    int n = read(pipefd[0], buf, sizeof(buf));
                    if(n < 0)
                    {
                        cout << "deal read signal error:" << strerror(errno) << endl;
                        continue; 
                    }
                    else if(n > 0)
                    {
                        for(int i = 0; i < n; i++)
                        {
                            switch (buf[i])
                            {
                            case SIGINT:
                                running = false;
                                break;
                            }
                        }
                    }
                }
                else if(ev->fd == fd )
                {
                    acceptConn(ev, htc);
                }
                else if(ev->event.events & EPOLLIN)
                {
                    ev->read_cb(ev, htc);
                }
                else if(ev->event.events & EPOLLOUT)
                {
                    ev->write_cb(ev, htc);
                }
            }
        }
        else
        {
            htc->tick();
        }
    }

    close(fd); 
    close(pipefd[0]); 
    close(pipefd[1]); 
    delete htc;

    return 0;
}

.
其它相关博客:
红黑树实现的定时器
时间轮实现的定时器

参考资料:《Linux高性能服务器编程》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值