网络编程基础漫谈(三)之 select 函数重难点解析 乙篇

接上一篇《网络编程基础漫谈(三)之 select 函数重难点解析 甲篇》。

关于上述代码在实际开发中有几个需要注意的事项,这里逐一来说明一下:

1. select 函数调用前后会修改 readfds、writefds 和 exceptfds 这三个集合中的内容(如果有的话),所以如果您想下次调用 select 复用这个变量,记得在下次调用前再次调用 select 前先使用 FD_ZERO 将集合清零,然后调用 FD_SET 将需要检测事件的 fd 再次添加进去

select 函数调用之后,readfdswritefdsexceptfds 这三个集合中存放的不是我们之前设置进去的 fd,而是有相关有读写或异常事件的 fd,也就是说 select 函数会修改这三个参数的内容,这也要求我们当一个 fd_set 被 select 函数调用后,这个 fd_set 就已经发生了改变,下次如果我们需要使用它,必须使用 FD_ZERO 宏先清零,再重新将我们关心的 fd 设置进去。这点我们从 FD_ISSET 源码也可以看出来:

 #define __FD_ISSET(d, set) \
    ((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

如果调用 select 函数之后没有改变 fd_set 集合,那么即使某个 socket 上没有事件,调用 select 函数之后我们用 FD_ISSET 检测,会原路得到原来设置上去的 socket。这是很多初学者在学习 select 函数容易犯的一个错误,我们通过一个示例来验证一下,这次我们把 select 函数用在客户端。

/**
 * 验证调用select后必须重设fd_set,select_client.cpp
 * zhangyl 2018.12.24
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <errno.h>
#include <string.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT     3000

int main(int argc, char* argv[])
{
    //创建一个socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }

    //连接服务器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
    {
        std::cout << "connect socket error." << std::endl;
        close(clientfd);
        return -1;
    }

    fd_set readset;
    FD_ZERO(&readset);

    //将侦听socket加入到待检测的可读事件中去
    FD_SET(clientfd, &readset); 
    timeval tm;
    tm.tv_sec = 5;
    tm.tv_usec = 0; 
    int ret;
    int count = 0;
    fd_set backup_readset;
    memcpy(&backup_readset, &readset, sizeof(fd_set));
    while (true)
    {
        if (memcmp(&readset, &backup_readset, sizeof(fd_set)) == 0)
        {
            std::cout << "equal" << std::endl;
        }
        else
        {
            std::cout << "not equal" << std::endl;
        }

        //暂且只检测可读事件,不检测可写和异常事件
        ret = select(clientfd + 1, &readset, NULL, NULL, &tm);
        std::cout << "tm.tv_sec: " << tm.tv_sec << ", tm.tv_usec: " << tm.tv_usec << std::endl;
        if (ret == -1)
        {
            //除了被信号中断的情形,其他情况都是出错
            if (errno != EINTR)
                break;
        } else if (ret == 0){
            //select函数超时
            std::cout << "no event in specific time interval, count:" << count << std::endl;
            ++count;
            continue;
        } else {
            if (FD_ISSET(clientfd, &readset))
            {
                //检测到可读事件
                char recvbuf[32];
                memset(recvbuf, 0, sizeof(recvbuf));
                //假设对端发数据的时候不超过31个字符。
                int n = recv(clientfd, recvbuf, 32, 0);
                if (n < 0)
                {
                    //除了被信号中断的情形,其他情况都是出错
                    if (errno != EINTR)
                        break;
                } else if (n == 0) {
                    //对端关闭了连接
                    break;
                } else {
                    std::cout << "recv data: " << recvbuf << std::endl;
                }
            }
            else 
            {
                std::cout << "other socket event." << std::endl;
            }
        }
    }       

    //关闭socket
    close(clientfd);

    return 0;
}

在 shell 窗口输入以下命令编译程序产生可执行文件 select_client

g++ -g -o select_client select_client.cpp

这次产生的是客户端程序,服务器程序我们这里使用 Linux nc 命令来模拟一下,由于客户端连接的是 127.0.0.1:3000 这个地址和端口号,所以我们在另外一个shell 窗口的 nc 命令的参数可以这么写:

nc -v -l 0.0.0.0 3000

执行效果如下:接着我们启动客户端 select_client

[root@myaliyun testsocket]# ./select_client 

需要注意的是,这里我故意将客户端代码中 select 函数的超时时间设置为5秒,以足够我们在这 5 秒内给客户端发一个数据。如果我们在 5 秒内给客户端发送 hello 字符串:

客户端输出如下:

[root@myaliyun testsocket]# ./select_client 
equal
recv data: hello

...部分数据省略...
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31454
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31455
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31456
not equal
tm.tv_sec: 0, tm.tv_usec: 0
no event in specific time interval, count:31457
...部分输出省略...

除了第一次 select_client 会输出 equal 字样,后面再也没输出,而 select 函数以后的执行结果也是超时,即使此时服务器端再次给客户端发送数据。因此验证了:select 函数执行后,确实会对三个参数的 fd_set 进行修改select 函数修改某个 fd_set 集合可以使用如下两张图来说明一下:

因此在调用 select 函数以后, 原来位置的的标志位可能已经不复存在,这也就是为什么我们的代码中调用一次 select 函数以后,即使服务器端再次发送数据过来,select 函数也不会再因为存在可读事件而返回了,因为第二次 clientfd 已经不在那个 read_set 中了。因此如果复用这些 fd_set 变量,必须按上文所说的重新清零再重新添加关心的 socket 到集合中去。

2. select 函数也会修改 timeval 结构体的值,这也要求我们如果像复用这个变量,必须给 timeval 变量重新设置值。

注意观察上面的例子的输出,我们在调用 select 函数一次之后,变量 tv 的值也被修改了。具体修改成多少,得看系统的表现。当然这种特性却不是跨平台的,在 Linux 系统中是这样的,而在其他操作系统上却不一定是这样(Windows 上就不会修改这个结构体的值),这点在 Linux man 手册 select 函数的说明中说的很清楚:

On  Linux,  select()  modifies timeout to reflect the amount
of time not slept; most other implementations do not do this.
(POSIX.1-2001 permits either behavior.)  This causes problems 
both when Linux code which reads timeout is ported to  other 
operating systems, and when code is ported to Linux that reuses
a struct timeval for multiple select()s in a loop without
reinitializing it.  Consider timeout to be undefined after
select() returns.

由于不同系统的实现不一样,man 手册的建议将 select 函数修改 timeval 结构体的值的行为当作是未定义的,言下之意是如果你要下次使用 select 函数复用这个变量时,记得重新赋值。这是 select 函数需要注意的第二个地方。

3. select 函数的 timeval 结构体的 tv_sec 和 tv_sec 如果两个值设置为 0,即检测事件总时间设置为0,其行为是 select 会检测一下相关集合中的 fd,如果没有需要的事件,则立即返回

我们将上述 select_client.cpp 修改一下,修改后的代码如下:

   /**
    * 验证select时间参数设置为0,select_client_tv0.cpp
    * zhangyl 2018.12.25
    */
   #include <sys/types.h> 
   #include <sys/socket.h>
   #include <arpa/inet.h>
   #include <unistd.h>
   #include <iostream>
   #include <string.h>
   #include <errno.h>
   #include <string.h>

   #define SERVER_ADDRESS "127.0.0.1"
   #define SERVER_PORT     3000

   int main(int argc, char* argv[])
   {
       //创建一个socket
       int clientfd = socket(AF_INET, SOCK_STREAM, 0);
       if (clientfd == -1)
       {
           std::cout << "create client socket error." << std::endl;
           return -1;
       }

       //连接服务器
       struct sockaddr_in serveraddr;
       serveraddr.sin_family = AF_INET;
       serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
       serveraddr.sin_port = htons(SERVER_PORT);
       if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
       {
           std::cout << "connect socket error." << std::endl;
           close(clientfd);
           return -1;
       }

       int ret;
       while (true)
       {
           fd_set readset;
           FD_ZERO(&readset);
           //将侦听socket加入到待检测的可读事件中去
           FD_SET(clientfd, &readset); 
           timeval tm;
           tm.tv_sec = 0;
           tm.tv_usec = 0; 

           //暂且只检测可读事件,不检测可写和异常事件
           ret = select(clientfd + 1, &readset, NULL, NULL, &tm);
           std::cout << "tm.tv_sec: " << tm.tv_sec << ", tm.tv_usec: " << tm.tv_usec << std::endl;
           if (ret == -1)
           {
               //除了被信号中断的情形,其他情况都是出错
               if (errno != EINTR)
                   break;
           } else if (ret == 0){
               //select函数超时
               std::cout << "no event in specific time interval." << std::endl;
               continue;
           } else {
               if (FD_ISSET(clientfd, &readset))
               {
                   //检测到可读事件
                   char recvbuf[32];
                   memset(recvbuf, 0, sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 32, 0);
                   if (n < 0)
                   {
                       //除了被信号中断的情形,其他情况都是出错
                       if (errno != EINTR)
                           break;
                   } else if (n == 0) {
                       //对端关闭了连接
                       break;
                   } else {
                       std::cout << "recv data: " << recvbuf << std::endl;
                   }
               }
               else 
               {
                   std::cout << "other socket event." << std::endl;
               }
           }
       }       


       //关闭socket
       close(clientfd);

       return 0;
   }

执行结果确实如我们预期的,这里 select 函数只是简单地检测一下 clientfd,并不会等待固定的时间,然后立即返回。

4. 如果将 select 函数的 timeval 参数设置为 NULL,则 select 函数会一直阻塞下去,直到我们需要的事件触发。

我们将上述代码再修改一下:

   /**
    * 验证select时间参数设置为NULL,select_client_tvnull.cpp
    * zhangyl 2018.12.25
    */
   #include <sys/types.h> 
   #include <sys/socket.h>
   #include <arpa/inet.h>
   #include <unistd.h>
   #include <iostream>
   #include <string.h>
   #include <errno.h>
   #include <string.h>

   #define SERVER_ADDRESS "127.0.0.1"
   #define SERVER_PORT     3000

   int main(int argc, char* argv[])
   {
       //创建一个socket
       int clientfd = socket(AF_INET, SOCK_STREAM, 0);
       if (clientfd == -1)
       {
           std::cout << "create client socket error." << std::endl;
           return -1;
       }

       //连接服务器
       struct sockaddr_in serveraddr;
       serveraddr.sin_family = AF_INET;
       serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
       serveraddr.sin_port = htons(SERVER_PORT);
       if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
       {
           std::cout << "connect socket error." << std::endl;
           close(clientfd);
           return -1;
       }

       int ret;
       while (true)
       {
           fd_set readset;
           FD_ZERO(&readset);
           //将侦听socket加入到待检测的可读事件中去
           FD_SET(clientfd, &readset); 
           //timeval tm;
           //tm.tv_sec = 0;
           //tm.tv_usec = 0;   

           //暂且只检测可读事件,不检测可写和异常事件
           ret = select(clientfd + 1, &readset, NULL, NULL, NULL);
           if (ret == -1)
           {
               //除了被信号中断的情形,其他情况都是出错
               if (errno != EINTR)
                   break;
           } else if (ret == 0){
               //select函数超时
               std::cout << "no event in specific time interval." << std::endl;
               continue;
           } else {
               if (FD_ISSET(clientfd, &readset))
               {
                   //检测到可读事件
                   char recvbuf[32];
                   memset(recvbuf, 0, sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 32, 0);
                   if (n < 0)
                   {
                       //除了被信号中断的情形,其他情况都是出错
                       if (errno != EINTR)
                           break;
                   } else if (n == 0) {
                       //对端关闭了连接
                       break;
                   } else {
                       std::cout << "recv data: " << recvbuf << std::endl;
                   }
               }
               else 
               {
                   std::cout << "other socket event." << std::endl;
               }
           }
       }       


       //关闭socket
       close(clientfd);

       return 0;
   }

我们先在另外一个 shell 窗口用 nc 命令模拟一个服务器,监听的 ip 地址和端口号是 0.0.0.0:3000

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000

然后回到原来的 shell 窗口,编译上述 select_client_tvnull.cpp,并使用 gdb 运行程序,这次使用 gdb 运行程序的目的是为了当程序“卡”在某个位置时,我们可以使用 Ctrl + C 把程序中断下来看看程序阻塞在哪个函数调用处:

[root@myaliyun testsocket]# g++ -g -o select_client_tvnull select_client_tvnull.cpp 
[root@myaliyun testsocket]# gdb select_client_tvnull
Reading symbols from /root/testsocket/select_client_tvnull...done.
(gdb) r
Starting program: /root/testsocket/select_client_tvnull 
^C
Program received signal SIGINT, Interrupt.
0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
Missing separate debuginfos, use: debuginfo-install glibc-2.17-196.el7_4.2.x86_64 libgcc-4.8.5-16.el7_4.1.x86_64 libstdc++-4.8.5-16.el7_4.1.x86_64
(gdb) bt
#0  0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
#1  0x0000000000400c75 in main (argc=1, argv=0x7fffffffe5f8) at select_client_tvnull.cpp:51
(gdb) c
Continuing.
recv data: hello

^C
Program received signal SIGINT, Interrupt.
0x00007ffff72e7783 in __select_nocancel () from /lib64/libc.so.6
(gdb) c
Continuing.
recv data: world

如上输出结果所示,我们使用 gdb 的 r 命令(run)将程序跑起来后,程序卡在某个地方,我们按 Ctrl + C(代码中的 ^C)中断程序后使用 bt 命令查看当前程序的调用堆栈,发现确实阻塞在 select 函数调用处;接着我们在服务器端给客户端发送一个 hello 数据:

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:55968.
hello

客户端收到数据后,select 函数满足条件,立即返回,并将数据输出来后继续进行下一轮 select 检测,我们使用 Ctrl + C 将程序中断,发现程序又阻塞在 select 调用处;输入 c 命令(continue)让程序继续运行, 此时,我们再用服务器端给客户端发送 world 字符串,select 函数再次返回,并将数据打印出来,然后继续进入下一轮 select 检测,并继续在 select 处阻塞。

[root@myaliyun ~]# nc -v -l 0.0.0.0 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Listening on 0.0.0.0:3000
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:55968.
hello
world

5. 在 Linux 平台上,select 函数的第一个参数必须设置成需要检测事件的所有 fd 中的最大值加1。所以上文中 select_server.cpp 中,每新产生一个 clientfd,我都会与当前最大的 maxfd 作比较,如果大于当前的 maxfd 则将 maxfd 更新成这个新的最大值。其最终目的是为了在 select 调用时作为第一个参数(加 1)传进去。

在 Windows 平台上,select 函数的第一个值传任意值都可以,Windows 系统本身不使用这个值,只是为了兼容性而保留了这个参数,但是在实际开发中为了兼容跨平台代码,也会按惯例,将这个值设置为最大 socket 加 1。这点请读者注意。

以上是我总结的 Linux 下 select 使用的五个注意事项,希望读者能理解它们。

实际的开发中,关于 select 函数的重难点远不止这么多,限于公众号文章篇幅,我们将在下一篇《网络编程基础漫谈(三)之 select 函数重难点解析 丙篇》中继续讲解 select 函数相关的知识。

原文发布于微信公众号 - 高性能服务器开发(easyserverdev)

原文发表时间:2018-12-25

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券