WinSock Socket 池

2018-08-12  本文已影响41人  一叶障目

之前在WinSock2.0 API 中说到,像DisConnectEx 函数这样,它具有回收SOCKET的功能,而像AcceptEx这样的函数,它不会自己在内部创建新的SOCKET,需要外部传入SOCKET作为传输数据用的SOCEKT,使用这两个函数,我们可以做到,事先创建大量的SOCKET,然后使用AcceptEx函数从创建的SOCKET中选择一个作为连接用的SOCKET,在不用这个SOCKET的时候使用DisConnectEx回收。这样的功能就是一个SOCKET池的功能。

SOCKET池

WinSock 函数就是为了提升程序的性能而产生的,这些函数主要使用与TCP协议,我们可以在程序启动的时候创建大量的SOCKET句柄,在必要的时候直接使用AcceptEx这样的函数来使用已有的SOCKET作为与客户端的连接,在适当的时候使用WSARecv、WSASend等函数金星秀数据收发操作。而在不用SOCKET的时候使用DisConnectEx 回收,这样在响应用户请求的时候就省去了大量SOCKET创建和销毁的时间,从而提高的响应速度。

IOCP本身也是一个线程池,如果用它结合WinSock 的线程池将会是Windows系统上最佳的性能组合,当然在此基础上可以考虑加入线程池、内存池的相关技术来进一步提高程序的性能。这里我想顺便扯点关于程序优化的理解。

程序优化主要考虑对函数进行优化,毕竟在C/C++中函数是最常用,最基本的语法块,它的使用十分常见。函数的优化一般有下面几个需要考虑的部分

  1. 是否需要大量调用这个函数。针对需要大量调用某个函数的情况,可以考虑对算法进行优化,减少函数的调用
  2. 函数中是否有耗时的操作,如果有可以考虑使用异步的方式,或者将函数中的任务放到另外的线程(只有当我们确实不关心这个耗时操作的结果的时候,也就是说不涉及到同步的时候)
  3. 函数中是否有大量的资源调用,如果有,可以考虑使用资源池的方式避免大量资源的申请与释放操作

下面是一个使用SOCKET池的客户端的实例

#include <stdio.h>
#include <process.h>
#include "MSScokFunc.h"

#define SERVICE_IP "119.75.213.61"
#define MAX_CONNECT_SOCKET 500
unsigned int WINAPI IOCPThreadProc(LPVOID lpParam);

LONG g_nPorts = 0;
CMSScokFunc g_MsSockFunc;

typedef struct _tag_CLEINT_OVERLAPPED
{
    OVERLAPPED overlapped;
    ULONG ulNetworkEvents;
    DWORD dwFlags;
    DWORD wConnectPort;
    DWORD dwTransBytes;
    char *pBuf;
    DWORD dwBufSize;
    SOCKET sConnect;
}CLIENT_OVERLAPPED, *LPCLIENT_OVERLAPPED;

SOCKADDR_IN g_LocalSockAddr = {AF_INET};

int main()
{
    WSADATA wd = {0};
    WSAStartup(MAKEWORD(2, 2), &wd);
    SYSTEM_INFO si = {0};
    const int on = 1;
    GetSystemInfo(&si);
    HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, si.dwNumberOfProcessors);

    HANDLE *pThreadArray = (HANDLE *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(HANDLE) * 2 * si.dwNumberOfProcessors);
    for (int i = 0; i < 2 * si.dwNumberOfProcessors; i++)
    {
        HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, IOCPThreadProc, &hIOCP, 0, NULL);
        pThreadArray[i] = hThread;
    }

    g_MsSockFunc.LoadAllFunc(AF_INET, SOCK_STREAM, IPPROTO_IP);
    LPCLIENT_OVERLAPPED *pOverlappedArray = (LPCLIENT_OVERLAPPED *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LPCLIENT_OVERLAPPED) * MAX_CONNECT_SOCKET);
    SOCKET *pSocketsArray = (SOCKET *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(SOCKET) * MAX_CONNECT_SOCKET);

    int nIndex = 0;
    g_LocalSockAddr.sin_addr.s_addr = INADDR_ANY;
    g_LocalSockAddr.sin_port = htons(0); //让系统自动分配

    printf("开始端口扫描........\n");

    for (int i = 0; i < MAX_CONNECT_SOCKET; i++)
    {
        g_nPorts++;
        SOCKET sConnectSock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_IP, NULL, NULL, WSA_FLAG_OVERLAPPED);
        //允许地址重用
        setsockopt(sConnectSock, SOL_SOCKET, SO_REUSEADDR, (char *)&on, sizeof(on));

        bind(sConnectSock, (SOCKADDR*)&g_LocalSockAddr, sizeof(SOCKADDR_IN));

        SOCKADDR_IN sockAddr = {0};
        sockAddr.sin_addr.s_addr = inet_addr(SERVICE_IP);
        sockAddr.sin_family = AF_INET;
        sockAddr.sin_port = htons(g_nPorts);

        LPCLIENT_OVERLAPPED lpoc = (LPCLIENT_OVERLAPPED)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(CLIENT_OVERLAPPED));
        lpoc->ulNetworkEvents = FD_CONNECT;
        lpoc->wConnectPort = g_nPorts;
        lpoc->sConnect = sConnectSock;
        lpoc->pBuf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(SOCKADDR_IN));
        lpoc->dwBufSize = sizeof(SOCKADDR_IN);
        CopyMemory(lpoc->pBuf, &sockAddr, sizeof(SOCKADDR_IN));

        if(!g_MsSockFunc.ConnectEx(sConnectSock, (SOCKADDR*)lpoc->pBuf, sizeof(SOCKADDR_IN), NULL, 0, &lpoc->dwTransBytes, &lpoc->overlapped))
        {
            if (WSAGetLastError() != ERROR_IO_PENDING)
            {
                printf("第(%d)个socket调用ConnectEx失败, 错误码为:%08x\n", i, WSAGetLastError());
                HeapFree(GetProcessHeap(), HEAP_ZERO_MEMORY, lpoc);
                closesocket(sConnectSock);
                continue;
            }
        }

        CreateIoCompletionPort((HANDLE)sConnectSock, hIOCP, NULL, 0);

        pSocketsArray[nIndex] = sConnectSock;
        pOverlappedArray[nIndex] = lpoc;
        nIndex++;
    }

    g_nPorts = nIndex;

    WaitForMultipleObjects(2 * si.dwNumberOfProcessors, pThreadArray, TRUE, INFINITE);

    printf("端口扫描结束.......\n");

    for (int i = 0; i < 2 * si.dwNumberOfProcessors; i++)
    {
        CloseHandle(pThreadArray[i]);
    }
    HeapFree(GetProcessHeap(), 0, pThreadArray);
    printf("清理对应线程完成........\n");

    CloseHandle(hIOCP);
    printf("清理完成端口句柄完成........\n");

    for (int i = 0; i < nIndex; i++)
    {
        closesocket(pSocketsArray[i]);
        HeapFree(GetProcessHeap(), 0, pOverlappedArray[i]);
    }
    HeapFree(GetProcessHeap(), 0, pSocketsArray);
    HeapFree(GetProcessHeap(), 0, pOverlappedArray);
    printf("清理sockets池成功...............\n");

    WSACleanup();
    return 0;
}

unsigned int WINAPI IOCPThreadProc(LPVOID lpParam)
{
    HANDLE hIOCP = *(HANDLE*)lpParam;
    LPOVERLAPPED lpoverlapped = NULL;
    LPCLIENT_OVERLAPPED lpoc = NULL;
    DWORD dwNumbersOfBytesTransfer = 0;
    ULONG uCompleteKey = 0;

    while (g_nPorts < 65536) //探测所有的65535个端口号
    {
        int nRet = GetQueuedCompletionStatus(hIOCP, &dwNumbersOfBytesTransfer, &uCompleteKey, &lpoverlapped, INFINITE);
        lpoc = CONTAINING_RECORD(lpoverlapped, CLIENT_OVERLAPPED, overlapped);
        switch (lpoc->ulNetworkEvents)
        {
        case FD_CONNECT:
            {
                int nErrorCode = WSAGetLastError();
                if (ERROR_SEM_TIMEOUT != nErrorCode)
                {
                    printf("当前Ip端口[%d]处于开放状态\n", lpoc->wConnectPort);
                }

                lpoc->ulNetworkEvents = FD_CLOSE;
                shutdown(lpoc->sConnect, SD_BOTH);
                g_MsSockFunc.DisConnectEx(lpoc->sConnect, lpoverlapped, TF_REUSE_SOCKET, 0);
            }
            break;

        case FD_CLOSE:
            {
                InterlockedIncrement(&g_nPorts); //进行原子操作的自增1
                lpoc->wConnectPort = g_nPorts;
                lpoc->ulNetworkEvents = FD_CONNECT;

                SOCKADDR_IN sockAddr = {0};
                sockAddr.sin_addr.s_addr = inet_addr(SERVICE_IP);
                sockAddr.sin_family = AF_INET;
                sockAddr.sin_port = htons(g_nPorts);
                lpoc->pBuf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(SOCKADDR_IN));
                lpoc->dwBufSize = sizeof(SOCKADDR_IN);
                CopyMemory(lpoc->pBuf, &sockAddr, sizeof(SOCKADDR_IN));

                g_MsSockFunc.ConnectEx(lpoc->sConnect, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR), NULL, 0, &lpoc->dwTransBytes, &lpoc->overlapped);
            }
            break;
        default:
            {
                lpoc->ulNetworkEvents = FD_CLOSE;
                HeapFree(GetProcessHeap(), 0, lpoc->pBuf);
                lpoc->pBuf = NULL;
                lpoc->dwBufSize = 0;
                g_MsSockFunc.DisConnectEx(lpoc->sConnect, lpoverlapped, TF_REUSE_SOCKET, 0);
            }
        }
    }
    return 0;
}

这例子的主要功能是针对具体的IP或者主机名进行TCP的端口探测,这里的端口探测也是采用最简单的方式,向对应的端口发送TCP连接的请求,如果能连上则表示该端口开放,否则认为端口未开放。
上述示例中,首先创建IOCP并绑定线程,这里我们绑定处理器数的2倍个线程,并且指定并行的线程数为CPU核数。接着创建对应的结构保存对应的连接信息。

然后就是在循环中创建足够数量的SOCKET,这里我们只创建了500个,在每个SOCKET连接完成并回收后再次进行提交去探测后面的端口。注意这里我们先对每个SOCKET进行了绑定,这个在一般的SOCKET客户端服务器模型中没有这个操作,这个操作是WinSock API2.0需要的操作。 创建了足够的socket后,使用ConnectEx进行连接。

在线程池中对相关的完成通知进行了处理,这里分了下面几种情况

最后当所有端口都探测完成后完成端口线程退出,程序进入资源回收的阶段,这个阶段的顺序如下:

  1. 关闭线程句柄
  2. 关闭IOCP句柄
  3. 关闭监听的SOCKET
  4. 关闭其余套接字
  5. 回收其他资源

这个顺序也是有一定讲究的,我们先关闭IOCP的相关,如果后续还有需要处理的完成通知,由于此时IOCP已经关闭了,所以这里程序不再处理这些请求,接着关闭监听套接字表示程序已经不再接受连接的请求。这个时候程序已经与服务端彻底断开。后面再清理其余资源。

WSABUF 参数

在WSASend 和WSARecv的参数中总有一个WSABUF的参数,这个参数很简单的就只有一个缓冲区指针和缓冲区长度,加上函数后面表示WSABUF的个数的参数,很容易想到这些函数可以发送WSABUF的数组,从而可以发送多个数据,但是这就有问题了,发送大数据的话,我们直接申请大一点的缓冲就完了,为什么要额外的定义这么一个结构呢?回答这个问题的关键在于散播和聚集这种I/O处理的机制

散播和聚集I/O是一种起源于高级硬盘I/O的技术,它的本质是将一组比较分散的小碎块数据组合成一个大块的IO数据操作,或者反过来是将一个大块的I/O操作拆分为几个小块的I/O操作。它的好处是,比如分散写入不同大小的几个小数据(各自是几个字节),这对于传统硬盘的写入来说是比较麻烦的一种操作,传统的磁盘需要经历几次机械臂的转动寻址,而通过聚集写操作,它会在驱动层将这些小块内存先拼装成一个大块内存,然后只调用一次写入操作,一次性写入硬盘。这样之后,就充分的发挥了高级硬盘系统(DMA/SCSI/RAID等)的连续写入读取的性能优势,而这些设备对于小块数据的读写是没有任何优势的,甚至性能是下降的。

而在Winsock中将这种理念发挥到了SOCKET的传输上。WSABUF正是用于这个理念的产物。

作为WSASend、WSASendto、WSARecv、WSARecvFrom等函数的数组参数,最终WSABUF数组可以描述多个分散的缓冲块用于收发。在发送的时候底层驱动会将多个WSABUF数据块组合成一个大块的内存缓冲,并一次发送出去,而在接收时会将收到的数据进行拆分,拆分成原来的小块数据,也就是说聚合散播的特性不仅能提高发送的效率,而且支持发送和接收结构化的数据。

其实在使用聚合散播的时候主要是应用它来进行数据包的拆分,方便封装自定义协议。

在一些应用中,每个数据包都是有自定义的结构的,这些结构就被称为自定义的协议。
其中最常见的封装就是一个协议头用以说明包类型和长度,然后是包数据,最后是一个包尾里面存放用于校验数据的CRC码等。但是对于面向伪流的协议来说,这样的结构会带来一个比较头疼的问题——粘包,即多个小的数据包会被连在一起被接收端接收,然后就是头疼和麻烦的拆包过程。而如果使用了散播和聚集I/O方法,那么所有的工作就简单了,可以定义一个3元素的WSABUF结构数组分别发送包头/包数据/包尾。然后接收端先用一个WSABUF接收包头,然后根据包头指出的长度准备包数据/包尾的缓冲,再用2元素的WSABUF接收剩下的数据。同时对于使用了IOCP+重叠I/O的通讯应用来说,在复杂的多线程环境下散播和聚集I/O方法依然可以很可靠的工作。

下面是一个使用聚合散播的服务器的例子:

#include "MSScokFunc.h"
#include <stdio.h>
#include "MSScokFunc.h"
#include <process.h>

typedef struct _tag_CLIENT_OVERLAPPED
{
    OVERLAPPED overlapped;
    char *pBuf;
    size_t dwBufSize;
    DWORD dwFlag;
    DWORD dwTransBytes;
    long lNetworkEvents;
    SOCKET sListen;
    SOCKET sClient;
}CLIENT_OVERLAPPED, *LPCLIENT_OVERLAPPED;

unsigned int WINAPI IOCPThreadProc(LPVOID lpParameter)
{
    HANDLE hIOCP = *(HANDLE*)lpParameter;
    DWORD dwNumberOfTransfered;
    ULONG uKey = 0;
    LPOVERLAPPED lpOverlapped = NULL;
    LPCLIENT_OVERLAPPED lpoc = NULL;

    BOOL bLoop = TRUE;
    while (bLoop)
    {
        GetQueuedCompletionStatus(hIOCP, &dwNumberOfTransfered, &uKey, &lpOverlapped, INFINITE);
        lpoc = CONTAINING_RECORD(lpOverlapped, CLIENT_OVERLAPPED, overlapped);
        switch (lpoc->lNetworkEvents)
        {
        case FD_CLOSE:
            {
                if (lpoc->sListen == INVALID_SOCKET)
                {
                    bLoop = FALSE;
                }else
                {
                    //再次提交AcceptEx
                    printf("线程(%08x)回收socket(%08x)成功\n", GetCurrentThreadId(), lpoc->sClient);
                    lpoc->dwBufSize = 2 * (sizeof(SOCKADDR_IN) + 16);
                    lpoc->lNetworkEvents = FD_ACCEPT;
                    lpoc->pBuf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 2 * (sizeof(SOCKADDR_IN) + 16));

                    g_MsSockFunc.AcceptEx(lpoc->sListen, lpoc->sClient, lpoc->pBuf, 0, sizeof(SOCKADDR_IN) + 16, sizeof(SOCKADDR_IN) + 16, &lpoc->dwTransBytes, &lpoc->overlapped);

                }
            }
            break;

        case FD_ACCEPT:
            {
                SOCKADDR_IN *pRemoteSockAddr = NULL;
                int nRemoteSockAddrLength = 0;
                SOCKADDR_IN *pLocalSockAddr = NULL;
                int nLocalSockAddrLength = 0;

                g_MsSockFunc.GetAcceptExSockAddrs(lpoc->pBuf, 0, sizeof(SOCKADDR_IN) + 16, sizeof(SOCKADDR_IN) + 16, (LPSOCKADDR*)&pLocalSockAddr, &nRemoteSockAddrLength, (LPSOCKADDR*)&pRemoteSockAddr, &nRemoteSockAddrLength);
                printf("有客户端[%s:%d]连接进来,当前通信地址[%s:%d]......\n", inet_ntoa(pRemoteSockAddr->sin_addr), ntohs(pRemoteSockAddr->sin_port), inet_ntoa(pLocalSockAddr->sin_addr), ntohs(pLocalSockAddr->sin_port));

                //设置当前通信用socket继承监听socket的相关属性
                int nRet = ::setsockopt(lpoc->sClient, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT, (char *)&lpoc->sListen, sizeof(SOCKET));

                //投递WSARecv消息, 这里仅仅是为了发起WSARecv调用而不接受数据,设置缓冲为0可以节约内存
                WSABUF buf = {0, NULL};
                HeapFree(GetProcessHeap(), 0, lpoc->pBuf);
                lpoc->lNetworkEvents = FD_READ;
                lpoc->pBuf = NULL;
                lpoc->dwBufSize = 0;

                WSARecv(lpoc->sClient, &buf, 1, &lpoc->dwTransBytes, &lpoc->dwFlag, &lpoc->overlapped, NULL);
            }
            break;

        case FD_READ:
            {
                WSABUF buf[2] = {0};
        DWORD dwBufLen = 0;
                buf[0].buf = (char*)&dwBufLen;
                buf[0].len = sizeof(DWORD);

                //当调用此处的时候已经完成了接收数据的操作,此时只要调用WSARecv将数据放入指定内存即可,这个时候不需要使用重叠IO操作了
                int nRet = WSARecv(lpoc->sClient, buf, 1, &lpoc->dwTransBytes, &lpoc->dwFlag, NULL, NULL);

                DWORD dwBufSize = dwBufLen;
                buf[1].buf = (char*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwBufSize);
                buf[1].len = dwBufLen;

                WSARecv(lpoc->sClient, &buf[1], 1, &lpoc->dwTransBytes, &lpoc->dwFlag, NULL, NULL);

                printf("client>%s\n", buf[1].buf);

                lpoc->pBuf = buf[1].buf; //这块内存将在FD_WRITE事件中释放
                lpoc->dwBufSize = buf[1].len;

                lpoc->lNetworkEvents = FD_WRITE;
                lpoc->dwFlag = 0;

                WSASend(lpoc->sClient, buf, 2, &lpoc->dwTransBytes, lpoc->dwFlag, &lpoc->overlapped, NULL);
            }
            break;

        case FD_WRITE:
            {
                printf("线程[%08x]完成事件(WSASend),缓冲(%d),长度(%d), 发送长度(%d)\n", GetCurrentThreadId(), lpoc->pBuf, lpoc->dwTransBytes, dwNumberOfTransfered);
                HeapFree(GetProcessHeap(), 0, lpoc->pBuf);
                lpoc->dwFlag = 0;
                lpoc->lNetworkEvents = FD_CLOSE;
                lpoc->pBuf = NULL;

                shutdown(lpoc->sClient, SD_BOTH);
                g_MsSockFunc.DisConnectEx(lpoc->sClient, &lpoc->overlapped, TF_REUSE_SOCKET, 0);
            }
            break;
        }
    }

    printf("线程[%08x] 退出.......\n", GetCurrentThreadId());
    return 0;
}

这里没有展示main函数的内容,main函数的内容与之前的相似,在开始的时候进行一些初始化,在结束的时候进行资源的回收操作。这里需要注意的是在main函数中给定的退出条件:

CLIENT_OVERLAPPED CloseOverlapped = {0};
    CloseOverlapped.lNetworkEvents = FD_CLOSE;
    CloseOverlapped.sListen = INVALID_SOCKET;

    for (int i = 0; i < 2 * si.dwNumberOfProcessors; i++)
    {
        PostQueuedCompletionStatus(hIOCP, 0, NULL, (LPOVERLAPPED)&CloseOverlapped.overlapped);
    }

在线程中,首先判断当前完成事件的类型

提高服务程序性能的一般方式

对于实际的面向网络的服务(主要指使用TCP协议的服务应用)来说,大致可以分为两大类:连接密集型/传输密集型。连接密集型服务的主要设计目标就是以最大的性能响应尽可能多的客户端连接请求,比如一个Web服务
传输密集型服务的设计目标就是针对每个已有连接做到尽可能大的数据传输量,有时以牺牲连接数为代价,比如一个FTP服务器。而针对像UDP这样无连接的协议,我们认为它主要负责传输数据,所以这里把它归结为传输密集型

对于面向连接的服务(主要指使用TCP协议的服务)来说,主要设计考虑的是如下几个环节:

  1. 接受连接
  2. 数据传输
  3. IOCP+线程池接力
  4. 其它性能优化考虑

接收连接

接收连接一般都采用AcceptEx的重叠IO方式来进行等待,并且一般需要加上SOCKET池的机制,开始时可能准备的AcceptEx数量可能会不足,此时可以另起线程在监听SOCKET句柄上等待FD_ACCEPT事件来决定何时再次投递大量的AcceptEx进行等待
当然再次调用AcceptEx时需要创建大量的SOCKET句柄,这个工作最好不要在IOCP线程池线程中进行,以防创建过程耗时而造成现有SOCKET服务响应性能下降。

最终需要注意的就是,任何处于"等待"AcceptEx状态的SOCKET句柄都不要直接调用closesocket,这样会造成严重的内核内存泄漏。应该先关闭监听套接字,防止在关闭SOCKET的时候有客户端连接进来,然后再调用closesocket来断开。

数据传输

在这个环节中可以将SOCKET句柄上的接收和发送数据缓冲区设置为0,这样可以节约系统内核的缓冲,尤其是在管理大量连接SOCKET句柄的服务中,因为一个句柄上这个缓冲大小约为17K,当SOCKET句柄数量巨大时这个缓冲耗费还是惊人的。设置缓冲为0的方法如下:

int iBufLen= 0;
setsockopt(skAccept,SOL_SOCKET,SO_SNDBUF,(const char*)&iBufLen,sizeof(int));
setsockopt(skAccept,SOL_SOCKET,SO_RCVBUF,(const char*)&iBufLen,sizeof(int));

IOCP + 线程池

为了性能的考虑,一般网络服务应用都使用IOCP+重叠I/O+SOCKET池的方式来实现具体的服务应用。这其中需要注意的一个问题就是IOCP线程池中的线程不要用于过于耗时或复杂的操作,比如:访问数据库操作,存取文件操作,复杂的数据计算操作等。这些操作因为会严重占用SOCKET操作的IOCP线程资源因此会极大降低服务应用的响应性能。但是很多实际的应用中确实需要在服务端进行这些操作,那么如何来平衡这个问题呢? 这时就需要另起线程或线程池来接力IOCP线程池的工作。比如在WSARecv的完成通知中,将接收到的缓冲直接传递给QueueUserWorkItem线程池方法,启动线程池中的线程去处理数据,而IOCP线程池则继续专心于网络服务。这也就是一般书上都会说的专门的线程干专门的事

其他的性能考虑

其他的性能主要是使用之前提到的聚合与散播机制,或者对函数和代码执行流程进行优化

关于IOCP 聚合与散播的代码全放在码云上了: 示例代码

<hr />

上一篇下一篇

猜你喜欢

热点阅读