文章目錄
- 前言
- 一. 五種IO模型
- 二. select實現多路轉接
- 2.1 select接口
- 2.2 select服務器實現
- 2.2.1 對網絡套接字進行封裝
- 2.2.2 構建出服務器類
- 2.2.3 進行初始化
- 2.2.4 獲取要進行等待的fd_set對象
- 2.2.5 對讀寫事件就緒的文件進行處理
- 2.2.6 服務器主循環
- 三. select 的優缺點
前言
在網絡編程領域,IO 模型是支撐高效通信的核心基礎之一。當需要讓單個進程或線程同時處理多個網絡連接的 IO 事件時,“IO 多路複用(多路轉接)” 技術成為了關鍵解法 —— 它能讓程序通過少量進程 / 線程,高效監控並處理多個 IO 事件,極大提升系統對 IO 資源的利用效率。
select作為 IO 多路複用模型中經典且具有代表性的實現,是開發者接觸 “多路轉接” 的重要入門點。儘管隨着技術演進,它逐漸顯現出一些侷限性,但深入理解select的工作機制、使用邏輯及其優缺點,不僅能幫助我們掌握 “單進程管理多連接” 的核心思路,更是學習更先進多路複用技術(如poll、epoll)的重要前提。
本文將圍繞 “select 實現多路轉接” 展開,從select接口的基本定義入手,逐步講解基於select的多路轉接服務器實現(包含套接字封裝、初始化流程、fd_set對象操作及服務器主循環設計等),最後剖析select自身的優勢與不足。希望通過對這些內容的梳理,能讓讀者清晰把握select在多路 IO 轉接中的核心作用,為後續 IO 模型學習與網絡編程實踐築牢基礎。
在介紹select這三種多路轉接的IO模型之前,有必要先介紹以下5中IO模型分別是哪幾種。
一. 五種IO模型
我們在操作系統中直接調用,read && write將數據讀取上來,其本質就是將數據從用户層拷貝到操作系統中/從操作系統中拷貝到用户層——就是“拷貝”;
- 雖然我們通過拷貝來發送/獲取數據,但是我們必須要明確一個概念:IO = 等數據 + 拷貝,而不僅僅是對數據進行拷貝;
- 對於寫於要等發送緩衝區中有位置,對於讀取要等接收緩衝區中有數據。
因此在進行拷貝之前,必須先判斷條件是否成立,也就是讀寫事件是否就緒。
我們通常定義高效IO指的是:單位時間內,IO過程中,等的比重越小,效率越高。
下面介紹五種IO模型:
- 阻塞性:直到 “等待數據就緒” 和 “數據拷貝” 兩個階段完全完成,IO 調用才返回;
- 非阻塞性:等待數據就緒階段不阻塞(內核會立即返回結果),即若數據未就緒,內核會返回
EAGAIN或EWOULDBLOCK錯誤; - 信號驅動型:用一個線程監控多個 IO,避免進程在單個未就緒 IO 上阻塞;
- 多路複用/多路轉接型:讓內核在 IO 數據就緒時主動發送
SIGIO信號通知進程來拿取數據; - 異步IO型:應用進程發起異步 IO 調用後,兩個階段(等待就緒、數據拷貝)均由內核完成,全程不阻塞進程。內核在完成所有操作後,通過 “信號” 或 “回調函數” 通知進程,進程直接使用已拷貝到用户緩衝區的數據。
- 對於阻塞IO和非阻塞IO在效率上並沒有什麼區別,只不過非阻塞IO在不等待期間可以做其他事情,因此我們通常説它的效率更高一些。
下面介紹實現多路轉接IO的3中方式。
二. select實現多路轉接
關於select實現多路轉接,此處將分為兩部分進行介紹:
- 介紹select的接口;
- 使用select實現一個簡單的ech服務器。
2.1 select接口
select可以一次等待多個文件,當有一個文件就緒了就返回,這樣可以一次性等待多個文件,提高了等待的效率。
int select(int nfds , fd_set *readfds , fd_set *writefds , fd_set *expectfds , struct timeval *timeout*);
該就接口就是select的等待接口:
- 參數一
nfds:標識等待的文件描述符中最大的 + 1;
fd_set是內核提供的一種數據結構,其本質是一張位圖,記錄着要關心的文件描述符。
2. 參數二readfds:是一個結構體,記錄要關心讀事件就緒的文件描述符;
3. 參數三writefds:記錄要關心寫事件就緒的文件描述符;
4. 參數四expectfds:記錄要關心異常事件的文件描述符;
struct timeval也是內核提供的一種結構體,用於記錄select要進行等待的事件:
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
當時間到達/有文件讀寫時間就緒就會進行返回。
- 參數五
timeval:標識select等待的時間,如果等待時間到了,還沒有一個文件讀寫時間就緒select也會進行返回,傳nullptr標識阻塞式的等待; - 返回值:一個整形,標識就緒的文件描述符的個數。
上面的fd_set是操作系統提供給我們的數據結構,我們不能直接對該數據結構進行操作,而應該使用操作系統提供的接口來進行操作:
void FD_ZERO(fd_set *set):將位圖全部請零,用於初始化;void FD_SET(int fd , fd_set *set):將fd文件描述符添加到位圖中;void FD_CLR(int fd , fd_set *set):將fd文件描述符從位圖中移除;void FD_ISSET(int fd , fd_set *set):檢查fd文件描述符是否在位圖中。
如果有文件描述符就緒,操作系統怎麼告訴我們是那些文件就緒了???
為了讓操作系統能夠通知我們,select接口的後4個參數被設計為輸入輸出型參數。
readfds輸出來告訴,那些文件描述符的讀事件已經就緒;writefds和expectfds也一樣;timeval告訴我們,距離規定的返回時間還剩餘多久。
select使用的是內核提供的現成的數據結構fd_set,因此這也就意味着其可以監視的文件描述符的數量是有限的,可以通過sizeof(fd_set)*8來計算出來。
2.2 select服務器實現
為了方便理解,我們實現一個簡單的服務器,將用户發送過來的數據在前面添加一個server got a message後直接進行返回。
2.2.1 對網絡套接字進行封裝
首先我們先對網絡套接字的接口進行封裝:創建套接字,綁定,監聽;關於這方面的知識可以查看之前的TCP相關內容,此時就直接貼實現方法:
const std::string defaultip_ = "0.0.0.0";
enum SockErr
{
SOCKET_Err,
BIND_Err,
};
class Sock
{
public:
Sock(uint16_t port)
: port_(port),
listensockfd_(-1)
{
}
void Socket()
{
listensockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (listensockfd_ < 0)
{
Log(Fatal) << "socket fail";
exit(SOCKET_Err);
}
Log(Info) << "socket sucess";
}
void Bind()
{
struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(port_);
inet_pton(AF_INET, defaultip_.c_str(), &server.sin_addr);
if (bind(listensockfd_, (struct sockaddr *)&server, sizeof(server)) < 0)
{
Log(Fatal) << "bind fail";
exit(BIND_Err);
}
Log(Info) << "bind sucess";
}
void Listen()
{
if (listen(listensockfd_, 10) < 0)
{
Log(Warning) << "listen fail";
}
Log(Info) << "listen sucess";
}
int Accept()
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
return fd;
}
int Accept(std::string& ip , uint16_t& port)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int fd = accept(listensockfd_ , (sockaddr*)&client , &len);
if(fd < 0)
{
Log(Warning) << "accept fail";
}
port = ntohs(client.sin_port);
char bufferip[64];
inet_ntop(AF_INET , &client.sin_addr , bufferip , sizeof(bufferip) - 1);
ip = bufferip;
return fd;
}
int Get_fd()
{
return listensockfd_;
}
~Sock()
{
close(listensockfd_);
}
private:
uint16_t port_;
int listensockfd_;
};```
下面就來實現selectserver服務器:
2.2.2 構建出服務器類
首先就是構造出Selectserver類來對服務器進行管理:
- 首先需要一個
Sock對象,進行TCP通信; - 接着我們需要使用一個容器來存儲所有要進行等待讀寫事件就緒的容器,此處為了簡單我們直接使用一個數組來實現,該數組的大小就是
fd_set能夠等待的文件個數; - 此處我們假設TCP接收到的就是完整報文,因此就不設置
writefds的位圖了,理論上是要進行設置的,大家可以自行實現以下;
const int fds_num_max = sizeof(fd_set) * 8;
const int defaultfd = -1;
class Selectserver
{
public:
Selectserver(uint16_t port)
: _sock_ptr(new Sock(port))
{
for (int i = 0; i < fds_num_max; i++)
{
_fds_array[i] = defaultfd;
}
}
private:
std::shared_ptr<Sock> _sock_ptr;
int _fds_array[fds_num_max]; // 該數組用來存儲select要進行等待的文件描述符,初始值為-1
};
下一步就是進行初始化:
2.2.3 進行初始化
初始化一共就分為4個步驟:
- 創建套接字;
- 進行綁定;
- 設置監聽模式;
- 將套接字添加到
_fd_array數組中。
對於前三個步驟在前面我們已經進行封裝過來,因此,此處可以直接進行調用。
- 對於第四個步驟來説:我們在與客户端建立連接的時候,不知道什麼時候客户端來進行連接,因此也需要進行等待,而這一等待工作本質上是在等待
Sock指向的套接字文件,因此也應該使用select進行等待。
以下是具體實現:
void AddToArray(int fd)
{
int pos = 0;
for(; pos < fds_num_max && _fds_array[pos] != defaultfd ; pos++)
;
if(pos == fds_num_max)
{
// select已經到達監聽極限了,不能再添加要進行監聽的文件了
// 1. 關閉文件
// 2. 打印日誌
close(fd);
Log(Warning) << "select is full";
}
else
{
// 1. 有位置直接進行添加
_fds_array[pos] = fd;
Log(Info) << "add a new fd : " << fd;
}
}
void Init()
{
// 1. 創建套接字
// 2. 綁定
// 3. 設置監聽
// 4. 將套接字描述符加入到_fds_array數組中
_sock_ptr->Socket();
_sock_ptr->Bind();
_sock_ptr->Listen();
AddToArray(_sock_ptr->Get_fd());
}
2.2.4 獲取要進行等待的fd_set對象
我們此處設計的select接口並不考慮writefds和expectfds,因此我們只需要實現初始化傳入的readfds接口即可,我們需要有一個已經設置好了的fd_set,以及一個其中最大的文件描述符,因此此處使用一個pair作為返回值。
std::pair<fd_set , int> Get_readfds()
{
// 1. 對位圖進行初始化
// 2. 循環遍歷_fds_array數組,將要進行等待的文件描述符添加到位圖中
int max_num = 0;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < fds_num_max; i++)
{
if (_fds_array[i] == -1)
continue;
FD_SET(_fds_array[i], &readfds);
max_num = std::max(max_num , _fds_array[i]);
}
return std::make_pair(readfds , max_num);
}
2.2.5 對讀寫事件就緒的文件進行處理
當select等待後,存在文件描述符就緒,就需要將這些文件描述符對應的數據拿上來。
而文件描述符又分為兩種:
- 是Sock套接字文件描述符,要將已經建立好連接的文件描述符拿上來;
- 普通文件描述符,直接將輸入緩衝區中的數據拿上來。
// 是套接字就緒
void Sockfd_Ready()
{
// 1. 將套接字中建立好的連接拿上來
// 2. 將拿上來的文件描述符加入到_fds_array中,等到客户端發送消息過來
int fd = _sock_ptr->Accept();
AddToArray(fd);
}
// fd表示文件描述符 , i表示在數組中的位置
void Normalfd_Ready(int fd , int i)
{
// 1. 讀取文件描述符中的數據
// 2. 將數據簡單處理後,進行返回(此處假設TCP接收的報文是完整的)
char inbuffer[1024];
int n = read(fd , inbuffer , sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0;
std::string ret = "server got a message : ";
ret += inbuffer;
write(fd , ret.c_str() , ret.size());
}
else if(n == 0)
{
// 對方已經關閉文件
// 1. 將在_fds_array中的對應位置設為-1表示已經被移除了,不需要再進行等待
// 2. 關閉文件描述符
_fds_array[i] = defaultfd;
close(fd);
}
else
{
// 出錯了
Log(Error) << "read fail";
}
}
2.2.6 服務器主循環
- 進行select等待;
- 有文件描述符就緒,識別對應的文件描述符,將任務進行派發,看交給哪一個函數進行完成。
void Dispather(fd_set* fdreads)
{
int listensock = _sock_ptr->Get_fd();
for(int i = 0 ; i < fds_num_max ; i++)
{
if(_fds_array[i] == defaultfd || !FD_ISSET(_fds_array[i] , fdreads)) continue;
if(_fds_array[i] == listensock)
{
Sockfd_Ready();
}
else
{
Normalfd_Ready(_fds_array[i] , i);
}
}
}
void Run()
{
while (true)
{
auto [fdreads , max_num] = Get_readfds();
int n = select(max_num + 1 , &fdreads , nullptr , nullptr , nullptr);
if(n > 0)
{
// 有事件就緒, 進行任務的派發
Dispather(&fdreads);
}
else if(n == 0)
{
Log(Info) << "no file";
}
else
{
Log(Error) << "select fail";
}
}
}
以上就是整個selectserver類的實現了。
三. select 的優缺點
優點:
- 所有的等待交給
select來做,只要有讀事件就緒就通知上層來將數據取走; - 多路轉接,在單進程的情況下能夠處理多個用户的請求;
缺點:
- 使用的是內核提供的數據結構
fd_set,等待的文件描述符的數量是有限的; - 輸入輸出型參數使用起來麻煩,並且每次進行
select的時候都要進行重新設置; - 要將
fd_set從用户層拷貝到內核中,又要拷貝回來,拷貝數據頻繁; - 使用第三方數組對用户的fd進行管理,用户稱需要進行多次遍歷,內核在進行檢測的時候也要進行多次遍歷。
後續文章中我們將講解select的替代方案:poll和epoll.