五大IO模型

1.IO过程

任何IO过程,都要包含两个步骤,第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少

等待IO就绪:相当于想要获取的资源已经准备好了,可以进行操作

读 recv/recvfrom: 等待接收缓冲区当中有数据来(等待IO过程)、接收缓冲区之中有了数据(等待IO就绪)
写 send/sendto:发送缓冲区当中有空间(等待IO过程)、发送缓冲区之中有了空间(等待IO就绪)

拷贝数据到缓冲区中:
读: recv/recvfrom(sockfd,buff,size,0): 将接收缓冲区的数据拷贝到自己准备的空间(buff)之中
写: send/sendto:将应用层数据,拷贝到发送缓冲区之中

2.典型IO模型

2.1阻塞IO

资源不可用的情况下,IO请求一直被阻塞,直到资源可用,就叫做阻塞IO,所有的套接字, 默认都是阻塞方式,阻塞IO是最常见的IO模型

实现流程:
在这里插入图片描述

特点:

1.发起IO调用后,等待的时间取决于内核

2.在等待的过程之中,执行流是被挂起的,对CPU的利用率是非常低

3.在IO就绪到拷贝数据之间,实时性是非常高的(有鱼咬立马起杠提钩)

2.2非阻塞IO

资源不可用的时候,IO请求不会阻塞,而是直接返回,返回当前资源不可用,并且返回EWOULDBLOCK错误码

如果当前资源不可用,IO请求返回之后,表示本次IO请求没有真正完成,所以想要完成IO请求,非阻塞需要搭配循环使用,直至完成IO请求

非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用

实现流程:

在这里插入图片描述

特点:

1.非阻塞IO对CPU的利用率比阻塞IO高

2.代码复杂,流程控制复杂,因为需要循环的缘故

3.需要搭配循环调用,直至IO请求完成

4.IO准备就绪到拷贝数据之间不够实时

while{
非阻塞IO调用  //资源还未准备好,返回错误码
此时资源已经准备就绪,但是还需要指向下面代码,因此实时性比较差
//代码1
//代码2
}

区分阻塞IO和非阻塞IO:只需要关心IO调用是否立即返回即可,没有立即返回说明是阻塞的,直接返回说明是非阻塞的

非阻塞接口函数

  int fcntl(int fildes, int cmd, ...);
  
  cmd:获得/设置文件状态标记(cmd=F_GETFL或者F_SETFL)

实现非阻塞函数

void SetNoBlock(int fd)
{
	int fl=fcntl(fd,F_GETFL);// F_GETFL将当前文件描述符fd的属性取出来(当前属性是一个位图)
	if(fl < 0)
	{
		cerr<<"fctnl error"<<endl;
		exit(0);
	}
	fcntl (fd,F_SETFL,fl|O_NONBLOCK);//F_SETFL设置属性,在原有属性上增加非阻塞属性(O_NONBLOCK)
}

2.3信号驱动IO

实现流程:

1.自定义一个IO信号(SIGIO)的处理函数,在处理函数当中发起IO调用

2.内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

3.程序收到一个IO信号,内核就会调用自定义的处理函数,内核调用了自定义的处理函数,在自定义处理函数中发起了IO调用

在这里插入图片描述

特点:

1.IO准备就绪,到拷贝数据之间,实时性增强了

2.代码更加复杂,流程控制更加困难,因为引入了信号

3.好处是不用再重复发起IO调用,但是需要在代码当中增加自定义信号的逻辑

2.4异步IO

由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

实现流程:

1.自定义信号处理函数 -> 通知数据拷贝完成

2.发起一个异步IO调用,并且异步IO调用直接返回

3.异步IO调用返回之后,执行流可以执行用户代码,由操作系统内核等待IO就绪和数据拷贝

4.数据拷贝完成之后,内核通过信号通知调用者
在这里插入图片描述

3.多路转接IO模型

作用:IO多路转接可以完成大量文件描述符的监控,监控的时间包括:可读事件、可写事件、异常事件

监控文件描述符:那么个文件描述符就绪,就处理哪一个文件描述符

3.1select模型

select系统调用是用来让我们的程序监视多个文件描述符的状态变化的,程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变

select、poll、epoll都只负责一件事情~等,由于等待的是多个文件描述符,而IO主要是两个过程,等待和拷贝。多路转接,一次性等待多个文件描述符,等待的效率提高了,所以IO效率提高了

实现流程:

1.将用户关心的文件描述符拷贝到内核之中,由内核进行监控

2.如果内核监控到某个文件描述符就绪,则返回该描述符

3.用户针对返回的描述符进行操作

接口:
在这里插入图片描述

代码演示1:不带超时时间

int main()
  7 {
  8   //设置事件集合
  9   fd_set readfds;//
 10 
 11   FD_ZERO(&readfds);//清空集合
 12   FD_SET(0,&readfds);//将0号文件描述符添加进去
 13 
 14   while(1)
 15   {
 16       int ret=select(1,&readfds,NULL,NULL,NULL);//阻塞监控
 17     if(ret < 0)
 18     {
 19       cerr<<"select error"<<endl;//监控出错
 20       exit(1);
 21     }
 22 
 23     if(FD_ISSET(0,&readfds)!=0)//判断0号文件描述符是否准备就绪
 24     {
 25       char buff[100]={0};
 26       read(0,buff,sizeof(buff)-1);
 27       printf("echo:%s",buff);
 28     }
 29   }
 30 
 31   return 0;
 32 }                   

在这里插入图片描述

代码演示2:带超时时间

int main()
{
  //设置事件集合
  fd_set readfds;//
  
  FD_ZERO(&readfds);//清空集合
  FD_SET(0,&readfds);//将0号文件描述符添加进去

  while(1)
  {
    //设置超时时间
    struct timeval tv;
    tv.tv_sec=3;//超时时间设置为3秒
    tv.tv_usec=0;

    int ret=select(1,&readfds,NULL,NULL,&tv);//阻塞监控

    if(ret < 0)
    {
      cerr<<"select error"<<endl;//监控出错
      exit(1);
    }

    if(ret==0)//超时了
    {
      cout<<"time out"<<endl;

      if(FD_ISSET(0,&readfds)==0)//超时了,select会去除没有就绪的文件描述符
        cout<<" 0 fd is not in readfds"<<endl;

      FD_SET(0,&readfds);//从新设置
      continue;
    }

    if(FD_ISSET(0,&readfds)!=0)//判断0号文件描述符是否准备就绪
    {
      char buff[100]={0};
      read(0,buff,sizeof(buff)-1);
      printf("echo:%s",buff);
    }
  }

  return 0;
}

在这里插入图片描述

select优缺点:

优点:
1.遵循的是posix标准,即可以跨平台使用
2.select超时时间可以精确到微秒
3.一次可以等待多个文件描述符,提高了等待的效率,即提高了IO的效率

缺点:
1.select的事件集合参数为输入输出型一体,因此每次调用select都需要再次设置事件集合
2.每次调用select都需要将参数拷贝至内核之中,就绪的文件描述符也需要从内核之中拷贝至用户,开销很大
3.select的文件描述符个数有上限,在centos 7 之中为1024,无法进行大量的监控

用select构建tcp服务器

构建流程:

在这里插入图片描述

构建代码:

#pragma once
#include <sys/select.h>
#include <iostream>
using namespace std;
#include <math.h>
#include <vector>

class Select
{
  public:

    Select()
    {
      //初始化事件集合、最大文件描述符
      FD_ZERO(&readfds);
      _maxfd=-1;
    }
    

    void AddSet(int fd)//添加需要监控文件描述符
    {
      _maxfd=fmax(_maxfd,fd);//更新最大文件描述符
      FD_SET(fd,&readfds);

    }

    void DeleteSet(int fd)//删除监控的文件描述符
    {
      FD_CLR(fd,&readfds);
      
      //更新最大文件描述符
      if(fd==_maxfd)
      {
        for(int i=fd;i>=0;i--)//从后往前寻找第一个就是最大的
        {
          if(FD_ISSET(i,&readfds))
          {
            _maxfd=i;
            break;
          }
        }
      }
    }
    
    bool SelectWait(vector<int>& arr)//监控接口
    {
      //设置延迟时间
      struct timeval tv;
      tv.tv_sec=2;
      tv.tv_usec=0;
      
      fd_set copy = readfds;//保存一份,返回后可以进行恢复

      int ret=select(_maxfd+1,&copy,NULL,NULL,NULL);
      
      if(ret < 0)//监控错误
      {
        cerr<<"select error"<<endl;
        return false;
      }
      else if(ret==0)//等待超时了
      {
        cout<<"time out"<<endl;
        return false;
      }

      //监控了多个文件描述符,但是不知道那个文件描述符已经就绪了
      for(int i=0;i<=_maxfd;i++)
      {
        if(FD_ISSET(i,&copy))//为i的文件描述符,已经就绪了
            arr.push_back(i);//添加到准备就绪的集合之中
      }
        return true;
    }


    ~Select()
    {}



  private:
    int _maxfd;//最大的文件描述符
    fd_set readfds;//可读事件集合
};

#ifndef _SERVER_HPP_
#define _SERVER_HPP_

#include <iostream>
using namespace std;
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#endif
#define BACKLOCK 5

class Server
{
  private:
    int port;
    int lsock;//监听套接字
  
  public:
    
    Server(int _port)
      :port(_port)
       ,lsock(-1)
    {}

    void Init()//初始化服务器
    {
      lsock=socket(AF_INET,SOCK_STREAM,0);
      if(lsock<0)
      {
        cerr<<"socket fail"<<endl;
        exit(2);
      }
      
      struct sockaddr_in local;
      local.sin_family=AF_INET;//填充协议
      local.sin_port=htons(port);//填充端口,转换成网网络字节序
      local.sin_addr.s_addr=INADDR_ANY;//填写ip
    
      if(bind(lsock,(struct sockaddr*)&local,sizeof(local)) < 0)//进行绑定
      {
        cerr<<"bind error"<<endl;
        exit(3);
      }
      if(listen(lsock,BACKLOCK) < 0)
      {
        cerr<<"listen error"<<endl;
        exit(4);
      }
    }

    int Task(int sock)//用链接的套接字去执行任务
    {
      char buff[200];
        size_t size=recv (sock,buff,sizeof(buff)-1,0);
        if(size>0)//读取到了
        {
          buff[size]='\0';//末尾添加\0
          cout<<"client:"<<buff<<endl;

          string str="ser echo:";
          str+=buff;
          send(sock,str.c_str(),str.size(),0);//不需要+1,\0是语言的标准,文件不认识
          
          return 0;
        }
        else 
          return -1;//表示对方已经关闭了
    }

    int Stat()//用套接字去获取链接
    {
      sockaddr_in end_point;//获取客户结构体信息,和udp作用一样
        socklen_t len=sizeof(end_point);
        int sock=accept(lsock,(struct sockaddr*)&end_point,&len);
        
        if(sock < 0)
        {
          cout<<"accept error"<<endl;
            return -1;
        }
        
        return sock;
    }

    int GetPid()//返回监听套接字
    {
      return lsock;
    }



  ~Server()
  {
	  close(lsock);
  }
};

#include "select.hpp"
#include "server.hpp"


int main(int argc,char *argv[])
{
  if(argc!=2)
  {
    cout<<"please enter your port"<<endl;
    exit(0);
  }

  Server se(atoi(argv[1]));
  se.Init();
  
  Select st;
  st.AddSet(se.GetPid());

  while(1)
  {
    vector<int> arr;
    bool ret=st.SelectWait(arr);//进行监控
    
    if(ret==false)
        continue;
    
    for(size_t i=0;i < arr.size();i++)
    {
      if(arr[i]==se.GetPid())
      {
          int sock=se.Stat();//获取链接
          
          if(sock>0)
          {
            st.AddSet(sock);//将获取的链接添加至监控中
            cout<<"get a new link"<<endl;
          }
      }
      else
      {
        int ret=se.Task(arr[i]);
        if(ret==-1)//对方已经关掉了
        {
          st.DeleteSet(arr[i]);
          cout<<"link end"<<endl;
        }
      }
    }
  }
  return 0;
}

3.2poll模型

接口:
timeout =0 非阻塞监控
timeout =-1 阻塞监控
timeout>0 超时时间为多少

在这里插入图片描述

代码验证:

int main()
{
  struct pollfd arr[10];
  arr[0].fd=0;//关心0号文件描述符
  arr[0].events=POLLIN;//关心可读事件

  while(1)
  {
    int ret=poll(arr,1,2000);//1个有效元素,超时时间为2000毫秒
    if(ret==0)//等待超时
    {
      cout<<"time out"<<endl;
      continue;
    }
    else if(ret < 0)
    {
      cerr<<"poll error"<<endl;//poll失败
   	  exit(0);
    }
    else
    {
      char buff[100];
      for(int i=0;i<ret;i++)
      {
        if(arr[i].events==POLLIN)
        {
          int size=read(arr[i].fd,buff,sizeof(buff)-1);
          buff[size-1]=0;//会将换行符也读进来
          cout<<"echo:"<<buff<<endl;
         }
      }
    }
}

  return 0;

}

在这里插入图片描述

特点:

1.poll和select相比,跨平台移植性不如select,与epoll相比,监控效率不如epoll

2.相较于select改进的点:

a.不限制文件描述符的个数了,由用户自己定义结构体数组的数量
b.相较于select之前的事件集合的方式,改进成为事件结构。事件结构告诉我们,关心的文件描述符是什么,关心的文件描述符发生事件是什么

优缺点:

优点:
1.采用了事件结构的方式,简化了代码的编写
2.不限制文件描述符的个数
3.不需要在二次监控的时候重新添加文件描述符,因为输入型参数和输出型参数分开了

缺点:
1.采用轮询遍历事件结构数组的方式,随着文件描述符增多,性能下降
2.不支持跨平台
3.也需要将事件结构拷贝到内核,再从内核拷贝用户空间

3.3epoll模型

3.3.1epoll的使用介绍

目前公认的在linux操作系统下,监控性能最高的

接口:

epoll_create:
在这里插入图片描述

epoll_ctl:
在这里插入图片描述

epoll_wait:

在这里插入图片描述

epoll原理:

在这里插入图片描述

使用epoll构建tcp服务器

构建流程:
1.epoll服务器初始化:创建epoll模型
2.epoll服务器开始运行:将监听套接字添加进去
3.进行等待,如果监听套接字准备就绪,就获取链接套接字,并且以读的方式添加至epoll模型之中,事件中给每个文件描述符一个独立的空间
4.如果链接套接字准备就绪,判断事件,再进行操作
5.如果链接套接字为可读事件,则进行读取,读满后,修改对应的事件为可写事件
6.写完之后、断开连接

构建代码

sock.h

#pragma once
#include <iostream>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
using namespace std;

class Server
{
  public:

    static int Socket()
    {
      int sock =socket(AF_INET,SOCK_STREAM,0);
      if(sock < 0)
      {
        cerr<<"socket error "<<endl;
        exit(1);
      }
      return sock;
    }

    static void Bind(int sock,int port)
    {
      struct sockaddr_in local;
      local.sin_family=AF_INET;
      local.sin_port=htons(port);
      local.sin_addr.s_addr=htonl(INADDR_ANY);
	  
	  Setsockpot(sock);
      
      if(bind(sock,(struct sockaddr*)&local,sizeof(local)) < 0)
      {
        cerr<<"bind error"<<endl;
        exit(2);
      }
    }

    static void Listen(int sock)
    {
      if(listen(sock,5) < 0)
      {
        cerr<<"listen error"<<endl;
        exit(3);
      }
    }

    static int Accept(int sock)
    {
      struct sockaddr_in peer;
      socklen_t len=sizeof(peer);

      int fd=accept(sock,(struct sockaddr*)&peer,&len);
      if(fd < 0)
        cerr<<"accept error"<<endl;

      return fd;
    }
    
	 static void Setsockpot(int sock)//设置短时间内可以再次绑定端口
   {
       int opt=1;
       setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
   }

};

EpollServer.h
#pragma once
#include "sock.h"
#include <sys/epoll.h>
#include <unistd.h>
#include <string.h>

class EpollServer
{
  private:
    int _port;
    int _lsock;
    int _epfd;

  public:
    EpollServer(int port)
      :_port(port)
      ,_lsock(-1)
      ,_epfd(-1)
    {}

    ~EpollServer()
    {
      close(_lsock);
      close(_epfd);
    }

    void EpollServerInit()
    {
      //创建套接字
      _lsock=Server::Socket();
      Server::Bind(_lsock,_port);
      Server::Listen(_lsock);

      //创建epoll模型
      _epfd=epoll_create(128);
      if(_epfd < 0)
      {
        cerr<<"epoll create error"<<endl;
        exit(0);
      }

    }

    struct Block
    {
      int fd;//关心的文件描述符
      char buff[20];//每个文件描述符对应的缓冲区
      size_t  pos=0;//读取或者输出到什么位置了

      Block(int sock)
      :fd(sock)
      {
        memset(buff,0,sizeof(buff));//清空缓冲区
      }
    };

    void Add(int sock,uint32_t even)//添加事件
    {
      //创建事件结构 -> 并且进行填充
        struct epoll_event events;
        events.events=even;//对应的事件

        if(sock==_lsock)//监听套接字不需要文件缓冲区
          events.data.ptr=nullptr;
        else
          events.data.ptr=new Block(sock);//对应的缓冲区
        
      //添加至epoll模型之中(给对应的红黑树增加节点)
        epoll_ctl(_epfd,EPOLL_CTL_ADD,sock,&events);

    }


    void Delete(int sock)//删除节点
    {
      close(sock);//关闭文件描述符
      //从epoll模型中删除这个节点
      epoll_ctl(_epfd,EPOLL_CTL_DEL,sock,nullptr);

    }

    //事件填充的时候,是从下表0开始填充的
    void HandelEvent(struct epoll_event *ret,int num)
    {
       for(int i=0;i < num;i++)
       {
          uint32_t even =ret[i].events;//获取对应的事件
          
          if(ret[i].data.ptr==nullptr)//监听套接字
          {
            int sock=Server::Accept(_lsock);//获取链接套接字
            //将其以可读的方式,添加至epoll模型之中
            if(sock > 0)
            {
              cout<<"get a new link"<<endl;
              Add(sock,EPOLLIN);
            }
          }
          else//获取来的链接
          {
            if(even==EPOLLIN)//可读事件
            {
              //读取内容至各自的缓冲区
              Block *bk=(Block*)ret[i].data.ptr;
              int size=recv(bk->fd,bk->buff+bk->pos,sizeof(bk->buff)-bk->pos,0);
              
              if(size > 0)//读到了数据
              {
                cout<<"client#"<<bk->buff+bk->pos<<endl;

                bk->pos+=size;
                if(bk->pos >= sizeof(bk->buff))//读满了
                {
                    //输出后,将读事件修改为写事件
                    struct epoll_event temp;
                    bk->pos=0;
                    temp.events=EPOLLOUT;
                    temp.data.ptr=bk;

                    epoll_ctl(_epfd,EPOLL_CTL_MOD,bk->fd,&temp);//修改事件为写事件
                }
              }
              else if(size ==0 )//对方关闭了链接
              {
                cout<<"perr  quit  me to"<<endl;
                  Delete(bk->fd);//从红黑树中删除节点
                  delete bk;
              }
              else //异常
              {
                cerr<< "exception"<<endl;
                exit(5);
              }

            }
            else if(even==EPOLLOUT)//可写事件
            {
              Block *bk=(Block*)ret[i].data.ptr;
              size_t size=send(bk->fd,bk->buff+bk->pos,sizeof(bk->buff)-bk->pos,0);
              bk->pos+=size;

              if(bk->pos >= sizeof(bk->buff))//全部发送过去了
              {
               Delete(bk->fd);//关闭链接
               delete bk;
              }

            }
          }

       }
    }

    void Start()
    {
      //将监听套接字添加至epoll模型之中
      
      Add(_lsock,EPOLLIN);//监听套接字可读事件
      struct epoll_event ret[20];//接收事件结构体数组
      
      while(1)
      {
        int num=epoll_wait(_epfd,ret,20,-1);// timeout=-1 阻塞等

        switch(num)
        {
          case 0://0超时
            cerr<<"wait out"<<endl;
            break;
          case -1://小于0,一般是-1 出错
            cerr<<"wait error"<<endl;
            break;
          default://否则有事件已经就绪
            HandelEvent(ret,num);
            break;
        }
      }
      
    }

};

main.cpp

#include "sock.h"
#include "EpollServer.hpp"

int main(int argc,char *argv[])
{
  if(argc!=2)
  {
    cerr<<"please enter your port"<<endl;
    exit(0);
  }
 
	int port=atoi(argv[1]);
    EpollServer *ep=new EpollServer(port);
    ep->EpollServerInit();
    ep->Start();
 
  return 0;

}

实验效果
在这里插入图片描述

3.3.2poll对文件描述符就绪事件的触发方式

3.3.2.1水平触发EPOLLLT(LT模式)

满足条件就会一直触发 -> 比如你在打游戏,你的妈妈叫你去吃饭,你没去,她又会过来叫你
EPOLLLT -> epoll的默认工作方式,select和poll都是水平触发方式

可读事件:

只要接收缓冲区当中的数据大于低水位标记(1字节),就会一直触发可读事件,直到接收缓冲区当中没有数据可读(接收缓冲区当中的数据低于低水位标记)

可写事件:

只要发送缓冲区当中的空间大于低水位标记(1字节),就会一直触发可写事件,直到发送缓冲区当中没有空间可写(发送缓冲区当中的空间低于低水位标记)

3.3.2.1边缘触发(边沿触发)EPOLLET(ET模式)

满足条件后只会触发一次 -> 比如你在打游戏,你的爸爸叫你去吃饭,只会叫你一次

EPOLLET -> 只有epoll才拥有

设置:
设置文件描述符对应的事件结构的时候,只需要在事件结构当中的事件变量中按位或上EPOLLET即可

struct epoll_event et; 
ev.events = EPOLLIN|EPOLLET;

可读事件:

只有当新的数据到来的时候,才会触发可读,否则通知一次之后,就不再通知了 -> 每次到来一个新的数据,只会通知一次,如果应用程序没有将接收缓冲区的数据读完(没有读完的数据留在缓冲区之中,下次触发就从这里开始),也不会再次通知,直到新的数据到来,才会触发可读事件,因此需要尽量将数据读完

可写事件:

只有当发送缓冲区之中剩余空间从不可写变成可写的时候,才会触发一次可写事件就绪

对于ET模式而言,如果就绪事件产生,一定要把握好机会,对于可读事件,将数据读完,对于可写事件,将数据写完
ET模式结合了循环将数据进行读取和发送,不是频繁的进行通知,因此效率就比较高

构建ET细节注意点:

1.如何判断数据读完了 :

设size=期望读取的字节、ret为实际读取的字节
ret<size表示缓冲区之中一定没有数据了 -> 读完了

ret==size
此时有可能有数据,有可能没有 ->都需要再次进行读取
再次读取有可能会进入阻塞,因此需要将其更改为非阻塞状态

2.将数据发送出去:

同样需要构建循环进行发送,当缓冲区没有容量的时候,就循环发送,直至缓冲区有容量

3.将描述符设置为非阻塞接口介绍:

int fcntl(int fd, int cmd, … /* arg */ );
1.fd:要设置的文件描述符
2.cmd:操作方式
F_GETFL获取当前文件描述符的属性
F_SETFL将非阻塞属性设置到文件描述符的属性当中(O_NONBLOCK)

4.代码
1.对侦听套接字进行监控时,采用默认的方式
2.对链接的套接字进行监控时,采用ET模式
3.由于是ET模式,应该通知的时候就需要将所有的数据读取或者发送完毕,因此需要采用循环来处理
4.循环处理中,如果是阻塞的发送,那么最后一次处理会陷入阻塞的状态,因此需要改为非阻塞的状态

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值