浅谈Linux-I/O复用【select、poll、epoll】+详细使用示例

目录

1 为什么要进行I/O复用?

2 select

3 poll 

4 epoll(linux特有)

4.1 epoll介绍与示例

4.2 LT模式和ET模式

5 select、poll和epoll的区别


本文中的代码示例建议实践一遍。

1 为什么要进行I/O复用?

在这之前,大家应该先对5中I/O模型有一个简单的了解。

首先我们应该都知道,I/O复用可以同时对多个文件描述符进行监听,能很大程度上去提高程序的性能。就相当于一个同时能监听多个电话的电话接线员。

扫描二维码关注公众号,回复: 11210982 查看本文章

I/O复用在网络编程中最为常用,在传统的网络编程中,如果有多个TCP连接,往往都是去开辟多个线程去进行处理,在线程数量较多的情况下,线程的开辟、唤醒等操作大大的浪费CPU时间。因此如果能有一个类似于观察者的角色去监听多个连接,那么程序的性能将能得到大幅度的提升。这个时候I/O复用就闪亮登场了。

在网络编程的过程中,以下几种情况(不限于)需要使用I/O复用模型:

  • 客户端要处理多个socket。
  • 客户端程序要同时处理用户输入和网络连接。
  • TCP服务器要同时处理监听socket和连接socket。这种情况是I/O复用使用最多的一种情况。
  • 服务器要同时处理TCP请求和UDP请求。
  • 服务器要同时监听多个窗口,或者处理多种任务。

I/O复用和其他I/O模型的比较如下图所示:

Linux通过select、poll和epoll三种系统调用实现了I/O复用。epoll是linux特有的。下面进行一一介绍。

2 select

select系统调用可以在某一时间段内监听文件描述符上的可读、可写或者异常事件。

为了便于理解select,我形象的比喻一下这句话:

假如你是公司老板,我们将select看作是你的专属接线员,你有几个客户的电话号码(相当于文件描述符),然后你将这几个电话好号码告诉了你的接线员,并且告诉他,如果这些客户要是买产品的话再告诉你(将可读操作比喻为买产品),其他情况不用告诉我,并且一个小时汇报一次,不管有没有客户买产品。那么对于你而言,只有当固定的几个客户打电话并且要买产品,你才能从接线员那里收到消息。当然除了买产品以外,你还可以规定其他两件事情,但是不能超过三件。(这些事情相当于:可读、写和异常)

select其实是在指定事件内轮询一定数量的文件描述符,检测是否有就绪者。

下面是select常用的操作:

#include<sys/select.h>

int select(int nfds,fd_set *readfds,fd_set* writefds,fd exceptfds,struct timeval * timeout);

此函数用于监听文件描述符,文件描述符存于readfds,writefds,exceptfds.

nfds是需要监听文件描述符的个数,假设readfds包含1个文件描述符,writefds包含两个文件描述符,exceptfds包含3个文件描述符,那么,nfds = 1+2+3=6。通常设置为文件描述符的最大值+1,因为文件描述符是从0开始计数的。

readfds、writefds和exceptfds是三个fd_set指针,fd_set是一个结构体,里面有一个整型数组,用于存储文件描述符。三个指针为空,那么select只监听超时。如果不为空,那么select就去监听文件描述符的响相应事件。readfds监听读事件,writefds监听写事件,exceptfds监听异常事件。某一个为NULL,那就不监听相应的事件。

timeout为一个结构体,用于设置超时时间。

struct timeval

{

long tv_ser;//秒

long tv_usec;//毫秒

};

select失败返回-1,超时返回0,事件就绪返回大于0的数,表示满足就绪事件文件描述符的个数。

例如有3个文件描述符满足可读操作,那么select就返回3。

FD_ZERO(fd_set* fdset);//对fdset进行清零操作

FD_SET(int fd,fd_set *fdset);//即将fd添加到fdset的存储描述符的数组中

FD_CLR(int fd,fd_set* fdset);//将fd从fdset的文件描述符数组中删除

int FD_ISSET(int fd,fd_set *fdset);//即检查fdset文件描述符数组中的fd是否发生变化,即相应的事件是否满足,如果事件满足返回真,否则返回假。这个函数是在使用select中最常使用的一个函数,用于检测相应文件描述符是否有事件发生。

下面是一个示例,用监听socket和连接socket,包含两个文件select.c和cli.c。

在介绍poll和epoll的时候客户端的代码和cli.c一样,这里给出,后面不再展示。

select.c

#include <stdio.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/select.h>
#include<sys/time.h>
#include<signal.h>
//同时监听套接字的最大个数
# define MAXD 10
//收集文件描述符
void FdsAdd(int fds[],int fd)
{
    int i = 0;
    for(;i<MAXD;++i)
    {
        //找到一个位置放入
        if(fds[i]==-1)
        {
            fds[i]=fd;
            break;
        }
    }
}
//删除文件描述符
void FdsDel(int fds[],int fd)
{
    int i = 0;
    for(;i<MAXD;++i)
    {
        if(fds[i]==fd)
        {
            fds[i]=-1;
            break;
        }
    }
}
int main()
{
    //创建套接字和地址
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    assert(sockfd!=-1);
    struct sockaddr_in saddr;
    memset(&saddr,0,sizeof(saddr));
    saddr.sin_family=AF_INET;
    saddr.sin_port = htons(6000);
    saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
    assert(res!=-1);
    listen(sockfd,5);
    fd_set fdSet;//一个描述符集合,用于监听固定事件
    int fds[MAXD];//收集要监听的描述符
    //对fds进行初始化操作
    int i = 0;
    for(;i<MAXD;++i)
        fds[i]=-1;
    //将监听套接字sockfd添加到集合fds中
    FdsAdd(fds,sockfd);
    while(1)
    {
        //对fdSet进行清零操作
        FD_ZERO(&fdSet);
        //用于存储要最大的文件描述符
        int maxFd = 0;
        for(i=0;i<MAXD;++i)
        {
            if(fds[i]==-1)
                continue;
            //将文件描述符添加到fdSet中
            FD_SET(fds[i],&fdSet);
            if(fds[i]>maxFd)
                maxFd=fds[i];
        }
        //设置超时时间
        struct timeval tv = {5,0};
        int n = select(maxFd+1,&fdSet,NULL,NULL,&tv);//只对读事件感兴趣
        printf("n = %d\n",n);
        //从select的返回值来判断
        if(n==-1)
            perror("error\n");
        else if(n==0)
            printf("time out\n");
        else
        {
            printf("进入事件处理\n");
            //判断是哪个文件描述符有可读的操作
            for(i = 0;i<MAXD;++i)
            {
                if(fds[i]==-1)
                    continue;
                //可读
                if(FD_ISSET(fds[i],&fdSet))
                {
                    //监听套接字sockfd可读,表明有新连接
                    if(fds[i]==sockfd)
                    {
                        //创建新连接
                        struct sockaddr_in caddr;
                        int len = sizeof(caddr);
                        int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
                        if(c<0)
                            continue;
                        printf("accept c =%d\n",c);
                        //将连接套接字放入集合中
                        FdsAdd(fds,c);   
                    }
                    //监听套接字可读
                    else
                    {
                        char buff[128] = {0};
                        int res = recv(fds[i],buff,127,0);
                        if(res<=0)//断开连接
                        {
                            close(fds[i]);
                            FdsDel(fds,fds[i]);
                            printf("one client is over\n");
                        }
                        else
                        {
                            printf("recv %d :%s\n",fds[i],buff);
                            send(fds[i],"ok",2,0);
                        }
             
                    }
                                                  
                }
        
           }
        }
    }
    return 0;
}

cli.c

#include <stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<assert.h>
#include<string.h>
#include<stdlib.h>
int main()
{
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    struct sockaddr_in caddr;
    memset(&caddr,0,sizeof(caddr));
    caddr.sin_family=AF_INET;
    caddr.sin_port=htons(6000);
    caddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    int res = connect(sockfd,(struct sockaddr*)&caddr,sizeof(caddr));
    assert(res!=-1);
    while(1)
    {
        printf("input:\n");
        char buff[128]={0};
        fgets(buff,128,stdin);
        if(strncmp(buff,"end",3)==0)
        {
            break;
        }
        send(sockfd,buff,strlen(buff),0);
        memset(buff,0,128);
        recv(sockfd,buff,127,0);
        printf("recv buff = %s\n",buff);
    }
    close(sockfd);
    return 0;
}

执行结果如下图所示:

可以发现select可以完美的处理监听套接字和连接套接字。

我们可以发现,select有个缺点,假设我们有一个文件描述符,如果我们要监听它的可读和可写,那么这个文件描述符就需要传入到两个fd_set中,再将两个fd_set传入到select中,略显繁琐,另一个系统调用poll将这个问题完美的解决掉了。另外,select监测的事件只有3种,而poll将监测事件细化了。

3 poll 

poll和select非常相似,也是在指定事件内轮询一定数量的文件描述符,检测是否有就绪者。

至于poll和select有什么区别,看完poll的相关操作就明了了。

#include<poll.h>

int poll(struct pollfd* fds,nfds_t nfds,int timeout);

用于监听文件描述符。nfds文件描述符个数,timeout为超时时间,单位毫秒,如果为-1,poll永远阻塞,为0立即返回。

返回值的含义也和select相同,所以我们着重看一下pollfd这个结构体。

struct pollfd

{

     int fd;//文件描述符

    short events;//注册的事件,即感兴趣的事件。可以同时注册多个事件,用“|”隔开即可

    short revents;//实际发生的事件,当检测有某个事件发生后,内核会将事件填充到这个变量里。如果没有事件发生,内核会将这个变量清零。

}

poll中传入的是一个pollfd数组

poll可以监测的事件和相应的描述如下所示:(图片来源于网络)

其中POLLIN最常用。接下来我们来看poll的示例,两个文件poll.c和cli.c,cli.c上一节中已经给出。

poll.c

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

//监听文件符最大个数
#define MAXFD 10
//创建监听套接字
int CreateSocket()
{
    int sockfd= socket(AF_INET,SOCK_STREAM,0);
    assert(sockfd!=-1);
    struct sockaddr_in saddr;
    memset(&saddr,0,sizeof(saddr));
    saddr.sin_family=AF_INET;
    saddr.sin_port = htons(6000);
    saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
    assert(res!=-1);
    listen(sockfd,5);
    return sockfd;
}
//创建连接套接字
int  CreateConSocket(int sockfd)
{
    struct sockaddr_in caddr;
    int len = sizeof(caddr);
    memset(&caddr,0,sizeof(caddr));
    int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
    assert(c>=0);
    printf("accept c = %d\n",c);
    return c;
}
//添加文件描述符
void FdsAdd(struct pollfd fds[],int fd)
{
    int i =0;
    for(;i<MAXFD;++i)
    {
        //插入到合适的位置
        if(fds[i].fd==-1)
        {
            fds[i].fd= fd;
            fds[i].events=POLLIN;//注册可读事件
            fds[i].revents=0;
            break;
        }
    }
}
//删除文件描述符
void FdsDel(struct pollfd fds[],int fd)
{
    int i = 0;
    for(;i<MAXFD;++i)
    {
        if(fds[i].fd==-1)
            continue;
        if(fds[i].fd==fd)
        {
            fds[i].fd=-1;
            fds[i].events=0;
            fds[i].revents=0;
            break;
        }
    }
}
//对pollfd数组进行初始化
void FdsInit(struct pollfd fds[])
{
    int i = 0;
    for(;i<MAXFD;++i)
    {
        fds[i].fd=-1;
        fds[i].events=0;
        fds[i].revents=0;
    }
}


int main()
{
    int sockfd = CreateSocket();
    struct pollfd fds[MAXFD];
    FdsInit(fds);
    FdsAdd(fds,sockfd);
    while(1)
    {
        int n = poll(fds,MAXFD,5000);//5秒超时
        if(n==-1)
            perror("poll error\n");
        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&POLLIN)
                {
		            //监听套接字
                    if(fds[i].fd==sockfd)
                    {
                        int c =CreateConSocket(sockfd);
                        FdsAdd(fds,c);
                     }
                    //连接套接字
                    else
                    {
                        char buff[128]={0};
                        int n = recv(fds[i].fd,buff,127,0);
                        if(n<=0)
                        {
                            close(fds[i].fd);
                             FdsDel(fds,fds[i].fd);
                            printf("one client is over\n");
                        }
                        else
                        {
                            printf("recv %d:%s\n",fds[i].fd,buff);
                            send(fds[i].fd,"ok",2,0);
                        }
                }
               }
                
            }
        }

    }
    return 0;
}

程序运行效果如下图所示:

select和poll采用的都是轮询检测的机制,即每次调用都要重复的将文件描述符传入到内核当中,这一点很大程度上降低了程序的运行效率,因此linux提出了自己特有的epoll。

4 epoll(linux特有)

epoll是linux特有的I/O复用函数,它在实现、使用上与select和poll有很大的差异。首先,epoll是用一组函数来完成监听任务,而不是一个函数。其次,epoll把用户关心的文件描述符上的事件放在内核中的一个事件表上,无需像select和poll那样每次都将文件描述符拷入到内核当中。

4.1 epoll介绍与示例

接下来我们介绍一下epoll的几个操作函数。

#include<sys/epoll.h>

int epoll_create(int size);

此函数用于在内核中创建一个事件表,size并不起作用,只是给一个提示,告诉内核事件表多大。

函数返回内核事件表的文件描述符。

因为epoll需要在内核中去创建一个内核事件表,因此需要使用一个额外的文件描述符去标识内核中的这个事件表。

int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);

此函数用于对事件表进行操作,epfd为epoll_create的返回值。

op为操作方式,有如下几种:

EPOLL_CTL_ADD:向事件表中添加事件。

EPOLL_CTL_MOD:修改事件表上的事件。

EPOLL_CTL_DEL:删除事件表上的事件。

fd为要操作的文件描述符,event参数指定事件,即用来表明用户感兴趣的文件描述事件。

epoll_event的定义如下:

struct epoll_event

{

    _uint32_t events;//epoll事件,即用户感兴趣的事件

    epoll_data_t data; //用户数据

};

typedef union epoll_data

{

   ……

   int fd;//用来存储文件描述符

   ……

};

当要删除文件描述符的时候,可以将event设为NULL.

epoll的核心函数为epoll_wait,用于监听文件描述符

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

epfd、maxevents和timeout很好理解。

events是指向一个epoll_event的数组,是一个用来收集文件描述符的集合。内核会将事件发生的文件描述符和相关信息存入events。

返回值的含义与select和poll一样。

什么都没有代码来的直观,我们能来看一下epoll的示例。epoll.c和cli.c,cli.c和前面一样。

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

//监听文件符最大个数
#define MAXFD 10
//创建监听套接字
int CreateSocket()
{
    int sockfd= socket(AF_INET,SOCK_STREAM,0);
    assert(sockfd!=-1);
    struct sockaddr_in saddr;
    memset(&saddr,0,sizeof(saddr));
    saddr.sin_family=AF_INET;
    saddr.sin_port = htons(6000);
    saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
    int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
    assert(res!=-1);
    listen(sockfd,5);
    return sockfd;
}
//创建连接套接字
int  CreateConSocket(int sockfd)
{
    struct sockaddr_in caddr;
    int len = sizeof(caddr);
    memset(&caddr,0,sizeof(caddr));
    int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
    if(c<0)
        perror("accept error\n");
    printf("accept c = %d\n",c);
    return c;
}
//向事件表中注册事件
void EpollAdd(int epfd,int fd)
{
    //创建epoll_event结构体
    struct epoll_event ev;
    ev.data.fd=fd;
    ev.events=EPOLLIN;//这个事件和poll的很相似,前面加E
    //将事件添加到事件表
    if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
        perror("epoll add error\n");
}
//从事件表中删除事件
void EpollDel(int epfd,int fd)
{
    if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
        perror("epoll del error\n");
}
int main()
{
    int sockfd = CreateSocket();
    //创建内核事件表
    int epfd = epoll_create(MAXFD);
    if(epfd==-1)
        perror("epoll create errror\n");
    //将监听套接字加入
    EpollAdd(epfd,sockfd);
    //用来存储事件发生变化的文件描述符和相关的信息
    struct epoll_event  events[MAXFD];
    while(1)
    {
        int n = epoll_wait(epfd,events,MAXFD,5000);
        if(n==-1)
            perror("epoll wait error\n");
        if(n==0)
            printf("time out\n");
        else
        {
            int i = 0;
            for(;i<n;++i)
            {
                int fd = events[i].data.fd;
                if(events[i].events&EPOLLIN)
                {
                    //监听套接字
                    if(fd==sockfd)
                    {
                        int c =CreateConSocket(sockfd);
                        EpollAdd(epfd,c);
                    }
                    else
                    {
                        char buff[128]={0};
                        int res = recv(fd,buff,127,0);
                        if(res<=0)
                        {
                            EpollDel(epfd,fd);
                        
                            close(fd);
                            printf("one client is over\n");
                        }
                        else
                        {
                            printf("recv %d : %s\n",fd,buff);
                            send(fd,"ok",2,0);
                        }
                    }
                }
            }
        }
    }
    return 0;

}

代码运行结果如下图所示:

你以为epoll这就完了?想的太简单,接下来我介绍一些epoll的两种工作模式。

4.2 LT模式和ET模式

linux epoll的工作方式有两种,一种是LT(电平触发)模式,另一种是ET(边沿触发)模式。

LT模式:即当文件描述符上有数据的时候,如果一次没有读完,io复用函数会一直提醒我们知道数据读完。LT模式下有阻塞和非阻塞两种模式,epoll默认的工作方式是阻塞的LT模式。

ET模式:当数据描述符上有数据时,io复用函数只会提醒一次。因此在ET模式下,当文件描述符事件发生的时候,要一次将数据处理完,如果一次没有将数据处理完那么不会有第二次提醒。因此ET工作方式只有非阻塞模式,因为如果是阻塞模式的话,那么程序一定会阻塞在最后一次的write或者read函数。

如上述,ET模式很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式的效率要比LT模式高。

应用下面代码可以将文件描述符设置位非阻塞的:

void setnonblock(int fd)
{
	int oldfl = fcntl(fd,F_GETFL);//取得文件标志位
	int newfl = oldfl|O_NONBLOCK;//设置非阻塞模式
	if(fcntl(fd,F_SETFL,newfl)==-1)
	{
		perror("fcntl error\n");
	}

}

下面的代码实现了一个非阻塞的ET模式,大家可以参考下面代码来搞清楚上述函数setnoblock是怎么使用的。



# include<stdio.h>
# include<unistd.h>
# include<stdlib.h>
# include<string.h>
# include<assert.h>
# include<poll.h>
# include<netinet/in.h>
# include<sys/socket.h>
# include<arpa/inet.h>
# include<sys/epoll.h>
# include<fcntl.h>
# include<signal.h>
# define MAXFD 10
# include <errno.h>
int pipefd[2];



int create_sockfd()
{
	int sockfd = socket(AF_INET,SOCK_STREAM,0);
	if(sockfd ==-1)
	{
		return -1;
	}
	struct sockaddr_in saddr;
	memset(&saddr,0,sizeof(saddr));
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(6000);
	saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
	int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
	if(res ==-1)
	{
		return -1;
	}
	listen(sockfd,5);
	return sockfd;

}
void setnonblock(int fd)
{
	int oldfl = fcntl(fd,F_GETFL);//取得文件标志位
	int newfl = oldfl|O_NONBLOCK;//设置非阻塞模式
	if(fcntl(fd,F_SETFL,newfl)==-1)
	{
		perror("fcntl error\n");
	}

}
void epoll_add(int epfd,int fd)
{

	struct epoll_event ev;
	ev.data.fd = fd;
	ev.events = EPOLLIN|EPOLLRDHUP;//开启et EPOLLET
	if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
	{
		perror("epoll add error \n");
	}
	setnonblock(fd);
}
void epoll_del(int epfd,int fd)
{
	if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
	{
		perror("epoll del error");
	}
}
void fun(int sig)
{
	write(pipefd[1],&sig,sizeof(sig));
}
int main()
{
	int sockfd = create_sockfd();
	assert(sockfd !=-1);
	int epfd = epoll_create(MAXFD);//创建了一个内核事件表
	assert(epfd!=-1);
	epoll_add(epfd,sockfd);
	pipe(pipefd);
	epoll_add(epfd,pipefd[0]);
	signal(SIGINT,fun);
	struct epoll_event events[MAXFD];
	int run =1;	
	while(run)
	{
		int n = epoll_wait(epfd,events,MAXFD,5000);
		if(n==-1)
		{
			if(errno!=EINTR)
			{
				perror("epoll wait error\n");
			}
		}
		if(n==0)
		{
			printf("time out\n");
		}
		else
		{
			int i = 0;
			for(;i<n;++i)
			{
				int fd = events[i].data.fd;
				if(events[i].events&EPOLLIN)
				{
					if(fd == sockfd)
					{
						struct sockaddr caddr;
						int len = sizeof(caddr);
						int c =accept(sockfd,(struct sockaddr*)&caddr,&len);
						if(c<0)
						{
							continue;
						}
						printf("accept c = %d\n",c);
						epoll_add(epfd,c);
					}
					else if(fd == pipefd[0])
					{
						int sig = 0;
						read(pipefd[0],&sig,sizeof(sig));
						printf("recv sig = %d",sig);
						run = 0;				
					}
					else
					{
							char buf[128]={0};
							int res = recv(fd,buf,1,0);
							if(res==0)
							{
								epoll_del(epfd,fd);
								close(fd);
								//epoll_del(epfd,fd);
								printf("client %d is out\n",fd);
							}
							else if(res==-1)
							{
								send(fd,"ok",2,0);
								break;
							}
							else
							{
								printf("recv(%d)=%s\n",fd,buf);
							}
						
					}
				}
			}
		
		}
	
	}
	close(sockfd);
	close(epfd);
	printf("service over\n");
	exit(0);
}

如上所示,ET模式除了在添加事件的时候要添加EPOLLRDHUP事件,开启ET模式外,还要设置非阻塞的文件描述符。

5 select、poll和epoll的区别

经过上面的几个示例,对三种模式有了很深的了解,三者的区别上述也断断续续有提到,下图展示了三种模式的详细区别。

I/O复用是网络编程的一个重点内容,面试也会经常问道,希望我的博文能够帮助大家进行理解。同时欢迎各位批评指正。

原创文章 162 获赞 95 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_42214953/article/details/105857169