大数跨境
0
0

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

网络编程基础漫谈(三)之 select 函数重难点解析 乙篇 CppGuide
2018-12-25
2
导读:select 函数的五个注意事项。

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

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

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

select 函数调用之后,readfdswritefds 和 exceptfds 这三个集合中存放的不是我们之前设置进去的 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, NULLNULL, &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, 0sizeof(recvbuf));
                //假设对端发数据的时候不超过31个字符。
                int n = recv(clientfd, recvbuf, 320);
                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
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, NULLNULL, &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, 0sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 320);
                   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, NULLNULLNULL);
           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, 0sizeof(recvbuf));
                   //假设对端发数据的时候不超过31个字符。
                   int n = recv(clientfd, recvbuf, 320);
                   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 函数相关的知识。


相关阅读

网络编程基础漫谈(一)之 bind 函数

网络编程基础漫谈(二)之 socket 的阻塞模式和非阻塞模式


欢迎关注公众号『easyserverdev』。如果有任何技术或者职业方面的问题需要我提供帮助,可通过这个公众号与我取得联系,同时,您也可以加入我的QQ群 578019391。此公众号不仅分享高性能服务器开发经验和故事,同时也免费为广大技术朋友提供技术答疑和职业解惑,您有任何问题都可以在微信公众号直接留言,我会尽快回复您。



【声明】内容源于网络
0
0
CppGuide
专注于高质量高性能C++开发,站点:cppguide.cn
内容 1260
粉丝 0
CppGuide 专注于高质量高性能C++开发,站点:cppguide.cn
总阅读783
粉丝0
内容1.3k