关于Select Model的两篇译文

文章来源

GETTING STARTED WITH THE SELECT MODEL

select模型用于在指定时间内监听用户感兴趣的文件描述符上的可读、可写和异常事件。

为什么会有select模型?

看看以下代码,这在套接字编程中很常见:

int iResult = recv(sock, buffer, 1024);

这行代码被用来接收数据。在socket默认的阻塞模型中,recv函数会阻塞到这个socket连接可读为止。

recv会在数据读入到buffer中以后返回,否则它会永远阻塞在那。这就导致了一个问题,在单线程程序中,如果没有数据被发送过来,这会导致主线程被阻塞,即整个程序都会被阻塞住。而我们期望的是在等待数据发送的期间程序的其他部分仍能够正常被执行。程序不应该在IO操作上阻塞(recv就是一种IO操作)。

这个问题能够在引入多线程后得到解决,但在多个套接字连接的情况下,这不是一个好的选择,而且可扩展性很差。

看看另外一串代码:

int iResult = ioctlsocket(sock, FIOBIO, (unsigned long * ) & ul);
iResult = recv(sock, buffer, 1024);

这一次,无论套接字连接上是否有任何可以接收的数据,recv调用都会立即返回,原因是我们使用ioctlsocket将套接字设置为非阻塞模式。然而,如果你使用这个方式,你会发现recv确实在没有数据的情况下立即返回,但也返回了一个错误:WSAEWOULDBLOCK,这意味着请求的操作没有成功完成。

看到这你可能认为我们可以反复调用recv并检查返回值,直到成功,但这种方式非常有问题,并且成本高昂。我们应该避免定期检查。

select模型就是为了解决上述问题。

选择模型的关键是使用有序的方式来统一管理和调度多个套接字。

看看选择模型的以下序列图。

如上所示,用户首先添加需要I/O操作来进行select的套接字,然后等待两次select系统调用返回。当数据到达时,套接字被激活,选择函数返回。用户线程正式启动读取请求,读取数据并继续执行。

从这个过程来看,对I/O请求使用选择函数与同步模型没有太大区别。甚至还添加了额外的监听套接字和调用select函数的额外操作。然而,使用select后的最大优势是,用户可以在单个线程中同时处理多个套接字I/O请求。

用户可以注册多个套接字,然后不断调用select读取激活的套接字,以实现在同一线程中同时处理多个I/O请求的目的。而在同步双向模型中,这必须通过多线程来实现。

选择过程伪代码如下(只是为了举例说明选择模型的过程):

select(socket);
while (1) {
  sockets = select();
  for (socket in sockets) {
    if (can_read(socket)) {
      read(socket, buffer);
      process(buffer);
    }
  }
}

select模型的相关API

struct timeval {
  long tv_sec; /* second */
  long tv_usec; /* microsecond */
};

#include <sys/select.h>
/*
* @param nfds or maxfdp : 被监控的文件描述符总数,比所有文件描述符集中文件描述符的最大值大一个,因为文件描述符从0开始计算
													(假设是fd依次为1,3,5,则该值为6)
* @param readfds				: 可读事件对应的描述符集。
* @param writefds				: 可写事件对应的描述符集。
* @param errorfds				: 异常事件对应的描述符集。
* @param timeout				: 用于设置选择函数的超时,即告诉内核最多等待多长时间。timeout == NULL 表示等待无限时间。
* @return 
		* 超时返回0
		* 失败发挥-1
		* 成功返回一个大于0的数字,表示就绪的文件描述符的个数
*/
int  select(int nfds, 
           fd_set * readfds, 
           fd_set * writefds, 
           fd_set * errorfds,
           struct timeval * timeout);

// fd_set变量的所有位都设置为0
void FD_ZERO(fd_set *fdset);

//清除文件描述符的一个比特位
void FD_CLR(fd, fd_set *fdset);

//设置文件描述符的一个比特位
void FD_SET(fd, fd_set *fdset);

// 测试一个文件描述符比特位
int  FD_ISSET(fd, fd_set *fdset);

// 拷贝一个fd_set
void FD_COPY(fd_set *fdset_orig, fd_set *fdset_copy);

一个简单例子

当声明文件描述符(fd)集时,必须使用FD_ZERO将所有位置归零。然后设置与我们感兴趣的描述符相对应的位,如下所示:

fd_set fdset;
int fd;
FD_ZERO( & fdset);//所有位置归零
FD_SET(fd, & rset);
FD_SET(stdin, & rset);

然后调用选择函数,阻塞等待文件描述符事件的到来;如果它超过设置的时间,它将不再等待并继续执行。

select(fd, &fdset, NULL, NULL, NULL);

选择返回后,使用FD_ISSET测试是否设置了定位:

if (FD_ISSET(fd, & fdset) {
	// Do something  
}

完整例子

#include <sys/select.h>
#include <cstdio>
#include <unistd.h>


int main() {
    fd_set rd;
    struct timeval tv{};
    int ret;

    FD_ZERO(&rd); //所有位置归零

    // #include <unistd.h>
    // 0:STDIN_FILENO 标准输入
    // 1:STDOUT_FILENO 标准输出
    // 2:STDERR_FILENO 标准错误输出
    FD_SET(0, &rd); //标准输入文件描述符加入到rd集合中

    // 设置超时时间位5s
    tv.tv_sec = 3;
    tv.tv_usec = 0;

    // 将rd集合进行select,监听其可读事件
    // 程序在这里阻塞,直到超时或者标准输入上有数据可读
    ret = select(1, &rd, nullptr, nullptr, &tv);

    if (ret == 0) // Timeout
    {
        printf("select timeout!\n");
    } else if (ret == -1) // Failure
    {
        printf("fail to select!\n");
    } else // Successful
    {
        printf("data is available!\n");
        char buffer[1024]{0};
        read(0,buffer,sizeof(buffer));
        printf("the data is [%s]!\n",buffer);
    }
    return 0;
}

运行该程序并输入hello得到如下结果

hello
data is available!
the data is [hello
]!

如果不输入任何字符,等待3s后会出现超时提示select timeout!

总结

Select模型是最常见的I/O管理。通过调用select函数,应用程序可以确定数据是否准备就绪以及数据是否可以写入。然后,在I/O操作完成之前,应用程序无需在那阻塞。

从上一节的示例中,我们可以看到select模型需要一些fd_set,这意味着选择模式提供了等待多个I/O操作的能力。

然而,select模型也有一些缺点。例如:

  • 每次我们调用select时,我们都需要将fd_set从用户模式复制到内核模式。当有许多文件描述符时,这种开销非常大。
  • 每个select调用都需要遍历内核中传递的所有fd,当有许多文件描述符时,这种开销也非常大。
  • select支持的文件描述符数量太少,默认数字为1024。

在下一篇文章中,我们将深入研究选择模型,以便我们能够更详细地了解选择模型的优缺点。还将在未来介绍更先进的模型,如poll和epoll。

DIVE INTO THE SELECT MODEL

本部分翻译自:DIVE INTO THE SELECT MODEL

上一篇文章简要介绍了选择模型和系统调用用法。在这篇文章中,让我们更深入地研究它。

Select模型的关键:FD_SET

理解选择模型的关键是了解fd_set。fd_set类型实际上是一个long类型的数组。为了方便起见,假设fd_set的长度为1字节,fd_set中的每个位可以对应一个文件描述符,则1字节长的fd_set最多可以对应8个fds。

fd_set set;
FD_ZERO( &set); //该集合以位表示为0000,0000

//添加一个为5的fd进去
int fd5 = 5;
FD_SET(fd5, &set);// 该集合变成了0001,0000 (第五位为1)

//继续添加2和1
int fd1 = 1;
int fd2 = 2;
FD_SET(fd1, &set);// 该集合变成了0001,0001
FD_SET(fd2, &set);// 该集合变成了0001,0011


// 6是最大的fd+1
int ret = select(6, &set, 0, 0, 0);//执行select(6, &set, 0, 0, 0)进入阻塞等待

//如果fd = 1和fd = 2上都发生可读事件,则select停止阻止等待,并设置set为0000,0011。
//注意:fd = 5被清除,因为该位上没有发生任何事件。

根据上述讨论,select模型的特征可以很容易地推导出:

  1. 可以监控的文件描述符数量取决于sizeof(fd_set)的值。在我的电脑上,sizeof(fd_set) = 512。每个位代表一个文件描述符,那么我的计算机上支持的最大文件描述符是512 * 8 = 4096。值的上限取决于FD_SETSIZE的值。此值在编译Linux内核后被设置。
  2. 当fd添加到select中时,将使用数组fd_set来存储fd。
  3. 在select返回(停止阻止I/O)后,通过调用FD_ISSET,我们可以检查表示I/O操作返回的文件描述符的位是否已设置,然后我们可以知道是否发生了I/O事件。
  4. 在调用select之前,我们需要先通过FD_ZERO清空fd_set,然后使用FD_SET在fd_set的位中设置fd。请注意,在select返回后,所有没有发生相关事件的fds都将被清除。因此,在每次选择调用之前,我们需要使用FD_SET在fd_set中设置fd。

针对4,这里有一个例子:

fd_set rd;
int fd;
FD_ZERO(&rd);
while (1) {
  FD_SET(fd,&rd);//每次都需要重新设置
  ret = select(1, & rd, NULL, NULL, NULL);//大抵是从某一个时刻检查一下,这个时刻可读的fd标记会被保留,其他的都会被清除
  //...
}

在while循环中可以看到,每次调用select之前都会调用FD_SET。

让我们看看一个更复杂的select示例,这些示例在生产中很常见。

EXAMPLE 1: HANDLING OUT-OF-BAND DATA WITH SELECT

在网络程序中,select只能处理一类异常情况:在套接字上接收带外数据。

什么是带外数据?带外数据,有时也称为经加速数据,意味着连接中的双方之一有重要的东西,并希望快速通知另一方。

通常的数据被放入传输队列中。这些数据被称为“带内”数据。对于“带外”数据,它们需要在任何“带内”数据之前发送。然后我们可以知道:

  • 带外数据被设计为比正常数据具有更高的优先级。
  • 带外数据被映射到现有连接中,而不是在客户端和服务器之间使用另一个连接。

通常,select用于接收“带内”数据。然而,我们的服务器需要同时接收“带内”数据和“带外”数据

以下是使用select处理“带内”数据和“带外”数据的示例:

#include <iostream>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int, char **) {
    struct sockaddr_in address{};
    bzero( &address, sizeof(address));
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = inet_addr("127.0.0.1");
    address.sin_port = htons(1234);

    int listen_fd = socket(PF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        std::cerr<<"Fail to create a listen socket!"<<std::endl;
        return -1;
    }

    int ret = bind(listen_fd, (struct sockaddr * ) & address, sizeof(address));

    if (ret == -1) {
        std::cerr<<"Fail to bind socket!"<<std::endl;
        return -1;
    }

    // Set the maximum number of listening fds to 5
    ret = listen(listen_fd, 5);
    if (ret == -1) {
        std::cerr<<"Fail to listen socket!"<<std::endl;
        return -1;
    }

    struct sockaddr_in client_address{}; // The IP address of the client
    socklen_t client_addr_length = sizeof(client_address);
    int conn_fd = accept(listen_fd, (struct sockaddr * ) & client_address, & client_addr_length);
    if (conn_fd < 0) {
        std::cerr<<"Fail to accept!"<<std::endl;
        close(listen_fd);
    }

    char buff[1024]; // Data receive buffer
    fd_set read_fds; // Read file descriptor for in-band data
    fd_set exception_fds; // Exception file descriptor for out-of-band data

    // Empty these file descriptors
    FD_ZERO( & read_fds);
    FD_ZERO( & exception_fds);

    while (true) {
        memset(buff, 0, sizeof(buff));

        // Set the fd_set bits before each select call
        FD_SET(conn_fd, & read_fds);
        FD_SET(conn_fd, & exception_fds);

        // 使用select 监听conn_fd上的读事件和异常事件
        ret = select(conn_fd + 1, & read_fds, nullptr, & exception_fds, nullptr);
        if (ret < 0) {
            std::cerr<<"Fail to select!"<<std::endl;
            return -1;
        }

        // Check if we received any data (read event)
        if (FD_ISSET(conn_fd, & read_fds))
        {
            // If so, read the data.
            ret = recv(conn_fd, buff, sizeof(buff) - 1, 0);
            if (ret <= 0) {
                break;
            }
            std::cout<<"Got "<<ret<<" bytes of normal data (in-bound): "<<buff<<std::endl;

        }
        else if (FD_ISSET(conn_fd, & exception_fds)) // Exception event
        {
            // Receive the data as out-of-band (MSG_OOB)
            ret = recv(conn_fd, buff, sizeof(buff) - 1, MSG_OOB);//MSG_OOB传递到recv系统调用中
            if (ret <= 0) {
                break;
            }
            std::cout<<"Got "<<ret<<" bytes of exception data (out-of-band): "<<buff<<std::endl;
        }
    }

    close(conn_fd);
    close(listen_fd);
    return 0;
}

从上面的示例中,可以看到我们将“带内”数据和“带外”数据区分为两个不同的文件描述符(fd),并使用FD_ISSET检查发生的事件。

当读取“带外”数据时,我们需要将MSG_OOB传递到recv系统调用中。然后,系统能够在“带内”数据之前接收“带外”数据。

Example 2: Handling multiple clients in the socket programming

Select模型的最大优势之一是,我们可以在一个线程中同时处理多个套接字I/O请求。在网络编程中,当涉及到多个客户端访问服务器时,我们首先想到的方法是fork多个进程来单独处理每个客户端连接。但是,这种方式非常耗费资源。通过select,我们能够在没有fork的情况下处理多个客户。让我们看一个具体的例子。

SERVER SIDE CODE


#include <unistd.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/ioctl.h>
#include <netinet/in.h>
#include <cstdio>
#include <cstdlib>

int main() {
    int server_sockfd, client_sockfd;
    int server_len, client_len;
    struct sockaddr_in server_address{};
    struct sockaddr_in client_address{};
    int result;
    fd_set readfds, testfds;

    // Create the server side socket
    server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);
    server_address.sin_port = htons(8888);
    server_len = sizeof(server_address);
    bind(server_sockfd, (struct sockaddr * ) & server_address, server_len);

    // Set the maximum number of listening fds to 5
    listen(server_sockfd, 5);

    FD_ZERO( & readfds);
    // Put the fd of the socket to the fd_set
    FD_SET(server_sockfd, & readfds);

    while (true) {
        char ch;
        int fd;
        int nread;

        // As select will modify the fd_set readfds
        // So we need to copy it to another fd_set testfds
        testfds = readfds;
        printf("Server is waiting\n");

        // Block indefinitely and test file descriptor changes
        // FD_SETSIZE:the system's default number of maximum file descriptors
        result = select(FD_SETSIZE, & testfds, nullptr, nullptr, nullptr);

        if (result < 1) {
            perror("Failed to select!\n");
            exit(1);
        }

        // Loop all the file descriptors
        for (fd = 0; fd < FD_SETSIZE; fd++) {

            // Find the fd that associated event occurs
            if (FD_ISSET(fd, & testfds)) {
                // Determine if it is a server socket
                // if yes, it indicates that the client requests a connection
                if (fd == server_sockfd) {
                    client_len = sizeof(client_address);
                    client_sockfd = accept(server_sockfd,(struct sockaddr * ) & client_address,
                                           reinterpret_cast<socklen_t *>(&client_len));
                    // Add the client socket to the collection
                    FD_SET(client_sockfd, & readfds);
                    printf("Adding the client socket to fd %d\n", client_sockfd);
                }
                // If not, it means there is data request from the client socket
                else {

                    // Get the amount of data to nread
                    ioctl(fd, FIONREAD, & nread);

                    // After the client data request is completed
                    // The socket is closed and the corresponding fd is cleared
                    if (nread == 0) {
                        close(fd);
                        // Remove closed fd
                        // (from the unmodified fd_set readfds)
                        FD_CLR(fd, & readfds);
                        printf("Removing client on fd %d\n", fd);
                    }
                    // Processing the client data requests
                    else {
                        read(fd, & ch, 1);
                        sleep(5);
                        printf("Serving client on fd %d,%c\n", fd,ch);
                        ch++;
                        write(fd, & ch, 1);
                    }
                }
            }
        }
    }

    return 0;
}

CLIENT SIDE CODE


#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstdio>
#include <cstdlib>

int main() {
    int client_sockfd;
    int len;
    struct sockaddr_in address{}; // Server address structure family/ip/port
    int result;
    char ch = 'A';

    // Create the client socket
    client_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = inet_addr("127.0.0.1");
    address.sin_port = htons(8888);
    len = sizeof(address);

    result = connect(client_sockfd, (struct sockaddr * ) & address, len);
    if (result == -1) {
        perror("Failed to connect to the server");
        exit(1);
    }

    // The first read & write
    write(client_sockfd, & ch, 1);
    read(client_sockfd, & ch, 1);
    printf("The first time: char from server = %c\n", ch);
    sleep(5);

    // The second read & write
    write(client_sockfd, & ch, 1);
    read(client_sockfd, & ch, 1);
    printf("The second time: char from server = %c\n", ch);

    close(client_sockfd);
    return 0;
}

以下是测试的步骤

  • 运行单个服务器
  • 运行多个客户端(如2个客户端)

以下是结果:

$./server1
Server is waiting
Adding the client socket to fd 4
Server is waiting
Serving client on fd 4,A
Server is waiting
Adding the client socket to fd 5
Server is waiting
Serving client on fd 5,A
Server is waiting
Serving client on fd 4,B
Server is waiting
Serving client on fd 5,B
Server is waiting
Removing client on fd 4
Server is waiting
Removing client on fd 5
Server is waiting

$./client1 
The first time: char from server = B
The second time: char from server = C
$./client2
The first time: char from server = B
The second time: char from server = C

服务器在一个线程中监听两个客户端,并且两个客户端都正确地从服务器接收了数据。

总结

Select 模型支持I/O多路复用,因此使用select模型,我们能够在单个线程中处理多个套接字连接,它比多线程解决方案要好得多。

然而,它也有明显的缺点:

  • 单个进程可以监控的fd数量有限,即监听端口的大小是有限的。该限制与系统内存的大小有关,具体数字可以通过cat /proc/sys/fs/file-max查看。32位机器的默认数字是1024,64位机器的默认数字是2048。
  • 套接字被线性扫描,即使用轮询的方法,效率低。当有更多套接字时,每个select()必须通过遍历所有FD_SETSIZE个套接字来完成调度,无论哪个套接字处于活动状态。这将浪费很多CPU时间。如果您可以为套接字注册回调函数,并在它们处于活动状态时自动完成相关操作,则可以避免轮询,这正是epoll和kqueue所做的。
  • 需要维护用于存储大量fds的数据结构,这将导致用户空间和内核空间复制具有大量开销的结构。
posted @ 2024-04-29 11:30  料峭春风吹酒醒  阅读(10)  评论(0编辑  收藏  举报