本文主要分析了幾種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。在服務端開發中使用線程,也要勁量保證無鎖,鎖也是很高的開銷的。