典型IO模型
IO的種類
IO模型根據特性可以分爲以下幾個種類:阻塞IO,非阻塞IO,信號驅動IO,異步IO,多路轉接IO。
阻塞IO
爲了IO發起IO調用,若IO條件不滿足則一直等待,直到條件具備。
非阻塞IO
爲了IO發起IO調用,若條件不滿足則直接報錯返回,執行其他指令。之後再次發起IO調用,條件不滿足則繼續報錯返回,條件滿足則直接進行數據拷貝後調用返回。
阻塞與非阻塞的區別在與發起一個調用是否能夠立即返回。
信號驅動IO
自定義IO信號,如果IO條件具備則發送IO信號,收到信號後則打斷其他操作進行信號處理,執行IO操作進行數據拷貝,結束後調用返回。
異步IO
自定義IO信號,發起IO調用,然後讓操作系統進行等待條件滿足,滿足後操作系統進行數據拷貝,拷貝完後通知進程,進程收到後直接處理數據。
與之對應的是同步的操作同步與異步的區別在於功能的完成是否由自身完成。
那麼是同步好還是異步好呢?答案是視使用場景而定。同步的流程控制更加簡單,但是不管是否阻塞都會浪費CPU資源,因此對CPU的利用率不足。而異步對CPU的利用率更高,但是流程控制更加複雜,並且IO調用越多,同一時間佔用的空間資源越多。
從以上IO種類來看,IO效率越來越高,但是流程控制越來越複雜,資源佔用也越來越多。
多路轉接IO
多路轉接IO對大量描述符進行事件監控,能夠讓用戶只對事件就緒的描述符進行操作。在網絡通信中,如果用戶僅僅對就緒的描述符進行操作,則流程在一個執行流中就不會阻塞,可以實現在一個執行流中對多個描述符進行併發操作。多路轉接IO的實現主要是通過幾種多路轉接模型實現:select/poll/epoll。
select模型
工作原理
select模型對大量描述符進行幾種事件監控,讓用戶能夠僅僅針對事件就緒的描述符進行操作,對就緒事件的判斷主要有以下幾個標準:
1、可讀事件:接收緩衝區中數據大小大於等於低水位標記(默認一個字節)。
2、可寫事件:發送緩衝區中空閒空間的大小大於等於低水位標記(默認一個字節)。
3、異常事件:描述符是否發生了某些異常。
實現流程
1、用戶首先定義事件集合,一共三種事件集合可讀/可寫/異常,每一個集合實際是一個位圖,將某個事件集合中描述符對應的位置1
用於標記用戶關心該描述符的某些事件。
2、將集合拷貝到內核進行監控。對集合中所有描述符進行遍歷判斷,判斷是否事件就緒。
3、若有某個描述符就緒了用戶關心的事件,則該返回給用戶結果了。返回的時候分別從各個事件集合中將沒有就緒該事件的描述符對應的位置0
,返回給用戶三種表示就緒的描述符事件集合。
4、用戶拿到就緒描述符事件集合後,通過分別遍歷三種事件集合找到哪些描述符還在集合中,來判斷哪些描述符已經就緒了指定事件,然後進行操作。
接口
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
//nfds:集合中最大的描述符+1
//readfds:可讀事件集合
//writefds:可寫事件集合
//exceptfds:異常事件集合
//timeout:NULL表示永久阻塞,timeout.tv_sec表示限時阻塞
//返回值:>0表示就緒的描述符個數;==0表示沒有描述符就緒;<0監控出錯
void FD_CLR(int fd, fd_set *set);//將指定的fd描述符從set集合中刪除
int FD_ISSET(int fd, fd_set *set);//判斷某個fd描述符是否在set集合中
void FD_SET(int fd, fd_set *set);//將指定的fd描述符添加到set集合中
void FD_ZERO(fd_set *set);//清空set集合內容
用select模型實現tcp服務器
按照以下步驟實現:
1、搭建tcp服務器。
2、在accept之前創建select監控集合,並且將監聽socket添加到可讀事件集合中。
3、進行select監控,當select返回並且有就緒描述符。
4、若有事件就緒,判斷就緒的描述符是否是監聽socket,如果是則accept
新的socket
添加監控,否則recv
數據。
每處理完一遍之後,都要再次對所有描述符進行監控,而每次系統都會將沒有就緒的描述符剔除,因此每次監控前我們都得先重新添加一遍所有要監控的描述符。
//用select模型實現併發服務器
//對select進行封裝
#include <iostream>
#include <vector>
#include <unistd.h>
#include <string>
#include <stdlib.h>
#include <sys/select.h>
#include "tcp_socket.hpp"
class Select
{
public:
Select():_maxfd(-1)
{
FD_ZERO(&_rfds);
}
//添加要監聽的套接字
bool Add(TcpSocket& sock)
{
int fd = sock.GetFd();
FD_SET(fd, &_rfds);
_maxfd = _maxfd > fd ? _maxfd : fd;
return true;
}
//監聽,list返回就緒的描述符,sec爲默認等待時間
bool Wait(std::vector<TcpSocket>& list, int sec = 3)
{
struct timeval tv;
tv.tv_sec = sec;
tv.tv_usec = 0;
fd_set tmp_set = _rfds;//每次定義新的集合進行監控,爲了避免原有的監控集合被修改
int count = select(_maxfd + 1, &tmp_set, NULL, NULL, &tv);
if(count < 0)
{
std::cout << "select error" << std::endl;
return false;
}
else if(count == 0)
{
std::cout << "wait timeout" << std::endl;
return false;
}
for(int i = 0; i <= _maxfd; i++)
{
if(FD_ISSET(i, &tmp_set))
{
TcpSocket sock;
sock.SetFd(i);
list.push_back(sock);
}
}
return true;
}
//刪除不用再監聽的套接字
bool Del(TcpSocket& sock)
{
int fd = sock.GetFd();
FD_CLR(fd, &_rfds);
for(int i = _maxfd; i >= 0; i--)
{
if(FD_ISSET(i, &_rfds))
{
_maxfd = i;
return true;
}
}
_maxfd = -1;
return true;
}
private:
fd_set _rfds;//讀事件描述符集合
int _maxfd;//最大描述符
};
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout << "./tcpselect ip port" << std::endl;
return -1;
}
TcpSocket sock;
std::string srv_ip = argv[1];
uint16_t srv_port = atoi(argv[2]);
CHECK_RET(sock.Socket());
CHECK_RET(sock.Bind(srv_ip, srv_port));
CHECK_RET(sock.Listen());
Select s;
s.Add(sock);
while(1)
{
std::vector<TcpSocket> list;
if(!s.Wait(list))
{
sleep(1);
continue;
}
for(auto clisock : list)
{
if(clisock.GetFd() == sock.GetFd())//監聽套接字
{
TcpSocket socktmp;
if(sock.Accept(socktmp) == false)
{
continue;
}
s.Add(socktmp);
}
else //通信套接字
{
std::string buf;
if(clisock.Recv(buf) == false)
{
s.Del(clisock);
clisock.Close();
continue;
}
std::cout << "client say:" << buf << std::endl;
buf.clear();
std::cin >> buf;
if(clisock.Send(buf) == false)
{
s.Del(clisock);
clisock.Close();
continue;
}
}
}
}
sock.Close();
}
以上的實現用到了我們之前實現的tcp_socket.hpp
,並將其類中的析構函數自動關閉套接字去掉。之後再用之前實現的tcp_cli.cpp
即tcp客戶端進行連接測試。
服務端:
[misaki@localhost AdvancedIO]$ ./tcpselect 192.168.239.128 9000
wait timeout
wait timeout
wait timeout
wait timeout
client say:nihao
wohenhao
wait timeout
client say:heihei
haha
wait timeout
wait timeout
wait timeout
peer shutdown
wait timeout
客戶端:
[misaki@localhost AdvancedIO]$ ./tcpclient 192.168.239.128 9000
nihao
server say: wohenhao
heihei
server say: haha
select優缺點分析
缺點:
1、select所能監控的描述符數量是有上線的,FD_SETSIZE = 1024
。
2、每次監控都需要將監控集合拷貝到內核中。
3、在內核中進行輪詢遍歷查詢,隨着描述符的增多性能降低。
4、返回的是就緒集合,需要用戶自己進行判斷才能對描述符進行操作。
5、每次返回都會清空未就緒描述符,因此每次監控都需要重新添加到集合中。
優點:
1、POSIX標準
,支持跨平臺。
poll模型
工作原理
poll
採用事件結構的方式對描述符進行事件監控,只需要一個事件結構數組,將要響應的描述符提交到數組的每一個結構節點的fd中,以及將用戶關心的事件添加到響應節點events
中。即可進行監控。
接口
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
//以下這個結構體用於告訴系統需要監控哪些描述符以及所需監控的狀態
struct pollfd
{
int fd; /* file descriptor */
short events; //事件,POLLIN-可讀/POLLOUT-可寫
short revents等 //這次監控返回之後這個描述符就緒的狀態
}
//fds:struct pollfd數組
//nfds:數組的大小
//timeout:超時時間,單位ms
//返回值:小於0出錯,等於0超時,大於零有描述符就緒
使用流程
1、用戶定義一個事件結構數組,然後將關心的描述符以及事件添加到數組中。
2、將數組拷貝到內核進行監控。在內核中會輪詢遍歷監控。
3、當事件數組中有描述符事件就緒/等待超時則poll返回,poll返回時將每個描述符就虛的事件添加到相應節點revents
中。
4、用戶對數組中的每個節點的revents
進行判斷之後進行操作。
優缺點分析
缺點:
1、無法跨平臺。
2、依然需要將監控的描述符事件數組拷貝到內核中。
3、
在內核中同樣是輪詢遍歷監控,性能會隨着描述符的增多而下降。
4、只是將就緒的事件放到了結構的revents
中,需要用戶輪詢查找就緒的描述符。
優點:
1、沒有描述符監控的數量上限。
2、採用事件結構進行監控,簡化了select
三種事件集合的操作流程。
3、不需要每次重新向集合中添加事件數組。
epoll模型
epoll
模型是爲了處理大批量句柄而做了改進的poll
,它幾乎具備了所需的一切優點,是Linux下性能最好的多路I/O模型。
接口
int epoll_create(int size);
//在內核中創建eventpoll結構,結構中主要信息:雙向鏈表,紅黑樹,具體作用看下文。返回一個操作句柄描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//向內核中的eventpoll結構中的紅黑樹添加用戶關心的描述符事件結構
//epfd:epoll操作句柄
//op:工作模式,EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL
//fd:用戶關心的描述符
//event:對於fd描述符所要監控的事件,結構體參考如下
struct epoll_event {
uint32_t events; //要監聽的事件EPOLLIN可讀/EPOLLOUT可寫
epoll_data_t data; //就緒的描述符,這個值通常與fd一致
};
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
//epfd:epoll操作句柄
//events:就緒事件數組,返回就緒的描述符和對應的描述符
//maxevents:最大節點數
//timeout:超時事件,單位ms,-1阻塞,0非阻塞
工作原理
epoll
監控是一個異步阻塞操作。對描述符的監控由操作系統完成,當描述符就緒之後,則將就緒的描述符對應的epoll_event
結構添加到雙向鏈表list
中,而當前進程只是每隔一段時間判斷以下list
是否爲空,即可知道是否有描述符就緒。
操作系統完成監控,對於每一個描述符所關心的事件都定義了一個事件回調,當描述符就緒事件的時候就會調用回調函數,這個回調函數負責將事件結構信息即struct epoll_event
添加到雙向鏈表中。
epoll_wait
會自動檢測list
雙向鏈表,檢測到鏈表list
不爲空,表示有就緒事件,則將這個鏈表中這些epoll_event
放到用戶的events
數組中返回出去。
用epoll模型實現tcp服務器
#include <iostream>
#include <vector>
#include <string>
#include <sys/epoll.h>
#include <unistd.h>
#include "tcp_socket.hpp"
#define MAX_EPOLL 1024
class Epoll
{
public:
Epoll()
:_epfd(-1)
{
}
~Epoll()
{
}
bool Init()
{
_epfd = epoll_create(MAX_EPOLL);
if(_epfd < 0)
{
std::cerr << "create epoll error" << std::endl;
return false;
}
return true;
}
bool Add(TcpSocket& sock)
{
struct epoll_event ev;
int fd = sock.GetFd();
ev.events = EPOLLIN;
ev.data.fd = fd;
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
if(ret < 0)
{
std::cerr << "append monitor error" << std::endl;
return false;
}
return true;
}
bool Del(TcpSocket& sock)
{
int fd = sock.GetFd();
int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
if(ret < 0)
{
std::cerr << "remove monitor error" << std::endl;
return false;
}
return true;
}
bool Wait(std::vector<TcpSocket>& list, int timeout = 3000)
{
struct epoll_event evs[MAX_EPOLL];
int nfds = epoll_wait(_epfd, evs, MAX_EPOLL, timeout);
if(nfds < 0)
{
std::cerr << "epoll monitor error" << std::endl;
return false;
}
else if(nfds == 0)
{
std::cerr << "epoll monitor timeout" << std::endl;
return false;
}
for(int i = 0; i < nfds; i++)
{
int fd = evs[i].data.fd;
TcpSocket sock;
sock.SetFd(fd);
list.push_back(sock);
}
return true;
}
private:
int _epfd;
};
int main(int argc, char* argv[])
{
if(argc != 3)
{
std::cout << "./tcp_epoll ip port" << std::endl;
return -1;
}
std::string srv_ip = argv[1];
uint16_t srv_port = atoi(argv[2]);
TcpSocket lst_sock;
CHECK_RET(lst_sock.Socket());
CHECK_RET(lst_sock.Bind(srv_ip, srv_port));
CHECK_RET(lst_sock.Listen());
Epoll e;
CHECK_RET(e.Init());
CHECK_RET(e.Add(lst_sock));
while(1)
{
std::vector<TcpSocket> list;
if(e.Wait(list) == false)
{
sleep(1);
continue;
}
for(size_t i = 0; i < list.size(); i++)
{
//監聽套接字
if(list[i].GetFd() == lst_sock.GetFd())
{
TcpSocket cli_sock;
if(lst_sock.Accept(cli_sock) == false)
{
continue;
}
CHECK_RET(e.Add(cli_sock));
}
else
{
std::string buf;
if(list[i].Recv(buf) == false)
{
list[i].Close();
continue;
}
std::cout << "Client Say:" << buf << std::endl;
buf.clear();
std::cin>> buf;
if(list[i].Send(buf) == false)
{
list[i].Close();
continue;
}
}
}
}
lst_sock.Close();
}
//服務端
[misaki@localhost AdvancedIO]$ ./tcpepoll 192.168.239.128 9000
epoll monitor timeout
epoll monitor timeout
epoll monitor timeout
Client Say:nihao
heihei
epoll monitor timeout
Client Say:haha
xixi
peer shutdown
epoll monitor timeout
//客戶端
[misaki@localhost AdvancedIO]$ ./tcpclient 192.168.239.128 9000
nihao
server say: heihei
haha
server say: xixi
在事件結構體中events
字段還有另外幾種事件觸發模式EPOLLLT
水平觸發,EPOLLET
邊緣觸發。
水平觸發對於可讀事件來說,只要接收緩衝區中的數據大小高於低水位標記就會觸發事件。對於可寫事件來說,只要發送緩衝區中剩餘空間高於低水位標記時就會觸發事件。總結來說就是隻要緩衝區滿足可讀或可寫要求就會觸發事件。舉個例子,如果你在讀取緩衝區,一次沒有讀完,只要緩衝區中還有數據那麼下一次epoll
會繼續觸發事件告訴你有東西可讀。
邊緣觸發對於可讀和可寫事件來說都是隻有當緩衝區中內容改變即新數據接收到接收緩衝區或發送緩衝區數據被髮送走時纔會觸發時間。同樣舉個例子,如果想要讀取接收緩衝區的內容,一次沒有讀取完,雖然緩衝區中還有數據但是下一次epoll
不會觸發事件讓你繼續讀取,知道有新的數據接收到接收緩衝區中,此時你可以連着上次的數據和新數據一起讀取。
水平觸發和邊緣觸發沒有明確的性能差距,但是邊緣觸發一次讀寫只觸發一次,確實比水平觸發要觸發的次數少,系統就不會觸發一些你不關心的就緒文件描述符。因此有時使用邊緣觸發更好一些,但是爲了一次觸發就能把緩衝區中數據全部讀完需要循環讀取數據,知道讀完位置,又爲了不造成阻塞要將描述符設置爲非阻塞,然後循環讀取數據直到EAGAIN
錯誤就表示一次讀完了。其實實際使用中都是非阻塞循環進行數據讀取,畢竟在不清楚數據到底有多少的情況下,要想一次讀完就只能採用以上方法。
epoll優缺點分析
優點:
1、採用事件結構方式監控,簡化多個監控集合的操作流程。
2、沒有所能監控的描述符數量上限。
3、epoll監控的事件只需要向內核拷貝一次,不需要每次都拷貝。
4、監控採用異步阻塞,在內核中進行事件回調方式監控,因此性能不會隨着描述符的增多而降低。
5、直接返回就緒事件結構,用戶可以通過就緒事件結構中的描述符直接操作,不需要進行遍歷判斷。
缺點:
1、不支持跨平臺。
多路轉接IO適用場景
多路轉接模型適用於對大量描述符進行監控,但是同一時間只有少量活躍的場景。多線程/多進程的併發處理時比較高效且公平的。但是多路轉接模型的併發是輪詢處理的,一個處理完纔會處理下一個,如果活躍描述符很多,則會導致後面的描述符等待時間過長。
因此使用epoll
往往是用其進行活躍判斷,當描述符活躍再將其放到線程池中進行公平處理。這樣的搭配纔是較爲完美的。