select 函數用法

select 函數是網路通信編程中非常常用的一個函數,因此應該熟練掌握它。雖然它是 BSD 標準之一的 Socket 函數之一,但在 Linux 和 Windows 平台,其行為表現還是有點區別的。我們先來看一下 Linux 平台上的 select 函數。

Linux 平台下的 select 函數

select 函數的作用是檢測一組 socket 中某個或某幾個是否有「事件」就緒,這裡的「事件」一般分為如下三類:

  • 讀事件就緒
  1. socket 內核中,接收緩衝區中的位元組數大於等於低水位標記 SO_RCVLOWAT,此時調用 recvread 函數可以無阻塞的讀該文件描述符, 並且返回值大於0;
  2. TCP 連接的對端關閉連接,此時調用 recvread 函數對該 socket 讀,則返回 0;
  3. 偵聽 socket 上有新的連接請求;
  4. socket 上有未處理的錯誤。
  • 寫事件就緒:
  1. socket 內核中,發送緩衝區中的可用位元組數(發送緩衝區的空閑位置大?) 大於等於低水位標記 SO_SNDLOWAT,此時可以無阻塞的寫, 並且返回值大於0;
  2. socket 的寫操作被關閉(調用了 close 或者 shutdown 函數)( 對一個寫操作被關閉的 socket 進行寫操作, 會觸發 SIGPIPE 信號);
  3. socket 使?非阻塞 connect 連接成功或失敗之後;
  • 異常事件就緒

socket 上收到帶外數據。

函數簽名如下:

int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);

參數說明:

  • 參數 nfds, Linux 下 socket 也稱 fd,這個參數的值設置成所有需要使用 select 函數監聽的 fd 中最大 fd 值加 1。

  • 參數 readfds,需要監聽可讀事件的 fd 集合。
  • 參數 writefds,需要監聽可寫事件的 fd 集合。
  • 參數 exceptfds,需要監聽異常事件 fd 集合。

readfdswritefdsexceptfds 類型都是 fd_set,這是一個結構體信息,其定義位於 /usr/include/sys/select.h 中:

/* The fd_set member is required to be an array of longs. */
typedef long int __fd_mask;

/* Some versions of <linux/posix_types.h> define this macros. */
#undef __NFDBITS
/* Its easier to assume 8-bit bytes than to get CHAR_BIT. */
#define __NFDBITS (8 * (int) sizeof (__fd_mask))
#define __FD_ELT(d) ((d) / __NFDBITS)
#define __FD_MASK(d) ((__fd_mask) 1 << ((d) % __NFDBITS))

/* fd_set for select and pselect. */
typedef struct
{
/* XPG4.2 requires this member name. Otherwise avoid the name
from the global namespace. */
#ifdef __USE_XOPEN
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
// 在我的centOS 7.0 系統中的值:
// __FD_SETSIZE = 1024
//__NFDBITS = 64
__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;

/* Maximum number of file descriptors in `fd_set. */
#define FD_SETSIZE __FD_SETSIZE

我們假設未定義宏 __USE_XOPEN,將上面的代碼整理一下:

typedef struct
{
long int __fds_bits[16];
} fd_set;

將一個 fd 添加到 fd_set 這個集合中需要使用 FD_SET 宏,其定義如下:

void FD_SET(int fd, fd_set *set);

其實現如下:

#define FD_SET(fd,fdsetp) __FD_SET(fd,fdsetp)

FD_SET 在內部又是通過宏 __FD_SET 來實現的,__FD_SET 的定義如下(位於 /usr/include/bits/select.h 中):

#if defined __GNUC__ && __GNUC__ >= 2

# if __WORDSIZE == 64
# define __FD_ZERO_STOS "stosq"
# else
# define __FD_ZERO_STOS "stosl"
# endif

# define __FD_ZERO(fdsp)
do {
int __d0, __d1;
__asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS
: "=c" (__d0), "=D" (__d1)
: "a" (0), "0" (sizeof (fd_set)
/ sizeof (__fd_mask)),
"1" (&__FDS_BITS (fdsp)[0])
: "memory");
} while (0)

#else /* ! GNU CC */

/* We dont use `memset because this would require a prototype and
the array isnt too big. */
# define __FD_ZERO(set)
do {
unsigned int __i;
fd_set *__arr = (set);
for (__i = 0; __i < sizeof (fd_set) / sizeof (__fd_mask); ++__i)
__FDS_BITS (__arr)[__i] = 0;
} while (0)

#endif /* GNU CC */

#define __FD_SET(d, set)
((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))
#define __FD_CLR(d, set)
((void) (__FDS_BITS (set)[__FD_ELT (d)] &= ~__FD_MASK (d)))
#define __FD_ISSET(d, set)
((__FDS_BITS (set)[__FD_ELT (d)] & __FD_MASK (d)) != 0)

重點看這一行:

((void) (__FDS_BITS (set)[__FD_ELT (d)] |= __FD_MASK (d)))

__FD_MASK__FD_ELT 宏在上面的代碼中已經給出定義:

#define __FD_ELT(d) ((d) / __NFDBITS)
#define __FD_MASK(d) ((__fd_mask) 1 << ((d) % __NFDBITS))

__NFDBITS 的值是 648 * 8),也就是說 __FD_MASK (d) 先計算 fd 與 64 的餘數 n,然後執行 1 << n,這一操作實際上是將 fd 的值放在 0~63 這 64 的位置上去,這個位置索引就是 fd 與 64 取模的結果;同理 __FD_ELT(d) 就是計算位置索引值了。舉個例子,假設現在 fd 的 值是 57,那麼在這 64 個位置的 57 位,其值在 64 個長度的二進位中置位是:

0000 0010 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

這個值就是 1 << (57 % 64) 得到的數字。

但是前面 fd 數組的定義是:

typedef struct
{
long int __fds_bits[16]; //可以看成是128 bit的數組
} fd_set;

long int 占 8 個位元組,每個位元組 8 bit,一共 16 個 long int,如果換成二進位的位( bit )就是 8 * 8 * 16 = 1024, 這說明在我的機器上,select 函數支持操作的最大 fd 數量是 1024。

同理,如果我們需要從 fd_set 上刪除一個 fd,我們可以調用 FD_CLR,其定義如下:

void FD_CLR(int fd, fd_set *set);

原理和 FD_SET 相同,即將對應標誌清零即可。

如果,我們需要將 fd_set 中所有的 fd 都清掉,則使用宏 FD_ZERO

void FD_ZERO(fd_set *set);

當 select 函數返回時, 我們使用 FD_ISSET 宏來判斷某個 fd 是否有我們關心的事件,FD_ISSET 宏的定義如下:

int FD_ISSET(int fd, fd_set *set);

FD_ISSET 宏本質上就是檢測對應的位置上是否置 1,實現如下:

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

提醒一下: FD_ELT 和 FD_MASK 宏前文的代碼已經給過具體實現了。

  • 參數 timeout,超時時間,即在這個參數設定的時間內檢測這些 fd 的事件,超過這個時間後 select 函數將立即返回。這是一個 timeval 類型結構體,其定義如下:

struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};

select 函數的總超時時間是 timeout->tv_sectimeout->tv_usec 之和, 前者的時間單位是秒,後者的時間單位是微秒。

說了這麼多理論知識,我們先看一個具體的示例:

/**
* select函數示例,server端, select_server.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 <sys/time.h>
#include <vector>
#include <errno.h>

//自定義代表無效fd的值
#define INVALID_FD -1

int main(int argc, char* argv[])
{
//創建一個偵聽socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1)
{
std::cout << "create listen socket error." << std::endl;
return -1;
}

//初始化伺服器地址
struct sockaddr_in bindaddr;
bindaddr.sin_family = AF_INET;
bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bindaddr.sin_port = htons(3000);
if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
{
std::cout << "bind listen socket error." << std::endl;
close(listenfd);
return -1;
}

//啟動偵聽
if (listen(listenfd, SOMAXCONN) == -1)
{
std::cout << "listen error." << std::endl;
close(listenfd);
return -1;
}

//存儲客戶端socket的數組
std::vector<int> clientfds;
int maxfd = listenfd;

while (true)
{
fd_set readset;
FD_ZERO(&readset);

//將偵聽socket加入到待檢測的可讀事件中去
FD_SET(listenfd, &readset);

//將客戶端fd加入到待檢測的可讀事件中去
int clientfdslength = clientfds.size();
for (int i = 0; i < clientfdslength; ++i)
{
if (clientfds[i] != INVALID_FD)
{
FD_SET(clientfds[i], &readset);
}
}

timeval tm;
tm.tv_sec = 1;
tm.tv_usec = 0;
//暫且只檢測可讀事件,不檢測可寫和異常事件
int ret = select(maxfd + 1, &readset, NULL, NULL, &tm);
if (ret == -1)
{
//出錯,退出程序。
if (errno != EINTR)
break;
}
else if (ret == 0)
{
//select 函數超時,下次繼續
continue;
} else {
//檢測到某個socket有事件
if (FD_ISSET(listenfd, &readset))
{
//偵聽socket的可讀事件,則表明有新的連接到來
struct sockaddr_in clientaddr;
socklen_t clientaddrlen = sizeof(clientaddr);
//4. 接受客戶端連接
int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen);
if (clientfd == -1)
{
//接受連接出錯,退出程序
break;
}

//只接受連接,不調用recv收取任何數據
std:: cout << "accept a client connection, fd: " << clientfd << std::endl;
clientfds.push_back(clientfd);
//記錄一下最新的最大fd值,以便作為下一輪循環中select的第一個參數
if (clientfd > maxfd)
maxfd = clientfd;
}
else
{
//假設對端發來的數據長度不超過63個字元
char recvbuf[64];
int clientfdslength = clientfds.size();
for (int i = 0; i < clientfdslength; ++i)
{
if (clientfds[i] != -1 && FD_ISSET(clientfds[i], &readset))
{
memset(recvbuf, 0, sizeof(recvbuf));
//非偵聽socket,則接收數據
int length = recv(clientfds[i], recvbuf, 64, 0);
if (length <= 0 && errno != EINTR)
{
//收取數據出錯了
std::cout << "recv data error, clientfd: " << clientfds[i] << std::endl;
close(clientfds[i]);
//不直接刪除該元素,將該位置的元素置位-1
clientfds[i] = INVALID_FD;
continue;
}

std::cout << "clientfd: " << clientfds[i] << ", recv data: " << recvbuf << std::endl;
}
}

}
}
}

//關閉所有客戶端socket
int clientfdslength = clientfds.size();
for (int i = 0; i < clientfdslength; ++i)
{
if (clientfds[i] != INVALID_FD)
{
close(clientfds[i]);
}
}

//關閉偵聽socket
close(listenfd);

return 0;
}

我們編譯並運行程序:

[root@localhost testsocket]# g++ -g -o select_server select_server.cpp
[root@localhost testsocket]# ./select_server

然後,我們再多開幾個 shell 窗口,我們這裡不再專門編寫客戶端程序了,我們使用 Linux 下的 nc 指令模擬出兩個客戶端。

shell 窗口1,連接成功以後發送字元串 hello123

[root@localhost ~]# nc -v 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
hello123

shell 窗口2,連接成功以後發送字元串 helloworld

[root@localhost ~]# nc -v 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
helloworld

此時伺服器端輸出結果如下:

注意,由於 nc 發送的數據是按換行符來區分的,每一個數據包默認的換行符以
結束(當然,你可以 -C 選項換成
),所以伺服器收到數據後,顯示出來的數據每一行下面都有一個空白行。

當斷開各個客戶端連接時,伺服器端 select 函數對各個客戶端 fd 檢測時,仍然會觸發可讀事件,此時對這些 fd 調用 recv 函數會返回 0(recv 函數返回0,表明對端關閉了連接,這是一個很重要的知識點,下文我們會有一章節專門介紹這些函數的返回值),伺服器端也關閉這些連接就可以了。

客戶端斷開連接後,伺服器端的運行輸出結果:

以上代碼是一個簡單的伺服器程序實現的基本流程,代碼雖然簡單,但是非常具有典型性和代表性,而且同樣適用於客戶端網路通信,如果用於客戶端的話,只需要用 select 檢測連接 socket 就可以了,如果連接 socket 有可讀事件,調用 recv 函數來接收數據,剩下的邏輯都是一樣的。上面的代碼我們畫一張流程圖如下:

關於上述代碼在實際開發中有幾個需要注意的事項,這裡逐一來說明一下:

  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 到集合中去。

  1. 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 函數需要注意的第二個地方。

  1. 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,並不會等待固定的時間,然後立即返回。

  1. 如果將 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

  1. 在 Linux 平台上,select 函數的第一個參數必須設置成需要檢測事件的所有 fd 中的最大值加 1。所以上文中 select_server.cpp 中,每新產生一個 clientfd,我都會與當前最大的 maxfd 作比較,如果大於當前的 maxfd 則將 maxfd 更新成這個新的最大值。其最終目的是為了在 select 調用時作為第一個參數(加 1)傳進去。

在 Windows 平台上,select 函數的第一個值傳任意值都可以,Windows 系統本身不使用這個值,只是為了兼容性而保留了這個參數,但是在實際開發中為了兼容跨平台代碼,也會按慣例,將這個值設置為最大 socket 加 1。這點請讀者注意。

以上是我總結的 Linux 下 select 使用的五個注意事項,希望讀者能理解它們。

Linux select 函數的缺點也是顯而易見的:

  • 每次調用 select 函數,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 較多時會很大,同時每次調用 select 函數都需要在內核遍歷傳遞進來的所有 fd,這個開銷在 fd 較多時也很大;
  • 單個進程能夠監視的文件描述符的數量存在最大限制,在 Linux 上一般為 1024,可以通過修改宏定義然後重新編譯內核的方式提升這一限制,這樣非常麻煩而且效率低下;
  • select 函數在每次調用之前都要對傳入參數進行重新設定,這樣做比較麻煩而且會降低性能。

在 Linux 平台上,select 函數的實現是利用 poll 函數的,有興趣的讀者可以查找一下相關的資料來閱讀一下。關於 poll 函數的使用,接下來我們會介紹。

Windows 平台上 select 函數不會修改 timeval 的值

上文提到,在 Windows 系統上,select 函數結束後,不會修改其參數 timeval 的值。我們可以使用下面這段代碼來驗證:

bool Connect(const char* pServer, short nPort)
{
SOCKET hSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
return false;

//將socket設置成非阻塞的
unsigned long on = 1;
if (::ioctlsocket(hSocket, FIONBIO, &on) == SOCKET_ERROR)
return false;

struct sockaddr_in addrSrv = { 0 };
struct hostent* pHostent = NULL;
unsigned int addr = 0;

if ((addrSrv.sin_addr.s_addr = inet_addr(pServer) == INADDR_NONE)
{
pHostent = ::gethostbyname(pServer);
if (!pHostent)
return false;
else
addrSrv.sin_addr.s_addr = *((unsigned long*)pHostent->h_addr);
}

addrSrv.sin_family = AF_INET;
addrSrv.sin_port = htons((nPort);
int ret = ::connect(hSocket, (struct sockaddr*)&addrSrv, sizeof(addrSrv));
if (ret == 0)
return true;

if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK)
return false;

fd_set writeset;
FD_ZERO(&writeset);
FD_SET(hSocket, &writeset);
struct timeval tm = { 3, 200 };
if (::select(hSocket + 1, NULL, &writeset, NULL, &tm) != 1)
{
printf("tm.tv_sec: %d, tm.tv_usec: %d
", tm.tv_sec, tm.tv_usec);
return false;
}

printf("tm.tv_sec: %d, tm.tv_usec: %d
", tm.tv_sec, tm.tv_usec);

return true;
}

上述代碼中,38 行調用了 select 函數,無論 select 是成功還是出錯,我們都會列印出其參數的 tm 的值(4044 行),經測試驗證 tm 結構體的兩個成員值在 select 函數調用前後並沒有發生改變。

雖然 Windows 系統並不會改變 select 的超時時間參數的值,但是為了代碼的跨平台性,我們在實際開發中不應該依賴這種特性,而是每次調用 select 函數前都重新給超時時間參數重新設置值。


本文首發於『easyserverdev』公眾號,歡迎關注,轉載請保留版權信息。

歡迎加入高性能伺服器開發 QQ 群一起交流: 578019391 。

推薦閱讀:

相关文章