【Linux】I/O多路复用——select、poll、epoll的区别

I/O复用

I/O多路复用是为了解决进程或线程阻塞到某个I/O系统调用而出现的技术,使进程不再阻塞于某个特定的I/O系统调用。

利用select、poll、epoll实现多个端口通信。I/O复用使得程序能同时监听多个文件描述符,当某个文件描述符就绪时,能够通知程序进行相应的读写操作,但select()、poll()、epoll()本身是阻塞的,直到出现就绪的文件描述符。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串形工作的。如果要实现并发,只能使用多进程或多线程的手段。

与多进程和多进程相比,I/O多路复用的最大优势是系统开销小,系统不需要建立新的进程或线程,也不必维护进程和线程。

 

文件描述符的就绪条件

满足下列条件之一,套接字准备好读:

  • 套接字接收缓冲区当中的数据字节数大于等于套接字接收缓冲区中设置的最小值。(对于TCP和UDP来说默认值为1)。此时可以无阻塞地读该socket,并且读操作返回的字节数大于0。
  • socket通信的对方关闭连接。此时对该socket的读操作将返回0。
  • 监听socket上有新的连接请求。
  • socket上有未处理的错误。此时我们可以使用getsockopt来读取和清除该错误。

满足下列条件之一,套接字准备好写:

  • 该套接字发送缓冲区中可用空间的大小大于等于套接字发送缓冲区中设置的最小值时,此时我们可以无阻塞地写该socket,并且写操作返回的字节数大于0;
  • socket的写操作被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE的信号。
  • socket使用非阻塞connect连接成功或者失败(超时)之后
  • socket有未处理的错误。

 

select

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

功能:select监视readfds、writefds、exception三个参数作为自己感兴趣的文件描述符;应用程序在调用select时,函数会阻塞,直到有文件描述符就绪(有数据可读、可写、或者有错误异常)、或者超时,select函数将调用返回,内核会修改他们来通知应用程序那些文件描述符已经就绪,可以遍历fd_set来找到就绪的文件描述符。

 

参数:
nfds:监听的最大文件描述符+1;文件描述符存放在fd_set中,fd_set是一个long类型的数组,并且数组中的每一位表示一个文件描述符,所以最多可维护1024个文件描述符(0~1023文件描述符从0开始);

 

readfds:监视可读的文件描述符集合,只要有文件描述符即将进行读操作,这个文件描述符就存储到这;
writefds:监视可写的文件描述符集合;
exception:监视的错误异常文件描述符的集合;
这三个参数是我们指定让内核检测读、写、异常条件的文件描述符。如果不需要的则设置为NULL。结构体fd_set就是存放文件描述符的集合,可以通过以下四个宏进行设置:
FD_ZERO(fd_set  *fdset);                            /*清除fdset的所有位*/
FD_SET(int fd,fd_set *fdset);                         /*设置fdset的位fd*/
FD_CLE(int fd,fd_set *fdset);                        /*清除fdset的位fd*/     
int FD_ISSET(int fd,fd_set *fdset)                /*测试fdset的位fd是否被设置*/

 

timeout:设置select函数的超时时间,它告知内核等待所指定描述字中的任何一个就绪可花多少时间,其结构如下:
struct timeval
{
    time_t tv_sec;//秒
    suseconds_t tv_usec;//微妙
}


这个参数有以下几种可能:


(1)永远等待下去,仅在有一个描述符准备好I/O时才返回。为此,把该参数设置为NULL;
(2)等待固定时间,在指定的固定时间内,在有一个描述字准备好I/O时返回,如果时间到了,没有文件描述符发生变化,这个函数会返回0;
(3)根本不等待,检查描述字后立即返回,这称为轮询。为此,struct timeval变量的时间指定为0秒0微秒,文件描述符属性无变化返回0,有变化返回准备好的描述符数量;

 

返回值:
出错:返回-1
超时:返回0
成功:返回就绪文件描述符的个数

 

简单实例select函数:
将标准输入文件描述符0存放在集合中,利用select监听此描述符,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/time.h>
#include <sys/select.h>
#include <netinet/in.h>

#define STDIN 0//标准输入的文件描述符为0

int main()
{
    fd_set readfd;//文件描述符的集合
    int fd = STDIN;
    while(1)
    {
        FD_ZERO(&readfd);
        FD_SET(fd,&readfd);
        struct timeval vl = {5,0};//时间设置为5秒
        int n = select(fd+1,&readfd,NULL,NULL,&vl);
        if(n == -1)
        {
            perror("select error\n");
            continue;
        }
        else if(n == 0)
        {
            printf("time out\n");
            continue;
        }
        else
        {
            if(FD_ISSET(fd,&readfd))//检测就绪的文件描述符
            {
                char buff[128] = {0};
                read(fd,buff,127);
                printf("read:%s",buff);
                //close(fd);//不能关闭fd。如果关闭,fd是无效的文件描述符,将一直打印出timeout
            }
        }
    }
}

结果:

 

使用多客户端的网络通信实例(服务器利用select函数实现I/O复用)
服务器端:

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

int maxfd = -1;//初始化最大描述符
int sockfds[1024];//存放文件描述符的集合,最大为1024个

void InitFds()//对文件描述符的集合初始化
{
    int i = 0;
    for(;i < 1024;++i)
    {
        sockfds[i] = -1;
    }
}

int AddFd(int fd)//添加文件描述符
{
    int i = 0;
    for(;i < 1024;i++)
    {    
        if(sockfds[i] == -1)
        {
            sockfds[i] = fd;
            return 1;
        }
    }
    return 0
}

int Delete(int fd)//删除文件描述符
{
    int i = 0;
    for(;i < 1024;i++)
    {
        if(sockfds[i] == fd)
        {
            sockfds[i] = -1;
            return 1;
        }

    }
    return 0;
}

int CreateSocket(int port,char *p)//服务器端创建一个文件描述符
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    assert(sockfd != -1);
    
    struct sockaddr_in ser;
    memset(&ser,0,sizeof(ser));
    ser.sin_family = AF_INET;
    ser.sin_port = htons(port);
    ser.sin_addr.s_addr = inet_addr(p);

    int res = bind(sockfd,(struct sockaddr*)&ser,sizeof(ser));
    assert(res != -1);

    listen(sockfd,5);
    
    AddFd(sockfd);
    return sockfd;
}
int main()
{
    InitFds();//对文件描述符的集合初始化
    int sockfd = CreateSocket(6000,"127.0.0.1");//服务器端创建一个文件描述符
    fd_set readfds;
    while(1)
    {
        FD_ZERO(&readfds);
        int i = 0;
        for(;i < 1024;i++)
        {    
            if(sockfds[i] != -1)
            {
                FD_SET(sockfds[i],&readfds);//将文件描述符设置到readfds相应的位中
                if(sockfds[i] > maxfd)
                {
                    maxfd = sockfds[i];//找到最大的文件描述符
                }
            }
        }
        int n = select(maxfd+1,&readfds,NULL,NULL,NULL);//设置为永久阻塞,直到有就绪的文件描述符
        if(n <= 0)//出错
        {
            printf("error\n");
            exit(0);
        }
        for(i = 0;i < 1024;i++)
        {
            int fd = sockfds[i];
            if(fd != -1 && FD_ISSET(fd,&readfds))
            {
                if(fd == sockfd)//sockfd的处理,监听套接字
                {
                    struct sockaddr_in cli;
                    int len = sizeof(cli);
                    int c = accept(sockfd,(struct sockaddr*)&cli,&len);
                    if(c == -1)
                    {
                        printf("Link error\n");
                        continue;
                    }
                    AddFd(c);
                    printf("accept:%d\n",c);
                }
                else //c的处理,链接套接字
                {
                    char buff[128] = {0};
                    int n = recv(fd,buff,127,0);
                    if(n <= 0)
                    {
                        close(fd);
                        Delete(fd);
                        continue;
                    }
                    printf("recv(%d):%s\n",fd,buff);
                    send(fd,"OK",2,0);
                }
            }
        }
        
        
    }
    
}

客户端:

#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main()
{
    int sockfd = socket(AF_INT,SOCK_STREAM,0);
    assert(sockfd != -1);
    struct sockaddr_in ser;
    ser.sin_family = AF_INET;
    ser.sin_port = htons(6000);
    ser.sin_addr.s_addr = inet_addr("127.0.0.1");

    int res = connet(sockfd,(struct sockaddr*)&ser,sizeof(ser));
    assert(res != -1);
    
    while(1)
    {
        printf("please input:");
        char buff[128] = {0};
        fgets(buff,128,stdin);
        if(strncmp(buff,"end",3) == 0)
        {
            close(sockfd);
            break;
        }
        printf("recvbuff:%s\n",recvbuff);
    }
    
}

运行结果:

 

select()的补充:

  1. 记录每种事件的结构,在数组按位记录关注的文件描述符上的事件
  2. 每次最多可以监听1024个文件描述符,并且最大值1023。
  3. select函数返回时,通过传递的结构体变量(fd_set)将结果带回(就绪的和未就绪的)所以:
               (1)每次都必须循环探测那些文件描述符就绪 时间复杂度O(n)
               (2)每次调用select之前必须重新设置三个结构体变量
  4. select函数第一个参数最大的文件描述符值+1,可以提高底层效率

 

select()的优点:目前几乎在所有的平台上支持,其良好跨平台支持

 

select()的缺点:

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;
  • 文件描述符就绪时,内核会修改readfds、writefds、exception结构,所以每次调用select之前,必须重新将文件描述符注册一遍
  • 每次调用select都需要在内核遍历传递进来的所有的fd,这个开销在fd很多时,也很大
  • 单个进程能够监视的文件描述符存在最大的限制

 

 

poll

select()和poll()系统调用的本质一样,前者在BSD UNIX中引入的,后者在Sysem V中引入的。poll()的机制和select()类似,管理多个描述符也是轮询,根据描述符的状态进行处理,但是poll()没有最大文件描述符数量的限制(但是数量过大后性能也是会下降的),并且poll的底层是用链表实现的,而select底层是数组。

#include <poll.h>
int poll(struct pollfd *fds,int nfds,int timeout);

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

 

参数:
fds:fds是指向polled这一结构体数组,结构体中包括用户关注的文件描述符(fd),用户关注的事件(events),调用后实际发生的事件,也就是由内核修改的事件,放在revents中;如下
struct polled
{
    int fd;                                     /*用户关注的文件描述符*/
    short events;                       /*用户关注的事件,由用户设置*/
    short reventd                       /*由内核修改,表示发生了那些事件*/
}


poll事件类型:

ndfs:指的是第一个参数数组元素的个数,也就是用户关注的文件描述符的个数。

timeout:指定等待的毫秒。当等待时间为0时,poll()函数立即返回,为-1时poll()一直阻塞直到一个事件发生

 


返回值:

成功时,poll()返回结构体中revents域不为0的文件描述符个数,如果在超时前没有任何事件发生,poll()返回0;
失败时,返回-1;

 

使用多客户端的网络通信实例(服务器利用poll函数实现I/O复用)
服务器端:

#define _GNU_SOURCE//必须放在首行
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <assert.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <fcntl.h>
#define MAXFD 10
int Createsockfd(int port,char *ip)
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    if(sockfd == -1)
    {
        return -1;
    }
    
    struct sockaddr_in ser;
    memset(&ser,0,sizeof(ser));
    ser.sin_family = AF_INET;
    ser.sin_port = htons(port);
    ser.sin_addr.s_addr = inet_addr(ip);

   int res = bind(socket,(struct sockaddr*)&ser,sizeof(ser));
    if(res == -1)
    {
        return -1;
    } 
    listen(sockfd,5);
    return sockfd;
}

void Init(struct pollfd fds[])
{
    int i = 0;
    for(;i < MAXFD;i++)
    {
        fds[i].fd = -1;
        fds[i].events = 0;
        fds[i].revens = 0;
    }
}

void add_fds(struct pollfd fds[],int sockfd)
{
    int i = 0;
    for(;i < MAXFD;i++)
    {
        if(fds[i].fd == -1)
        {
            fds[i].fd = sockfd;
            fds[i].events = POLLIN|POLLRDHUP;
            fds[i].revents = 0;
            break;
        }
    }
}


void DeleteFd(struct pollfd fds[],int fd)
{
    int i = 0;
    for(;i < MAXFD;i++)
    {
        if(fds[i].fd == fd)
        {
            fds[i].fd = -1;
            fds[i].events = 0;
            fds[i].revents = 0;
            break;
        }
    }
}
int main()
{
    int sockfd = Createsockfd(6000,"127.0.0.1");
    assert(sockfd != -1);

    struct polled fds[MAXFD];
    Init(fds);

    add_fds(fds,sockfd);
    while(1)
    {
        int n = poll(fds,MAXFD,-1);//永久阻塞,直到事件发生
        if(n < 0)
        {
            perror("poll error\n");
        }
        else if(n == 0)
        {
            printf("time out\n");
        }
        else
        {
            int i = 0;
            for(;i < MAXFD;i++)
            {
                if(fds[i].fd == -1)
                {
                    continue;
                }
                if(fds[i].revents & POLLRDHUP)
                {
                    close(fds[i].fd);
                    DeleteFd(fds,fds[i].fd);
                }
                if(fds[i].revent & POLLIN)
                {
                    if(fds[i].fd == sockfd)
                    {
                        struct sockaddr_in cli;
                        int len = sizeof(cli);
                        int c = accept(fds[i].fd,(struct sockaddr*)&cli,&len);
                        if(c < 0)
                        {
                            continue;
                        }
                        add_fds(fds,c);
                    }
                    else
                    {
                        char buff[128] = {0};
                        int num = recv(fds[i].fd,buff,127,0);
                        if(num > 0)
                        {
                            printf("recv(%d) = %s\n",fds[i].fd,buff);
                            send(fds[i].fd,"OK",2,0);
                        }
                    }
                }
            }
        }
    }
}

客户端同select客户端代码;

结果:

poll的优点:

  • 将用户关注的文件描述符的事件单独表示,可关注更多的事件类型。
  • 将用户传递的和内核修改的分开,每次调用poll之前,不需要重新设置。
  • poll函数没有最大文件描述符的限制。

 

poll的缺点:

  • 每次调用都需要将用户空间数组拷贝到内核空间。
  • 每次返回都需要将所有的文件描述符拷贝到用户空间数组中,无论是否就绪。
  • 返回的是所有就绪和未就绪的文件描述符,搜索就绪文件描述符的时间复杂度为O(n)。

 

 

epoll

epoll是在2.6内核中提出的,是select()和poll()的增强版本,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的复制只需一次。epoll底层是用红黑树和链表实现的。

#include <sys/epoll.h>
int epoll_create(size);

功能:该函数创建内核事件表;返回epoll专用的文件描述符

 

参数:
size:用来告诉内核这个监听的数目一共有多大,参数size并不是限制了epoll所监听的文件描述符的最大值个数,只是对内核初始内部数据结构的一个建议。自从linux2.6.8之后,size参数是被忽略的

 

返回值:

成功:epoll专用的文件描述符
失败:-1

#include <sys/epoll.h>
int epoll_ctl(int epollfd,int cmd,int fd,struct epoll_event *event);

功能:epoll的事件注册函数,对内核事件表中的文件描述符对应的事件进行增删改。

 

参数:
epollfd:epoll的专用文件描述符,epoll_create()的返回值。
cmd:表示需要设置的操作,用以下三个宏表示:
             EPOLL_CTL_ADD                添加新的文件描述符
             EPOLL_CTL_MOD               修改已经注册的文件描述符的监听事件
             EPOLL_CTL_DEL                 删除一个文件描述符
fd:需要监听的文件描述符。
event:作用是指定事件,结构如下:
struct epoll_event
{
    _uint32_t   events;          /*epoll事件*/
    epoll_data_t  data;         /*用户数据*/
};
typedef union epoll_data
{
     void* ptr;
      int fd;                                 /*用户关注的事件*/
      uint32_t  u32;
      uint64_t  u64;
}epoll_data_t;

返回值:

成功:0
失败:-1

#include <sys/epoll.h>
int epoll_wait(int epollfd,struct epoll_event *revent,int maxevents,int timeout);

功能:此函数如果检测到事件,就将所有就绪事件从内核事件表中(由epollfd中的参数指定)复制到它的第二个参数revent指定的数组中,这个数组只输出epoll_wait检测出的就绪事件。所以,搜索就绪文件描述符的时间复杂度为O(1)。

 

参数:
epollfd:epoll专用的文件描述符,epoll_create()的返回值。
revent:分配好的epoll_event结构体数组,epoll将会把发生的事件赋值到revent数组中(revent不可以是空指针,内核只负责把数据复制到这个events数组中,不会帮助我们在用户态中分配内存)。
maxevents:maxevents告知内核这个revent有多大。
timeout:超时时间,单位为毫秒,为-1时,函数为阻塞。

 

返回值:
成功:返回需要处理事件数目,如返回0,表示超时
失败:返回-1

文件描述符的操作有两种模式:LT和ET。LT模式时默认模式。区别如下:

LT模式:epoll_wait检测到就绪事件,将其通知应用程序,应用程序可以不立即处理或处理不完,下一次epoll_wait依旧会通知这一事件。
ET模式:epoll_wait检测到就绪事件,将其通知应用程序,应用程序必须立即处理并且必须将事件处理完,如果未处理或者未处理完成,则下一次epoll_wait并不会通知这个就绪事件。

 

ET模式比LT模式高效的原因:

  • 同一个事件ET只会通知一次,LT会多次通知,epoll_wait函数调用多次。epoll_wait调用需要消耗时间。
  • LT模式下。epoll_wait因为上一个时间未处理完而直接返回,造成对后续事件的延迟处理。
  • ET模式内核实现时,将rdlist中的就绪的文件描述符通过txlist拷贝给用户空间,并且rdlist会被清空。
    LT模式内核实现时,将rdlist中的就绪文件描述符通过txlist拷贝给用户空间,rdlist也会被清空,但是会将未处理的或处理未完成的文件描述符又返回给rdlist

而epoll采用高效的ET模式,通过将文件描述符设置成非阻塞而实现ET模式。

 

 

使用多客户端的网络通信实例(服务器利用poll函数实现I/O复用)
服务器端:

#define _GNU_SOURCE
#include <stdio.h>
#include <assert.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <string.h>
#define MAXFD 10

Createfd(int port,char *ip)
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    if(sockfd == -1)
    {
        return -1;
    }
    struct sockaddr_in ser;
    memset(&ser,0,sizeof(ser));
    ser.sin_family = AF_INET;
    ser.sin_port = htons(port);
    ser.sin_addr.s_addr = inet_addr(ip);
    int res = bind(sockfd,(struct sockaddr*)&ser,sizeof(ser));
    if(res == -1)
    {
        return -1;
    }
    listen(sockfd,5);
    return sockfd;
}

void epoll_add(int epfd,int fd)
{
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = epfd;
    if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev) == -1)
    {
        perror("EPOLL_CTL_ADD ERROR\n");
    }
}

void epoll_del(int epfd,int fd)
{
    if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL) == -1)
    {
        perror("EPOLL_CTL_DEL ERROR\n");
    }
}
int main()
{
    int sockfd = Createfd(6000,"127.0.0.1");
    assert(sockfd != -1);
    
    int epfd = epoll_create(MAXFD);
    struct epoll_event events[MAXFD];

    epoll_add(epfd,sockfd);
    while(1)
    {
        int n = epoll_wait(epfd,events,MAXFD,5000);
        if(n == -1)
        {
            perror("epoll_wait error\n");
        }
        else if(n == 0)
        {
            printf("time out\n");
            continue;
        }
        else
        {
            int i = 0;
            for(;i < n;i++)
            {
                int fd = events[i].data.fd;
                if(fd & EPOLLRDHUP)
                {
                    close(fd);
                    epoll_del(epfd,fd);
                }
                if(fd == sockfd)
                {
                    struct sockaddr_in cli;
                    int len = sizeof(cli);
                    int c = accept(sockfd,(struct sockaddr*)&cli,&len);
                    if(c < 0)
                    {
                        continue;
                    }
                    printf("accept = %d\n",c);
                    epoll_add(epfd,c);
                }
                else
                {
                    char buff[128] = {0};
                    int num = recv(fd,buff,127,0);
                    if(num <= 0)
                    {
                        printf("one client over!");
                        epoll_del(epfd,fd);
                        close(fd);
                    }
                    else
                    {
                        printf("recv(%d) = %s\n",fd,buff);
                        send(fd,"OK",2,0);
                    }
                }
            }
        }
    }
}

客户端同select客户端代码;

结果:

 

epoll的优点:

  • 监视的描述符数量不受限制
  • 时间类型更多
  • 用户关注的事件由内核维护,每次调用epoll_wait时,不需要将用户空间数据拷贝到内核空间
  • 每次epoll只返回就绪的文件描述符
  • 用户程序检测就绪文件描述符的效率O(1)
  • epoll的内核比select和poll高效,select和poll采用轮询的方式,而epoll回调的方式
  • epoll支持高效的ET模式

 

 

select、poll、epoll总结

  1. select通过三个结构分别表示可读、可写、异常事件;poll和epoll用一个short类型的变量表示关注的事件,事件类型更多
  2. select通过long类型的数组按位记录文件描述符,最多关注1024个文件描述符,并且范围是0-1023;poll和epoll都是通过一个int类型的fd表示文件描述符,poll通过用户数组记录所有文件描述符,epoll通过内核事件表来记录,一般能达到系统允许打开的最大文件描述符
  3. select通过三个结构传递传递用户关注的文件描述符,也是通过其返回就绪的和未就绪的文件描述符,所以每次调用select都必须重新设置三个结构体,而poll和epoll则不需要。poll将用户关注的事件和内核反馈发生的事件分开表示,epoll通过数组返回就绪的内核事件表。
  4. select和poll返回的就绪和未就绪的文件描述符,检测就绪文件描述符的时间复杂度为O(n),epoll直接通过数组仅仅返回所有就绪的文件描述符,检测就绪文件描述符的时间复杂度为O(1)。
  5. select和poll采用轮询的方式,epoll采用回调的方式
  6. select内核通过数组,poll内核使用链表,epoll内核是红黑树+链表
  7. select和poll仅仅支持LT模式,epoll支持高效的ET模式
  8. select和poll都是单独的函数,epoll是一组函数。
  9. select、poll每次调用都要把文件描述符集合从用户态往内核态拷贝一次,而epoll只要拷贝一次,这也能节省不少的开销

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值