本文主要分析了幾種Socket程式設計的模式。主要包括基本的阻塞Socket、非阻塞Socket、I/O多路複用。其中,阻塞和非阻塞是相對于套接字來說的,而其他的模式本質上來說是基于Socket的并發模式。I/O多路複用又主要分析了分析linux和windows下的常用模型。最後,比較這幾種Socket程式設計模式的優缺點,并讨論多線程與Socket的組合使用和伺服器開發的常用模式。
阻塞模式
阻塞模式是最基本的Socket程式設計模式,在各種關于網絡程式設計的書籍中都是入門的例子。就像其名所說,阻塞模式的Socket會阻塞目前的線程,直到結果傳回,否則會一直等待。
非阻塞模式
非阻塞模式是相對阻塞模式來說,Socket并不會阻塞目前線程,非阻塞模式不會等到結果傳回,而會立即運作下去。
//設定套接字為非阻塞模式
fcntl( sockfd, F_SETFL, O_NONBLOCK); //O_NONBLOCK标志設定非阻塞模式
這裡需要注意,阻塞/非阻塞、同步/異步之前的差別。在本質上它們是不同的。同步和異步是相對操作結果來說,會不會等待結果結果傳回。而阻塞和非阻塞是相對線程是否被阻塞來說的。其實,這兩者存在本質的差別,它們的修飾對象是不同的。阻塞和非阻塞是指程序通路的資料如果尚未就緒,程序是否需要等待,簡單說這相當于函數内部的實作差別,也就是未就緒時是直接傳回還是等待就緒。而同步和異步是指通路資料的機制,同步一般指主動請求并等待I/O操作完畢的方式,當資料就緒後在讀寫的時候必須阻塞,異步則指主動請求資料後便可以繼續處理其它任務,随後等待I/O,操作完畢的通知,這可以使程序在資料讀寫時也不阻塞。因為兩者在表現上經常相同,是以經常被混淆。
I/O多路複用
I/O多路複用是一種并發伺服器開發技術(處理多個用戶端的連接配接)。通過該技術,系統核心緩沖I/O資料,當某個I/O準備好後,系統通知應用程式該I/O可讀或可寫,這樣應用程式可以馬上完成相應的I/O操作,而不需要等待系統完成相應I/O操作,進而應用程式不必因等待I/O操作而阻塞。
在linux下主要有select、poll、epoll三種模型,在freeBSD下則有kqueue,windwos下select、事件選擇模型、重疊I/O和完成端口等。
linux上I/O複用模型
select
select本質是通過設定或檢查存放fd标志位的資料結構來進行下一步的處理。select是采用輪詢fd集合來進行處理的。
//select相關函數
int select(int maxfdp1, fd_set *readset, fd_set *writeset,
fd_set *exceptset,const struct timeval *timeout)
//傳回值:就緒描述符的數目,逾時傳回0,出錯傳回-1
void FD_ZERO(fd_set *fdset); //清空集合
void FD_SET(int fd, fd_set *fdset); //将一個給定的檔案描述符加入集合之中
void FD_CLR(int fd, fd_set *fdset); //将一個給定的檔案描述符從集合中删除
int FD_ISSET(int fd, fd_set *fdset); // 檢查集合中指定的檔案描述符是否可以讀寫
但是,select存在一定的缺陷。單個程序可監視的fd數量被限制,linux下一般為1024。雖然是可以修改的,但是總是有限制的。在每次調用select時,都需要把fd集合從使用者态拷貝到核心态,而且需要循環整個fd集合,這個開銷很多時候是比較大的。
poll
poll的實作和select非常相似,本質上是相同,隻是描述fd集合的方式不同。poll是基于連結清單來存儲的。這雖然沒有了最大連接配接數的限制,但是仍然還有fd集合拷貝和循環帶來的開銷。而且poll還有一個特點是水準觸發,核心通知了fd後,沒有被處理,那麼核心就會不斷的通知,直到被處理。
//poll相關函數
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
epoll
epoll是對select和poll的改進。相較于poll,epoll使用“事件”的就緒通知,通過epoll_ctl注冊fd,一旦該fd就緒,核心就會采用類似callback的回調機制來激活該fd,把就緒fd放入就緒連結清單中,并喚醒在epoll_wait中進入睡眠的程序,這樣不在需要輪詢,判斷fd合計合集是否為空。而且epoll不僅支援水準觸發,還支援邊緣觸發。邊緣觸發是指核心通知fd之後,不管處不處理都不在通知了。在存儲fd的集合上,epoll也采用了更為優秀的mmap,而且會保證fd集合拷貝隻會發生一次。
//epoll相關函數
int epoll_create(int size); //句柄的建立
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event); //事件注冊
int epoll_wait(int epfd, struct epoll_event * events,
int maxevents, int timeout); //等待事件的發生
Windows上的I/O複用模型
事件選擇模型
事件選擇模型是基于消息的。它允許程式通過Socket,接收以事件為基礎的網絡事件通知。
//事件選擇模型相關函數
WSAEVENT WSACreatEvent(void); //建立事件對象
int WSAEventSelect(SOCKET s, WSAEVENT hEventObject,
long lNetworkEvents); //關聯事件
重疊I/O模型
重疊I/O模型是異步I/O模型。重疊模型的核心是一個重疊資料結構。重疊模型是讓應用程式使用重疊資料結構(WSAOVERLAPPED),一次投遞一個或多個Winsock I/O請求。若想以重疊方式使用檔案,必須用FILE_FLAG_OVERLAPPED 标志打開它。當I/O操作完成後,系統通知應用程式。利用重疊I/O模型,應用程式在調用I/O函數之後,隻需要等待I/O操作完成的消息即可。
HANDLE hFile = CreateFile(lpFileName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
完成端口模型(IOCP)
IOCP完成端口是目前Windows下性能最好的I/O模型,當然也是最複雜的。簡單的說,IOCP 是一種高性能的I/O模型,是一種應用程式使用線程池處理異步I/O請求的機制。IOCP将所有使用者的請求都投遞到一個消息隊列中去,然後線程池中的線程逐一從消息隊列中去取出消息并加以處理,就可以避免針對每一個I/O請求都開線程。不僅減少了線程的資源,也提高了線程的使用率。
//IOCP簡單流程
//建立完成端口
Port port = createIoCompletionPort(INVALID_HANDLE_VALUE,
0, 0, fixedThreadCount());
//将Socket關聯到IOCP
CreateIoCompletionPort((HANDLE )m_sockClient,m_hIocp,
(ULONG_PTR )m_sockClient, 0);
//投遞AcceptEx請求
LPFN_ACCEPTEX m_lpfnAcceptEx; // AcceptEx函數指針
GUID GuidAcceptEx = WSAID_ACCEPTEX; // GUID,這個是識别AcceptEx函數必須的
DWORD dwBytes = 0;
WSAIoctl(
m_pListenContext->m_Socket,
SIO_GET_EXTENSION_FUNCTION_POINTER,
&GuidAcceptEx,
sizeof(GuidAcceptEx),
&m_lpfnAcceptEx,
sizeof(m_lpfnAcceptEx),
&dwBytes,
NULL,
NULL);
//使用GetQueuedCompletionStatus()監控完成端口
void *lpContext = NULL;
OVERLAPPED *pOverlapped = NULL;
DWORD dwBytesTransfered = 0;
BOOL bReturn = GetQueuedCompletionStatus(
pIOCPModel->m_hIOCompletionPort,
&dwBytesTransfered,
(LPDWORD)&lpContext,
&pOverlapped,
INFINITE );
//收到通知
int nBytesRecv = WSARecv(pIoContext->m_Socket, pIoContext ->p_wbuf,
1, &dwBytes, 0, pIoContext->p_ol, NULL);
線程的使用
在以上I/O複用模型的讨論中,其實都含有線程的使用。重疊I/O和I/O完成端口都是利用了線程。這也可以看出在高并發伺服器的開發中,采用線程也是十分必要的。在I/O完成端口的使用中,還會使用到線程池,這也是現在應用十分廣泛的。通過線程池,可以降低頻繁建立線程帶來的開銷。
在Windows下一般使用windows提供I/O模型就足夠應付很多場景。但是,在linux下I/O模型都是和線程不相關的。有時為了更高的性能,也會采取線程池和I/O複用模型結合使用。比如許多Linux服務端程式就采用epoll和線程池結合的形式,當然引入線程也帶來了更多的複雜度,需要注意線程的控制和性能開銷(線程的主要開銷線上程的切換上)。而epoll本來也足夠優秀,是以僅用epoll也是可以的,像libevent這種著名的網絡庫也是采用epoll實作的。當然,在linux下也有隻使用多程序或多線程來達到并發的。這樣會帶來一定缺點,程式需要維護大量的Scoket。在服務端開發中使用線程,也要勁量保證無鎖,鎖也是很高的開銷的。