【C语言】epoll_wait / select

187 篇文章 1 订阅
88 篇文章 1 订阅

一、epoll_wait和select对比

1. 阻塞和非阻塞

在Linux C语言中进行socket编程时,`epoll_wait` 和 select 都是用于多路I/O复用的系统调用,但是它们的行为可以设置为阻塞和非阻塞模式,这取决于调用它们时所使用的参数。

让我们分别看看 epoll_wait 和 select

epoll_wait

   epoll_wait 函数用于等待由 epoll 文件描述符指向的事件,它可以工作在阻塞模式,也可以工作在非阻塞模式。

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

   在这里,`timeout` 参数决定 epoll_wait 的行为,其值可以是:
   - -1 表示无限期阻塞,直到有事件发生。
   - 0 表示非阻塞调用,即使没有事件,也立即返回。

   - 大于0的值表示等待指定毫秒数,如果在这个时间段内没有事件发生,它将返回。

select

   select 函数也是多路I/O复用的调用,它监视一组文件描述符,以查看是否有数据可读、可写或有异常。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

   其中,`timeout` 参数同样控制 select 的行为,可以设置为:
   - NULL 表示无限期阻塞,直到有文件描述符就绪。
   - 其中的 tv_sec 和 tv_usec 成员均为 0 表示非阻塞调用,立即返回。

   - 时间结构设置为某个特定的时间量(秒和微秒),这意味着 select 会阻塞但最多等待这段时间。

`epoll_wait` 和 select 都可以根据需求设置为阻塞或非阻塞。在性能方面,`select` 受制于能够监视的文件描述符数量的限制(通常是FD_SETSIZE,通常为1024),而 epoll 更适合大规模文件描述符的监视,因为 epoll 内部使用了不同的机制,它不受固定大小限制,并且当活动文件描述符的数量远小于总数时,它能提供更好的性能。

选择使用 select 或 epoll(以及 poll),取决于具体的应用需求以及对性能和可扩展性的考量。在现代Linux系统上,`epoll` 通常是大规模且长时间运行的网络服务程序的首选。

2. 工作方式

在Linux C语言socket编程中,`epoll_wait`和`select`都是用来监视多个文件描述符的可读、可写、异常等状态的系统调用,但它们的工作方式有所不同。

select

select函数是POSIX标准的一部分,它允许应用程序监视一组文件描述符,等待一个或多个文件描述符成为非阻塞状态。`select`使用一个固定大小的文件描述符集合,并且在每次调用`select`时,都需要重新设置文件描述符集和超时时间。这种方法在监控大量文件描述符时效率不高,因为它需要线性地检查每一个文件描述符。

epoll

epoll系列函数是Linux特有的,提供了一种更高效的机制来处理大量文件描述符。`epoll`使用一个事件表来跟踪每个文件描述符的状态,并在文件描述符状态改变时通知应用程序。`epoll`的优势在于它不需要在每次调用时都重新设置文件描述符集合,而且它能以常数时间复杂度管理文件描述符集合,这在处理大量文件描述符时可以提供更好的性能。

当使用`epoll`时,通常是通过以下步骤操作的:

1. 使用`epoll_create`创建一个`epoll`实例。
2. 使用`epoll_ctl`添加、修改或删除要监视的文件描述符。

3. 使用`epoll_wait`等待事件的发生,并处理发生的事件。

如果选择使用`epoll`系列函数来进行socket编程,就不需要使用`select`函数了,因为`epoll`提供了更高效且功能更完备的替代方案。在处理大规模并发连接时,`epoll`通常是更好的选择。

二、epoll代码示例

1. python示例

epoll 是 Linux 上的一个高效的 IO 事件通知系统,它能够告诉你哪些文件描述符(sockets、文件、pipes等)已经准备好执行非阻塞的读取或写入操作。与传统的 select 和 poll 相比,`epoll` 在处理大量文件描述符时更加高效。下面是一个简单的 epoll 示例代码。

注意:以下示例代码仅适用于 Linux 系统。

import socket
import select
import errno

# 创建一个 TCP/IP 套接字
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 设置套接字选项,允许我们重新绑定同一个端口
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# 绑定套接字到端口
server_address = ('localhost', 10000)
print('开始在 %s 端口 %s 上监听' % server_address)
server_socket.bind(server_address)

# 监听入站连接
server_socket.listen(1)

# 设置非阻塞模式
server_socket.setblocking(0)

# 创建一个 epoll 对象
epoll = select.epoll()

# 在 epoll 事件循环中注册服务器套接字,监听读事件
EPOLLIN = select.EPOLLIN
epoll.register(server_socket.fileno(), EPOLLIN)

try:
    connections = {}
    requests = {}
    responses = {}

    while True:
        # 等待事件发生,可能会使得程序进入阻塞状态
        events = epoll.poll(1)

        for fileno, event in events:
            if fileno == server_socket.fileno():
                # 新入站连接
                connection, client_address = server_socket.accept()
                print('新连接来自', client_address)
                connection.setblocking(0)
                # 注册读事件
                epoll.register(connection.fileno(), EPOLLIN)
                connections[connection.fileno()] = connection
                requests[connection.fileno()] = b''
                responses[connection.fileno()] = b''
            elif event & EPOLLIN:
                # 可读事件
                data = connections[fileno].recv(1024)
                if data:
                    print(f"接收到 {len(data)} 字节: '{data.decode()}'")
                    requests[fileno] += data
                else:
                    # 如果没有数据,意味着客户端关闭了连接
                    epoll.unregister(fileno)
                    connections[fileno].close()
                    del connections[fileno], requests[fileno], responses[fileno]
            elif event & select.EPOLLOUT:
                # TODO: 可写事件处理逻辑
                # 在这里处理 responses 字典中待发送的数据
                pass
            elif event & select.EPOLLHUP:
                # TODO: 处理挂起的连接
                epoll.unregister(fileno)
                connections[fileno].close()
                del connections[fileno]
finally:
    # 释放资源
    epoll.unregister(server_socket.fileno())
    epoll.close()
    server_socket.close()

这段代码创建了一个 TCP 服务器,它使用 epoll 来管理每个连接的事件。在这个简单的例子中,服务器仅仅是读取客户端数据,并打印出来。

注意代码中有几个 TODO 注释,提示在哪里添加处理可写事件和挂起连接的代码。对于一个完整的服务器,通常还需要处理客户端发送的请求,并产生响应发送回客户端。

确保在实际运行这段代码前,已经对 epoll、非阻塞套接字和事件驱动编程有所理解。通过细读并且动手实践,将能够理解在一个高性能网络程序中如何使用 epoll

2. C语言示例

Linux C 语言中使用 epoll 的 socket 编程示例相对复杂,因为它涉及对 socket API 的理解以及对事件驱动编程模型的使用。以下是一个简单的 epoll 示例,这个示例程序的目的是创建一个 TCP echo 服务器,它使用 epoll 来处理多个客户端连接。

请注意,以下代码将仅作为示教用途,它并没有处理所有可能的错误,并且不应该在生产环境中直接使用。此外,确保在编译时链接到 -lnsl 库(如果需要)。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/socket.h>
#include <resolv.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

#define PORT        12345
#define MAX_EVENTS  32

void die(const char *msg) {
    perror(msg);
    exit(EXIT_FAILURE);
}

int main() {
    int listenfd, connfd, epollfd, nfds, i;
    struct sockaddr_in addr;
    struct epoll_event ev, events[MAX_EVENTS];

    // 创建和绑定 socket
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) die("socket() failed");

    bzero(&addr, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(listenfd, (struct sockaddr *)&addr, sizeof(addr)) != 0)
        die("bind() failed");

    // 监听 socket
    if (listen(listenfd, 10) != 0)
        die("listen() failed");

    // 创建 epoll 实例
    epollfd = epoll_create1(0);
    if (epollfd < 0) die("epoll_create1() failed");

    ev.events = EPOLLIN;
    ev.data.fd = listenfd;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev) == -1)
        die("epoll_ctl: listenfd");

    for (;;) {
        nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        if (nfds < 0) die("epoll_wait() failed");

        for (i = 0; i < nfds; ++i) {
            if (events[i].data.fd == listenfd) {
                connfd = accept(listenfd, NULL, NULL);
                if (connfd < 0) die("accept() failed");

                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = connfd;
                if (epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &ev) == -1)
                    die("epoll_ctl: connfd");
            } else {
                char buffer[256];
                ssize_t bytes_read;

                // 处理客户端数据
                bytes_read = read(events[i].data.fd, buffer, sizeof(buffer));
                if (bytes_read <= 0) {
                    // 读取完成或者发生错误,关闭连接
                    close(events[i].data.fd);
                } else {
                    buffer[bytes_read] = '\0';
                    printf("Received: %s", buffer);

                    // Echo 回数据
                    write(events[i].data.fd, buffer, bytes_read);
                }
            }
        }
    }

    close(listenfd);
    return 0;
}

在这个示例中,我们创建了一个 TCP 服务器 socket,并将它绑定到指定的端口(这里是 12345)。我们对这个 socket 调用了 listen,把它变成一个监听 socket,并创建了一个 epoll 实例来处理链接事件和数据接收事件。服务器以事件循环的方式运行,使用 epoll 等待新事件。

当新的连接到达时,它会被接受并添加到 epoll 实例以供监视。当已连接的 socket 有数据可读时,服务器会读取这些数据并简单地将其作为 echo 响应发回客户端。

记得在多客户端的情况下还应该考虑:

- 不同客户端之间进行全面的状态管理。
- 对于边缘触发(EPOLLET)的处理更为复杂,需要确保数据完全读取。
- 此代码示例没有使用SSL,对于安全连接需要集成SSL库。

- 更强的错误处理和程序稳健性措施。

三、epoll事件

epoll 是 Linux 下的一种 I/O 事件通知机制,它可以高效地处理数以万计的文件描述符。当注册一个文件描述符(比如 socket)到 epoll 实例时,需要指定感兴趣的事件类型。`epoll` 能够监视多种类型的事件,以下是一些常见的 epoll 事件及其含义:
1. EPOLLIN:表示对应的文件描述符可以读取(非高优先级)数据,比如 TCP Socket 的接收缓冲区非空,可以调用 recv(),或者 UDP Socket 收到了数据包,可以调用 recvfrom()。
2. EPOLLOUT:表示对应的文件描述符可以写入数据,比如 TCP Socket 的发送缓冲区有空间,可以调用 send()。
3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(带外数据),这适用于 TCP Socket。
4. EPOLLERR:表示对应的文件描述符发生了错误。这不必由用户设置,即使未指定,系统也会报告它。
5. EPOLLHUP:表示对应的文件描述符被挂断。如果 Socket 对端关闭了连接,或者是某种半关闭状态,你会收到这个事件。类似 EPOLLERR,这个事件也会不论是否被用户请求都报告它。
6. EPOLLRDHUP(自 Linux 2.6.17 版本起):表示 Socket 监测到连接的另一端关闭了连接或者半关闭了连接。适用于 TCP Socket。
7. EPOLLET:将 epoll 设置为边缘触发(Edge Triggered)模式,这意味着 epoll 仅通知你一次事件,直到下一个事件发生,不会再次通知你相同的事件。
8. EPOLLONESHOT:表示一次性的事件,当事件被触发后,如果需要再次被触发必须重新设置。
这些事件均适用于 TCP 和 UDP Socket,但是某些事件在实际应用中更常用于 TCP(如:`EPOLLRDHUP`、`EPOLLPRI`)。由于 TCP 是一个面向连接的协议,它支持如连接的建立、数据的可靠传输、流控制等多种事件;而 UDP 是一个无连接的协议,通常只关心数据的接收与发送(`EPOLLIN` 和 EPOLLOUT)。在使用 UDP 时,`EPOLLERR` 也可能被用来处理错误情况。

四、处理EPOLLERR事件

在Linux系统中,`epoll` 是一种高效的 IO 事件通知机制,它能够告诉你哪些文件描述符(file descriptors, FDs)已经准备好进行非阻塞的读写操作。`EPOLLERR` 是 epoll 等待 (epoll_wait) 函数返回的可能事件之一,它表明一个出错的文件描述符。
当 epoll_wait 检测到某个文件描述符上有 EPOLLERR 事件时,通常需要采取以下步骤来处理它:
1. 识别错误原因:当 EPOLLERR 发生时,需要确定导致错误的具体原因。可以通过调用 getsockopt 函数与 SO_ERROR 选项来检索和清除该文件描述符的错误状态。

   int err;
   socklen_t errlen = sizeof(err);
   if (getsockopt(fd, SOL_SOCKET, SO_ERROR, (void *)&err, &errlen) == 0) {
       // Now 'err' contains the error code or 0 if there's no error
   }

err 中可能包含的错误码是一系列定义在 <errno.h> 头文件中的标准 POSIX 错误码。这些错误码表示了套接字上可能发生的不同错误。以下是一些常见的错误码和它们的含义:
- ECONNRESET - 连接被对端重置。
- ETIMEDOUT - 连接尝试超时。
- ECONNREFUSED - 连接被远程主机拒绝。
- EHOSTDOWN - 主机已宕机。
- EHOSTUNREACH - 没有到主机的网络路由。
- EPIPE - 管道破裂,通常是在对一个非连接的套接字执行写操作时。
- ENETUNREACH - 网络不可达。
- ENETDOWN - 网络系统不可用。
- EAGAIN 或 EWOULDBLOCK - 资源临时不可用。
处理这些错误通常需要根据应用程序的上下文和设计来决定。以下是一些基本的处理策略:
- 重新连接:对于 ECONNRESET 或 ETIMEDOUT,可能需要尝试重新建立连接。
- 报告错误:对于 ECONNREFUSED、`EHOSTUNREACH` 或 ENETUNREACH,应当报告错误给用户并考虑是否需要重试。
- 资源管理:对于 EAGAIN 或 EWOULDBLOCK,这通常意味着非阻塞操作会在没有立即完成的情况下返回。可能需要稍后再试或者使用异步 I/O。
清除文件描述符的错误状态:
在调用 getsockopt 函数并使用 SO_ERROR 选项之后,错误状态就被清除了。如果检索到 err 变量中的错误码后,这个特定的套接字错误就被认为是 "已被消费" 的。以后再次调用带 SO_ERROR 选项的 getsockopt 将返回 0,除非自从上一次调用以来发生了新的错误。
注意,对文件描述符本身的错误状态执行 "清除" 操作只是指读取出错误信息。如果套接字真正遭受了致命错误(比如 ECONNRESET),为了恢复,将需要关闭它,并且取决于情况,或许需要创建一个新的套接字来替代。某些错误可能是暂时性的,可能可以继续使用现有的套接字,但通常还是需要应用程序采取一些形式的恢复措施。

2. 响应错误:错误的具体原因将决定你的下一步行动。有时,可能需要关闭并重新打开文件描述符;其他情况下,可能需要记录错误并通知应用程序其他部分。
3. 关闭文件描述符:如果文件描述符因错误不再可用,应该关闭它。继续使用一个出现错误的文件描述符可能导致未定义行为。

close(fd);

4. 清理资源:如果该文件描述符是某种资源的一部分(例如一个客户端连接),确保适当地清理资源,例如释放内存、取消定时器等。
5. 日志记录:记录日志信息可以帮助调试和跟踪系统的行为,尤其是错误条件发生时。
6. 用户通知(如有必要):如果错误会影响用户操作,你可能需要通知用户或启动一些恢复流程。
值得注意的是,`EPOLLERR` 通常与其他 I/O 事件(`EPOLLIN`、`EPOLLOUT` 等)一起返回,表示即使有错误,文件描述符也可能处于可读或可写状态。但是,处理读写之前应该始终先检查和响应错误。此外,对于某些类型的文件描述符(如 epoll 自己的文件描述符),`EPOLLERR` 也可能意味着某些系统层面的问题,你需要具备相应的处理逻辑。

五、EPOLLHUP和EPOLLRDHUP事件的区别

EPOLLHUP和`EPOLLRDHUP`是Linux提供的epoll机制中的两个事件,用于监控文件描述符的状态变化。这两个事件与网络编程中的socket状态变化有关,尤其是在处理TCP连接时。以下是它们的定义和区别:
EPOLLHUP(挂起事件):
- EPOLLHUP标志用于表示一个挂起的事件。这通常意味着文件描述符被挂断(无更多的读写操作能够执行),无论是另一端的正常关闭(如调用`close()`),还是因为某种错误而非正常关闭。
- EPOLLHUP事件表明连接已经完全关闭,或者对应的socket出现了一些异常,不再可用。
- 在某些情况下,即使没有向epoll注册`EPOLLHUP`事件,epoll_wait仍然可能返回该事件。这是因为`EPOLLHUP`被视为一个异常事件,epoll希望你能够注意到并采取适当的行动。
EPOLLRDHUP(读挂断事件):
- EPOLLRDHUP是在Linux内核2.6.17中引入的一个标志,用于表示对端的socket已经关闭了写操作,或者说执行了半关闭(关闭了连接的写入部分)。它通常用于检测TCP连接的这种"半关闭"状态。
- EPOLLRDHUP事件可以让程序更加精细地检测到对方是否还在发送数据,或者只是单方面关闭了发送通道。可以在检测到`EPOLLRDHUP`后继续从socket读取直到得到EOF,这时才真正意味着对方完成了所有发送。
- 如果希望通过epoll监测这个事件,需要显式地在注册epoll事件时指定`EPOLLRDHUP`。
区别:
- EPOLLHUP通常用来表示文件描述符完全不可用了(不再连接)。
- EPOLLRDHUP用来表示对端关闭了写半部分(半关闭状态),本端仍可以读取剩余数据。
实际用法中的注意:
- 在编写使用epoll机制的网络编程时,应当注意处理`EPOLLHUP`和`EPOLLRDHUP`事件。例如,一个web服务器需要知道什么时候客户端关闭了连接,这样才能使资源得到及时释放。
- EPOLLRDHUP在TCP连接管理中尤其有用,它允许服务端检测到客户端的半关闭状态,从而能够优雅地关闭连接。
- 当使用epoll时记得仔细区分并处理这两种事件,避免误解它们的意图而导致bug或资源泄漏。
总结来说,`EPOLLRDHUP`和`EPOLLHUP`都是与连接关闭有关的事件,但具体的语义和用途是有区别的。理解并正确处理这些事件对于开发稳定和高效的网络服务是非常关键的。

六、EPOLLPRI事件

在网络编程中,有两种数据传输方式:普通数据传输和带外数据传输。普通数据是应用程序正常通信的数据,它们遵循正常的数据流和处理顺序。而带外数据,也称作紧急数据,是一种特殊的传输方式,使得某些数据能够越过正常的数据流,得到网络栈以及应用程序的优先处理。
带外数据主要特征如下:
1. 优先级高:带外数据被设计为有更高的优先级,它会被发送和接收的过程中优先对待。
2. 数量少:相对于普通数据,带外数据通常不用于大量数据的传输,而是发送少量紧急信息。
3. 单独的通道:带外数据经常被看作是通过一个独立的通道进行传输,以便在接收端立刻被注意到并处理,它与普通数据流分开。
4. 紧急通知:带外数据通常用于告知远端系统某种紧急状况或者控制信息,如TCP协议中的紧急指针字段被用于通知接收端有紧急数据要处理。
在TCP/IP协议中,带外数据通常是通过TCP的URG(紧急)标志位来实现的。当一个TCP段设置了URG标志位时,紧跟在TCP头之后的数据被标记为紧急数据。收到这样的TCP段时,操作系统会通知应用程序有紧急数据到达。
在应用程序层面,带外数据通常通过特定的API调用来发送和接收。例如,在套接字编程中,可通过设置SO_OOBINLINE的套接字选项,决定是在正常数据流中接收带外数据,还是通过特定的带外数据机制处理它们。
带外数据的一个常见应用示例是中断远程服务器上长时间运行的进程。例如,用户可能发送一个紧急数据包来通知服务器立即停止当前的传输。
然而,需要注意的是,不是所有的协议或系统都支持带外数据。在实际应用中,带外数据由于其复杂性和一致性问题,并不经常使用,很多新的协议和应用都避免使用它。在设计现代网络应用时,更通常的做法是在应用层面设定协议来处理紧急情况,而不是依赖底层网络的带外数据功能。

七、epoll_create和epoll_create1有什么不同?

epoll_create 和 epoll_create1 都是用来创建一个新的 epoll 实例的系统调用,但它们之间有一些差异:
1. epoll_create:
   - 在较旧的 Linux 内核版本中引入。
   - 需要一个参数 size,该参数在早期用于告诉内核监听的文件描述符数量。然而,在现代 Linux 内核中,这个参数并不起作用,因为 epoll 会根据需要动态调整,但出于向后兼容性的原因,这个参数必须大于零。
   - 用法示例:`int epoll_fd = epoll_create(1);`
   - 不接受标志(flags)参数,所以创建的 epoll 文件描述符总是具有相同的属性。
2. epoll_create1:
   - 在较新的 Linux 内核版本(2.6.27 及之后版本)中引入。
   - 添加了一个参数 flags,它允许你改变 epoll 文件描述符的行为,例如设置为非阻塞。
   - flags 参数可以是以下之一:
      - 0:效果与 epoll_create 相同。
      - EPOLL_CLOEXEC:使用这个标志创建的 epoll 文件描述符,在执行 exec 系列调用产生的新进程中将会被自动关闭。这有助于防止文件描述符泄漏到子进程。
   - 如果不需要设置任何标志,可以传入 0 作为 flags 的值。
   - 用法示例:`int epoll_fd = epoll_create1(0); 或者 int epoll_fd = epoll_create1(EPOLL_CLOEXEC);`
总体来说,`epoll_create1` 提供了更多的控制和扩展性,并且是推荐使用的方式,如果你的环境中存在支持它的 Linux 内核版本。如果仅需要创建一个 epoll 实例而不考虑 exec 调用中的文件描述符行为,那么使用 0 作为 flags 的值就足够了。 

八、普通文件并不是epoll支持的文件描述符类型

错误 "epoll_ctl: Operation not permitted" 通常发生在尝试对不支持epoll操作的文件描述符执行epoll_ctl()时。Epoll 主要用于I/O多路复用,它适用于套接字、管道和其他一些特定类型的文件描述符,这些文件描述符应该是可边缘触发的(ET)或水平触发的(LT)。
普通文件,如尝试打开的 test.txt,并不是epoll支持的文件描述符类型。对于普通文件,因为它们总是准备好被读取或写入,所以epoll机制不适用。普通文件的I/O是非阻塞的,epoll等待机制仅适用于可能阻塞的操作,如套接字I/O。
如果目的是监视文件的变化,可以考虑使用 inotify 机制而不是 epoll。`inotify` 机制是用来监视文件系统事件的,比如文件的创建、修改、删除等。
然而,如果仍希望使用 epoll,需要使用它来监视套接字之类的文件描述符,而不是常规文件。对于常规文件I/O,可以直接使用 read()、`write()` 等系统调用,通常不需要使用 epoll 这样的机制。
如果需要使用epoll监控文件描述符,则可以测试套接字的场景。例如,可以创建一个简单的网络服务,使用套接字并使用epoll来处理来自客户端的连接和数据。 

  • 35
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

109702008

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

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

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

打赏作者

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

抵扣说明:

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

余额充值