之前我们写了一些简单的网络程序,使用的都是基本的socket,这些程序有一个特点就是都是阻塞执行的。何谓阻塞呢?就是函数不会立即返回,直到等到结果才返回,像 acceptrecv 就属于阻塞函数。

这种方式在人数(客户端)较少的情境下没有什么问题,但要是人数比较多了服务器就不能及时处理后面的客户请求了。若第一个连接的用户受理时间为 1 秒,那么第一百个连接的用户就得等 100秒。一个方法是可以为每个连接进来的用户开启一个线程进行处理,但开启太多的线程得付出很大的代价,所以这种方式也仅适用于用户较少的情况。

基于此,Windows 提供了一些网络模型来提高服务器同时接受客户端的能力。这里将会以几篇来分别讲解几个常用的模型:

  • select
  • 事件选择
  • 重叠 IO
  • IOCP 完成端口

这些模型从弱到强,从易到难。select 用来解决基本 socket 多线程的问题,适合 60 多个用户。事件选择异步了接受操作,适合 300-500 个用户。重叠 IO 就完全非阻塞了,适合上千个用户。IOCP 更加强大,可以支持万级别的用户,QQ 那些就使用的这个。

还有 Linux 上的 epoll 模型,这个和 IOCP 是一个级别的,因为我没怎么接触 Linux,所以这里就主要说 Windows 上的。

这里首先来说最简单的 select 模型,在此之前,我先画了一张图:

flow chart

从图中可以看出有两个部分是阻塞的,一个是等待数据到来,一个是将数据从内核复制到程序缓冲区。

基本的 socket 这两个部分都是阻塞的,若是使用多线程的方式来处理,我们得用 n+1 个线程来服务 n 个客户。而申请大量线程代价极大且效率非佳,select 的目的就是来解决基本 socket 的多线程问题,它也是阻塞的,却只需要用 2 个线程来服务 n 个客户。

之所以叫 select 模型,是因为这个模型主要是通过一个叫 select 的函数来完成的,其原型如下:

int select (
    int nfds,                          // 在 Windows 仅仅是为了保持和别的系统的兼容性,无意义
    fd_set FAR * readfds,              // 若有待读取数据,则注册到 fd_set
    fd_set FAR * writefds,             // 若可传输无阻塞数据,则注册到 fd_set
    fd_set FAR * exceptfds,            // 若发生异常,则注册到 fd_set
    const struct timeval FAR * timeout // 超时时间
);

其中,fd_set 称为文件描述符,是一个结构体:

typedef struct fd_set {
        u_int fd_count;                 /* how many are SET? */
        SOCKET  fd_array[FD_SETSIZE];   /* an array of SOCKETs */
} fd_set;

select() 可以将多个文件描述符集中到一起统一监视,这里 fd_count 表示套接字的数量,可以把需要监视的 socket 存到 fd_array 中,FD_SETSIZE 是一个宏,表示最多可监视的 socket 数量,现在这个宏定义为 64,所以最多可以监视 64 个套接字。

因为 fd_set 是以位为单位进行操作的,所以 OS 提供了一些宏来方便操作:

FD_ZERO(fd_set * fdset)           // 将 fd_set 的所有位置 0
FD_SET(int fd, fd_set * fdset)    // 在 fdset 中注册文件描述符 fd 的信息
FD_CLR(int fd, fd_set * fdset)    // 从 fdset 中清除文件描述符 fd 的信息
FD_ISSET(int fd, fd_set * fdset)  // 若 fdset 中包含文件描述符 fd 的信息,则为 true

比如,往文件 fd_set 中注册 5 个套接字,那么表示和操作就像这样:

fd1 fd2 fd3 fd4 fd5
 1   0   0   1   0  // fd_set 中存放的是位数组

fd_set reads;
FD_ZERO(&reads);     // 0 0 0 0 0
FD_SET(1, &reads);   // 0 1 0 0 0
FD_SET(4, &reads);   // 0 1 0 0 1
FD_ISSET(1, &reads); // 返回 true
FD_ISSET(2, &reads); // 返回 false
FD_CLR(1, &reads);   // 0 0 0 0 1

select 函数的最后一个参数用于指定超时时间,其原型如下:

struct timeval {
    long tv_sec;  /* seconds */
    long tv_usec; /* and microseconds */
};

tv_sec 用于指定秒数,tv_usec 用于指定微秒。这个结构体被 typedefTIMEVAL

select 函数只有在监视的文件描述符发生变化时才返回,是以需要指定超时时间来防止一直阻塞,若不想使用,可以传入 NULL

现在来看 select 函数的返回值,若发生错误,返回 -1;若超时,返回 0;若执行后返回大于 0 的整数,就说明相应的文件描述符发生了变化,它会把这些发生变化的位置 1,其余全置为 0。所以若 fd1,fd3 位有变化,那么就是这样:

fd1 fd2 fd3 fd4 fd5
 0   0   0   0   0

返回 1 0 1 0 0

现在以 select 来完成服务端,首先定义一个 CSelect 类:

class CSelect
{
public:
    CSelect(int port);
    ~CSelect();
    void Accept();  // 接受连接

private:
    void InitSock();  // 初始化套接字
    void RequestHandler();  // 处理请求的线程

private:
    WSADATA m_wsaData;
    SOCKET m_listenSock, m_clntSock;
    SOCKADDR_IN m_listenAddr, m_clntAddr;
    fd_set m_fdReads;  // 文件描述符
    TIMEVAL m_timeout;  // 超时时间
    int m_nPort;  // 端口
};

在构造函数中,保存端口和初始化套接字:

CSelect::CSelect(int port)
    : m_nPort(port)
{
    InitSock();
}

InitSock() 用于初始化套接字,这些操作已是老生常谈:

void CSelect::InitSock()
{
    // 初始化 winsock 库
    int ret = WSAStartup(MAKEWORD(2, 2), &m_wsaData);
    assert(ret == 0);

    // 创建监听套接字
    m_listenSock = socket(PF_INET, SOCK_STREAM, 0);

    // 设置地址信息
    memset(&m_listenAddr, 0, sizeof(m_listenAddr));
    m_listenAddr.sin_family = AF_INET;
    m_listenAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    m_listenAddr.sin_port = htons(m_nPort);

    // 绑定
    ret = bind(m_listenSock, (SOCKADDR *)&m_listenAddr, sizeof(m_listenAddr));
    assert(ret != SOCKET_ERROR);

    // 监听
    ret = listen(m_listenSock, 5);
    assert(ret != SOCKET_ERROR);

    FD_ZERO(&m_fdReads);  // 将文件描述符所有位置 0
}

需要注意的是,在最后我们还将文件描述符的位全部置 0。然后在析构函数中关闭释放:

CSelect::~CSelect()
{
    closesocket(m_listenSock);
    WSACleanup();
}

现在来看第一个阻塞的部分,即等待数据到来:

void CSelect::Accept()
{
    int adrSize = sizeof(m_clntAddr);

    // 开启线程,频繁地检测 fd_set 中的数组是否有请求
    auto fu = std::async(std::launch::async, std::bind(&CSelect::RequestHandler, this));

    // 在主线程中处理连接请求
    while (true)
    {
        // 当 fd_set 装满时,等待一会再继续尝试
        if (m_fdReads.fd_count >= FD_SETSIZE)
        {
            Sleep(100);
            continue;
        }

        // 当有一个客户端进行连接时,主线程的 Accept 会进行返回
        m_clntSock = accept(m_listenSock, (SOCKADDR *)&m_clntAddr, &adrSize);
        FD_SET(m_clntSock, &m_fdReads);  // 设置文件描述符
        printf("connected client:%d----当前连接数:%d\n", m_clntSock, m_fdReads.fd_count);
    }
}

首先,开启线程 RequestHandler 去频繁地检测文件描述符中是否有客户端的请求信息,起初自然是没有信息的,因为此时没有任何套接字注册进去。

其次,在主线程中进行接收连接请求,fd_set 装满便无法再注册套接字,因此需要先检测一下。当有客户端连接进来的时候,使用 FD_SET 将其注册到文件描述符中,打印下方便查看。

最后,也是最重要的部分,即第二个阻塞,将数据从内核复制到程序缓冲区中。

void CSelect::RequestHandler()
{
    fd_set copyReads;
    while (true)
    {
        copyReads = m_fdReads;
        m_timeout.tv_sec = 5;
        m_timeout.tv_usec = 0;

        //设置检查范围及超时
        int fdNum = select(0, &copyReads, NULL, NULL, &m_timeout);

        if (fdNum != SOCKET_ERROR)
        {
            for (unsigned i = 0; i < m_fdReads.fd_count; ++i)
            {
                if (FD_ISSET(m_fdReads.fd_array[i], &copyReads))
                {
                    char buf[BUF_SIZE] = "";
                    int recvLen = recv(m_fdReads.fd_array[i], buf, BUF_SIZE, 0);

                    // 客户端退出或 socket 错误,则关闭
                    if (recvLen == 0 || recvLen == SOCKET_ERROR)
                    {
                        closesocket(m_fdReads.fd_array[i]);
                        FD_CLR(m_fdReads.fd_array[i], &m_fdReads);
                    }
                    else
                    {
                        printf("received from client %d: %s \n", m_fdReads.fd_array[i], buf);
                        send(m_fdReads.fd_array[i], buf, recvLen, 0);
                    }
                }
            }
        }
    }
}

每次执行 select 函数后都会改变发生变化的文件描述符的值,为了保护初始值,在第 3 行声明的一个 copyReads,在每次的开始将 m_fdReads 复制到 copyReads 中操作。

接下来设置了超时时间为 5 秒,使用 select 函数监视文件描述符。当文件描述符中的套接字有了请求,文件描述符就会将发生变化的位置 1 返回。

遍历文件描述符中的每一项,使用 FD_ISSET 来检测结果,当客户端退出或 socket 错误时,从文件描述符中关闭 socket 并清除记录。否则,做相应的消息处理,这里将接收到的数据再次发送给客户端。

现在,就可以测试下了:

#include "CSelect.h"

int main() {
    CSelect select(8000);
    select.Accept();
}

测试客户端是用的以前写的 TCP 客户端,非常简单,就不写了,我用脚本开了 60 多个客户端,测试结果如图:

Output

因为客户端只是简单地发送接收一条数据便已关闭,所以这里连接数多数都为 1。

使用 select 写的服务器要比我们以前用基本 socket 写的效率高得多,而且只用了 2 条线程。但它性能终究还是有限,因为说到底还是阻塞的,占用的还是程序自己的时间片,若人数在 60 左右,大家可以用这个。

虽然 select 模型比较简单,但和后面的各个模型都是有联系的,只有把这个先理解了才能充分理解后面的,才能理解这个为什么比那个好,为什么这个性能要高,什么时候用哪个好。

这篇就先到这儿了,最近比较忙,后面的就看时间更新了。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.