网络编程-Linux下四种模型及本地套接字

Linux下四种模型:

阻塞式IO 非阻塞式IO 信号驱动IO(了解) IO多路复用(帮助TCP实现并发)

linux下的四种IO模型假设:

假设妈妈有一个孩子,孩子在房间里睡觉,妈妈需要及时获知孩子是否醒了,如何做?

阻塞式IO: 进到房间陪孩子一起睡觉,孩子醒了吵醒妈妈,不累,但是不能干别的了

非阻塞式IO: 时不时进房间看一下:简单,空闲时间还能干点别的,但是很累

信号驱动IO: 妈妈在客厅干活,小孩醒了他会自己走出房门告诉妈妈:互不耽误

假设妈妈有三个孩子, 分别在不同房间睡觉, 需要及时检测每个房间信息, 如何做?

阻塞式IO: 阻塞处理多条路, 得不到该目的;

非阻塞IO: 一直轮询, 大量占用CPU;

多线程多进程, 或IO多路复用, 可以做到

Linux下四种模型的特点:

1. 阻塞式IO: 最简单, 最常用,效率低、不浪费cpu

阻塞I/O 模式是最普遍使用的I/O 模式

系统默认状态,套接字建立后所处于的模式就是阻塞I/O 模式。

目前学习的读写函数中会发生阻塞相关函数如下:

· read、recv、recvfrom

读阻塞--》需要读缓冲区中有数据可读,读阻塞才会解除

· write, send

写阻塞--》阻塞就是写入数据时遇到缓冲区满了的情况,需要等待缓冲区有空间后才能继续写入, 所以写阻塞发生的情况比较少.

· accept connect

需要注意的是使用UDP时,UDP没有发送缓存区 ,则sendto没有阻塞

1)原因:

sendto不是阻塞函数,本身udp通信不是面向链接的,udp无发送缓冲区,即sendto没有发送缓冲区,send是有发送缓存区的,即sendto不是阻塞函数。

2)UDP不用等待确认,没有实际的发送缓冲区,所以UDP协议中不存在缓冲区满的情况,在UDP套接字上进行写操作永远不会阻塞。

udp与tcp缓存区 (仅作为了解)

UDP是一种无连接的传输协议,它不保证数据的可靠性和顺序性, 所以不需要考虑连接和缓存,而是将数据尽快发送出去,不关心数据是否到达目标主机或者是否按照发送顺序到达. 但是UDP有接受缓存区, 因为数据发送过快, 如果接收缓存区内数据已满, 则继续发送数据, 可能会出现丢包。

TCP是一种面向连接的传输协议,有发送缓存区和接收缓存区, 如果发送频率过快, 且内容小于发送缓存区的大小 , 可能会导致多个数据的粘包。如果发送的数据大于发送缓存区的剩余大小,send将会阻塞, 在阻塞期间,send函数会自动拆分数据包发送,直到所有数据都被发送完毕或者发送缓冲区的空间不足以继续发送为止, 这就是拆包。

UDP不会造成粘包和拆包, TCP不会造成丢包

UDP_数据报: 本质是独立的包, 有边界;

TCP_流,:本质是字节流形式, 一帧一帧发送,且每一帧没有边界,一帧丢失会重新补发;

2. 非阻塞式IO :可以处理多路IO;需要轮询,大量浪费CPU资源

1. 当一个应用程序使用了非阻塞模式的套接字,则它需要使用一个循环来不停的测试是否一个文件描述符有数据可读(称做polling)

2. 应用程序不停的polling 内核来检查是否I/O操作已经就绪。这将是一个极浪费CPU 资源的操作 ,所以说一般不适用

3.当我们将一个套接字设置为非阻塞模式,我们相当于告诉了系统内核:“当我请求的I/O 操作不能够马上完成,你想让我的进程进行休眠等待的时候,不要这么做,请马上返回一个错误给我。”

将recv设置为非阻塞时, recv在接收缓存区内, 未拿到客户端发送来的数据, 那么recv就会报错 , 所以会一直打印 recv is err: 资源不可用

fcntl设置文件描述符的属性

声明: int fcntl (int fd, int cmd,  ...arg);
头文件: #include<fcntl.h>      #include<unistd.h>
功能:设置文件描述符的属性
参数:fd:文件描述符
         cmd: 操作功能选项 (可以定义个变量,通过vi -t F_GETFL 来找寻功能赋值 )
          F_GETFL:获取文件描述符的原有的状态信息 
           //不需要第三个参数,返回值为获取到的属性
          F_SETFL:设置文件描述符的状态信息 - 需要填充第三个参数
         //需要填充第三个参数  O_RDONLY, O_RDWR ,O_WRONLY ,O_CREAT
          O_NONBLOCK 非阻塞   O_APPEND追加
          O_ASYNC 异步        O_SYNC  同步 
    
          F_SETOWN:    可以用于实现异步通知机制。
          //当文件描述符上发生特定事件时(例如输入数据到达),内核会向拥有该  文件描述符的进程发送 SIGIO 信号(异步),以便进程能够及时处理这些事件。

        arg:文件描述符的属性      --------------------同上参数


返回值: 特殊选择:根据功能选择返回 (int 类型)   
            其他:  成功0   失败: -1;

使用:  int flag;
 // 1.获取该文件描述符0 (标准输入) 的原属性 : 标准输入原本具有阻塞的功能  
int flag = fcntl(0, F_GETFL); //获取文件描述符原有信息后,保存在flag变量内
 //2.修改对应的位nonblock(非阻塞)
int flag |= O_NONBLOCK;  ( flag = flag | O_NONBLOCK)
// 3. 将修改好的属性写回去 (0 标准输入 -- 阻塞  改为  非阻塞)
 fcntl (0, F_SETFL, flag); //文件描述符   设置状态  添加的新属性

实现: 将输入改为非阻塞功能;

int main(int argc, char const*argv[])
{
    char buf[128];
    while(1)
    {
        //先获取原文件描述符的原属性
        int flag = fcntl(0,F_GETFL);
        //修改,添加权限
        flag = flag | O_NONBLOCK;
        //将修改好的权限 写回去
        fcntl(0,F_SETFL,flag);

        fgets(buf,sizeof(buf),stdin);
        sleep(1);
        printf("buf: %s\n",buf);
    }
    return 0;

3. 信号驱动IO -异步通知模式,底层驱动支持 (非重点)

特点:

异步通知模式,需要底层驱动的支持

异步通知:异步通知是一种非阻塞的通知机制,发送方发送通知后不需要等待接收方的响应或确认。通知发送后,发送方可以继续执行其他操作,而无需等待接收方处理通知。

信号提供了一种处理异步事件的方法。举一个不恰当的例子,比如你正兴奋在玩游戏,突然你手机响了,你立马放下手上的游戏,去接听电话。手机随时都会响,随时都会中断你当下的事情。所以称之为异步事件。

1. 通过信号方式,当内核检测到设备数据后,会主动给应用发送信号SIGIO。

2. 应用程序收到信号后做异步处理即可。

3. 应用程序需要把自己的进程号告诉内核,并打开异步通知机制。

思路 代码

//1.设置将文件描述符和进程号提交给内核驱动
//一旦fd有事件响应, 则内核驱动会给进程号发送一个SIGIO的信号
   fcntl(fd,F_SETOWN,getpid());

//2.设置异步通知
    int flags;
    flags = fcntl(fd, F_GETFL); //获取原属性
    flags |= O_ASYNC;       //给flags设置异步   O_ASUNC 通知
    fcntl(fd, F_SETFL, flags);  //修改的属性设置进去,此时fd属于异步
    
//3.signal捕捉SIGIO信号 --- SIGIO:内核通知会进程有新的IO信号可用
//一旦内核给进程发送sigio信号,则执行handler
    signal(SIGIO,handler);
// 设置套接字的拥有者
 fcntl(sock_fd ,F_SETOWN, getpid());

 // 添加信号触发
 int state;
 state = fcntl(sock_fd,F_GETFL);
 state |= O_ASYNC;
 fcntl(sock_fd,F_SETFL,state);
 
 while(1)// 循环挂起不让程序退出
 { 
     printf("__%d__\n" , __LINE__);
     pause();
 }

signal信号处理相关函数

头文件: #include <signal.h>
        typedef void (*sighandler_t)(int);
        sighandler_t   signal(int signum, sighandler_t handler)
功能:信号处理函数(注册信号)
参数: int signum:要处理的信号(要修改的信号)
           sighandler_t handler: 函数指针: void(*handler)(int) (修改的功能:)
           handler:------void handler(int num) 自定义的信号处理函数指针
返回值: 成功:设置之前的信号处理方式
失败:   SIG_ERR

代码实例:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

int fd;

void handler(int sig)
{
    char buf[128] = "";
    int ret = read(fd, buf, sizeof(buf));
    buf[ret] = '\0';

    printf("mouse: %s\n", buf);
}

int main(int argc, char const *argv[])
{
    //1.打开鼠标文件
    fd = open("/dev/input/mouse0", O_RDONLY);
    if (fd < 0)
    {
        perror("open is err:");
        return -1;
    }

    //1.把文件描述符和进程号上传给底层驱动  //SIGIO
    fcntl(fd, F_SETOWN, getpid());

    //1.先获取文件描述符的原属性
    int flag = fcntl(fd, F_GETFL);
    //2.加入一个异步的机制
    flag = flag | O_ASYNC;

    fcntl(fd, F_SETFL, flag);

    //3.signal
    signal(SIGIO, handler);

    char buf[128];
    while (1)
    {
        fgets(buf, sizeof(buf), stdin);
        printf("%s\n", buf);
    }

    return 0;
}
//异步 I/O 机制和信号处理来实现在鼠标事件发生时进行响应。
//它的优点是在等待数据到达时不会阻塞进程,但需要处理信号和异步机制

4. I/O多路复用 - 帮助TCP实现并发服务器(重点)

1. 进程中若需要同时处理多路输入输出 ,在使用单进程和单线程的情况下, 可使用IO多路复用处理多个请求;

2. IO多路复用不需要创建新的进程和线程, 有效减少了系统的资源开销。

就比如服务员给50个顾客点餐,分两步:

顾客思考要吃什么(等待客户端数据发送)

顾客想好了,开始点餐(接收客户端数据)

要提高效率有几种方法?

1. 安排50个服务员 (类似于多进程/多线程实现服务器连接多个客户端,太占用资源)

2. 哪个顾客想好了吃啥, 那个顾客来柜台点菜 (类似IO多路复用机制实现并发服务器)

实现IO多路复用的方式: select poll epoll

基本流程是:

1. 先构造一张有关文件描述符的表;

2. 清空表

3. 将你关心的文件描述符加入到这个表中;

4. 调用select函数。

5. 判断是哪一个或哪些文件描述符产生了事件(IO操作);

6. 做对应的逻辑处理;

1) select

 头文件: #include<sys/select.h>   #include<sys/time.h>   
             #include<sys/types.h>   #include<unistd.h>
             
声明: int select(int nfds, fd_set *readfds, fd_set *writefds,\
                         fd_set *exceptfds, struct timeval *timeout);
功能:监测是哪些文件描述符产生事件,阻塞等待产生.

参数:nfds:    监测的最大文件描述个数(文件描述符从0开始,这里是个数,记得+1)
         readfds:  读事件集合; // 键盘鼠标的输入,客户端连接都是读事件
         writefds: 写事件集合;  //NULL表示不关心
         exceptfds:异常事件集合;  //NULL 表示不关心
         timeout:   设为NULL,等待直到某个文件描述符发生变化;
                               设为大于0的值,有描述符变化或超时时间到才返回。
        超时时间检测:如果规定时间内未完成函数功能,返回一个超时的信息,我们可以根据该信息设定相应需求;

例子:
        select(maxfd + 1, &tempfds, NULL, NULL, NULL);

返回值:  <0 出错            >0 表示有事件产生;
             如果设置了超时检测时间:&tv
             <0 出错            >0 表示有事件产生;      ==0 表示超时时间已到;        

结构体如下:                     
            struct timeval {
               long    tv_sec;         以秒为单位,指定等待时间
               long    tv_usec;        以毫秒为单位,指定等待时间
           };

       void FD_CLR(int fd, fd_set *set);  //将set集合中的fd清除掉 
       int  FD_ISSET(int fd, fd_set *set); //判断fd是否在set集合中产生了事件
       void FD_SET(int fd, fd_set *set); //将fd加入到集合中
       void FD_ZERO(fd_set *set);   //清空集合

void FD_CLR(int fd, fd_set *set); //将set集合中的fd清除掉

int FD_ISSET(int fd, fd_set *set); //判断fd是否在set集合中产生了事件

void FD_SET(int fd, fd_set *set); //将fd加入到集合中

void FD_ZERO(fd_set *set); //清空集合

Select特点:

1. 一个进程最多只能监听1024个文件描述符 (32位) [64位为 2048]

2. select被唤醒之后要重新轮询(0-1023)一遍驱动,效率低(消耗CPU资源)

3. select每次会清空未响应的文件描述符,每次都需要拷贝用户空间的表到内核空间,效率低,开销较大

(0~3G是用户态,3G~4G是内核态,两个状态来回切换 拷贝是非常耗时,耗资源的)

select机制:

1. 头文件检测1024个文件描述符 0-1023

2. 在select中0~2存储标准输入、标准输出、标准出错

3. 监测的最大文件描述个数为fd+1(如果fd = 3,则最大为 4) : //因为从0开始的

4. select只对置1的文件描述符感兴趣

假如事件产生,select检测时 , 产生的文件描述符会保持1,未产生事件的会置0;

5. select每次轮询都会清空表(置零的清空) //需要在select前备份临时表

练习

练习1:如何通过select实现 响应鼠标事件同时响应键盘事

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/select.h>
#include <string.h>

//响应鼠标的时候, 打印鼠标事件
//输入键盘的时候, 打印键盘内容

int main(int argc, char const *argv[])
{
    //1.打开鼠标文件
    int fd = open("/dev/input/mouse0", O_RDONLY);
    if (fd < 0)
    {
        perror("open is err:");
        return -1;
    }
    //1.创建文件描述符的表
    fd_set readfds, tempfds;
    //2.清空表
    FD_ZERO(&readfds);
    //3.添加关心的文件描述符
    FD_SET(0, &readfds);//将fd加入到集合中
    FD_SET(fd, &readfds);
    int maxfd = fd;

    char buf[128];
    while (1)
    {
        tempfds = readfds;
        //4.select检测   阻塞
        select(maxfd + 1, &tempfds, NULL, NULL, NULL);

        if (FD_ISSET(0, &tempfds))
        {
            //1.键盘
            fgets(buf, sizeof(buf), stdin);
            if (buf[strlen(buf) - 1] == '\n')
                buf[strlen(buf) - 1] = '\0';
            printf("key: %s\n", buf);
        }
        if (FD_ISSET(fd, &tempfds))
        {
            //2.鼠标
            int ret = read(fd, buf, sizeof(buf));
            buf[ret] = '\0';
            printf("mouse: %s\n", buf);
        }
    }
    close(fd);
    return 0;
}

练习2: 实现键盘事件与sockfd

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/select.h>

int main(int argc, char const *argv[])
{
    // 1. 创建套接字 >> 返回一个建立连接的文件描述符
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("创建套接字失败");
        return -1;
    }
    printf("sockfd: %d\n", sockfd); // 打印套接字文件描述符

    // 2. 填充服务器地址结构体
    struct sockaddr_in saddr, caddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[1])); // 从命令行参数获取端口号
    saddr.sin_addr.s_addr = inet·_addr("0.0.0.0");
    int len = sizeof(caddr);

    // 绑定 IP 和端口
    if (bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("绑定失败");
        return -1;
    }

    // 3. 监听连接请求
    if (listen(sockfd, 5) < 0)
    {
        perror("监听失败");
        return -1;
    }
    printf("监听成功\n");

    // 1. 创建文件描述符集合
    fd_set readfds, tempfds;
    // 2. 清空文件描述符集合
    FD_ZERO(&readfds);
    FD_ZERO(&tempfds);
    // 3. 将关心的文件描述符添加到集合
    FD_SET(0, &readfds);      // 标准输入
    FD_SET(sockfd, &readfds); // 监听套接字

    int maxfd = sockfd;
    char buf[128];
    int recvbyte;
    while (1)
    {
        tempfds = readfds;
        // 4. 使用 select 轮询检测事件
        int ret = select(maxfd + 1, &tempfds, NULL, NULL, NULL);
        if (ret < 0)
        {
            perror("select 错误");
            return -1;
        }

        // 5. 判断是否有事件产生
        if (FD_ISSET(0, &tempfds))
        {                                   // 标准输入事件
            fgets(buf, sizeof(buf), stdin); // 从标准输入读取数据
            if (buf[strlen(buf) - 1] == '\n')
                buf[strlen(buf) - 1] = '\0'; // 去除换行符
            printf("键入内容:%s\n", buf);
        }
        if (FD_ISSET(sockfd, &tempfds))
        {                                                                   // 监听套接字事件
            int acceptfd = accept(sockfd, (struct sockaddr *)&caddr, &len); // 接受新连接
            if (acceptfd < 0)
            {
                perror("接受连接失败");
                return -1;
            }
            printf("新连接 - 文件描述符:%d,IP:%s,端口:%d\n",
                   acceptfd, inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port));

            FD_SET(acceptfd, &readfds); // 将新连接的文件描述符添加到集合

            if (acceptfd > maxfd)
            {
                maxfd = acceptfd;//看上面的图
            }
        }
        for (int i = sockfd + 1; i <= maxfd; i++)//遍历文件描述符数组,检测并处理事件
        {
            if (FD_ISSET(i, &tempfds))
            {                                            // 已连接套接字事件
                recvbyte = recv(i, buf, sizeof(buf), 0); // 接收数据
                if (recvbyte < 0)
                {
                    perror("接收错误");
                    return -1;
                }
                else if (recvbyte == 0)
                {
                    printf("%d 客户端退出\n", i);
                    close(i);
                    FD_CLR(i, &readfds); // 从集合中移除文件描述符

                    if (maxfd == i)
                        maxfd--;
                }
                else
                {
                    printf("从 %d 接收:%s\n", i, buf);//打印从客户端接收到的数据
                }
            }
        }
    }

    // 6. 关闭套接字
    close(sockfd);
    return 0;
}

练习3: 使用select实现client的全双工

#include <stdio.h>
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h> /* superset of previous */
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/select.h>

int main(int argc, const char *argv[])
{
	int sockfd = socket(AF_INET,SOCK_STREAM,0);
	if(sockfd < 0)
	{
	   perror("socker is err:");
	   return -1;
	}

    struct sockaddr_in saddr;
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(atoi(argv[1]));
	saddr.sin_addr.s_addr = inet_addr(argv[2]);

    if(connect(sockfd,(struct sockaddr *)&saddr,sizeof(saddr)) < 0)
    {
        perror("connect is err:");
        return -1;
    }
    
   //1.创建表
   fd_set readfds,tempfds;
   //2.清空表
   FD_ZERO(&readfds);
   FD_ZERO(&tempfds);
   //3.添加文件描述符
   FD_SET(0,&readfds);
   FD_SET(sockfd,&readfds);
   
   int maxfd = sockfd;
   int ret;
   char buf[128];
   while(1)
   {
       tempfds = readfds;
      //4.调select检测
       ret = select(maxfd+1,&tempfds,NULL,NULL,NULL);
       if(ret < 0)
       {
           perror("select is err:");
           return -1;
       }
       if(FD_ISSET(0,&tempfds))
       {
           fgets(buf,sizeof(buf),stdin);
              if(buf[strlen(buf)-1] == '\n')
                 buf[strlen(buf)-1] = '\0';
        
          send(sockfd,buf,sizeof(buf),0);
       }
       if(FD_ISSET(sockfd,&tempfds))
       {
           int recvbyte = recv(sockfd,buf,sizeof(buf),0);
           if(recvbyte < 0)
           {
               perror("recv is err:");
               return -1;
           }
           printf("%s\n",buf);
       }
   }
   close(sockfd);
 return 0;
}
select的超时时间检测:

超时检测的必要性:

1. 避免进程在没有数据时无限制的阻塞;

2. 规定时间未完成语句应有的功能,则会执行相关功能;

头文件: #include<sys/select.h> #include<sys/time.h>

#include<sys/types.h> #include<unistd.h>

声明: int select(int nfds, fd_set *readfds, fd_set *writefds,\

fd_set *exceptfds, struct timeval *timeout);

功能:监测是哪些文件描述符产生事件,阻塞等待产生.

参数:nfds: 监测的最大文件描述个数(文件描述符从0开始,这里是个数,记得+1)

readfds: 读事件集合; // 键盘鼠标的输入,客户端连接都是读事件

writefds: 写事件集合; //NULL表示不关心

exceptfds:异常事件集合; //NULL 表示不关心

timeout: 设为NULL,等待直到某个文件描述符发生变化;

设为大于0的值,有描述符变化或超时时间到才返回。

超时时间检测:如果规定时间内未完成函数功能,返回一个超时的信息,我们可以根 据该信息设定相应需求;

返回值: <0 出错 >0 表示有事件产生;

如果设置了超时检测时间:&tv

<0 出错 >0 表示有事件产生; ==0 表示超时时间已到;

结构体如下:

struct timeval {

long tv_sec; 以秒为单位,指定等待时间

long tv_usec; 以毫秒为单位,指定等待时间

};

void FD_CLR(int fd, fd_set *set); //将set集合中的fd清除掉

int FD_ISSET(int fd, fd_set *set); //判断fd是否在set集合中产生了事件

void FD_SET(int fd, fd_set *set); //将fd加入到集合中

void FD_ZERO(fd_set *set); //清空集合

struct timeval tm;
    while (1)
    {
        struct timeval tm = {3, 0};
        tempfds = readfds;
        // 使用 select 轮询检测事件
        int ret = select(maxfd + 1, &tempfds, NULL, NULL, &tm);
        if (ret < 0)
        {
            perror("select 错误:");
            return -1;
        }
        else if (ret == 0)
        {
            printf("等待连接");
            continue;
        }
    }

POLL

特点:

1. 优化文件描述符个数的限制;

(根据poll函数第一个函数的参数来定,如果监听的事件为1个,则结构体数组容量为1,如果想监听100个,那么这个结构体数组的容量就为100,多少文件描述符由程序员自己来决定)

2. poll被唤醒之后需要重新轮询一遍驱动,效率比较低(消耗CPU)

3. poll不需重新构造文件描述符表(也不需清空表),只需要从用户空间向内核空间拷贝一次数据(效率相对比较高)

poll的流程

使用: 1.先创建结构体数组

2.添加结构体成员的文件描述符以及触发方式

3.保存数组内最后一个有效元素的下标

4. 调用函数poll

5.判断结构体内文件描述符是否触发事件

6.根据不同的文件描述符触发不同事件

poll函数
声明:int poll(struct pollfd *fds, nfds_t nfds, int timeout);
头文件: #include<poll.h>
功能: 监视并等待多个文件描述符的属性变化
参数:
	  1.struct pollfd *fds:   关心的文件描述符数组,大小自己定义
   若想检测的文件描述符较多,则建 立结构体数组struct pollfd fds[N]; 
           struct pollfd{
	                  int fd;	 //文件描述符
	             short events;	//等待的事件触发条件----POLLIN读时间触发
	             short revents;	//实际发生的事件(未产生事件: 0 ))
                            }
	    2.   nfds:    最大文件描述符个数
	    3.  timeout: 超时检测 (毫秒级):1000 == 1s      
                    如果-1,阻塞          如果0,不阻塞
返回值:  <0 出错		>0 表示有事件产生;
              如果设置了超时检测时间:&tv
      <0 出错		>0 表示有事件产生;	      ==0 表示超时时间已到;	

程序实例:
服务器程序:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <poll.h>

int main(int argc, char const *argv[])
{
    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("创建套接字失败");
        return -1;
    }
    printf("套接字创建成功: %d\n", sockfd);

    // 2. 填充服务器地址结构体
    struct sockaddr_in saddr, caddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[1])); // 从命令行参数获取端口号
    saddr.sin_addr.s_addr = inet_addr("0.0.0.0");
    int len = sizeof(caddr);

    // 3. 将套接字绑定到指定地址和端口
    if (bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("绑定失败");
        return -1;
    }
    printf("套接字绑定成功。\n");

    // 4. 开始监听连接请求
    if (listen(sockfd, 5) < 0)
    {
        perror("监听失败");
        return -1;
    }
    printf("正在等待传入连接...\n");

    // 5. 创建结构体数组
    struct pollfd fds[100];

    // 6. 添加关心的文件描述符到结构体数组内并设置触发方式
    fds[0].fd = 0;          // 标准输入文件描述符
    fds[0].events = POLLIN; // 关注可读事件

    // 不需要添加第三个成员变量 revents,一旦文件描述符产生事件,内核会自动填充 revents

    fds[1].fd = sockfd;     // 监听套接字文件描述符
    fds[1].events = POLLIN; // 关注可读事件

    int nfds = 1; // 文件描述符数组的有效长度
    char buf[128];
    while (1)
    {
        // 7. 使用 poll 轮询检测事件
        int ret = poll(fds, nfds + 1, -1);//-1代表阻塞
        if (ret < 0)
        {
            perror("poll 错误:");
            return -1;
        }

        // 8. 遍历文件描述符数组,检测并处理事件
        for (int i = 0; i <= nfds; i++)
        {
            // 先判断文件描述符是否触发事件,revents 如果被内核填充,则为触发
            if (fds[i].revents == POLLIN)
            {
                // 如果触发的文件描述符是标准输入
                if (fds[i].fd == 0)
                {
                    fgets(buf, sizeof(buf), stdin);
                    if (buf[strlen(buf) - 1] == '\n')
                        buf[strlen(buf) - 1] = '\0';
                    printf("键入内容: %s\n", buf);
                    // 将消息广播给所有连接的客户端
                    for (int j = 2; j <= nfds; j++)
                    {
                        send(fds[j].fd, buf, sizeof(buf), 0);
                    }
                }
                // 如果触发的文件描述符是监听套接字
                else if (fds[i].fd == sockfd)
                {
                    int acceptfd = accept(sockfd, (struct sockaddr *)&caddr, &len);
                    if (acceptfd < 0)
                    {
                        perror("接受连接失败:");
                        return -1;
                    }
                    printf("新连接 - 文件描述符: %d, IP: %s, 端口: %d\n",
                           acceptfd, inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port));
                    // 将新的文件描述符添加到结构体数组内
                    nfds++;
                    fds[nfds].fd = acceptfd;
                    fds[nfds].events = POLLIN;
                }
                // 如果触发的文件描述符是已连接的客户端
                else
                {
                    int recvbyte = recv(fds[i].fd, buf, sizeof(buf), 0);
                    if (recvbyte < 0)
                    {
                        perror("recv 错误:");
                        return -1;
                    }
                    else if (recvbyte == 0)
                    {
                        printf("客户端退出\n");
                        close(fds[i].fd);
                        // 移除退出的客户端文件描述符
                        fds[i] = fds[nfds];
                        nfds--;
                        i--; //返回上一次循环
                        //看下面的图
                    }
                    else
                    {
                        printf("从客户端接收: %s\n", buf);
                    }
                }
            }
        }
    }

    // 9. 关闭套接字
    close(sockfd);
    return 0;
}

127行 i--原因

客户端程序:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <poll.h>

int main(int argc, char const *argv[])
{
    // 1. 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("创建套接字失败");
        return -1;
    }
    printf("套接字创建成功: %d\n", sockfd);

    // 2. 填充服务器地址结构体
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[2])); // 从命令行参数获取端口号
    saddr.sin_addr.s_addr = inet_addr(argv[1]); // 从命令行参数获取服务器 IP 地址

    // 3. 连接到服务器
    if (connect(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("连接服务器失败");
        return -1;
    }
    printf("连接服务器成功。\n");

    // 4. 创建结构体数组
    struct pollfd fds[2];

    // 5. 添加关心的文件描述符到结构体数组内并设置触发方式
    fds[0].fd = 0; // 标准输入文件描述符
    fds[0].events = POLLIN; // 关注可读事件

    fds[1].fd = sockfd; // 套接字文件描述符
    fds[1].events = POLLIN; // 关注可读事件

    char buf[128];
    while (1)
    {
        // 6. 使用 poll 轮询检测事件
        int ret = poll(fds, 2, -1);
        if (ret < 0)
        {
            perror("poll 错误:");
            return -1;
        }

        // 7. 判断标准输入是否有数据可读
        if (fds[0].revents == POLLIN)
        {
            fgets(buf, sizeof(buf), stdin);
            if (buf[strlen(buf) - 1] == '\n')
                buf[strlen(buf) - 1] = '\0';

            // 将数据发送到服务器
            if (send(sockfd, buf, strlen(buf), 0) < 0)
            {
                perror("发送数据失败");
                return -1;
            }
        }

        // 8. 判断套接字是否有数据可读
        if (fds[1].revents == POLLIN)
        {
            int recvbyte = recv(sockfd, buf, sizeof(buf), 0);
            if (recvbyte < 0)
            {
                perror("recv 错误:");
                return -1;
            }
            else if (recvbyte == 0)
            {
                printf("与服务器的连接已断开\n");
                break;
            }
            else
            {
                printf("从服务器接收: %s\n", buf);
            }
        }
    }

    // 9. 关闭套接字
    close(sockfd);
    return 0;
}

poll超时时间检测

声明:intpoll(structpollfd*fds, nfds_t nfds,int timeout);

头文件: #include<poll.h>

功能: 监视并等待多个文件描述符的属性变化

参数:

1.structpollfd*fds: 关心的文件描述符数组,大小自己定义

若想检测的文件描述符较多,则建 立结构体数组structpollfd fds[N];

structpollfd{

int fd; //文件描述符

short events; //等待的事件触发条件----POLLIN读时间触发

short revents; //实际发生的事件(未产生事件: 0 ))

}

2. nfds: 最大文件描述符个数

3. timeout: 超时检测 (毫秒级)1000==1s

如果-1,阻塞 如果0,不阻塞

返回值: <0 出错 >0 表示有事件产生;

如果设置了超时检测时间:&tv

<0 出错 >0 表示有事件产生; ==0 表示超时时间已到;

epoll实现: (异步)

epoll了解其机制就可以

select,poll都属于 同步IO机制(轮询)

epoll属于异步IO机制(不轮询):

epoll的提出--》它所支持的文件描述符上限是系统可以最大打开的文件的数目;

eg:1GB机器上,这个上限10万个左右。

每个fd上面有callback(回调函数)函数,只有产生事件的fd才有主动调用callback,不需要轮询。

注意:

Epoll处理高并发,百万级

epoll的流程:

Epoll的使用:

1.创建红黑树 和 就绪链表

2.添加文件描述符和事件信息到树上

3.阻塞等待事件的产生,一旦产生事件,则进行处理

4.根据链中准备处理的文件描述符 进行处理

1. 红黑树: 是特殊的二叉树(每个节点带有属性),Epoll怎样能监听很多个呢?首先创建树的根节点,每个节点都是一个fd以结构体的形式存储(节点里面包含了一些属性,callback函数)

2. 就绪链表: 当某一个文件描述符产生事件后,会自动调用callback函数,通过回调callback函数来找到链表对应的事件(读时间还是写事件)。

epoll特点

1. 监听的最大的文件描述符没有个数限制(取决与你自己的系统 1GB - 10万个左右)

2. 异步I/O,epoll当有事件产生被唤醒之后,文件描述符主动调用callback(回调函数)函数直接拿到唤醒的文件描述符,不需要轮询,效率高

3.epoll不需要重新构造文件描述符表,只需要从用户空间向内核空间拷贝一次数据即可.

函数接口

要使用的一组函数接口

epoll_create 创建红黑树 和 就序列表

epoll_ctl 添加文件描述符和事件到树上 / 从树上删除

epoll_wait 等待事件产生

epoll_create 创建红黑树以及链表
头文件:#include <sys/epoll.h>
声明:int epoll_create(int size); 
功能:创建红黑树根节点(创建epoll实例) , 同时也会创建就绪链表
返回值:成功时返回一个实例(二叉树句柄),失败时返回-1。
epoll_ctl 控制epoll属性
声明: int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

功能:控制epoll属性,比如给红黑树添加节点

参数: 1. epfd:   epoll_create函数的返回句柄。//一个标识符
     2. op:表示动作类型,有三个宏:			        
                EPOLL_CTL_ADD:注册新的fd到epfd中
			      EPOLL_CTL_MOD:修改已注册fd的监听事件
			      EPOLL_CTL_DEL:从epfd中删除一个fd
     3. 要操作的文件描述符
     4. 结构体信息: 
 typedef union epoll_data {
               int fd;      //要添加的文件描述符
               uint32_t u32;  typedef unsigned int
               uint64_t u64;   typedef unsigned long int
        } epoll_data_t;

   struct epoll_event {
       uint32_t events; 事件
       epoll_data_t data; //共用体(看上面)
		};

	  关于events事件:
			 EPOLLIN:  表示对应文件描述符可读
		    EPOLLOUT: 可写
			 EPOLLPRI:有紧急数据可读;
		    EPOLLERR:错误;
		    EPOLLHUP:被挂断;
			 EPOLLET:触发方式,边缘触发;(默认使用边缘触发)
			 ET模式:表示状态的变化;
           NULL: 删除一个文件描述符使用,无事件
           
返回值:成功:0, 失败:-1

epoll_wait等待事件产生

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

功能:等待事件产生
   内核会查找红黑树中有事件响应的文件描述符, 并将这些文件描述符放入就绪链表
    就绪链表中的内容, 执行epoll_wait会同时复制到第二个参数events

参数: 	epfd:句柄;
		events:用来保存从就绪链表中响应事件的集合;
		maxevents:  表示每次在链表中拿取响应事件的个数;
		timeout:超时时间,毫秒,0立即返回  ,-1阻塞	

返回值: 成功: 实际从链表中拿出的数目     失败时返回-1

程序实例
服务器:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/epoll.h>

int main(int argc, char const *argv[])
{
    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("socket 错误:");
    }
    printf("sockfd: %d\n", sockfd); // 套接字文件描述符

    // 填充结构体
    struct sockaddr_in saddr, caddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(atoi(argv[1])); // 从命令行参数获取端口号
    saddr.sin_addr.s_addr = inet_addr("0.0.0.0"); // 监听所有可用地址
    int len = sizeof(caddr);

    // 绑定 IP 和端口
    if (bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("绑定错误:");
        return -1;
    }

    // 监听连接
    if (listen(sockfd, 5) < 0)
    {
        perror("监听错误:");
        return -1;
    }
    printf("监听成功\n");

    // 定义 epoll 相关的变量
    struct epoll_event event;
    struct epoll_event events[32];
    int epfd = epoll_create(1); // 创建 epoll 实例

    // 将标准输入和套接字添加到 epoll 实例中
    event.events = EPOLLIN | EPOLLET; // 关注可读事件和边缘触发模式
    event.data.fd = 0; // 标准输入的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);

    event.events = EPOLLIN | EPOLLET;
    event.data.fd = sockfd; // 监听套接字的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

    char buf[128] = "";
    while (1)
    {
        // 阻塞等待事件产生
        int ret = epoll_wait(epfd, events, 32, -1);
        if (ret < 0)
        {
            perror("epoll_wait 错误:");
            return -1;
        }
        // 处理产生的事件
        for (int i = 0; i < ret; i++)
        {
            if (events[i].data.fd == 0) // 标准输入事件
            {
                fgets(buf, sizeof(buf), stdin);
                if (buf[strlen(buf) - 1] == '\n')
                    buf[strlen(buf) - 1] = '\0';
                printf("按键输入: %s\n", buf);
            }
            else if (events[i].data.fd == sockfd) // 监听套接字事件
            {
                int acceptfd = accept(sockfd, (struct sockaddr *)&caddr, &len);
                if (acceptfd < 0)
                {
                    perror("接受连接错误:");
                    return -1;
                }
                printf("连接建立 - fd: %d, IP: %s, Port: %d\n", \
                       acceptfd, inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port));

                // 将新连接的套接字添加到 epoll 实例中
                event.data.fd = acceptfd;
                event.events = EPOLLIN | EPOLLET;
                epoll_ctl(epfd, EPOLL_CTL_ADD, acceptfd, &event);
            }
            else // 客户端套接字事件
            {
                int recvbyte = recv(events[i].data.fd, buf, sizeof(buf), 0);
                if (recvbyte < 0)
                {
                    perror("接收错误:");
                    return -1;
                }
                else if (recvbyte == 0) // 客户端关闭连接
                {
                    printf("客户端退出\n");
                    close(events[i].data.fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
                }
                else // 接收到数据
                {
                    printf("接收到数据来自 %d: %s\n", events[i].data.fd, buf);
                }
            }
        }
    }
    close(sockfd);
    return 0;
}
客户端:
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <sys/epoll.h>

int main(int argc, char const *argv[])
{
    if (argc != 3)
    {
        fprintf(stderr, "Usage: %s <server_ip> <server_port>\n", argv[0]);
        return -1;
    }

    // 创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("socket 错误:");
        return -1;
    }

    // 设置服务器地址信息
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(atoi(argv[2]));      // 从命令行参数获取端口号
    server_addr.sin_addr.s_addr = inet_addr(argv[1]); // 从命令行参数获取服务器 IP 地址

    // 连接到服务器
    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
    {
        perror("连接错误:");
        return -1;
    }
    printf("连接成功\n");

    // 创建 epoll 实例
    int epfd = epoll_create(1);
    if (epfd < 0)
    {
        perror("epoll_create 错误:");
        return -1;
    }

    // 将标准输入和服务器连接套接字添加到 epoll 实例中
    struct epoll_event event;
    struct epoll_event events[2];

    event.events = EPOLLIN | EPOLLET;
    event.data.fd = 0; // 标准输入的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);

    event.events = EPOLLIN | EPOLLET;
    event.data.fd = sockfd; // 服务器连接套接字的文件描述符
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);

    char buf[128] = "";
    while (1)
    {
        int ret = epoll_wait(epfd, events, 2, -1);
        if (ret < 0)
        {
            perror("epoll_wait 错误:");
            return -1;
        }

        for (int i = 0; i < ret; i++)
        {
            if (events[i].data.fd == 0) // 标准输入事件
            {
                fgets(buf, sizeof(buf), stdin);
                if (buf[strlen(buf) - 1] == '\n')
                    buf[strlen(buf) - 1] = '\0';

                if (send(sockfd, buf, strlen(buf), 0) < 0)
                {
                    perror("发送错误:");
                    return -1;
                }

                if (strcmp(buf, "exit") == 0)
                {
                    printf("客户端退出\n");
                    close(sockfd);
                    return 0;
                }
            }
            else if (events[i].data.fd == sockfd) // 服务器连接套接字事件
            {
                int recvbyte = recv(sockfd, buf, sizeof(buf), 0);
                if (recvbyte < 0)
                {
                    perror("接收错误:");
                    return -1;
                }
                else if (recvbyte == 0) // 服务器关闭连接
                {
                    printf("服务器关闭连接\n");
                    close(sockfd);
                    return 0;
                }
                else // 接收到数据
                {
                    printf("从服务器接收到数据: %s\n", buf);
                }
            }
        }
    }

    close(sockfd);
    return 0;
}

io并发三种机制总结

表格:

特点

select

poll

epoll

适用范围

适用于小规模连接

适用于中等规模连接

适用于大规模连接

效率

效率相对较低,时间复杂度高

效率相对较低,时间复杂度高

效率高 时间复杂度小

可用性

跨平台支持,但性能较差

跨平台支持,性能相对select更好

仅限于Linux系统

同步/异步

同步,阻塞等待事件发生

异步,事件通知

水平触发(默认),需要循环处理所有事件

边缘触发,仅在状态变化时通知

思维导图

本地套接字通信

其实说到本地套接字通信 , 本质的改变就是通信的协议改变了,之前用的协议一直是 IPV4协议,使用本地套接字通信,用的就是AF_LOCAL或者AF_UNIX了,我们来看一下本地套接字的具体特性:

特性:

  1. unix网络编程最开始有的编程都是一台主机内进程和进程之间的编程。(本地通信)
  2. 创建套接字时使用本地协议AF_LOCAL或AF_UNIX
  3. 分为流式套接字和数据报套接字
  4. 和其他进程间通信相比使用方便、效率更高,常用于前后台进程通信。

unix域套接字编程,实现本间进程的通信,依赖的是s类型的文件;

TCP,UDP 都是依赖IP 端口号进行通信,实现两个主机通信

本地通信不需要ip和端口,所以无法进行两个主机通信\

查询man 7 unix

流程

核心代码:

#include <sys/socket.h>
#include <sys/un.h>

struct sockaddr_un {
sa_family_t sun_family;               /* 本地协议 AF_UNIX */
char       sun_path[UNIX_PATH_MAX];  /* 本地路径 s类型的套接字文件 */
};

unix socket = socket(AF_UNIX, type, 0); //type为流式或数据包套接字
                                
struct sockaddr_un myaddr;
myaddr.sun_family = AF_UNIX; //填充UNIX域套接字
strcpy(saddr.sun_path,"./myunix"); // 创建套接字的路径(将套接字创建到哪? 服务器与客户端一致)

代码实例:

服务器:

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <stdlib.h>

int main()
{
    int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("创建套接字时出错");
        return -1;
    }

    struct sockaddr_un saddr;
    saddr.sun_family = AF_UNIX;
    strncpy(saddr.sun_path, "local_socket", sizeof(saddr.sun_path) - 1);

    if (bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("绑定套接字地址时出错");
        return -1;
    }

    if (listen(sockfd, 5) < 0)
    {
        perror("监听套接字时出错");
        return -1;
    }

    printf("等待客户端连接...\n");

    struct sockaddr_un client_addr;
    socklen_t client_len = sizeof(client_addr);
    int acceptfd = accept(sockfd, (struct sockaddr *)&client_addr, &client_len);
    if (acceptfd < 0)
    {
        perror("接受客户端连接时出错");
        return -1;
    }

    printf("与客户端连接成功!\n");

    char buffer[128];
    while (1)
    {
        int ret = recv(acceptfd, buffer, sizeof(buffer), 0);
        if (ret <= 0)
        {
            printf("客户端断开连接。\n");
            break;
        }
        buffer[ret] = '\0';
        printf("接收到消息:%s\n", buffer);
    }

    close(acceptfd);
    close(sockfd);

    // 删除本地套接字文件
    unlink("./mylinux");

    return 0;
}

客户端:

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <stdlib.h>

int main()
{
    int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        perror("创建套接字时出错");
        return -1;
    }

    struct sockaddr_un saddr;
    saddr.sun_family = AF_UNIX;
    strncpy(saddr.sun_path, "local_socket", sizeof(saddr.sun_path) - 1);

    if (connect(sockfd, (struct sockaddr *)&saddr, sizeof(saddr)) < 0)
    {
        perror("连接到服务器时出错");
        return -1;
    }

    printf("与服务器连接成功!\n");

    char buffer[128];
    while (1)
    {
        printf("请输入要发送的消息(输入exit退出): ");
        fgets(buffer, sizeof(buffer), stdin);

        // 删除换行符
        size_t length = strlen(buffer);
        if (buffer[length - 1] == '\n')
        {
            buffer[length - 1] = '\0';
        }

        if (strcmp(buffer, "exit") == 0)
        {
            break;
        }

        if (send(sockfd, buffer, strlen(buffer), 0) < 0)
        {
            perror("发送消息时出错");
            break;
        }
    }

    close(sockfd);

    return 0;
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值