网络模型之select

Posted CPP编程客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了网络模型之select相关的知识,希望对你有一定的参考价值。

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

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

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

  • select

  • 事件选择

  • 重叠IO

  • IOCP完成端口

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

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

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

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

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

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

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

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

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

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

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

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

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

 1fd1 fd2 fd3 fd4 fd5
2 1   0   0   1   0  //fd_set中存放的是位数组
3
4fd_set reads;
5FD_ZERO(&reads);  //0 0 0 0 0
6FD_SET(1, &reads);  //0 1 0 0 0
7FD_SET(4, &reads);  //0 1 0 0 1
8FD_ISSET(1, &reads);  //返回true
9FD_ISSET(2, &reads);  //返回false
10FD_CLR(1, &reads);  //0 0 0 0 1

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

1struct timeval {
2        long    tv_sec;         /* seconds */
3        long    tv_usec;        /* and microseconds */
4};

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

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

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

1fd1 fd2 fd3 fd4 fd5
2 0   0   0   0   0
3
4返回1 0 1 0 0

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

 1class CSelect
2{

3public:
4    CSelect(int port);
5    ~CSelect();
6    void Accept();  //接受连接
7
8private:
9    void InitSock();  //初始化套接字
10    void RequestHandler();  //处理请求的线程
11
12private:
13    WSADATA m_wsaData;
14    SOCKET m_listenSock, m_clntSock;
15    SOCKADDR_IN m_listenAddr, m_clntAddr;
16    fd_set m_fdReads;  //文件描述符
17    TIMEVAL m_timeout;  //超时时间
18    int m_nPort;  //端口
19};

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

1CSelect::CSelect(int port)
2    : m_nPort(port)
3{
4    InitSock();
5}

InitSock用于初始化套接字,这些操作大家应该都非常熟悉了:

 1void CSelect::InitSock()
2{
3    //初始化winsock库
4    int ret = WSAStartup(MAKEWORD(22), &m_wsaData);
5    assert(ret == 0);
6
7    //创建监听套接字
8    m_listenSock = socket(PF_INET, SOCK_STREAM, 0);
9
10    //设置地址信息
11    memset(&m_listenAddr, 0sizeof(m_listenAddr));
12    m_listenAddr.sin_family = AF_INET;
13    m_listenAddr.sin_addr.s_addr = htonl(INADDR_ANY);
14    m_listenAddr.sin_port = htons(m_nPort);
15
16    //绑定
17    ret = bind(m_listenSock, (SOCKADDR *)&m_listenAddr, sizeof(m_listenAddr));
18    assert(ret != SOCKET_ERROR);
19
20    //监听
21    ret = listen(m_listenSock, 5);
22    assert(ret != SOCKET_ERROR);
23
24    FD_ZERO(&m_fdReads);  //将文件描述符所有位置0
25}

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

1CSelect::~CSelect()
2{
3    closesocket(m_listenSock);
4    WSACleanup();
5}

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

 1void CSelect::Accept()
2{
3    int adrSize = sizeof(m_clntAddr);
4
5    //开启线程,频繁地检测fd_set中的数组是否有请求
6    auto fu = std::async(std::launch::async, std::bind(&CSelect::RequestHandler, this));
7
8    //在主线程中处理连接请求
9    while (true)
10    {
11        //当fd_set装满时,等待一会再继续尝试
12        if (m_fdReads.fd_count >= FD_SETSIZE)
13        {
14            Sleep(100);
15            continue;
16        }
17
18        //当有一个客户端进行连接时,主线程的Accept会进行返回
19        m_clntSock = accept(m_listenSock, (SOCKADDR *)&m_clntAddr, &adrSize);
20        FD_SET(m_clntSock, &m_fdReads);  //设置文件描述符
21        printf("connected client:%d----当前连接数:%d\n", m_clntSock, m_fdReads.fd_count);
22    }
23}

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

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

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

 1void CSelect::RequestHandler()
2{
3    fd_set copyReads;
4    while (true)
5    {
6        copyReads = m_fdReads;
7        m_timeout.tv_sec = 5;
8        m_timeout.tv_usec = 0;
9
10        //设置检查范围及超时
11        int fdNum = select(0, &copyReads, NULLNULL, &m_timeout);
12
13        if (fdNum != SOCKET_ERROR)
14        {
15            for (unsigned i = 0; i < m_fdReads.fd_count; ++i)
16            {
17                if (FD_ISSET(m_fdReads.fd_array[i], &copyReads))
18                {
19                    char buf[BUF_SIZE] = "";
20                    int recvLen = recv(m_fdReads.fd_array[i], buf, BUF_SIZE, 0);
21
22                    //客户端退出或socket错误,则关闭
23                    if (recvLen == 0 || recvLen == SOCKET_ERROR)
24                    {
25                        closesocket(m_fdReads.fd_array[i]);
26                        FD_CLR(m_fdReads.fd_array[i], &m_fdReads);
27                    }
28                    else
29                    {
30                        printf("received from client %d: %s \n", m_fdReads.fd_array[i], buf);
31                        send(m_fdReads.fd_array[i], buf, recvLen, 0);
32                    }
33                }
34            }
35        }
36    }
37}

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

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

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

现在,就可以测试下了:

1#include "CSelect.h"
2
3int main()
4
{
5    CSelect select(8000);
6    select.Accept();
7
8    return 0;
9}

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

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

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

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

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


以上是关于网络模型之select的主要内容,如果未能解决你的问题,请参考以下文章

网络模型之select

IO模型之二-linux网络IO模式select,poll,epoll

Python网络编程之高级篇三

Socket I/O模型之select模型

多路I/O转接之select模型

网络模型之重叠IO