tcp状态转换-select

本文详细介绍了TCP/IP通信中的状态转换,包括三次握手和四次挥手的过程,以及客户端和服务器在不同阶段的状态。此外,还讨论了端口复用的问题,解释了为什么服务器重启时可能会遇到地址已被占用的情况,并给出了解决办法。最后,讲解了IO多路转接的概念,如select和poll函数的使用,以及它们在处理并发连接时的作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. TCP状态转换

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1O2KIvZG-1616401001286)(assets/20141015155713390.png)]

// 客户端通信流程
1. 创建通信的套接字
	socket();
2. 连接服务器
	connect();
3. 通信
	read();
	write();
4. 断开连接
	close();

// 服务器通信流程
1. 创建监听的套接字
2. 监听的fd绑定本地的IP和端口
3. 设置监听
4. 等待并接受客户端连接
5. 通信
6. 关闭连接
/*
	三次握手:
	第一次: 
		客户端:
            客户端发送连接请求 -> 服务器
                -> SYN, 发送随机序号
            客户端的状态: 从没有状态 -> SYN_SENT
                - 体现在程序中就是在客户端调用了: connect()函数
        服务器端:
        	- 在服务器启动之后, 默认的状态就是: LISTEN
        	- 接收到客户端的连接请求, 状态变化: LISTEN -> SYN_RCVD
     第二次握手:
     	- 服务器
     		给客户端回复: ACK, 同意建立连接, 给客户端发送连接请求 SYN, 状态没有变化
     	- 客户端:
     		收到服务器的回复: 状态变化: SYN_SENT -> ESTABLISHED
     第三次握手:
     	- 客户端: 
     		同意服务器的连接请求, 回复: ACK, 状态没有变化
     	- 服务器端:
     		接收客户端数据, 状态变化: SYN_RCVD -> ESTABLISHED
     		
        	
*/

/*
	通信过程中:
		通信的双方的状态: ESTABLISHED, 不会发生变化
		如果想要进行通信, 进程必须要处于: ESTABLISHED 状态 
*/

/*
	四次挥手:
		通信的双方, 谁都可以主动断开连接, 在程序中断开连接就是调用close()函数
	第一次挥手:
		主动断开连接的一方调用 close() 函数
			- 状态发生变化: ESTABLISHED -> FIN_WAIT_1
		被动断开连接的一方:
			- 状态变化: ESTABLISHED -> CLOSE_WAIT
	第二次挥手:
		被动断开连接的一方: 回复了一个 ACK, 同意断开连接, 状态没有变化, 还是 CLOSE_WAIT
		主动断开连接的一方: 收到了ACK
			- 状态变化: FIN_WAIT_1 -> FIN_WAIT_2
	第三次挥手:
		被动断开连接的一方: 向对方发送了断开连接的请求 FIN
			- 在程序中调用了 close() 函数
			- 状态有变化: CLOSE_WAIT -> LAST_ACK
		主动断开连接的一方: 收到了FIN
			- 状态发生变化: FIN_WAIT_2 -> TIME_WAIT
	第四次挥手:
		主动断开连接的一方: 回复 ACK
		主动断开连接的一方: 收到了ACK, 进程就退出了
*/

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NSfLeqC2-1616401001288)(assets/wKiom1ZLIP2CRdNUAAlNCKgqwI0818.jpg)]

// 绿线是服务器的状态转换
// 红线是客户端的状态转换
  • 2MSL(Maximum Segment Lifetime)

    • 等待的时间, 一个 MSL 长度是 30s左右
    • 2MSL 约等于 1分钟
    • 主动断开连接的一方最后会变成TIME_WAIT状态, 这个状态会持续一分钟, 这个进程才会真正的退出
    • 为什么要等这一分钟?
      • 保证被动断开连接的一方能够收到最后一个ACK
        • 当被动断开连接的一方没有收到最后一一个ACK的时候, 会继续给主动断开连接的一方发送FIN
        • 主动断开连接的一方收到FIN之后, 继续回复ACK

    当TCP连接主动关闭方接收到被动关闭方发送的FIN和最终的ACK后,连接的主动关闭方必须处于TIME_WAIT状态并持续2MSL时间。

    这样就能够让TCP连接的主动关闭方在它发送的ACK丢失的情况下重新发送最终的ACK。

    主动关闭方重新发送的最终ACK并不是因为被动关闭方重传了ACK(它们并不消耗序列号,被动关闭方也不会重传),而是因为被动关闭方重传了它的FIN。事实上,被动关闭方总是重传FIN直到它收到一个最终的ACK。

  • 半关闭

    当TCP链接中A向B发送 FIN 请求关闭,另一端B回应ACK之后,并没有立即发送 FIN 给A, A方处于半连接状态(半开关),此时A可以接收B发送的数据,但是A已经不能再向B发送数据。

    // 通过半关闭可以实现套接字通信过程中数据的单向流动, 默认情况下数据是可以双向流动的
    // 在四次挥手的时候, 只进行了两次
    	- 主动断开连一方 -> 客户端 -> 在客户端调用了close()
    	- 被动断开连接的一方 -> 服务器 -> 服务器端没有调用close()
    // 服务器和客户端的连接还有没有?
    	-// 客户端和服务器的连接还有没有?
    	- 没有
    // 以上称之为半关闭, 特点?
    	- 客户端因为和服务器断开了连接
    		- 客户端不能给服务器发送数据
    		- 客户端可以接收服务器的数据
    	- 服务器这边?
            - 理论上可以接收和发送
            - 实际上: 因为客户端不能发送数据了, 是收不到数据的, 只能给客户端发送数据
            
    #include <sys/socket.h>
    // 设置套接字半关闭
    int shutdown(int sockfd, int how);
    	参数:
    		sockfd: 要操作 的文件描述符
    		how: 
    			- SHUT_RD: 关闭读
    			- SHUT_WR: 关闭写
    			- SHUT_RDWR: 关闭读写
    
  • 查看网络相关信息命令

    $ netstat
    	○ 参数:
    		-a (all)显示所有选项
    		-p 显示建立相关链接的程序名
    		-n 拒绝显示别名,能显示数字的全部转化成数字。
    		-l 仅列出有在 Listen (监听) 的服务状态
    		-t (tcp)仅显示tcp相关选项
    		-u (udp)仅显示udp相关选项
    $ netstat -apn	
    
    robin@OS:~$ netstat -apn | grep 9999
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    tcp        0      0 0.0.0.0:9999            0.0.0.0:*               LISTEN      9558/server     
    robin@OS:~$ netstat -apn | grep 9999
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    tcp        0      0 0.0.0.0:9999            0.0.0.0:*               LISTEN      9558/server     
    tcp        0      0 127.0.0.1:51824         127.0.0.1:9999          ESTABLISHED 9586/client     
    tcp        0      0 127.0.0.1:9999          127.0.0.1:51824         ESTABLISHED 9558/server     
    robin@OS:~$ netstat -apn | grep 9999
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    tcp        1      0 127.0.0.1:51824         127.0.0.1:9999          CLOSE_WAIT  9586/client     
    tcp        0      0 127.0.0.1:9999          127.0.0.1:51824         FIN_WAIT2   -               
    robin@OS:~$ netstat -apn | grep 9999
    (Not all processes could be identified, non-owned process info
     will not be shown, you would have to be root to see it all.)
    tcp        0      0 127.0.0.1:9999          127.0.0.1:51824         TIME_WAIT   -   
    

2. 端口复用

/*
	1. 启动服务器
	2. 启动客户端
	3. 关闭服务器
	4. 关闭客户端
	5. 再次启动服务器程序
		./server 
		bind error: Address already in use
*/
绑定失败的原因: 服务器先关闭, 最后进程变成 TIME_WAIT 状态, 这个状态会持续一分钟左右
	- 在这个期间服务器绑定的端口没有被释放
	- 再次启动这个服务器程序的时候还需要绑定这个没有被释放的端口 -> 绑定失败了
	- 等一分钟之后就可以正常启动服务器程序了
	- 如果就是不想等呢?
    	- 设置端口复用: 在进程的 TIME_WAIT 期间, 绑定的没有被释放的端口,可以被重新使用
    	- 需要通过一个函数: setsockopt()
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
	参数:
		- sockfd: 要被设置属性的文件描述符
		- level: SOL_SOCKET
		- optname: 设置端口复用
			- SO_REUSEPORT
			- SO_REUSEADDR
		- optval: 要设置的属性的值
			- 根据查手册 设置端口复用 该指针指向的一个int数据
			-: 1 -> 设置端口复用
			-: 0 -> 不设置端口复用, 默认端口不能复用
		- optlen: 参数 optval 指针对应的内存大小
	返回值: 
		设置成功: 0
        失败失败: -1
			
// 在服务器端代码中设置
// 一定要在绑定之前进行设置
int main()
{
    // lfd -> 监听
    int lfd = socket();
    // 设置端口复用
    int val = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val));
    // 绑定
    bind(lfd, &addr, size);
    // 监听
    listen();
    // 等待接受连接
    accept();
}

3. IO多路转接

不再由应用程序自己监视客户端连接和数据通信,取而代之由内核替应用程序监视文件。

/*
	为什么要多进程或多进程处理并发?
		- 套接字通信过程中有哪些阻塞函数?
			- accept();
				- 负责接受客户端的连接, 没有连接就阻塞
				- 检测的是用于 [监听] 的文件描述符的 -> 读缓冲区
				
			- read()/recv();
				- 接收数据, 如果对方没有给发送数据, 阻塞
				- 检测的是用于 [通信] 的文件描述符的 -> 读缓冲区
				
			- write/send();
				- 发送数据, 如果缓冲区满了, 阻塞
				- 检测的是用于 [通信] 的文件描述符的 -> 写缓冲区
				
			- connect();	// 用于客户端
				- 连接过程, 三次握手的过程中是阻塞的, 连接成功解除阻塞函数返回
*/

// IO多路转接是怎么回事儿?
	- 上述文件描述法对应的读/写缓冲区的检测本来是需要在程序中由程序猿创建进程/线程进行状态检测
		- 调用了accept
		- read/write
	- 通过IO多路转接就可以将这些缓冲区的状态检测交给内核区处理
		- 内核检测到缓冲区的状态有变化, 会通知我们的程序
		- 在程序中得到通知之后就可以进行处理了
		- 如何交给内核检测?
        	- 体现在程序中就是使用了一些IO转接函数, 这些函数的状态默认是阻塞的
        		- select
        		- poll
        		- epoll
        	- 阻塞是因为检测这些缓冲区需要时间
        	- 如果检测到缓冲区有状态变化, 函数会解除阻塞

3.3 select

主旨思想:

  • 先构造一张有关文件描述符的列表, 将要监听的文件描述符添加到该表中

  • 调用一个函数,监听该表中的文件描述符,直到这些描述符表中的一个进行I/O操作时,该函数才返回。

    • 该函数为阻塞函数

    • 函数对文件描述符的检测操作是由内核完成的

  • 在返回时,它告诉进程有多少(哪些)描述符要进行I/O操作。

/*
	select的使用步骤:
	1. 是需要完成以服务器端程序
	2. 需要有监听的文件描述符   --->  第一个文件描述符
	3. 建立连接, 得到了通信的文件描述符  -----> 第2-n个文件描述符
	4. 在程序运行之前, 将现有的文件描述符初始化到一个表中
		- 读操作的表: 检测文件描述符的读缓冲区 -> 读集合
		- 写操作的表: 检测文件描述符的写缓冲区  -> 写集合
		- 异常表:     文件描述符在读写过程中出现了错误 -> 异常集合
	5. 将这三个集合传递给select函数, select函数是系统调用, 将数据传递到内核中
		- 内核就知道要检测哪些文件描述符的状态, 开始检测
		- 内核当检测到文件描述符的状态发生了变化, 会将信息写回到对应的三张表中
	6. 我们程序中会得到新的三个集合, 通过这三个集合就可以判断哪些文件描述符状态发生了变化
		- 发生变化之后去处理:
			accept();   -> 绝对不阻塞
			read();		-> 绝对不会
			write();    -> 绝对不会
*/

// 函数原型
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

// 表示一个时间段: sec+usec
struct timeval {
	time_t      tv_sec;         /* seconds */
	suseconds_t tv_usec;        /* microseconds */
};
// fd_set 数据类型, sizeof(fd_set) = 128byte == 1024 bit
// 参数 读, 写, 异常 都是传入传出参数
int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);
	参数:
		- nfds: 内核区检测文件描述符遍历的范围, 指定结束位置
			- 内核是以线性的方式遍历的, 从开始 -> 结束
			- 结束位置 == 检测的最大的文件描述符+1 [0, nfds) (nfds=maxfd+1)
		- readfds: 委托内核检测读缓冲区的所有文件描述符的集合
			- 使用的最多
			- 检测读缓冲区中有数据, 内核给通知
		- writefds: 委托内核检测写缓冲区的所有文件描述符的集合
			- 检测的是写缓冲区是不是可以写, 不满就可以写
				- 缓冲区有空间就是满足条件, 内核给通知
		- exceptfds: 异常集合
		- timeout: 函数阻塞的时长
				- NULL: 表示一直阻塞, 直到检测的三个集合中的文件描述符有状态变化的时候, 解除阻塞
	返回值:
       调用没有问题: 发生状态变化的文件描述符的总个数
       失败: -1
                                     
// 将对应文件描述符初始到fd_set集合中
// 将参数fd对应的文件描述符从检测的集合中删除, 标志位1->0    
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符 fd, 是不是在检测的集合 set中
// 如果在集合中, 返回1, 不在集合中返回0
int  FD_ISSET(int fd, fd_set *set);
// 将参数fd对应的文件描述符设置到检测的集合中, 标志位0->1
void FD_SET(int fd, fd_set *set);
// 将检测的集合中所有的标志位初始化为 0
void FD_ZERO(fd_set *set);                                     

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XrOFHzaV-1616401001290)(assets/1558346304301.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2LzO6Moe-1616401001292)(assets/1558346328481.png)]

// 服务器端使用select代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main()
{
    // 1. 创建监听的fd
    int lfd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(10000);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(lfd, (struct sockaddr*)&addr, sizeof(addr));

    // 3. 设置监听
    listen(lfd, 128);

    // 将监听的fd的状态检测委托给内核检测
    int maxfd = lfd;
    // 初始化检测的读集合
    fd_set rdset;
    fd_set rdtemp;
    // 清零
    FD_ZERO(&rdset);
    // 将监听的lfd设置到检测的读集合中
    FD_SET(lfd, &rdset);
    // 通过select委托内核检测读集合中的文件描述符状态, 检测read缓冲区有没有数据
    // 如果有数据, select解除阻塞返回
    // 应该持续检测
    while(1)
    {
        // 默认阻塞
        // rdset 中是委托内核检测的所有的文件描述符
        rdtemp = rdset;
        int num = select(maxfd+1, &rdtemp, NULL, NULL, NULL);
        // rdset中的数据被内核改写了, 只保留了发生变化的文件描述的标志位上的1, 没变化的改为0
        // 只要rdset中的fd对应的标志位为1 -> 缓冲区有数据了
        // 判断
        // 有没有新连接
        if(FD_ISSET(lfd, &rdtemp))
        {
            // 接受连接请求, 这个调用不阻塞
            struct sockaddr_in cliaddr;
            int cliLen = sizeof(cliaddr);
            int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &cliLen);

            // 得到了有效的文件描述符
            // 通信的文件描述符添加到读集合
            // 在下一轮select检测的时候, 就能得到缓冲区的状态
            FD_SET(cfd, &rdset);
            // 重置最大的文件描述符
            maxfd = cfd > maxfd ? cfd : maxfd;
        }

        // 没有新连接, 通信
        for(int i=lfd+1; i<maxfd+1; ++i)
        {

            // 和客户端通信
            if(FD_ISSET(i, &rdtemp))
            {
                // 接收数据
                char buf[1024] = {0};
                int len = read(i, buf, sizeof(buf));
                if(len == 0)
                {
                    printf("客户端关闭了连接...\n");
                    // 将检测的文件描述符从读集合中删除
                    FD_CLR(i, &rdset);
                    close(i);
                }
                else if(len > 0)
                {
                    // 收到了数据
                    // 发送数据
                    write(i, buf, strlen(buf)+1);
                }
                else
                {
                    // 异常
                    perror("read");
                }
            }
        }
    }


    return 0;
}

3.4 poll

  • 函数

    #include <poll.h>
    // 每个委托poll检测的fd都对应这样一个结构体
    struct pollfd {
    	int   fd;         /* 委托内核检测的文件描述符 */
    	short events;     /* 委托内核检测文件描述符的什么事件 */
    	short revents;    /* 文件描述符实际发生的事件 -> 传出 */
    };
    
    // events事件
    	- POLLIN 	-> 检测读
    	- POLLOUT	-> 检测写
    
    struct pollfd myfd;
    myfd.fd = 5;
    myfd.events = POLLIN | POLLOUT;	// 检测读写
    
    struct pollfd myfd[100];
    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    	参数:
    		- fds: 这是一个struct pollfd数组, 这是一个要检测的文件描述符的集合
    		- nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1
            - timeout: 阻塞时长
                0: 不阻塞
                    -1: 阻塞, 检测的fd有变化解除阻塞
                >0: 阻塞时长, 单位毫秒
        返回值:
    		-1: 失败
            >0(n): 检测的集合中有n个文件描述符发生状态变化
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8l5yttWO-1616401001294)(assets/1558308141721.png)]

多线程版服务器代码

/*
	思路:
		- 主线程
			- 不停的接受客户端连接
		- 子线程
			- 通信
*/

// 伪代码
// 线程回调函数
void* working(void* arg)
{
    int fd = *(int*)arg;
    // 通信
    while(1)
    {
        // 接收数据
        int len = read(fd, buf, sizeof(buf));
        if(len == 0)
        {
            // 客户端断开连接
            break;
        }
        // 发送数据
        write(fd, str, len);
    }
    close(fd);
    return NULL;
}

int fds[1024];
int main()
{
    // 1. 创建监听的套接字
    int lfd = socket();
    // 2. 绑定
    bind(lfd, addr);
    // 3. 设置监听
    listen(lfd, 128);
    // 4. 等待并接受客户端的连接
    memset(fds, -1, sizeof(fds));
    while(1)
    {
        int cfd = accept(lfd, &cliaddr, &addrlen);
        // 创建子线程
        int *ptr = NULL;
        for(int i=0; i<sizeof(fds)/sizeof(int); ++i)
        {
            if(fds[i] == -1)
            {
                fds[i] = cfd;
                ptr = &fds[i];
                break;
            }
        }
        fds[i] = cfd;
        pthread_create(&tid, NULL, working, ptr);
        // 线程分离
        pthread_detach(tid);
    }
    return 0;
}
// 完整代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>

struct SockInfo
{
    int cfd;    // 通信的文件描述符
    struct sockaddr_in addr;    // 客户端的地址信息
};

void* working(void* arg);
int main()
{
    // 1. 创建监听的套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket");
        exit(0);
    }
    // 2. lfd绑定本地的IP和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);   // 使用大端的端口
    addr.sin_addr.s_addr = INADDR_ANY;  // 这个宏的值就是0
    int ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
    if(ret == -1)
    {
        perror("bind");
        exit(0);
    }
    // 3. 设置监听
    ret = listen(lfd, 128);
    if(ret == -1)
    {
        perror("listen");
        exit(0);
    }

    struct SockInfo info[1024];
    int infoLen = sizeof(info)/sizeof(struct SockInfo);
    for(int i=0; i<infoLen; ++i)
    {
        info[i].cfd = -1;
    }

    // 4. 等待并接受连接
    struct sockaddr_in cliaddr;
    int clilen = sizeof(cliaddr);
    // 循环
    while(1)
    {
        printf("等待客户端连接....\n");
        int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &clilen);
        if(cfd == -1)
        {
            perror("accept");
            exit(0);
        }
        printf("和客户端连接成功建立....\n");
        // 创建子线程
        pthread_t tid;
        // 从数组中取出一个元素存储数据
        struct SockInfo* ptr = NULL;
        for(int i=0; i<infoLen; ++i)
        {
            if(info[i].cfd == -1)
            {
                ptr = &info[i];
                break;
            }
            if(ptr == NULL)
            {
                sleep(1);
                i = 0;
            }
        }
        // 赋值
        ptr->cfd = cfd;
        memcpy(&ptr->addr, &cliaddr, sizeof(cliaddr));
        pthread_create(&tid, NULL, working, ptr);
        // 线程分离
        pthread_detach(tid);

    }
    return 0;
}



void* working(void* arg)
{
    struct SockInfo* pt = (struct SockInfo*)arg;
    // 5. 通信
    while(1)
    {
        // 4.1 打印客户端的地址信息
        // 将cliaddr中的大端IP -> 点分十进制IP字符串
        // cliaddr中的端口也是大端的
        char ip[64];
        printf("client IP: %s, client port: %d\n",
               inet_ntop(AF_INET, &pt->addr.sin_addr.s_addr, ip, sizeof(ip)),
               ntohs(pt->addr.sin_port));

        // 接收数据
        char buf[1024];
        int len = recv(pt->cfd, buf, sizeof(buf), 0);
        printf("client say: %s\n", buf);
        if(len == 0)
        {
            printf("client disconnect....\n");
            break;
        }
        // 发送数据
        send(pt->cfd, buf, len, 0);
    }

    close(pt->cfd);
    pt->cfd = -1;

    return NULL;
}

. 通信
while(1)
{
// 4.1 打印客户端的地址信息
// 将cliaddr中的大端IP -> 点分十进制IP字符串
// cliaddr中的端口也是大端的
char ip[64];
printf(“client IP: %s, client port: %d\n”,
inet_ntop(AF_INET, &pt->addr.sin_addr.s_addr, ip, sizeof(ip)),
ntohs(pt->addr.sin_port));

    // 接收数据
    char buf[1024];
    int len = recv(pt->cfd, buf, sizeof(buf), 0);
    printf("client say: %s\n", buf);
    if(len == 0)
    {
        printf("client disconnect....\n");
        break;
    }
    // 发送数据
    send(pt->cfd, buf, len, 0);
}

close(pt->cfd);
pt->cfd = -1;

return NULL;

}


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值