63 多路转接epoll

目录

  1. 初识
  2. 相关调用
  3. 工作原理
  4. 工作方式
  5. 使用场景
  6. 惊群问题
  7. 优缺点
  8. 项目构建工具
  9. LT示例

1. 初识

按照man手册的说法:是为处理大批句柄而做了改进的poll
它是在2.5.44内核中被引进(epoll(4) is a new API introduced in Linux Kerner 2.5.44),它几乎具备了之前所说的一切优点,被公认为linux2.6下性能最好的多路io就绪通知的方法

2. 相关调用

有3个相关调用
epoll_create

int epoll_create (int size);

创建一个epoll的句柄

  • 自从linux2.6.8之后,size参数是被忽略的
  • 用完之后,必须调用close()关闭

epoll_ctl

int epoll_ctl (int epfd, int op, int fd, struct epoll_event* event);

事件注册函数:

  • 不同于selelct是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型
  • 第一个参数是epoll_create()的返回值(epoll的句柄)
  • 第二个参数表示动作,用三个宏表示
  • 第三个参数是需要监听的fd
  • 第四个参数是告诉内核需要监听什么事

第二个参数的取值:
EPOLL_CTL_ADD: 注册新的fd到epfd中
EPOLL_CTL_MOD: 修改已经注册的fd的监听事件
EPOLL_CTL_DEL: 从epfd中删除一个fd

struct epoll_event结构:
在这里插入图片描述

events可以是以下几个宏的集合:
EPOLLIN:表示对应的文件买搜狐福可读(包括对端SOCKET正常关闭)
EPOLLOUT:表示对应的文件描述符可写
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR:表示对应的文件描述符发生错误
EPOLLHUP:表示对应的文件描述符被挂断
EPOLLET:将EPOLL设为边缘触发(Edge TRiggered)模式,这时相对于水平触发(Level Triggered来说的)
EPOLLONSHOT:只监听一次事件,当监听完事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

epoll_wait

int epoll_wait( (int epfd, struct epoll_event* evnets, int maxevents, int timeout);

收集在epoll监控的时间中已经发送的事件

  • 参数evnets是分配好的epoll_event结构体数组
  • epoll将会把发生的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个evnets数组中,不会去帮助我们在用户态分配内存)
  • maxevents告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
  • 参数timeout是超时时间(毫秒,0会立即返回,-1是永久阻塞)
  • 如果函数调用成功,返回对应I/O上已经准备好的文件买搜狐福数目,如果返回0表示已超时,返回小于0表示函数失败

3. 工作原理

数据先到达网卡的,网卡是硬件,通过硬件中断的方式让os知道有数据到达,将数据拿到内存,通过回调函数将数据向上交付。维护一个红黑树,节点包括要关心的fd,还有要关心的事件,再维护一个就绪事件队列,当红黑树中要关心的事件就绪时,将这个链接到队列中。驱动层这个回调函数做4个事情,1.向上交付 2.交付给tcp的接收队列 3.查找红黑树,用文件描述符做键,找到就绪事件 4.构建就绪节点,插入到就绪队列

epoll模型就是创建和维护这些东西,将它看做文件,有对应的struct file结构,指向epoll模型。把它添加到进程对应的文件描述符表里,通过fd就可以找到这个结构
在这里插入图片描述在这里插入图片描述

当某一进程调用epoll_create方法时,内核会创建一个eventpoll及饿哦固体,这个里有两个成员与epoll的使用方式密切相关

struct eventpoll{

/红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件/
struct rb_root rbr;
/双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件/
struct list_head rdlist;

};

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象添加的事件
这些事件会挂在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来,(红黑树的插入时间效率是lgn,其中n为树的高度)
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,当响应的事件发生时,会调用这个回调方法
这个回调方法在内核中叫ep_poll_callback,它会将发生的事添加到rdlist双链表中
在epoll中,对于每一个时间事件,都会建立一个epitem结构体

struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}

当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表是否有epitem元素即可,所以是O(1)的,获取就绪时O(N)的
如果rdlist不为空,则把发生的事件复制到用户态,同时将时间数量返回给用户,这个操作的时间复杂度是O(1)

总结一下,epoll的使用过程就是三部曲:
调用epoll_create创建一个epoll句柄
调用epoll_ctl,将要监控的文件描述符进行注册
调用epoll_wait,等待文件描述符就绪

4. 工作方式

epoll有两种方式,水平触发(LT)和边缘触发(ET)

正在玩游戏,到了决赛时刻,你妈饭做好了,喊吃饭的两种方式

  1. 喊一次没动,继续喊第二次,第三次…(水平触发)
  2. 喊一次,没动,不管了(边缘触发)

假如有这样一个例子:
我们已经把一个tcp socket添加到epoll描述符
这时候socket的另一端被写入了2kb数据
调用epoll_wait,并且会返回,说明它已经准备好读取操作
然后调用read,只读取了1kb的数据
继续调用epoll_wait。。。

水平触发Level Triggered工作模式
epoll默认状态下就是LT工作模式,事件到来,上层不处理,高电平一直有效

  • 当epoll检测到socket上事件就绪的时候,可以不立刻进行处理,或者只处理一部分
  • 如上面的例子,由于只读了1k数据,缓冲区还剩1k数据,在第二次调用epoll_wait时,epoll_wait仍然会立刻返回并通知socket读事件就绪
  • 直到缓冲区上所有的数据都被处理完,epoll_wait才不会立刻返回

边缘触发Edge Triggered工作模式
如果在第一部将socket添加到epoll描述符使用了EPOLLET标志,进入ET工作模式。数据或者连接,从无到有,从有到多,变化的时候,才会通知一次

  • 当epoll检测到socket上事件就绪时,必须立刻处理
  • 上面的例子,只读取了1k的数据,还剩1k,第二次调用的时候,不会再返回了
  • ET模式下,文件描述就绪后,只有一次处理机会
  • ET的性能比LT性能更高,(epoll_wait返回的次数少了很多),Nginx默认采用ET模式epoll
  • 只支持非阻塞的读写

slect和poll也是工作在LT模式下,epoll两种都支持

对比LT和ET

LT是epoll默认行为,使用ET能减少epoll触发次数,代价就是必须一次相应就把所有数据处理完

相当于一个文件描述符就绪后,不会反复提示就绪,通知效率更高,减少了epoll次数,io效率更高,使得返回给对方的窗口更大。但是LT如果也能做到每次就绪的文件描述都立刻处理,设为nonblock循环读取,不让这个就绪重复提示,其实性能也是一样的

ET的代码复杂度更高了

理解ET模式和非阻塞文件描述符

使用et模式的epoll需要将文件描述符设为非阻塞,不只是接口的要求,而是实践上的要求
假如这样的场景:服务器接受到一个10k的请求,会向客户端返回一个应答数据,如果客户端收不到应答,不会发送第二个10k的请求
在这里插入图片描述
如果服务器的代码是阻塞式的read,并且一次只read1k的数据的话(read不能保证一次就把所有数据读出来,参考man手册说明,可能被信号打断),剩下9k数据会待在缓冲区
在这里插入图片描述
此时由于epoll是et模式,并不会认为文件描述符就绪,epoll_wait不会再返回,剩下9k数据一直在缓冲区中,客户端收不到应答不会再发送。直到下一次客户端再给服务器写数据,epoll_wait才返回
问题来了

  • 服务器只读到了1k个数据,要10k都读完才能给客户端返回响应数据
  • 客户端要读到服务器的响应,才会发送下一个请求
  • 客户端发送了下一个请求,epoll_wait才会返回,惨能去读缓冲区中剩余的数据

在这里插入图片描述

所以,为了解决上面的问题(阻塞read不一定能一次把完整的请求读完),于是使用非阻塞轮询的方式读缓冲区,保证一定吧完整的请求都读出来
如果lt没这个问题,只要缓冲区数据没读完,能让epollwait返回就绪

5. 使用场景

epoll的高性能,在一些特定场景不适宜
对于多连接,且多链接中只有一部分链接比较活跃时,比较适合用epoll

例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网app的入口服务器,这样的服务器很适合epoll,如果只是系统内部,服务器和服务器之间通信,只有少数几个连接,这种情况下用epoll不合适,具体根据需求和场景决定使用哪种io模型

6. 惊群问题

多线程或多进程环境下,多个线程同时监听socket,当新链接请求到来,os不知道派哪个线程处理,干脆将其中几个线程都唤醒,实际上只有一个线程能成功处理accept,其他线程都失败,错误码EAGAIN,这种现象称为惊群效应

多线程下,不建议让多个线程同时在epoll_wait监听socket,让其中一个线程监听,有新的连接到来,由这个线程建立,交给其他线程处理后续读写

多进程下,如lighttpd,nginx等很多采用了master/workers的模式提高并发能力,nginx中甚至采用了负载均衡。niginx的解决,同一时刻,只有一个进程监听,创建全局的mutex,子进程wait前,先获取锁,当连接数到达一定程度,不会再去获取锁

7. 优缺点

和select的缺点对应

  • 接口使用方便,虽然拆分成了三个函数,但是反而使用起来更方便高效,不需要每次虚幻都设置关注的文件描述符,也做到了输入输出参数分开
  • 数据拷贝轻量,只在合适的时候调用EPOLL_CTL_ADD,将文件描述符结构拷贝到内核中,这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
  • 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列中,epoll_wait返回直接访问就绪队列就知道那些文件描述符就绪,这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会受到影响
  • 没有数量限制:文件描述符数目无上限

缺点
1.系统依赖性,linux特有的io事件通知机制,跨平台不兼容
2.更复杂,调试困难,涉及多个复杂的回调机制
3.epoll_ctl开销,处理大量短连接会对性能影响
4.边缘触发模式的风险,支持边缘出发et模式,没有及时处理可能会导致数据丢失

注意:
有些地方说,epool使用了内存映射机制,内存映射机制是内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样的额外性能开销
这种说法是不准确的,我们定义的struct epoll_event是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝这个用户空间的内存中的

总结select,poll,epoll之间的优点和缺点(重要)

8. 项目构建工具

CMakeLists.txt

除过makefile构建项目外,还可以使用CmakeLists,自动生成makefile,创建这个文件
在这里插入图片描述
指定使用的cmake版本

cmake_minimum_required(VERSION 2.8.12.2)

项目名称和语言

project(名字)

编译选项
使用c++11标准,早期的版本可以适用该语句,较新的版本使用第二个

set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} -std=c++11”)
set(CMAKE_CXX_STANDARD 11)

确保使用c++11标准

set(CMAKE_CXX_STANDARD_REQUIRED ON)

添加源文件和程序名

add_executable(程序名 源文件)

编译命令

cmake . //编译生成在当前目录

构建程序

make

清除

make clean

重新编译,删除build的文件夹,就是CMakeFiles文件夹

9. LT示例

先创建cmakefile
CMakeLists.txt

cmake_minimum_required(VERSION 2.8.12.2)
project(EpollServer)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 确保必须使用指定的标准
add_executable(EpollServer EpollServer.cc)

将Epoll模型的几个函数封装成类
类维护模型返回的文件标识
EpollWait函数的返回值,调用epoll_wait会返回就绪的事件数量,可以作为函数的返回值
EpollUpdate函数封装epoll_ctl函数,参数传入需要进行的操作,文件标识,需要添加的事件。调用函数前根据需要进行的操作区分,分为添加事件和删除,添加事件需要创建一个epoll_event结构体,赋值传入。删除只需要将事件设为nullptr

Epoll.hpp

#pragma once
#include <sys/epoll.h>
#include "nocopy.hpp"
#include "log.hpp"

class Epoll : public nocopy
{

    static const int size = 128;
    Log log;

public:
    Epoll()
    {
        _epfd = epoll_create(size);
        if (_epfd == -1)
        {
            log.logmessage(ERROR, "epoll create error:%s", strerror(errno));
        }
        else
        {
            log.logmessage(info, "epoll create success:%d", _epfd);
        }
    }

    int EpollWait(struct epoll_event revents[], int num)
    {
        int n = epoll_wait(_epfd, revents, num, -1);
        return n;
    }

    int EpollUpdate(int oper, int sock, uint32_t event)
    {
        int n = 0;
        if (oper == EPOLL_CTL_DEL)
        {
            n = epoll_ctl(_epfd, oper, sock, nullptr);
            if (n != 0)
            {
                log.logmessage(ERROR, "epoll_ctl delete error");
            }
        }
        else
        {
            struct epoll_event ev;
            ev.events = event;
            ev.data.fd = sock;

            n = epoll_ctl(_epfd, oper, sock, &ev);
            if (n != 0)
            {
                log.logmessage(ERROR, "epoll_ctl add error");
            }
        }
    }

    ~Epoll()
    {
        if (_epfd >= 0)
        {
            close(_epfd);
        }
    }

private:
    int _epfd;
    int _timeout{3000};
};

上面的类不想让它拷贝,所以继承一个不能拷贝的类
nocopy.hpp

#pragma once

class nocopy
{
public:
    nocopy(){}
    nocopy(const nocopy &) = delete;
    nocopy& operator=(const nocopy&) = delete;
};

服务器源文件生成一个对象,调用初始化和执行函数
EpollServer.cc

#include <memory>
#include "EpollServer.hpp"

int main()
{
    std::unique_ptr<EpollServer> svr(new EpollServer);
    svr->Init();
    svr->Start();
    return 0;
}

epoll服务器类维护套接字类和epoll类的对象
在这里插入图片描述
构造的时候初始化,init函数套接字设为监听,start函数将listen套接字添加到epoll模型中,死循环调用epoll_wiat函数,for循环外创建一个epoll_event,作为函数的输出参数。当返回值大于0时说明有事件就绪,调用分配函数

分配函数需要创建的epoll_event变量,还有就绪的事件数量。根据事件数量循环取到事件位图和标识符,判断是读还是写事件,读的话区分是新链接还是其他事件,如果是新链接,获取套接字,并添加到epoll的监听事件里。如果是读事件就正常读写,然后回显给客户端

EpollServer.hpp

#pragma ocne
#include <iostream>
#include <memory>
#include "log.hpp"
#include "Epoll.hpp"
#include "Socket.hpp"

static const uint16_t port = 8000;
uint32_t EVENT_IN = (EPOLLIN);
uint32_t EVENt_OUT = (EPOLLOUT);

class EpollServer :public nocopy
{
    static const int num = 64;

public:
    EpollServer()
        : _listensocket_ptr(new Sock()), _epoll_ptr(new Epoll())
    {
    }

    void Init()
    {
        _listensocket_ptr->Socket();
        int opt = 1;
        setsockopt(_listensocket_ptr->Fd(), SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        _listensocket_ptr->Bind(port);
        _listensocket_ptr->Listen();
    }

    void Accepter()
    {
        // 获取新链接
        std::string ip;
        uint16_t port = 0;
        int sock = _listensocket_ptr->Accept(&ip, &port);
        if (sock > 0)
        {
            // 不能直接取,需要注册
            _epoll_ptr->EpollUpdate(EPOLL_CTL_ADD, sock, EVENT_IN);
            lg.logmessage(info, "get a new link,client@%s:%d", ip.c_str(), port);
        }
    }

    void Recver(int fd)
    {
        char buff[1024];
        ssize_t n = read(fd, buff, sizeof(buff) - 1);
        if (n > 0)
        {
            buff[n] = 0;
            std::cout << "get a message:" << buff << std::endl;
            // 回显
            std::string echo_str = "server echo$:";
            echo_str += buff;
            write(fd, echo_str.c_str(), echo_str.size());
        }
        else if (n == 0)
        {
            lg.logmessage(info, "client quit, me too, close fd:%d", fd);
            // 先取消关注,再关文件
            _epoll_ptr->EpollUpdate(EPOLL_CTL_DEL, fd, 0);
            close(fd);
        }
        else
        {
            lg.logmessage(warning, "recv error fd:%d", fd);
            _epoll_ptr->EpollUpdate(EPOLL_CTL_DEL, fd, 0);
            close(fd);
        }
    }

    void Dispatcher(struct epoll_event rev[], int num)
    {
        for (int i = 0; i < num; i++)
        {
            uint32_t events = rev[i].events;
            int fd = rev[i].data.fd;

            if (events & EVENT_IN)
            {
                if (fd == _listensocket_ptr->Fd())
                {
                    Accepter();
                }
                else
                {
                    Recver(fd);
                }
            }
            else if (events & EVENt_OUT)
            {
            }
            else
            {
            }
        }
    }

    void Start()
    {
        // 将listensock添加到epoll中 -> listensock和他关心的事件,添加到内核epoll模型中rb_tree
        _epoll_ptr->EpollUpdate(EPOLL_CTL_ADD, _listensocket_ptr->Fd(), EVENT_IN);
        struct epoll_event revs[num];

        for (;;)
        {
            int n = _epoll_ptr->EpollWait(revs, num);
            if (n > 0)
            {
                lg.logmessage(info, "event happened,fd:%d", revs[0].data.fd);
                Dispatcher(revs, n);
            }
            else if (n == 0)
            {
                lg.logmessage(info, "timeout...");
            }
            else
            {
                lg.logmessage(ERROR, "epoll wait error");
            }
        }
    }

private:
    std::unique_ptr<Sock> _listensocket_ptr;
    std::unique_ptr<Epoll> _epoll_ptr;
};

其他

#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include "log.hpp"

enum
{
    SOCKERR = 1,
    BINDERR,
    LISERR
};

Log lg;
const int backlog = 5;
class Sock
{
public:
    Sock()
    {

    }

    void Socket()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            lg.logmessage(fatal, "socket error");
            exit(SOCKERR);
        }

        int opt = 1;
        setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法进行立即重启(tcp协议的时候再说)
    }

    void Bind(uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY;
        local.sin_port = htons(port);

        int bret = bind(_sockfd, (const struct sockaddr*)&local, sizeof(local));
        if (bret < 0)
        {
            lg.logmessage(fatal, "bind error");
            exit(BINDERR);
        }
    }

    void Listen()
    {
        int lret = listen(_sockfd, backlog);
        if (lret < 0)
        {
            lg.logmessage(fatal, "listen error");
            exit(LISERR);
        }
    }
    
    int Accept(string* clientip, uint16_t* clientport)
    {
        sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int newfd = accept(_sockfd, (sockaddr*)&peer, &len);
        if (newfd < 0)
        {
            lg.logmessage(warning, "accept error");
            return -1;
        }

        char ipstr[64];
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));
        *clientip = ipstr;
        *clientport = ntohs(peer.sin_port);

        return newfd;
    }

    bool Connect(const string ip, const uint16_t port)
    {
        sockaddr_in peer;
        memset(&peer, 0, sizeof(peer));
        peer.sin_family = AF_INET;
        inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);
        peer.sin_port = htons(port);

        int cret = connect(_sockfd, (const struct sockaddr*)&peer, sizeof(peer));
        if (cret == -1)
        {
            lg.logmessage(warning, "connect error");
            return false;
        }

        return true;
    }

    void Close()
    {
        close(_sockfd);
    }

    int Fd()
    {
        return _sockfd;
    }
    ~Sock()
    {

    }
public:
    int _sockfd;
};

#pragma once
#include <stdarg.h>
#include <iostream>
#include <stdio.h>
#include <cstring>
#include <time.h>
#include <cerrno>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

using namespace std;

#define info 0
#define debug 1
#define warning 2
#define ERROR 3
#define fatal 4

#define screen 1
#define onefile 2
#define classfile 3

#define PATH "log.txt"

class Log
{
public:

    Log(int style = screen)
    {
        printstyle = style;
        dir = "log/";
    }

    void enable(int method)
    {
        printstyle = method;
    }

    const char *leveltostring(int level)
    {
        switch (level)
        {
        case 0:
            return "info";
            break;
        case 1:
            return "debug";
            break;
        case 2:
            return "warning";
            break;
        case 3:
            return "error";
            break;
        case 4:
            return "fatal";
            break;
        default:
            return "none";
            break;
        }
    }

    void printlog(int level, const string &logtxt)
    {
        switch (printstyle)
        {
        case screen:
            cout << logtxt;
            break;
        case onefile:
            printonefile(PATH, logtxt);
            break;
        case classfile:
            printclassfile(level, logtxt);
            break;
        }
    }

    void logmessage(int level, const char *format, ...)
    {
        time_t t = time(0);
        tm *ctime = localtime(&t);
        char leftbuff[1024];
        sprintf(leftbuff, "[%s]%d-%d-%d %d:%d:%d:", leveltostring(level), ctime->tm_year + 1900,
                ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec);

        char rightbuff[1024];
        va_list s;
        va_start(s, format);
        vsprintf(rightbuff, format, s);
        va_end(s);
        char logtext[2048];
        sprintf(logtext, "%s %s\n", leftbuff, rightbuff);
        //printf(logtext);
        printlog(level, logtext);
    }
 

    void printonefile(const string& logname, const string& logtxt)
    {
        int fd = open(logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);  
        if (fd < 0)
        {
            return;
        }

        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }

    void printclassfile(int level, const string &logtxt)
    {
        //log.txt.info
        string filename = dir + PATH;
        filename += ".";
        filename += leveltostring(level);
        printonefile(filename, logtxt);
    }

    ~Log(){};

private:
    int printstyle;
    string dir; //分类日志,放入目录中
};

// int sum(int n, ...)
// {
//     int sum = 0;
//     va_list s;
//     va_start(s, n);

//     while (n)
//     {
//         sum = sum + va_arg(s, int);
//         n--;
//     }

//     return sum;
// }

参考资料

epoll详解
apache/nginx网络模型

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值