网络编程(三):网络io多路复用select/poll/epoll:select&poll

文章目录

前言

一、io多路复用是什么?

1.定义

2.另一种理解

3.I/O 多路复用的核心特点

4.常见的 I/O 多路复用机制

1. select

2. poll

3. epoll(Linux 专用)

5.I/O 多路复用的工作流程

6.I/O 多路复用与其他 I/O 模型的对比

7.I/O 多路复用的优点

8.I/O 多路复用的典型场景

二、使用 select 实现 I/O 多路复用

1.select的核心思想

2.select函数调用过程

3.设置文件描述符

4.设置检查范围及超时

5.源码展示

(1)固定部分:

(2)差异部分:

6.代码分析

(1)定义 fd_set 并初始化

(2)select 监听多路 I/O

一个问题:为什么要用rset,rfds两个集合?

(3)处理新连接

补充1:FD_SET() 和 FD_ISSET() 的作用

若进入这一代码块,具体流程如下:

补充2:select函数函数只有在监视的文件描述符发生变化时才会返回。如果未发生变化,就会进入阻塞状态。如果select返回了大于0的整数,说明相应数量的文件描述符发生了变化。

(4)处理客户端数据

(5)处理客户端断开

(6)处理数据收发

7.代码优缺点

代码优点

代码缺陷

总结


前言

推荐几篇对本文的创作提供了帮助的文章:3.一文读懂网络 IO 模型_网络io-优快云博客

IO多路转接(复用)之select | 爱编程的大丙

IO多路转接(复用)之poll | 爱编程的大丙

0voice · GitHub


一、io多路复用是什么?

1.定义

I/O 多路复用是一种高效的 I/O 模型,用于同时处理多个文件描述符(如套接字、管道、文件等)的 I/O 事件。它通过监视这些文件描述符,确定哪些文件描述符已经准备好进行 I/O 操作(如读取或写入),从而避免阻塞在单个文件描述符的 I/O 操作上。

I/O 多路复用在网络编程中非常重要,尤其是在高并发服务器中,因为它允许单个线程或进程高效地处理多个客户端连接,而无需为每个连接都创建一个线程或进程。

2.另一种理解

某教室中有10名学生和1位教师,这些孩子并非等闲之辈,上课时不停地提问。 学校没办法,只能给每个学生都配1位教师,也就是说教室中现有10位教师。此后,只要有新的转校生,就会增加1位教师,因为转校生也喜欢提问。这个故事中,如果把学生当作客户端,把教师当作与客户端进行数据交换的服务器端进程,则该教室的运营方式为多线程服务器端方式。

有一天,该校来了位具有超能力的教师。这位教师可以应对所有学生的提问,而且回答速度很快,不会让学生等待。因此,学校为了提高教师效率,将其他老师转移到了别的班。现在,学生提问前必须举手,教师确认举手学生的提问后再回答问题。也就是说,现在的教室以I/O复用方式运行。

虽然例子有些奇怪,但可以通过它理解I/O复用技法:教师必须确认有无举手学生,同样,I/O复用服务器端的线程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。

3.I/O 多路复用的核心特点

  • 同时监听多个文件描述符
    • 监视多个文件描述符的状态变化,例如是否可以读、写或是否发生异常。
  • 避免阻塞
    • 不会在单个文件描述符上阻塞,可以同时等待多个文件描述符的 I/O 准备就绪。
  • 事件驱动
    • 操作系统通过通知告诉应用程序哪些文件描述符已准备好,从而减少无效的轮询。

4.常见的 I/O 多路复用机制

1. select
  • 特点:最早的 I/O 多路复用接口,跨平台支持好。
  • 限制
    • 文件描述符数量有限(通常为 1024 或 2048)。
    • 每次调用都需要遍历所有文件描述符,性能较差。
    • 需要重置监控的文件描述符集合。
2. poll
  • 特点:改进了 select,没有文件描述符数量的限制。
  • 限制
    • 仍需遍历所有文件描述符,性能在高并发下会下降。
3. epoll(Linux 专用)
  • 特点:高性能 I/O 多路复用机制,适用于大规模并发连接。
  • 优势
    • 不需要遍历所有文件描述符。
    • 支持事件通知模型(边沿触发)。
  • 限制:仅支持 Linux。

5.I/O 多路复用的工作流程

以下是 I/O 多路复用的典型工作流程:

  1. 应用程序将一组文件描述符(如套接字)注册到操作系统,告诉操作系统需要监听哪些事件(如读、写或异常)。
  2. 操作系统监视这些文件描述符的状态。
  3. 当某些文件描述符就绪(如有数据可读)时,操作系统会通知应用程序。
  4. 应用程序根据通知处理具体的 I/O 操作。

6.I/O 多路复用与其他 I/O 模型的对比

模型特点缺点
阻塞 I/O每个 I/O 操作会阻塞进程,直到操作完成效率低,浪费 CPU 时间。
非阻塞 I/OI/O 操作立即返回,进程需不断轮询文件描述符的状态轮询会浪费 CPU 时间,增加系统开销。
I/O 多路复用同时监听多个文件描述符,通过事件通知机制处理 I/Oselectpoll 的性能在高并发场景下可能较差;复杂性比简单阻塞 I/O 模型高。
信号驱动 I/O利用信号处理机制通知应用程序某文件描述符可读或可写信号处理机制较复杂,可靠性不足。
异步 I/O (AIO)操作系统全权负责 I/O,I/O 操作完成后通知应用程序(真正的异步模型)通用性较差,支持度有限(Linux 的实现不够完善)。

7.I/O 多路复用的优点

  1. 高效性
    • 单线程可以管理大量文件描述符,节省了线程/进程切换的开销。
  2. 灵活性
    • 可以监视多种事件(如可读、可写、异常),适合多种场景。
  3. 适合高并发场景
    • 在网络服务器开发中,尤其适合处理大量客户端连接。

8.I/O 多路复用的典型场景

  • 高并发网络服务器
    • 如 HTTP 服务器或聊天室服务器。
  • 实时数据传输
    • 需要同时监听多个数据源的应用程序。
  • 文件与网络操作混合处理
    • 同时处理文件 I/O 和网络 I/O。

二、使用 select 实现 I/O 多路复用

1.select的核心思想

将多个文件描述符集中交给操作系统进行监控,操作系统会阻塞程序的执行,直到至少一个文件描述符准备好读、写或出现异常。这种机制避免了程序通过轮询方式反复检查每个文件描述符的状态,节省了 CPU 资源。

2.select函数调用过程

3.设置文件描述符

fd_set 是一个固定大小的位图结构,用来表示文件描述符集合。

在fd_set变量中注册和更改值的操作由下列常用的宏函数完成:

  • FD_ZERO(fd_set *set):清空集合。
  • FD_SET(int fd, fd_set *set):将文件描述符 fd 添加到集合中。
  • FD_CLR(int fd, fd_set *set):将文件描述符 fd 从集合中移除。
  • FD_ISSET(int fd, fd_set *set):检查文件描述符 fd 是否在集合中。

4.设置检查范围及超时

想要完成这个步骤,需要先介绍select函数,select的函数原型如下:

#include <sys/select.h>
 
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

  • nfds:监视的文件描述符集合中最大文件描述符加1
  • readfds:指向fd_set结构的指针,用于监视读就绪的文件描述符。
  • writefds:指向fd_set结构的指针,用于监视写就绪的文件描述符。
  • exceptfds:指向fd_set结构的指针,用于监视异常的文件描述符。
  • timeout:指向timeval结构的指针,用于设置select的超时时间

如上所述,select函数用来验证3种监视项的变化情况。根据监视项声明3个fd_ set型变量,分别向其注册文件描述符信息,并把变量的地址值传递到上述函数的第二到第四个参数。但在此之前(调用select函数前)需要决定下面2件事

“文件描述符的监视(检查)范围是?”“如何设定select函数的超时时间?

第一,文件描述符的监视范围与select函数的第一个参数有关。实际上,select函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在fd_ set变量中的文件描述符数。但每次新建文件描述符时,其值都会增1,故只需将最大的文件描述符值加1再传递到select函数即可。加1是因为文件描述符的值从0开始。

第二,select函数的超时时间与select函数的最后一个参 数有关,其中timeval结构体定义如下。

struct timeval

{

        long tv _sec; //seconds

        long tv_ usec; / /microseconds

}

本来select函数只有在监视的文件描述符发生变化时才返回。如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。通过声明上述结构体变量,将秒数填人tv_ sec成员,将毫秒数填入tv_ usec成员,然后将结构体的地址值传递到select函数的最后一个参数。此时,即使文件描述符中未发生变化,只要过了指定时间,也可以从函数中返回。不过这种情况下,select函数返回0。因此,可以通过返回值了解返回原因。如果不想设置超时,则传递NULL参数

5.源码展示

(1)固定部分:


	int sockfd = socket(AF_INET, SOCK_STREAM, 0);

	struct sockaddr_in servaddr;
	servaddr.sin_family = AF_INET;
	servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
	servaddr.sin_port = htons(2000); // 0-1023, 

	if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s\n", strerror(errno));
	}

	listen(sockfd, 10);
	printf("listen finshed: %d\n", sockfd); // 3 

	struct sockaddr_in  clientaddr;
	socklen_t len = sizeof(clientaddr);

(2)差异部分:

    fd_set rfds, rset;

	FD_ZERO(&rfds);
	FD_SET(sockfd, &rfds);

	int maxfd = sockfd;

	while (1) {
		rset = rfds;

		int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

		if (FD_ISSET(sockfd, &rset)) { // 只有在 sockfd 可读时才 accept

			int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
			printf("accept finshed: %d\n", clientfd);

			FD_SET(clientfd, &rfds); // 
			
			if (clientfd > maxfd) maxfd = clientfd;
		}

		// recv
		int i = 0;
		for (i = sockfd+1; i <= maxfd;i ++) { // i fd

			if (FD_ISSET(i, &rset)) {
				char buffer[1024] = {0};
				
				int count = recv(i, buffer, 1024, 0);
				if (count == 0) { // disconnect
					printf("client disconnect: %d\n", i);
					close(i);
					FD_CLR(i, &rfds);
					
					continue;
				}

				printf("RECV: %s\n", buffer);

				count = send(i, buffer, count, 0);
				printf("SEND: %d\n", count);

			}

		}
		
	}

6.代码分析

这段代码实现了一个基于 select 的 TCP 服务器,能够同时处理多个客户端连接。其核心思想是使用 I/O 多路复用 来监听多个套接字,并在有事件发生时进行相应的处理。

固定部分不再赘述,可看上一篇文章。差异部分的分析如下:

(1)定义 fd_set 并初始化

fd_set rfds, rset;

FD_ZERO(&rfds);
FD_SET(sockfd, &rfds);

int maxfd = sockfd;

  • fd_set rfds, rset
    • rfds 是主集合,保存所有的监听文件描述符。
    • rset 是临时集合,每次 select() 调用时都会被复制。
  • FD_ZERO(&rfds) 清空 rfds
  • FD_SET(sockfd, &rfds) 将服务器监听的 sockfd 加入 rfds,用于监听新的客户端连接。
  • maxfd 记录当前所有文件描述符的最大值,供 select() 使用。

(2)select 监听多路 I/O

while (1) {
    rset = rfds; // 复制文件描述符集合
    int nready = select(maxfd+1, &rset, NULL, NULL, NULL);

select() 等待文件描述符集合中的 I/O 事件:

  • maxfd+1select() 的第一个参数,表示要监听的最大文件描述符值加 1(因为文件描述符从0开始)。
  • &rset 传入一个可读事件的监听集合,select() 会修改它,标记哪些文件描述符有可读数据。
  • NULL, NULL, NULL 表示不关心可写事件、异常事件,并且 select() 没有超时时间,会一直阻塞直到有事件发生。注意!在 select() 被调用时,它会阻塞,直到某些文件描述符上发生了指定的事件。

一个问题:为什么要用rset,rfds两个集合?

调用select函数后,除发生变化的文件描述符对应位外,剩下的所有位将初始化为0。因此,为了记住初始值,必须经过这种复制过程。这是使用select函数的通用方法。

(3)处理新连接

if (FD_ISSET(sockfd, &rset)) { // 监听到新连接
    int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
    printf("accept finished: %d\n", clientfd);

    FD_SET(clientfd, &rfds); // 将新客户端加入监听集合
    if (clientfd > maxfd) maxfd = clientfd; // 更新 maxfd
}

补充1:FD_SET()FD_ISSET() 的作用

  • FD_SET(fd, &fd_set):将文件描述符 fd 加入 fd_set 集合,表示你希望监听这个文件描述符的事件(比如可读)。
  • FD_ISSET(fd, &fd_set):检查文件描述符 fd 是否在 fd_set 集合中,表示该文件描述符是否发生了事件(如可读、可写或异常)。
  • 如果 sockfdrset 中,说明有新的客户端连接请求。
  • accept() 接收新的连接,并返回新的 clientfd,用于与客户端通信。
  • FD_SET(clientfd, &rfds) 将新客户端套接字加入 rfds,使 select() 监听它的数据读取事件。
  • maxfd 更新为 clientfd,保证 select() 监听到所有连接。

若进入这一代码块,具体流程如下:

  1. 在程序开始时,调用 FD_SET(sockfd, &rset)sockfd 添加到监听集合。
  2. select() 被调用,程序阻塞,直到 sockfd 上有事件发生(例如有新客户端请求连接)。
  3. 如果 sockfd 上有新连接请求,select() 返回并标记 sockfd 为可读(FD_ISSET(sockfd, &rset) 为真)。
  4. 程序进入 if (FD_ISSET(sockfd, &rset)) 代码块,调用 accept() 接受连接。

补充2:select函数函数只有在监视的文件描述符发生变化时才会返回。如果未发生变化,就会进入阻塞状态。如果select返回了大于0的整数,说明相应数量的文件描述符发生了变化。

(4)处理客户端数据

for (int i = sockfd+1; i <= maxfd; i++) { // 遍历所有可能的客户端套接字
    if (FD_ISSET(i, &rset)) { // 如果该套接字有数据可读
        char buffer[1024] = {0};
        int count = recv(i, buffer, 1024, 0);
 

遍历 sockfd+1maxfd 之间的所有文件描述符,检查它们是否有可读数据。

(5)处理客户端断开

if (count == 0) { // 连接关闭
    printf("client disconnect: %d\n", i);
    close(i);
    FD_CLR(i, &rfds); // 从监听集合中移除
    continue;
}

  • recv() 返回 0,表示客户端断开连接。
  • 关闭套接字 close(i),并从 rfds 中移除 FD_CLR(i, &rfds),避免 select() 监听已关闭的套接字。

(6)处理数据收发

printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);

服务器收到数据后,打印出来,并原封不动地返回给客户端(回显服务器)。

7.代码优缺点

代码优点

支持多个客户端同时连接

  • 通过 select(),可以同时监听多个客户端连接,而不是为每个连接创建线程。

高效

  • 服务器采用单线程 select() 处理 I/O 事件,避免了多线程切换的开销。

适用于高并发

  • 适合中等规模的并发网络服务器,如聊天室、在线客服等应用场景。

代码缺陷

select() 存在性能问题

  • select() 每次调用都需要遍历所有文件描述符,导致 O(n) 复杂度,在高并发情况下效率较低。
  • FD_SET()FD_CLR() 操作会带来额外的 CPU 开销。

文件描述符上限

  • select() 受限于 FD_SETSIZE(通常为 1024),意味着无法同时监听超过 1024 个连接。

三、使用 poll实现 I/O 多路复用

1.poll是什么?

poll()也 是一种 I/O 多路复用机制,poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似。相比select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制。poll的参数更少,更直观。

2.poll() 函数

#include <poll.h>
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
    int   fd;         /* 委托内核检测的文件描述符 */
    short events;     /* 委托内核检测文件描述符的什么事件 */
    short revents;    /* 文件描述符实际发生的事件 -> 传出 */
};

struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • 函数参数:
    • fds: 这是一个struct pollfd类型的数组, 里边存储了待检测的文件描述符的信息,这个数组中有三个成员:
      • fd:委托内核检测的文件描述符
      • events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
      • revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果

    • nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1(也可以指定参数1数组的元素总个数)
    • timeout: 指定poll函数的阻塞时长
      • -1:一直阻塞,直到检测的集合中有就绪的文件描述符(有事件产生)解除阻塞
      • 0:不阻塞,不管检测集合中有没有已就绪的文件描述符,函数马上返回
      • 大于0:阻塞指定的毫秒(ms)数之后,解除阻塞
  • 函数返回值:
    • 失败: 返回-1
    • 成功:返回一个大于0的整数,表示检测的集合中已就绪的文件描述符的总个数

3.源码展示

固定部分没有变化,可变部分如下:

struct pollfd fds[1024] = {0}; // 定义一个pollfd结构体数组,用于存储文件描述符和事件
fds[sockfd].fd = sockfd;       // 设置sockfd对应的文件描述符
fds[sockfd].events = POLLIN;   // 设置事件为可读事件

int maxfd = sockfd;            // 初始化最大文件描述符为sockfd

while (1) {

    int nready = poll(fds, maxfd+1, -1); // 阻塞等待文件描述符就绪

    if (fds[sockfd].revents & POLLIN) {  // 如果sockfd有可读事件,表示有新的连接请求

        int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len); // 接受新连接
        printf("accept finished: %d\n", clientfd);

        // 将新客户端加入pollfd监控
        fds[clientfd].fd = clientfd;
        fds[clientfd].events = POLLIN;   // 设置客户端为可读事件
        
        if (clientfd > maxfd) maxfd = clientfd; // 更新最大文件描述符

    }

    int i = 0;
    for (i = sockfd+1; i <= maxfd; i++) { // 遍历所有的文件描述符

        if (fds[i].revents & POLLIN) {  // 如果该文件描述符有可读事件

            char buffer[1024] = {0};   // 创建接收缓冲区
            
            int count = recv(i, buffer, 1024, 0);  // 从客户端接收数据
            if (count == 0) {  // 如果接收到的数据为0,表示客户端断开连接
                printf("client disconnect: %d\n", i);
                close(i);  // 关闭该连接

                fds[i].fd = -1;  // 将该文件描述符标记为无效
                fds[i].events = 0;  // 清除事件
                
                continue;
            }

            printf("RECV: %s\n", buffer);  // 打印接收到的数据

            count = send(i, buffer, count, 0);  // 回显数据给客户端
            printf("SEND: %d\n", count);  // 打印发送的数据长度

        }

    }

}

4.代码分析

(1) 初始化 pollfd 结构数组:

struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;

  • struct pollfd fds[1024] 定义了一个包含 1024 个元素的数组,每个元素是一个 pollfd 结构体。每个 pollfd 结构表示一个需要被监听的文件描述符。
  • fds[sockfd].fd = sockfd; 设置监听套接字的文件描述符为 sockfd
  • fds[sockfd].events = POLLIN; 设置监听套接字的事件为 POLLIN,即监听 sockfd 是否可读(即是否有新的连接请求)。

(2)设置最大文件描述符:

int maxfd = sockfd;

maxfd 是当前需要监听的最大文件描述符。在 poll() 调用时,我们需要指定 fds 数组的大小(实际上传给 poll() 的是最大文件描述符加一),所以需要知道当前最大的文件描述符。

(3)进入主循环,处理客户端连接与数据:

while (1) {
    int nready = poll(fds, maxfd+1, -1);

poll(fds, maxfd+1, -1) 调用 poll(),它会阻塞,直到某个文件描述符就绪(有数据可读)。fds 是包含所有文件描述符的数组,maxfd + 1 是传给 poll() 的文件描述符总数,-1 表示无限期地阻塞,直到某个文件描述符就绪。

(4)处理新连接(监听套接字 sockfd):

if (fds[sockfd].revents & POLLIN) {
    int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
    printf("accept finished: %d\n", clientfd);

    fds[clientfd].fd = clientfd;
    fds[clientfd].events = POLLIN;
    
    if (clientfd > maxfd) maxfd = clientfd;
}

  • fds[sockfd].revents & POLLIN 检查监听套接字 sockfd 是否有新的连接请求。如果有(即 sockfd 可读),则表示有新的客户端连接请求。
  • accept(sockfd, ...) 接受新的连接并返回客户端的套接字 clientfd
  • fds[clientfd].fd = clientfd; 将新客户端的文件描述符 clientfd 加入到 fds 数组中,以便后续对这个客户端的 I/O 进行监听。
  • fds[clientfd].events = POLLIN; 设置新客户端的监听事件为 POLLIN,表示监听该客户端套接字是否有数据可读。
  • if (clientfd > maxfd) 更新 maxfd,确保最大文件描述符始终保持为当前所有套接字中的最大值。

(5)处理已经连接的客户端数据(遍历文件描述符):

int i = 0;
for (i = sockfd+1; i <= maxfd; i++) { // i fd
    if (fds[i].revents & POLLIN) {
        char buffer[1024] = {0};
        
        int count = recv(i, buffer, 1024, 0);
        if (count == 0) { // disconnect
            printf("client disconnect: %d\n", i);
            close(i);

            fds[i].fd = -1;
            fds[i].events = 0;
            
            continue;
        }

        printf("RECV: %s\n", buffer);

        count = send(i, buffer, count, 0);
        printf("SEND: %d\n", count);
    }
}

  • 遍历所有已连接的文件描述符(从 sockfd + 1maxfd),检查每个客户端套接字是否有数据可读(即是否 revents 中包含 POLLIN)。
  • fds[i].revents & POLLIN 检查当前文件描述符 i 是否有数据可读。
  • 如果有数据可读,则调用 recv(i, buffer, 1024, 0) 接收数据,并打印接收到的数据。
  • 如果 recv() 返回 0,表示客户端断开连接(正常关闭)。此时,关闭客户端套接字,更新 fds[i].fd = -1,并将其事件设置为 0(即移除该客户端套接字的监听)。
  • 否则,使用 send(i, buffer, count, 0) 将接收到的数据发送回客户端,实现回显功能。

(6)总结

这段代码实现了一个基于 poll() 的多路复用服务器,能够同时处理多个客户端连接的输入输出。与 select() 相比,poll() 在处理大数量的文件描述符时更有效,因为 poll() 不限制文件描述符的数量,而是使用一个数组来存储文件描述符信息。此外,poll() 的性能比 select() 更高,因为它不会像 select() 那样每次都需要重设文件描述符集。


总结

这篇文章先介绍了io多路复用的基本概念,然后用select实现了基本的io多路复用。select在性能上虽然优于多线程并发的服务器,但仍有较大缺陷。接下来介绍了poll的实现,poll在select的基础上,做出了一些改进,获得了一定的性能提升;并且poll的参数更少,更直观。但我们一般不会使用poll,因为Linux系统中epoll效率更高。而select可以跨平台,poll只能用于Linux。在下一篇文章,将会介绍epoll这一Linux系统中实现io多路复用,性能最高的方案。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值