关于select、poll和epoll

前言

常规的TCP-socket通信的流程一般如下;1.服务端创建监听套接字(socket),初始化网卡(SOCKADDR_IN),监听套接字绑定网卡(bind),调用(listen)函数开始监听;2.客户端创建套接字1(socket),根据服务端的IP地址还有端口初始化网卡(SOCKADDR_IN),调用(connect)函数与服务端连接。3.服务端accept函数,取出连接,获得一个套接字2,之后双方的通信在套接字1和套接字2之间进行。

tips:accept只是在全连接队列中取出一个连接,这个时候三次握手已经完成。

有个问题?对服务端来说,那大量套接字上线,或者多个套接字同时接收到数据,如何处理。比较朴素的想法是多线程,但受内存空间和系统本身的限制,有很大的局限。因此出现了IO多路复用技术。

复用在这里的理解我认为是单个进程或者线程用某种模式可以对多个io进行处理,而不是一个进程或者线程只处理一个io。一般情况下,在处理socket通信时,一个线程或者进程在处理读写的时候要么阻塞在那一直等;要么非阻塞,然后过会查看是否可读可写。这样会浪费大量的资源,假如需要对多个套接字进行处理读写那么得开很多个线程或者进程,IO复用技术就是用来解决这个问题的。Linux下的解决方案分别是select、poll、epoll。

1.select

转自IO复用之select模型 - 知乎 (zhihu.com)

select模型如下图所示,用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

select模型主要通过select函数来实现,函数原型如下

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);

/
参数说明:
maxfdp:被监听的文件描述符的总数,它比所有文件描述符集合中的文件描述符的最大值大1,因为文件描述符是从0开始计数的;

readfds、writefds、exceptset:分别指向可读、可写和异常等事件对应的描述符集合。

timeout:用于设置select函数的超时时间,即告诉内核select等待多长时间之后就放弃等待。timeout == NULL 表示等待无限长的时间

返回值:超时返回0;失败返回-1;成功返回大于0的整数,这个整数表示就绪描述符的数目。

和select函数一起配套使用的宏

#include <sys/select.h>   
int FD_ZERO(fd_set *fdset);   //一个 fd_set类型变量的所有位都设为 0
int FD_CLR(int fd, fd_set *fdset);  //清除某个位时可以使用
int FD_SET(int fd, fd_set *fd_set);   //将fd装进set中
int FD_ISSET(int fd, fd_set *fdset); //看fd是否在set中

我们接下来看一个源码示例

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

#define MYPORT 1234    // 端口
#define BACKLOG 5     // 最大监听数量
#define BUF_SIZE 1024
int fd_A[BACKLOG];    // 已建立的连接
int conn_amount;      // 当前连接数量

void showclient()
{
    int i;
    printf("client amount: %d/n", conn_amount);
    for (i = 0; i < BACKLOG; i++) {
        printf("[%d]:%d  ", i, fd_A[i]);
    }
    printf("/n/n");
}
int main(void)
{
    int sock_fd, new_fd;             // 第一个是监听套接字, 第二个是创建连接后的通信套接字
    struct sockaddr_in server_addr;  // 服务端网卡信息
    struct sockaddr_in client_addr;  // 客户端网卡信息,accept调用的时候自会获得
    socklen_t sin_size;
    int yes = 1;
    char buf[BUF_SIZE];
    int ret;
    int i;
    if ((sock_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket");
        exit(1);
    }
    if (setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) {
        perror("setsockopt");
        exit(1);
    }
    server_addr.sin_family = AF_INET;         // 地址协议族,这里指定IPv4
    server_addr.sin_port = htons(MYPORT);     // 端口信息1234
    server_addr.sin_addr.s_addr = INADDR_ANY; // 服务端地址,INADDR_ANY的意思是0.0.0.0,意思就是你这台机子上的所有网卡,不管有几个,都由这个监听套接字来监听
    memset(server_addr.sin_zero, '/0', sizeof(server_addr.sin_zero));
    if (bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }
    if (listen(sock_fd, BACKLOG) == -1) {
        perror("listen");
        exit(1);
    }
    printf("listen port %d/n", MYPORT);
    fd_set fdsr;
    int maxsock;
    struct timeval tv;
    conn_amount = 0;
    sin_size = sizeof(client_addr);
    maxsock = sock_fd;
    while (1)
    {
        FD_ZERO(&fdsr);//清空这个套接字集合
        FD_SET(sock_fd, &fdsr);  // 把套接字添加进这个集合
        
        tv.tv_sec = 30;//秒
        tv.tv_usec = 0;//微妙
        // 把已创建的连接套接字也放进去
        for (i = 0; i < BACKLOG; i++) {
            if (fd_A[i] != 0) {
                FD_SET(fd_A[i], &fdsr);
            }
        }
        ret = select(maxsock + 1, &fdsr, NULL, NULL, &tv);
        if (ret < 0) {          //出错
            perror("select");
            break;
        } else if (ret == 0) {  //超时
            printf("timeout/n");
            continue;
        }
        // 检查每一个套接字
        for (i = 0; i < conn_amount; i++)
        {
            if (FD_ISSET(fd_A[i], &fdsr)) // check which fd is ready
            {
                ret = recv(fd_A[i], buf, sizeof(buf), 0);
                if (ret <= 0)
                {        // client close
                    printf("ret : %d and client[%d] close/n", ret, i);
                    close(fd_A[i]);
                    FD_CLR(fd_A[i], &fdsr);  // delete fd
                    fd_A[i] = 0;
                    conn_amount--;
                }
                else
                {        // receive data
                    if (ret < BUF_SIZE)
                        memset(&buf[ret], '/0', 1); // add NULL('/0')
                    printf("client[%d] send:%s/n", i, buf);
                }
            }
        }
        //检查是不是有新的连接
        if (FD_ISSET(sock_fd, &fdsr)) 
        {
            new_fd = accept(sock_fd, (struct sockaddr *)&client_addr, &sin_size);
            if (new_fd <= 0)
            {
                perror("accept");
                continue;
            }
            // add to fd queue
            if (conn_amount < BACKLOG)
            {
                fd_A[conn_amount++] = new_fd;
                printf("new connection client[%d] %s:%d/n", conn_amount,
                        inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                if (new_fd > maxsock)  // 更新连接的最大数量
                    maxsock = new_fd;
            }
            else
            {
                printf("max connections arrive, exit/n");
                send(new_fd, "bye", 4, 0);
                close(new_fd);
                break;   
            }
        }
        showclient();
    }
    // 关闭连接
    for (i = 0; i < BACKLOG; i++)
    {
        if (fd_A[i] != 0)
        {
            close(fd_A[i]);
        }
    }
    exit(0);
}

select的底层实现,是把set中的所有fd拷贝进内核,看看有没有受信,也就是来数据,被触发,如果有的话,这个fd保留着,其他没受信的就删掉了。也就是说调用完select函数后set中的fd都是受信的。因为已经保存好了监听fd还有客户端fd,挨个遍历看看在不在集合里就知道这个fd有没有受信。

2.poll

转载自poll模型详解_phymat.nico的博客-CSDN博客

poll模型是基于select最大文件描述符限制提出的,跟select一样,只是将select使用的三个基于位的文件描述符改为使用一个数组的形式,对于各种可能的事件进行了一个包装

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

参数说明:

第一个参数fds为一个pollfd结构数组,用来保存文件描述符

第二个参数nfds为pollfd结构体数组数量+1

第三个参数timeout为poll等待时间

返回值:

正常返回值为轮询文件描述符结构有事件发送的个数,-1返回失败

和select()不一样,poll()没有使用低效的三个基于位的文件描述符set,而是采用了一个单独的结构体pollfd数组,由fds指针指向这个组。pollfd结构体定义如下:

#include <sys/poll.h>
 
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

第一个成员变量,不用说就是描述符,也就是套接字 。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码。内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。合法的事件如下:
POLLIN 有数据可读。
POLLRDNORM 有普通数据可读。
POLLRDBAND 有优先数据可读。
POLLPRI 有紧迫数据可读。
POLLOUT 写数据不会导致阻塞。
POLLWRNORM 写普通数据不会导致阻塞。
POLLWRBAND 写优先数据不会导致阻塞。
POLLMSG SIGPOLL消息可用。

此外,revents域中还可能返回下列事件:
POLLER 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起事件。
POLLNVAL 指定的文件描述符非法。

这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。使用poll()和select()不一样,你不需要显式地请求异常情况报告。
POLLIN | POLLPRI等价于select()的读事件,POLLOUT | POLLWRBAND等价于select()的写事件。POLLIN等价于POLLRDNORM | POLLRDBAND,而POLLOUT则等价于POLLWRNORM。
例如,要同时监视一个文件描述符是否可读和可写,我们可以设置events为POLLIN | POLLOUT。在poll返回时,我们可以检查revents中的标志,对应于文件描述符请求的events结构体。如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。
timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。timeout指定为负数值表示无限超时;timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。
返回值和错误代码
成功时,poll()返回结构体中revents域不为0的文件描述符个数;如果在超时前没有任何事件发生,poll()返回0;失败时,poll()返回-1,并设置errno为下列值之一:
EBADF 一个或多个结构体中指定的文件描述符无效。
EFAULT fds指针指向的地址超出进程的地址空间。
EINTR 请求的事件之前产生一个信号,调用可以重新发起。
EINVAL nfds参数超出PLIMIT_NOFILE值。
ENOMEM 可用内存不足,无法完成请求。
来个例子:

    #include  <unistd.h>  
    #include  <sys/types.h>         
    #include  <sys/socket.h>      
    #include  <netinet/in.h>      
    #include  <arpa/inet.h>        
      
    #include <stdlib.h>  
    #include <errno.h>  
    #include <stdio.h>  
    #include <string.h>  
      
      
    #include <poll.h>  
    #include <limits.h>  
      
    #define MAXLINE 10240  
      
    #ifndef OPEN_MAX  
    #define OPEN_MAX 40960  
    #endif  
      
    void handle(struct pollfd* clients, int maxClient, int readyClient);  
      
    int  main(int argc, char **argv)  
    {  
        int servPort = 6888;  
        int listenq = 1024;  
        int listenfd, connfd;  
        struct pollfd clients[OPEN_MAX];  
        int  maxi;  
        socklen_t socklen = sizeof(struct sockaddr_in);  
        struct sockaddr_in cliaddr, servaddr;  
        char buf[MAXLINE];  
        int nready;  
      
        bzero(&servaddr, socklen);  
        servaddr.sin_family = AF_INET;  
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  
        servaddr.sin_port = htons(servPort);  
      
        listenfd = socket(AF_INET, SOCK_STREAM, 0);  
        if (listenfd < 0) {  
            perror("socket error");  
        }  
      
        int opt = 1;  
        if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {  
            perror("setsockopt error");  
        }  
      
        if(bind(listenfd, (struct sockaddr *) &servaddr, socklen) == -1) {  
            perror("bind error");  
            exit(-1);  
        }  
        if (listen(listenfd, listenq) < 0) {  
            perror("listen error");      
        }  
      
        clients[0].fd = listenfd;  
        clients[0].events = POLLIN;  
        int i;  
        for (i = 1; i< OPEN_MAX; i++)   
            clients[i].fd = -1;   
        maxi = listenfd + 1;  
      
        printf("pollechoserver startup, listen on port:%d\n", servPort);  
        printf("max connection is %d\n", OPEN_MAX);  
      
        for ( ; ; )  {  
            nready = poll(clients, maxi + 1, -1);  
            //printf("nready is %d\n", nready);  
            if (nready == -1) {  
                perror("poll error");  
            }  
            if (clients[0].revents & POLLIN) {  
                connfd = accept(listenfd, (struct sockaddr *) &cliaddr, &socklen);  
                sprintf(buf, "accept form %s:%d\n", inet_ntoa(cliaddr.sin_addr), cliaddr.sin_port);  
                printf(buf, "");  
      
                for (i = 0; i < OPEN_MAX; i++) {  
                    if (clients[i].fd == -1) {  
                        clients[i].fd = connfd;  
                        clients[i].events = POLLIN;  
                        break;  
                    }  
                }  
      
                if (i == OPEN_MAX) {  
                    fprintf(stderr, "too many connection, more than %d\n", OPEN_MAX);  
                    close(connfd);  
                    continue;  
                }  
      
                if (i > maxi)  
                    maxi = i;  
      
                --nready;  
            }  
      
            handle(clients, maxi, nready);  
        }  
    }  
      
    void handle(struct pollfd* clients, int maxClient, int nready) {  
        int connfd;  
        int i, nread;  
        char buf[MAXLINE];  
      
        if (nready == 0)  
            return;  
      
        for (i = 1; i< maxClient; i++) {  
            connfd = clients[i].fd;  
            if (connfd == -1)   
                continue;  
            if (clients[i].revents & (POLLIN | POLLERR)) {  
                nread = read(connfd, buf, MAXLINE);//读取客户端socket流  
                if (nread < 0) {  
                    perror("read error");  
                    close(connfd);  
                    clients[i].fd = -1;  
                    continue;  
                }  
                if (nread == 0) {  
                    printf("client close the connection");  
                    close(connfd);  
                    clients[i].fd = -1;  
                    continue;  
                }  
      
                write(connfd, buf, nread);//响应客户端    
                if (--nready <= 0)//没有连接需要处理,退出循环  
                    break;  
            }  
        }  
    }  

3.epoll

转载至深入理解 Epoll - 知乎 (zhihu.com)

通常来说,实现处理tcp请求,为一个连接一个线程,在高并发的场景,这种多线程模型与Epoll相比就显得相形见绌了。epoll是linux2.6内核的一个新的系统调用,epoll在设计之初,就是为了替代select, poll线性复杂度的模型,epoll的时间复杂度为O(1), 也就意味着,epoll在高并发场景,随着文件描述符的增长,有良好的可扩展性。

  • select 和 poll 监听文件描述符list,进行一个线性的查找 O(n)
  • epoll: 使用了内核文件级别的回调机制O(1)asda

epoll模型的关键函数如下所示:

  • epoll_create1: 创建一个epoll实例,文件描述符
  • epoll_ctl: 将监听的文件描述符添加到epoll实例中,实例代码为将标准输入文件描述符添加到epoll中
  • epoll_wait: 等待epoll事件从epoll实例中发生, 并返回事件以及对应文件描述符

看看相关的数据结构

typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event
{
  uint32_t events;  /* Epoll events */
  epoll_data_t data;    /* User data variable */
};

边沿触发vs水平触发

epoll事件有两种模型,边沿触发:edge-triggered (ET), 水平触发:level-triggered (LT)

LT:易于编码,未读完的数据下次还能继续读,不易遗漏;但是在并发量高的时候,epoll_wait返回的就绪队列比较大,遍历比较耗时。因此LT适用于并发量小的情况。

ET:并发量大的时候,就绪队列要比LT小得多,效率更高;但是难以编码,需要一次读完,有时会出现遗漏。

水平触发(level-triggered)(默认)

  • socket接收缓冲区不为空 有数据可读 读事件一直触发
  • socket发送缓冲区不满 可以继续写入数据 写事件一直触发

边沿触发(edge-triggered)

  • socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
  • socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发写事件

边沿触发仅触发一次,水平触发会一直触发。

事件宏(也就是epoll_event中的第一个成员变量)

  • EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
  • EPOLLOUT: 表示对应的文件描述符可以写;
  • EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR: 表示对应的文件描述符发生错误;
  • EPOLLHUP: 表示对应的文件描述符被挂断;
  • EPOLLET: 将 EPOLL设为边缘触发(Edge Triggered)模式(默认为水平触发),这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

libevent 采用水平触发, nginx 采用边沿触发

接下来再看一个例子:

#define MAX_EVENTS 10
           struct epoll_event ev, events[MAX_EVENTS];
           int listen_sock, conn_sock, nfds, epollfd;

           /*前期的一些工作',
              (socket(), bind(), listen()) omitted */

           // 创建epoll实例
           epollfd = epoll_create1(0);

           if (epollfd == -1) {
               perror("epoll_create1");
               exit(EXIT_FAILURE);
           }

           // 将监听的端口的socket对应的文件描述符添加到epoll事件列表中
           ev.events = EPOLLIN;
           ev.data.fd = listen_sock;
           if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
               perror("epoll_ctl: listen_sock");
               exit(EXIT_FAILURE);
           }

           for (;;) {
               // epoll_wait 阻塞线程,等待事件发生
               nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
               if (nfds == -1) {
                   perror("epoll_wait");
                   exit(EXIT_FAILURE);
               }

               for (n = 0; n < nfds; ++n) {
                   if (events[n].data.fd == listen_sock) {
                       // 新建的连接
                       conn_sock = accept(listen_sock,
                                          (struct sockaddr *) &addr, &addrlen);
                       // accept 返回新建连接的文件描述符
                       if (conn_sock == -1) {
                           perror("accept");
                           exit(EXIT_FAILURE);
                       }
                       setnonblocking(conn_sock);
                       // setnotblocking 将该文件描述符置为非阻塞状态

                       ev.events = EPOLLIN | EPOLLET;
                       ev.data.fd = conn_sock;
                       // 将该文件描述符添加到epoll事件监听的列表中,使用ET模式
                       if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                                   &ev) == -1)
                           perror("epoll_ctl: conn_sock");
                           exit(EXIT_FAILURE);
                       }
                   } else {
                       // 使用已监听的文件描述符中的数据
                       do_use_fd(events[n].data.fd);
                   }
               }
           }

epoll使用RB-Tree红黑树去监听并维护所有文件描述符,RB-Tree的根节点

调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个 红黑树 用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件.

epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已.

那么,这个准备就绪list链表是怎么维护的呢?

当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

综上,epoll高效的本质是:

  • 减少了用户态和内核态的文件句柄拷贝
  • 减少了对可读可写文件句柄的遍历
  • mmap 加速了内核与用户空间的信息传递,将内核的内存空间映射到用户态,这样的好处是减少了read. write系统调用的开销,避免了内核空间与用户空间之前内核拷贝的开销。
  • IO性能不会随着监听的文件描述的数量增长而下降
  • 使用红黑树存储fd,以及对应的回调函数,其插入,查找,删除的性能不错,相比于hash,不必预先分配很多的空间

4.各模型对比

select VS poll

区别1:select使用的是定长数组,而poll是通过用户自定义数组长度的形式(pollfd[])。

区别2:select只支持最大fd < 1024,如果单个进程的文件句柄数超过1024,select就不能用了。poll在接口上无限制,考虑到每次都要拷贝到内核,一般文件句柄多的情况下建议用epoll。

区别3:select由于使用的是位运算,所以select需要分别设置read/write/error fds的掩码。而poll是通过设置数据结构中fd和event参数来实现read/write,比如读为POLLIN,写为POLLOUT,出错为POLLERR:

区别4:select中fd_set是被内核和用户共同修改的,所以要么每次FD_CLR再FD_SET,要么备份一份memcpy进去。而poll中用户修改的是events,系统修改的是revents。所以参考muduo的代码,都不需要自己去清除revents,从而使得代码更加简洁。

区别5:select的timeout使用的是struct timeval *timeout,poll的timeout单位是int。

区别6:select使用的是绝对时间,poll使用的是相对时间。

区别7:select的精度是微秒(timeval的分度),poll的精度是毫秒。

区别8:select的timeout为NULL时表示无限等待,否则是指定的超时目标时间;poll的timeout为-1表示无限等待。所以有用select来实现usleep的。

epoll的优点

无最大并发连接的限制,能打开的FD上限远大于1024(1G内存能监听约10万个端口)

效率提升,不是轮询,不会随FD数目增加而效率下降。只有活跃可用的FD才会调用callback函数 即Epoll最大优点在于它只关心“活跃”连接,而跟连接总数无关,因此实际网络环境中,Epoll效率远高于select、poll

内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

epoll通过内核和用户空间共享一块内存而实现

使用场景

表面上看epoll的性能最好,但在连接数少且都十分活跃情况下,select/poll性能可能比epoll好,毕竟epoll通知机制需要很多函数回调。

epoll跟select都能提供多路I/O复用。在现在的Linux内核里有都能够支持,epoll是Linux所特有,而select则是POSIX所规定,一般os均有实现。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值