Linux多路轉接之select函數(shù)使用方式
首先我們要了解一下,什么是多路轉接?
多路轉接也叫多路復用,是一種用于管理多個IO通道的技術。
它能實現(xiàn)同時監(jiān)聽和處理多個IO事件,而不是為每個IO通道創(chuàng)建單獨的線程或者進程,多路轉接允許在單個進程或線程中同時處理多個IO操作,從而提高程序的性能和效率。
本篇文章介紹的select函數(shù),就用于select系統(tǒng)調用的多路轉接技術。
1. 認識select函數(shù)
select函數(shù)是系統(tǒng)提供的一個多路轉接接口。
IO = 等待就緒 + 數(shù)據(jù)拷貝,而select是只負責等。
- select系統(tǒng)調用可以讓我們的程序同時監(jiān)聽多個文件描述符上的事件是否就緒。
- select的核心工作就是等,當監(jiān)聽的多個文件描述符中有一個或多個事件就緒時,select函數(shù)才會成功返回并將對應文件描述符的就緒事件告知調用者。
2. select函數(shù)原型

參數(shù)說明:
- nfds:需要監(jiān)聽的文件描述符中,最大的文件描述符值 + 1。
- readfs:輸入輸出型參數(shù),調用時用戶告知內核需要監(jiān)聽哪些文件描述符的讀事件是否就緒,返回時內核告訴用戶哪些文件描述符的讀事件已經(jīng)就緒。
- writefds:輸入輸出型參數(shù),調用時用戶告知內核需要監(jiān)聽哪些文件描述符的寫事件是否就緒,返回時內核告知用戶哪些文件描述符的寫事件已經(jīng)就緒。
- exceptfds:輸入輸出型參數(shù),調用時告知內核需要監(jiān)聽哪些文件描述符的異常事件是否就緒,返回時內核告知用戶哪些文件描述符的異常事件已經(jīng)就緒。
- timeout:輸入輸出參數(shù),調用時由用戶設置select的等待時間,返回時表示timeout的剩余時間。
參數(shù)timeout的取值:
- NULL/nullptr:select調用后進行阻塞等待,直到被監(jiān)視的某個文件描述符上的事件就緒。
- 0:select調用后進行非阻塞等待,無論被監(jiān)視的文件描述符的事件是否就緒,select檢測后都會立即返回。
- 特定的時間值:select調用后在指定的時間內進行阻塞等待,如果被監(jiān)視的文件描述符上一直沒有事件就緒,則在該時間后select進行超時返回。
返回值說明:
- 如果函數(shù)調用成功,則返回有事件就緒的文件描述符個數(shù)。
- 如果timeout時間耗盡,則返回0。
- 如果函數(shù)調用失敗,則返回-1,同時錯誤碼會被設置。
select調用失敗,錯誤碼可能被設置為:
- EBADF:文件描述符有無效的或者該文件已關閉
- EINTR:此調用被信號所中斷
- EINVAL:參數(shù)nfds為負值
- ENOMEM:核心內存不足
fd_set 結構
fd_set 結構與 sigset_t 結構類似,fd_set 本質也是一個位圖,用位圖中對應的位來表示要監(jiān)聽的文件描述符。
調用select函數(shù)之前就需要用fd_set結構定義出對應的文件描述符集,然后將需要監(jiān)視的文件描述符添加到文件描述符集當中,這個添加的過程本質就是在進行位操作,但是這個位操作不需要用戶自己進行,系統(tǒng)專門提供了一組專門的接口,用戶對fd_set位圖進行各種操作。

timeval 結構
傳入select函數(shù)的最后一個參數(shù)timeout,就是一個指向timeval結構的指針,timeval結構用于描述一段時間長度,該結構當中包含兩個成員,其中tv_sec表示的是秒,tv_usec表示的是微妙。

3. socket就緒條件
讀就緒
- socket內核中,接收緩沖區(qū)中的字節(jié)數(shù),大于等于低水位標記物SO_RCVLOWAT,此時可以無阻塞地讀取該文件描述符,并且返回值大于0。
- socket TCP通信中,對端連接關閉,此時對該socket讀,則返回0。
- 監(jiān)聽的socket上有新的連接請求。
- socket上有未處理的錯誤。
寫就緒
- socket內核中,發(fā)送緩沖區(qū)中的可用字節(jié)數(shù),大于等于低水位標記SO_SNDLOWAT,此時可以無阻塞地寫,并且返回值大于0。
- socket的寫操作被關閉(close或者shutdown),對一個寫操作被關閉的socket進行寫操作,會觸發(fā)SIGPIPE信號。
- socket使用非阻塞connect連接成功或失敗之后。
- socket上有未讀取的錯誤。
異常就緒
- socket上收到帶外數(shù)據(jù)
4. select工作流程
這里我們只介紹select處理讀取的操作。
如果我們要實現(xiàn)一個簡單的select服務器,該服務器要做的就是讀取客戶端發(fā)來的數(shù)據(jù)并進行打印,那么這個select服務器的工作流程應該是這樣的:
- 先初始化服務器,完成套接字的創(chuàng)建、綁定和監(jiān)聽。
- 定義一個fd_array數(shù)組用于保存監(jiān)聽套接字和已經(jīng)與客戶端建立連接的套接字,剛開始時就將監(jiān)聽套接字添加到fd_array數(shù)組當中。
- 然后服務器開始循環(huán)調用select函數(shù),檢測讀事件是否就緒,如果就緒則執(zhí)行對應的操作。
- 每次調用select函數(shù)之前,都需要定義一個讀文件描述符集readfds,并將fd_array當中的文件描述符依此設置進readfds當中,表示讓select幫我們監(jiān)視這些文件描述符的讀事件是否就緒。
- 當select檢測到數(shù)據(jù)就緒時會讀事件就緒的文件描述符設置進readfds當中,此時我們能夠得知哪些文件描述符的讀事件就緒了,并對這些文件描述符進行對應的操作。
- 如果讀事件就緒的就是監(jiān)聽套接字,則調用accept函數(shù)從底層全連接隊列獲取已經(jīng)建立好的連接,并將該連接對應的套接字添加到fd_array數(shù)組當中。
- 如果讀事件就緒的是與客戶端建議連接的套接字,則調用read函數(shù)讀取客戶端發(fā)來的數(shù)據(jù)并進行打印輸出。
- 當然,服務器與客戶端建立連接的套接字讀事件就緒,也可能是因為客戶端將連接關閉了,此時服務器應該調用close關閉套接字,并將該套接字從fd_array中清除,因為下一次不需要再監(jiān)視該文件描述符的讀事件了。
注意:
- 因為傳入的select函數(shù)的readfds、writefds和exceptfds都是輸入輸出型參數(shù),當select函數(shù)返回時這些參數(shù)當中的值已經(jīng)被修改了,因此每次調用seletct函數(shù)時都需要對其進行重新設置,timeout也是類似的道理。
- 因為每次調用select函數(shù)之前都需要對readfds進行重新設置,所以需要定義一個fd_array數(shù)組保存與客戶端已經(jīng)建立的若干連接和監(jiān)聽套接字,實際fd_array數(shù)組當中的文件描述符就是需要讓select監(jiān)視讀事件的文件描述符。
- 我們的select服務器只是讀取客戶端發(fā)來的數(shù)據(jù),因此只需讓select幫我們監(jiān)視特定文件描述符的讀事件,如果同時讓select幫我們監(jiān)視特定文件描述符的讀事件和寫事件,則需要分別定義readfds和writefds,并定義兩個數(shù)組分別保存需要被監(jiān)視讀事件和寫事件的文件描述符,便于每次調用select函數(shù)前對readfds和writefds進行重新設置。
- 服務器剛開始運行時,fd_array數(shù)組當中只有監(jiān)聽套接字,因此select第一次調用時只需要監(jiān)視監(jiān)聽套接字的讀事件是否就緒,但每次調用accept獲取到新連接之后,都會將連接對應的套接字添加到fd_array當中,因此后續(xù)select調用時就需要監(jiān)視監(jiān)聽套接字和若干連接套接字的讀事件是否就緒。
- 由于調用select時還需要傳入被監(jiān)視的文件描述符中最大文件描述符值+1,因此每次在遍歷fd_array對readfds進行重新設置時,還需要記錄最大文件描述符的值。
5. select服務器
Socket類
我們編寫一個Socket類,對套接字相關的接口進行一定程序的封裝,為了讓外部能夠直接調用Socket類當中的函數(shù),我們將這些成員函數(shù)定義成靜態(tài)成員函數(shù)。
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
class Socket
{
public:
// 創(chuàng)建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 設置端口復用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
// 綁定
static void SocketBind(int sock, int port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0)
{
std::cerr << "bind error" << std::endl;
exit(3);
}
}
// 監(jiān)聽
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0)
{
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};SelectServer類
編寫SelectServer類,因為我當前使用的是云服務器,所以編寫的select服務器在綁定時只需將IP地址設置為INADDR_ANY即可,所以類中只包含監(jiān)聽套接字和端口號兩個成員變量即可。
- 在構造SelectServer對象時,需要指明select服務器的端口號,當然也可以在初始化select服務器的時候指明。
- 在初始化select服務器的時候需要調用Socket類當中的函數(shù),依此進行套接字的創(chuàng)建、綁定和監(jiān)聽即可。
- 在析構函數(shù)中可以選擇調用close函數(shù)將監(jiān)聽套接字進行關閉,但實際也可以不進行該動作,因為服務器運行后一般是不退出的。
#pragma once
#include "Socket.hpp"
#include <sys/select.h>
#define BACK_LOG 5
class SelectServer
{
public:
SelectServer(int port)
: _port(port)
{}
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~SelectServer()
{
if (_listen_sock >= 0) close(_listen_sock);
}
private:
int _listen_sock;
int _port;
};運行服務器
服務器初始化完畢之后就可以周期性地執(zhí)行某種動作了,而select服務器要做的就是不斷調用select函數(shù),當事件就緒對應執(zhí)行某種動作即可。
- 首先,在select服務器開始死循環(huán)調用select函數(shù)之前,需要先定義一個fd_array數(shù)組,先把數(shù)組中所有的位置初始化為無效,并將監(jiān)聽套接字添加到該數(shù)組當中,fd_array數(shù)組當中保存的就是需要被select監(jiān)視讀事件是否就緒的文件描述符。
- 此后,select服務器就不斷調用select函數(shù)監(jiān)視讀事件是否就緒,每次調用select函數(shù)之前都需要重新設置readfds,具體設置過程就是遍歷fd_array數(shù)組,將fd_array數(shù)組當中的文件描述符添加到readfds當中,并同時記錄最大的文件描述符maxfd。
- 當select函數(shù)返回后,如果返回值為0,則說明timeout時間耗盡,此時直接準備下一次select調用即可。如果select的返回值為-1,則說明select調用失敗,此時也讓服務器準備下一次select調用,但實際應該進一步判斷錯誤碼,根據(jù)錯誤碼來判斷是否應該繼續(xù)調用select函數(shù)。
- 如果select函數(shù)的返回值大于0,則說明select函數(shù)調用成功,此時已經(jīng)有文件描述符的讀事件就緒,接下來就應該對就緒事件進行處理。
#pragma once
#include "Socket.hpp"
#include <sys/select.h>
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class SelectServer
{
public:
SelectServer(int port)
: _port(port)
{}
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~SelectServer()
{
if (_listen_sock >= 0) close(_listen_sock);
}
void Run()
{
fd_set readfds; // 創(chuàng)建讀文件描述符集
int fd_array[NUM]; // 保存需要被監(jiān)視讀事件是否就緒的文件描述符
ClearFdArray(fd_array, NUM, DFL_FD); // 將數(shù)組中的所有位置設置為無效
fd_array[0] = _listen_sock; // 將監(jiān)聽套接字添加到fd_array數(shù)組中的第0個位置
while (1)
{
FD_ZERO(&readfds); // 清空readfds
// 將fd_array數(shù)組當中的文件描述符添加到readfds中,并記錄最大的文件描述符
int maxfd = DFL_FD;
for (int i = 0; i < NUM; ++i)
{
if (fd_array[i] == DFL_FD) continue; // 跳過無效的位置
FD_SET(fd_array[i], &readfds); // 將有效位置的文件描述符添加到readfds中
if (fd_array[i] > maxfd) maxfd = fd_array[i]; // 更新最大文件描述符
}
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr))
{
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
std::cout << "有事件發(fā)生..." << std::endl;
break;
}
}
}
private:
void ClearFdArray(int fd_array[], int num, int default_fd)
{
for (int i = 0; i < num; ++i) fd_array[i] = default_fd;
}
int _listen_sock;
int _port;
};啟動服務器
#include "SelectServer.hpp"
#include <string>
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << "SelectServer" << " port" << std::endl;
exit(1);
}
int port = atoi(argv[1]);
SelectServer* svr = new SelectServer(port);
svr->InitSelectServer();
svr->Run();
return 0;
}由于當前服務器調用select函數(shù)時直接將timeout設置為了nullptr,因此select函數(shù)調用后會進行阻塞等待。
而服務器在第一次調用select函數(shù)時只讓select函數(shù)監(jiān)視監(jiān)聽套接字的讀事件,所以運行服務器之后如果沒有客戶端發(fā)來連接請求,那么讀事件就不會就緒,而服務器會一直在第一次調用的select函數(shù)中進行阻塞等待。

當我們借助telnet工具向select服務器發(fā)起連接請求之后,select函數(shù)就會立馬檢測到監(jiān)聽套接字的讀事件就緒,此時select函數(shù)便會返回成功,并將我們設置的提示語句進行打印輸出,因為當前程序沒有對就緒事件進行處理,此后每次select函數(shù)一調用就會檢測到讀事件就緒成功返回,因此屏幕不但打印輸出提示語句。

如果服務器在調用select函數(shù)時將timeout的值設置為0,那么select函數(shù)調用后就會進行非阻塞等待,無論被監(jiān)視的文件描述符上的事件是否就緒,select檢測后都會立即返回。
此時如果select監(jiān)視的文件描述符上有事件就緒,那么select函數(shù)的返回值就是大于0的,如果select函數(shù)監(jiān)視的文件描述符上沒有事件就緒,那么select的返回值就是小于0的,這里也就不進行演示了。
事件處理
當select檢測到右文件描述符的讀事件就緒并成功返回后,接下來就應該對就緒事件進行處理了,這里編寫一個HandleEvent函數(shù),當讀事件就緒之后就調用該函數(shù)進行事件處理。
- 在進行事件處理時需要遍歷fd_array數(shù)組當中的文件描述符,以此判斷各個文件描述符對應的讀事件是否就緒,如果就緒則需要進行事件處理。
- 當一個文件描述符的讀事件就緒之后,還需要進一步判斷該文件描述符是否是監(jiān)聽套接字,如果是監(jiān)聽套接字的讀事件就緒,那么就應該調用accept函數(shù)將底層的連接獲取上來。但是只調用accept函數(shù)將連接獲取上來還不夠,為了下一次調用select函數(shù)時能夠讓select幫我們監(jiān)視新連接的事件是否就緒,在連接獲取上來后還應該將連接對應的文件描述符添加到fd_array數(shù)組當中,這樣在下一次調用select函數(shù)前對readfds重新設置時就能將該文件描述符添加進去了。
- 如果是客戶端建立的連接對應的讀事件就緒,那么就應該調用read函數(shù)讀取客戶端發(fā)來的連接,如果讀取成功則將讀到的數(shù)據(jù)在服務端進行打印。如果調用read函數(shù)讀取失敗或者客戶端關閉了連接,那么select服務器也應該調用close函數(shù)關閉對應的連接,但此時只關閉連接也是不夠的,還應該將該連接對應的文件描述符從fd_array數(shù)組當中清除,否則后續(xù)調用的select函數(shù)還會幫我們監(jiān)視該連接的讀事件是否就緒,但實際已經(jīng)不需要了。
void HandleEvent(const fd_set& readfds, int fd_array[], int num)
{
for (int i = 0; i < num; ++i)
{
// 跳過無效位置
if (fd_array[i] == DFL_FD) continue;
// 連接事件就緒
if (fd_array[i] == _listen_sock && FD_ISSET(fd_array[i], &readfds))
{
// 獲取連接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
// 將獲取到的文件描述符添加到fd_array中
if (!SetFdArray(fd_array, num, sock))
{
// 如果添加失敗,關閉文件描述符
close(sock);
std::cout << "select server is full, close fd: " << sock << std::endl;
}
}
}
}
private:
bool SetFdArray(int fd_array[], int num, int fd)
{
for (int i = 0; i < num; ++i)
{
if (fd_array[i] == DFL_FD)
{
fd_array[i] = fd;
return true;
}
}
return false;
}添加文件描述符到fd_array數(shù)組中,本質就是遍歷fd_array數(shù)組,找到一個沒有被使用的位置將該文件描述符添加進去即可。
但有可能fd_array數(shù)組中全部的位置都已經(jīng)被占用了,那么文件描述符就會添加失敗,此時就只能將剛剛獲取上來的連接對應的套接字進行關閉,因為此時服務器是沒有能力處理這個連接的。
該select服務器存在的一些問題
- 服務器沒有對客戶端進行響應,select服務器如果要向客戶端發(fā)送數(shù)據(jù),不能直接調用write函數(shù),因為調用write函數(shù)時實際也為了“等”和“拷貝”兩步,我們也應該將“等”的這個過程交給select函數(shù),因此在每次調用select函數(shù)之前,除了需要重新設置readfds之外還需要重新設置writefds,并且還需要一個數(shù)組來保存需要被監(jiān)視事件是否就緒的文件描述符,當某一文件描述符的寫事件就緒時我們才能夠調用write函數(shù)向客戶端發(fā)送數(shù)據(jù)。
- 沒有定制協(xié)議,代碼中讀取數(shù)據(jù)時并沒有按照某種規(guī)則進行讀取,此時就可能造成粘包問題。比如HTTP協(xié)議規(guī)定在讀取底層數(shù)據(jù)時讀取到空行就表明讀完了一個HTTP報頭,此時再根據(jù)HTTP報頭當中的Content-Length屬性得知正文的長度,最終就能讀取到一個完整的HTTP報文,HTTP協(xié)議通過這種方式就避免了粘包問題。
- 沒有對應的輸入輸出緩沖區(qū),代碼中直接將讀取的數(shù)據(jù)存儲到了字符數(shù)組buffer中,這是不嚴謹?shù)?,因為本地?shù)據(jù)讀取可能并沒有讀取到一個完整的報文,此時服務器就不能進行數(shù)據(jù)的分析處理,一個將讀取到的數(shù)據(jù)存儲到一個輸入緩沖區(qū)中,當讀取到一個完整的報文之后再讓服務器進行處理。此外,如果服務器能夠對客戶端進行響應,那么服務器的響應數(shù)據(jù)也不應該直接調用write函數(shù)發(fā)送給客戶端,應該先存儲到一個輸出緩沖區(qū)當中,因為響應數(shù)據(jù)可能很龐大,無法一次發(fā)送完畢,可能需要進行分批發(fā)送。
6. select的優(yōu)缺點
select的優(yōu)點
- 可以同時等待多個文件描述符,并且只負責等待,實際的IO操作由accept、read、write等接口完成,這些接口在進行IO操作時不會被阻塞。
- select同時等待多個文件描述符,因此可以將“讀”的時間重疊,提高了IO的效率。
當然,這也是所有多路轉接接口的優(yōu)點。
select的缺點
- 每次調用select,都需要手動設置fd集合,從接口使用角度來看并不方便。
- 每次調用select,都需要把fd集合從用戶態(tài)拷貝到內核態(tài),這個開銷在fd很多時會很大。
- 同時每次調用select時都需要在內核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大。
- select可監(jiān)控的文件描述符數(shù)量太少。
select可監(jiān)控的文件描述符有1024個,除去其中的一個監(jiān)聽套接字,那么它最多只能連接1023個客戶端。
總結
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
VirtualBox安裝Centos6.8出現(xiàn)E_INVALIDARG(0x80070057)的解決方法
這篇文章主要為大家詳細介紹了VirtualBox安裝Centos6.8出現(xiàn)E_INVALIDARG(0x80070057)的解決方法,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-07-07
ubuntu 16.04系統(tǒng)完美解決pip不能升級的問題
這篇文章主要介紹了ubuntu 16.04系統(tǒng)完美解決pip不能升級的問題 ,本文圖文并茂給大家介紹的非常詳細,需要的朋友可以參考下2018-04-04
Apache?Doris?中Compaction問題分析和典型案例分析
這篇文章主要介紹了Apache?Doris?中Compaction問題分析和典型案例,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-08-08

