關於Select Model的兩篇譯文

料峭春风吹酒醒發表於2024-04-29

文章來源

  • LINUX PROGRAMMING - GETTING STARTED WITH THE SELECT MODEL
  • DIVE INTO THE 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的資料結構,這將導致使用者空間和核心空間複製具有大量開銷的結構。

相關文章