开开心心学习五种IO阻塞模型

五种IO阻塞模型

一、准备知识

1、首先我们需要知道的是为什么会产生阻塞?
简单说就是IO操作时很费时的,当进程需要进行IO操作或者访问共享存储区的时候,该进程这时候有很长一段时间是用不到CPU的,那为了节省资源,这时候我们可以让CPU去干点别的事。这就是阻塞。
2、阻塞IO是如何唤醒的呢?
阻塞IO在读数据时会因为内核没有准备好数据而进入阻塞, 这时候内核的运行队列的该进程会 被加入到阻塞队列,当网络有数据传来时,网卡接收到数据并将数据写内存,然后发送中断信号,操作系统信号执行中断程序,这时候会将网络数据写入到内核相应的缓冲区,然后将该进程加入到就绪队列中,等待分配CPU。

二、阻塞IO

定义:当引用程序发起读数据申请时,内核还没有准备好数据,此时就会阻塞等待一直到内核准备好数据。

我们知道定义后可以实现一个简单的TcpServer了

#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
#define MAXSIZE 1024
int main(int atgc, char *argv[]) {
    int listenfd = -1;
    int connectfd = -1;
    int n = 0;
    char buffer[MAXSIZE];
    // 初始化服务端, 指定协议为Ipv4,设置可以连接任意IP
    struct sockaddr_in serv_addr;
    socklen_t serv_len = sizeof(serv_addr);
    memset(&serv_addr, 0x00, serv_len);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(9999);

    // 创建套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if( -1 == listenfd ) {
        std::cout << "Create socket error: " << strerror(errno) << "  Error:" << errno << std::endl;
        return -1;
    }
    // 绑定
    if( -1 == bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) ) {
        std::cout << "Bind listenfd error: " << strerror(errno) << "  Error:" << errno << std::endl;
        return -1;
    }
    // 监听
    if (listen(listenfd, 10) == -1) {
        std::cout << "Listen listenfd error: " << strerror(errno) << "  Error:" << errno << std::endl;
        return -1;
    }
    // 接收连接请求 
    struct sockaddr_in *cli_addr;
    socklen_t cli_len = sizeof(cli_addr);
    memset(&cli_addr, 0x00, cli_len);
    // 如果没有新连接请求,就会一直阻塞在这里,直到连接到来才会返回
    connectfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
    if( -1 == connectfd ) {
        std::cout << "Accept connectfd error: " << strerror(errno) << "  Error:" << errno << std::endl;
        return -1;
    }
    // 处理
    while(1) {
        n = recv(connectfd, buffer, sizeof(buffer), 0);
        if( n > 0 ) {
            buffer[n] = '\0';
            std::cout << "Recv message from client:" << buffer << std::endl;
            // send只是将数据放到协议栈里,并不一定表示发送成功,所以客户端数据显示具有随机性
            send(connectfd, buffer, sizeof(buffer), 0);
        }
        else if( n == 0 ) {
            close(connectfd);
        } 
    }
    return 0;
}

1、这里使用send时注意一个问题,send操作只是将数据copy到内核的缓冲区,但什么时候通过网络发送出去,是由协议栈决定的,那也就是时候,send成功并不等于数据发送成功。
2、这里实现了一连接一请求,但当存在多个连接请求时,这时候连接是会建立成功的,但是因为没有执行accept,这时候客户端fd,所以是无法接发数据的。

那如果将accept放到循环里呢,这样通过不断循环当多个客户端请求来临时,accpet就会建立连接了。

while (1)
    {
        // 处理连接请求
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);
        memset(&cli_addr, 0x00, cli_len);

        // 循环accept,这样能保证可以连接多个客户端,如果没有客户端连接就会阻塞到这里
        // 此时连接后成功接发一次数据,下次循环时会阻塞在accept那
        connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
        if (-1 == connfd)
        {
            std::cout << "Accept listenfd error: " << strerror(errno) << "  Error:" << errno;
            return -1;
        }

        len = recv(connfd, buffer, MAXSIZE, 0);
        if (len > 0)
        {
            buffer[len] = '\0';     // 字符串结束标志
            std::cout << "Recv Message From Client: " << buffer << std::endl;
            // 将数据写会到客户端
            send(connfd, buffer, len, 0);
        }
        else if (len == 0)
        {
            close(connfd);
        }
    }

这样就解决了第一版server遇到的问题,允许多个客户端连接,原因就是循环会一直执行accept,当新连接返回时会分配一个客户端fd。

但是这样会出现一个新问题,那就是每个客户端只能接发一次数据,原因就是当内核没准备好数据时会阻塞在recv这里,当数据准备完成后,recv就会执行完成,这样,这里循环就结束了,下一次循环accpet之后,recv会阻塞在新的客户端,这时候之前的客户端就没办法执行recv,也就不能再接发数据了。所以这种解决办法是不行的。

这个问题会在多线程哪里解决掉,我们先来看看非阻塞IO

三、非阻塞IO

定义:不会阻塞等待直到新连接过来,会立即返回,并去处理已用的连接请求的事件

int main(int argc, char *argv[])
{
    char buffer[MAXSIZE];
    int len = 0;
    // 初始化服务端
    struct sockaddr_in serv_addr;
    socklen_t serv_len = sizeof(serv_addr);
    memset(&serv_addr, 0x00, serv_len);

    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(8888);

    // 创建套接字
    int listenfd = socket(AF_INET, SOCK_STREAM, 0); // ipv4, TCP协议
    if (-1 == listenfd)
    {
        std::cout << "Create socket error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    // 绑定
    if (-1 == bind(listenfd, (struct sockaddr *)&serv_addr, serv_len))
    {
        std::cout << "Bind listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    int flags = fcntl(listenfd, F_GETFL, 0);
    flags |= O_NONBLOCK;
    fcntl(listenfd, F_SETFL, flags);

    
    // 监听
    if (-1 == listen(listenfd, 10))
    {
        std::cout << "Listen listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    int connfd = -1;
    while (1)
    {
        // 处理连接请求
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);
        memset(&cli_addr, 0x00, cli_len);
        
        connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
        if (-1 == connfd)
        {
            std::cout << "Accept listenfd error: " << strerror(errno) << "  Error:" << errno;
            return -1;
        }

        len = recv(connfd, buffer, MAXSIZE, 0);
        if (len > 0)
        {
            buffer[len] = '\0';     // 字符串结束标志
            std::cout << "Recv Message From Client: " << buffer << std::endl;

            // 将数据写会到客户端
            send(connfd, buffer, len, 0);
        }
        else if (len == 0)
        {
            close(connfd);
        }
    }

这版代码会在accept出直接返回,原因就是在绑定套接字之后,使用fcntl函数设置了listenfd为非阻塞,这样在accept处就会不等待直接返回,标准用法:

	int flags = fcntl(listenfd, F_GETFL, 0);
    flags |= O_NONBLOCK;
    fcntl(listenfd, F_SETFL, flags);

这版代码没啥好说的,就是看一个非阻塞IO是什么效果就行了。

多线程io 解决多客户端连接问题

这里我们可以通过多线程思路解决,就是我们在while循环里还是继续accept,但当连接请求过来时,accept分配好connfd之后,我们注册一个线程,让这个线程去处理读写操作,这样就可以做到读写数据分离和多连接了。

// 线程处理函数
void *client_routine(void *arg) {
    int connfd = *(int *)arg;

    char buffer[MAXSIZE];

    // 这里处理的知识客户端一次发送的数据
    while(1) {
        int len = recv(connfd, buffer, MAXSIZE, 0);
        if(len > 0) {
           buffer[len] = '\n';
           buffer[len + 1] = '\0';
           std::cout << "Recv Message From Client: " << buffer << std::endl;

          send(connfd, buffer, len, 0);
        }
        else if(len == 0) {
          // 关闭套接字,结束连接
          close(connfd);
        }
    }
}

int main(int  argc, char *argv[]) {

    struct sockaddr_in serv_addr;
    socklen_t serv_len = sizeof(serv_addr);
    memset(&serv_addr, 0x00, serv_len);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(7777);

    // 创建套接字
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(-1 == listenfd) {
        std::cout << "Create Socket error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    // 绑定
    if(-1 == bind(listenfd, (struct sockaddr*)&serv_addr, serv_len)) {
        std::cout << "Bind Listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    // 监听
    if( -1 ==listen(listenfd, 100) ) {
        std::cout << "Listen listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    while(1) {
        //连接处理
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);

        int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
        if(-1 == connfd) {
            std::cout << "Accept connfd error: " << strerror(errno) << "  Error:" << errno;
            return -1;
        }
        // 创建线程处理发送接收请求
        pthread_t thread_id = -1;
        pthread_create(&thread_id, NULL, client_routine, (void*)&connfd);
    }

    return 0;
}

其实这版server和上面阻塞IO的第二版是很相似的,只是这里将客户端的读写放到了线程的回调函数里去了,这样我们在建立完连接之后,就可以在线程的回调函数里去循环接发数据了。这种思路解决了上面提到的问题,当这里有一些缺点,那就是随着连接请求的增大,及时我们继续增加计算机的内存,线程的数量很难突破C10K的限制,这是没有办法避免的,而且,一个连接就占用一个线程,这是不合理,因为线程资源是很宝贵的,我们希望,只有当客户端执行读写操作时才调用线程执行,这样才符合需求。
那这就是IO多路复用实现的思路了,就是使用一个线程去统一处理连接请求,然后获取有哪些fd需要执行读写操作,然后我们再让这些fd去调用线程。

四、IO多路复用——select

select工作原理就是在应用层调用select函数进入阻塞队列,这个时候,kernel内核就会轮询检查所有select负责的文件描述符fd,当找到其中哪个数据准备好了文件描述符,会返回给select,select通知系统调用,将数据从内核复制到进程的缓存区。我们来看代码

// 使用select
    fd_set rfds, rset, wfds, wset;

    FD_ZERO(&rfds);
    FD_ZERO(&wfds);
    FD_SET(listenfd, &rfds);        //设置监听的套接字位

    int maxfd = listenfd;
	unsigned char buffer[MAXSIZE] = {0}; // 0 
	int ret = 0;
    
    int count = 0;
    while(1) {
        // 复制读写集合
        rset = rfds;
        wset = wfds;

        // 检查读写集合中到的bit位确定是否有对应事件发生
        int nready = select(maxfd + 1, &rset, &wset, NULL, NULL);           // 1、这里是将复制的读写集合放进内核中
        if(FD_ISSET(listenfd, &rset)) {     // 判断监听的fd是否有新连接
            
            struct sockaddr_in client;
            socklen_t cli_len = sizeof(client);

            int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
            count ++;
            if(-1 == connfd) {
                std::cout << "Accept connfd error: " << strerror(errno) << "  Error:" << errno;
                return -1;
            }

            // 将客户端fd加入到读集合事件中,也就是将原始读集合中对应bit位置1
            FD_SET(connfd, &rfds);
			if (connfd > maxfd) 
                maxfd = connfd;
            if (--nready == 0) continue;
        }

        int i = 0;
        // 从监听的fd开始循环,因为客户端新连接的fd不能在监听fd之前
        for(i = listenfd + 1; i <= maxfd; i++) {            // 2、从fd = 0开始轮询,浪费太多CPU资源
            if(FD_ISSET(i, &rset)) {
                ret = recv(i, buffer, MAXSIZE, 0);       // recv会阻塞,知道内核有数据
                if(ret == 0) {
                    count--;
                     close(i);
                     FD_CLR(i, &rfds);       // 断开连接,重置该fd相应的bit位
                }
                else if(ret > 0) {
                    std::cout << "Buffer: " << buffer << std::endl;
					FD_SET(i, &wfds);
                }
            }
            else if (FD_ISSET(i, &wset)) {
				ret = send(i, buffer, ret, 0); 
				FD_CLR(i, &wfds); 
				FD_SET(i, &rfds);
			}
        }
        std::cout << "Count:" << count << std::endl;
    }

这里我们首先需要定义读写事件集合,然后将复制一份,再将复制的读写集合作为select函数的参数传递进入,然后select进入内核并将进程加入到阻塞队列,这时内核会调用copy_from_user将三个集合复制到内核中,然后经过一系列的函数调用,最终返回,如果返回值大于0表示select成功,这时候我们需要轮询从监听fd开始到分配的最大fd,去检查这个范围内的每个fd是否发生读写事件,如果有就做相应处理。

从上面的过程我们可以看出select的部分缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,每个fd_set是1024,则三个就是3072
3、select支持的描述符的数量比较小,默认是1024,即数据类型fd_set的大小
4、select每次返回时,readfds和writefds都将相应的准备就绪的fd对应的位置位,下次调用时,需要重新初始化这些参数。
5、select返回的只是准备就绪的描述符的数量,具体哪一个描述符准备好了还需要应用程序一个一个进行判断

五、IO多路复用——poll

poll的实现原理和select差不多,它的主要核心在pollfd结构体数据,这个结构体有三个参数,fd存储要操作的fd,event表示监听到的时间,revent表示实际发生的事件,是由内核填写的,这样我们在使用时首先第一个需要将监听的fd加入进去,并将event设置为监听读写时间,然后将其他的结构体数组成员分别进行初始化,然后再调动poll函数

#include <iostream>
#include <errno.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/poll.h>

#define MAXSIZE 1024

int main(int argc, char *argv[])
{
    char buffer[MAXSIZE] {0};

    // 初始化服务端
    struct sockaddr_in serv_addr;
    socklen_t serv_len = sizeof(serv_addr);
    memset(&serv_addr, 0x00, serv_len);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl("");
    serv_addr.sin_port = htons(9999);

    // 创建套接字
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if(-1 == listenfd) {
        std::cout << "Create socket error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    // 绑定套接字
    if( bind(listenfd, (struct sockaddr*)&serv_addr, serv_len) == -1) {
        std::cout << "Bind listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }

    // 监听套接字
    if(listen(listenfd, 10) == -1) {
        std::cout << "Listen listenfd error: " << strerror(errno) << "  Error:" << errno;
        return -1;
    }
    
    struct pollfd clientfds[MAXSIZE];
    // 初始化结构体
    clientfds[0].fd = listenfd;
    clientfds[0].events = POLLIN;
    std::cout << "Events:" << clientfds[0].events << "   FD:" << clientfds[0].fd << std::endl;
    clientfds[0].revents = 0;
    for(int i = 1; i < MAXSIZE; i++) {
        clientfds[i].fd = -1;
        clientfds->events = 0;
        clientfds->revents = 0;
    }

    int client_MaxFD = 0;
    while(1) {
        int nready = poll(clientfds, client_MaxFD + 1, 1000);
        std::cout << "Nready:" << nready << std::endl;
        
        if(clientfds[0].revents & POLLIN) {
            // 说明有新连接
            struct sockaddr_in client;             // 初始化客户端
            socklen_t cli_len = sizeof(cli_len);
            int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
            if(-1 == connfd) {
                std::cout << "Accept listenfd error: " << strerror(errno) << "  Error:" << errno;
                return -1;
            }

            // 将fd加入到集合中
            for(int i = 1; i < MAXSIZE; i++) {
                if(clientfds[i].fd == -1) {
                    clientfds[i].fd = connfd;
                    client_MaxFD++;
                    clientfds[i].events = POLLOUT;
                    break;
                }
            }

            if(connfd > client_MaxFD)
                client_MaxFD = connfd;
        }

        for(int i = 1; i < client_MaxFD; i++) {
            if(clientfds[i].fd > 0 && clientfds[i].revents == POLLIN) {
                int ret = recv(clientfds[i].fd, buffer, 20, 0);
                if(ret < 0) {
                    clientfds[i].fd = -1;
                    clientfds[i].events = 0;
                    clientfds[i].revents = 0;
                    // 这里还需要重新设置client_MaxFD的值
                }
                ret = send(clientfds[i].fd, buffer, 20, 0);
            }
        }
    }
    return 0;
}

poll相对与select的优缺点
1、poll主要是解决select的最大文件描述符限制提出的,与select一样都是轮询文件描述符
2、pollfd数组也是需要复制进内核的,但是不需要每次重新赋值。
3、poll将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
4、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
5、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

六、IO多路复用——epoll

epoll是目前使用最广,也是效率最高的模型,很多服务器都是以epoll为基础设计的,epoll相比如select和poll主要提升在以下四个方面。
1、监视的描述符数量不受限制,它所支持的 fd 上限是最大可以打开文件的数目,这个数字一般远大于 2048, 举个例子, 在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看, 一般来说这个数目和系统内存关系很大。select 的最大缺点就是进程打开的 fd 是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。
2、IO 的效率不会随着监视 fd 的数量的增长而下降。epoll 不同于 select 和 poll 轮询的方式,而是通过每个 fd 定义的回调函数来实现的。只有就绪的 fd 才会执行回调函数。
3、支持水平触发和边沿触发两种模式,这两种模式后面介绍。
4、mmap 加速内核与用户空间的信息传递。epoll 是通过内核与用户空间 mmap 同一块内存,避免了无谓的内存拷贝。

// 开始使用epoll树和事件结构体
    int epfd = epoll_create(1);

    struct epoll_event ev, events[EVENT_LENGTH];
    ev.events = EPOLLIN;
    ev.data.fd = listenfd;

    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    while(1) {
        // 记录就绪队列中的连接数
        int nready = epoll_wait(epfd, events, EVENT_LENGTH, -1);
        printf("-----nready:%d-----\n", nready);

        // 遍历就绪队列
        for(int i = 0; i < nready; i++) {
            int clientfd = events->data.fd;
            // 首先判断fd是不是监听
            if(listenfd == clientfd) {
                struct sockaddr_in client;
                socklen_t cli_len = sizeof(client);
                memset(&cli_len, 0x00, cli_len);
                int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
                if(-1 == connfd) {
                    std::cout << "Accept connfd error: " << strerror(errno) << "  Error:" << error;
                    return -1;
                }

                // 将connfd事件置为可读并加到epoll树上
                events[i].events = EPOLLIN;
                events[i].data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);

            }
            else if(events[i].events & EPOLLIN) {
                while(1) {
                    int n = recv(clientfd, rbuffer, BUFFER_LENGTH, 0);
                    if(n > 0) {
                        rbuffer[n] = '\0';
                        std::cout << "Recv message from client: " << rbuffer << std::endl;
                        memccpy(wbuffer, rbuffer, BUFFER_LENGTH);

                        // 将fd事件置为可读
                        ev.events = EPOLLOUT;
                        ev.data.fd = clientfd;
                        epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
                    }
                    else if(n == 0) {
                        ev.events = EPOLLIN;
					    ev.data.fd = clientfd;

					    epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
					    close(clientfd);
                    }
                } 
            }
            else if(events[i].events & EPOLLOUT) {
                int sent = send(clientfd, wbuffer, BUFFER_LENGTH, 0); //
				printf("sent: %d\n", sent);

				ev.events = EPOLLIN;
				ev.data.fd = clientfd;

				epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
            }
        }
    }

    close(listenfd);

我们在使用epoll的时候归根结底就是使用epoll_create、epoll_ctl和epoll_wait三个函数,首先需要cpoll_create创建一个epfd根节点,然后调用epoll_ctl将listenfd加入到epfd上去,最后再调用epoll_wait 去等待连接,然后返回连接数,这时候我们不需要去轮询fd集合,epoll会告诉我们那些fd上有事件发生。需要注意的就是epoll_wait是监听挂载到epfd上的fd,那也就是说我们在连接成功后,必须将connfd加到epfd上去。

然后我们在循环遍历是需要分情况讨论,第一种就是fd是监听fd,这时候我们需要建立客户端,然后将该fd对应event时间置为EPOLLIN,最厚将connfd加入到epoll树上去。如果不是监听fd,我们需要判断是可读还是可写事件,然后最相应处理。

然后我们在来看看什么是LT和ET?
epoll默认是LT(水平触发),也就是一次事件会触发多次,比如当读事件来临时,内核有1024个字节数据可读,但是recv的buffer每次只会接受128个字节,那么这时候LT就会一直读,直到内核数据读完之后才结束。但是ET(边沿触发)就不是这样,他是一次事件触发一次,也就是说buffer读满128个字节数据就不会再从内核中拿数据了,必须等待下一次读事件发生,这时候会继续读取128个字节,这种情况会导致数据一致积压在内核缓冲区。

ET要与非阻塞fd一起使用,因为ET一次事件只触发一次,所以epoll_wait返回后一定要处理完毕,对于可读事件,要一直read fd到此fd被read完为止,而如果设置成blocking以后,fd上的数据read完后会阻塞,即while{epoll_wait(); read(fd)}这段代码会一直阻塞而影响重新调用epoll_wait来监听其他事件,正确做法是设置fd成non_blocking,且epoll_wait返回后吧事件read到EAGAIN为止,注意这里只是单线程下(不过哪怕是用线程池,线程池中线程阻塞了也进行不了其他任务)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值