socket I/O機制學習
socket 多路I/O 機制學習
socket是不同程序間通訊的一種方法,在作業系統看來是一種I/O的介面。之前在計算機網路的課程中學習過socket的基本使用,但是對於其中的細節並不是完全瞭解,特別是除了同步阻塞模式,對其他I/O模式並不瞭解。這裡簡單總結一下不同的I/O模式,然後介紹一下現在業界常用的epoll方法進行多路複用I/O的大致細節。
I/O模式
常見的I/O模式按照同步/阻塞,分為以下這幾種:
- 同步阻塞模式
- 同步非阻塞模式
- 多路複用I/O
- 非同步非阻塞模式
所謂同步,是指程序呼叫read/write方法時,會等待資料到達核心緩衝區,然後再主動取出資料並返回。而阻塞是指,程序等待資料到達核心緩衝區,是停在此處一直等待(阻塞),還是返回一個狀態碼後繼續執行,等到下一次再來檢查(非阻塞)。據此,可以很容易看出來同步阻塞模式和同步非阻塞模式的區別:呼叫read/write方法是否導致程序阻塞。
但是,這兩種模式都是建立在單個socket的基礎上來分析的。如果某個程序同時管理多個socket,想要在某個連線可用的時候對該連線執行相應呼叫,那麼就需要多路複用I/O來支援該操作。例如,對於一個Web伺服器程序,同時開了多個連線到不同的客戶端,每個客戶端發來的請求都是不定的。對於服務程序來說,就想要在某個客戶端發來請求的時候被告知,然後處理這個請求。
而非同步非阻塞模式,則是指程序從不等待資料到達緩衝區,而是由作業系統核心負責在網路資料到達時,將資料複製到程序指定的區域,並呼叫程序的回撥函式來處理該資料。(那麼,為何多數仍使用多路複用I/O,而不是非同步I/O?)
多路複用I/O:select/epoll
select/epoll的實現主要是基於Linux核心的wakeup callback機制。下面首先簡單介紹一下該機制。
wakeup callback機制
該機制主要是為每個socket建立了一個連結串列sleep_list,維護了所有等待該socket的process。對於每個process,都維護了一個wait_entry來表示其關心的事件,並帶上一個callback。如果某個socket的事件發生,就會尋找這個連結串列上的wait_entry,執行其callback並喚醒對應的process。
select
最簡單的多路複用的方法是select方法。select方法的原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
前幾個引數指定了要管理的sockets的列表,最後一個引數指定了一個timeout,如果在timeout內都沒有socket就緒,則返回一個狀態碼。
select的流程如下:
- 核心從使用者區域複製監控的fds到核心區域,掃描這些fds,對於每個fd,檢視是否有事件發生。
- 如果沒有,則對於每個fd對應的socket,連同process包裝成一個wait_entry物件,放在該socket的sleep_list上。
- 如果某個socket有事件發生,則會通知核心。核心會執行在wait_entry中註冊的callback函式:掃描所有監控的socket,檢查哪個socket發生了事件。如果找到,則從該socket的sleep_list上遍歷sk_wait_entry物件喚醒process,將收集的事件返回給使用者。
從上面的流程可以看出,select主要有這麼幾個缺點:
- 每次呼叫都會導致所有fds從使用者態到核心態的複製,然而在實際業務中,監控的fds絕大多數是不會變動的,沒有必要每次都全量複製
- 即使只有一個socket可用,select還是會掃描所有fds
在高併發的場景下,上述兩個問題會造成很大的效能開銷。為解決這兩個問題,大神們提出了epoll方法。
epoll
我們來看一下epoll是怎麼解決上述兩個問題的。首先來看一下epoll的使用。不同於select,epoll有三個呼叫介面,分別是epoll_create,epoll_ctl和epoll_wait。epoll_create建立一個epoll管理器,epoll_ctl負責對監控的sockets進行增刪改,而epoll_wait則使process進入阻塞狀態,等待可用socket事件喚醒。它們的原型如下:
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);
這種設計利用瞭解耦的思想,將高頻的epoll_wait和低頻的epoll_ctl分開了,使得我們可以只傳遞增加/刪除的socket到核心區域,而不需要每次等待socket就緒的時候,都需要把所有的fds都複製到核心區域,節約了大量的記憶體複製開銷。同時,epoll也通過mmap,將一部分使用者區域和核心區域共同對映到一個儲存fds的地方,使得不需要複製資料,使用者程序就可以讀到fds的資訊,因此解決了第一個問題。
那麼,第二個問題是如何解決的呢?我們先來看一下呼叫這三個介面的時候,核心都幹了些什麼。
在epoll_create呼叫時,核心在其空間內建立了一個雙向連結串列ready_list,用於維護已經就緒的socket。當一個socket發生了某個事件,核心會將其插入到ready_list中。該設計使得我們獲取就緒socket的時候,不需要遍歷所有sockets。
在程序呼叫epoll_ctl的時候,核心會維護其內建的資料結構,記錄程序監控的fds。這個資料結構要求能夠快速的增刪查改,hash表是一個好的選擇。同時,核心會包裝一個sk_wait_entry物件,放在該socket的sleep_list上。
除此之外,核心也維護了一個single_epoll_wait_list佇列,當某個process呼叫epoll_wait等待某個socket集合的時候,會被插入到single_epoll_wait_list佇列中。
瞭解了這三個介面背後的動作,我們再來看一下當一個socket可用的時候,epoll幹了些什麼。當某個socket事件發生的時候,wakeup callback機制會在該socket的sleep_list中找到之前的sk_wait_entry物件。與select不同,在epoll的這個物件中,callback函式的動作是將該socket到之前提到的ready_list中。然後,對於每個ready_list上的socket,核心檢查single_epoll_wait_list中的process是否監控該socket(由於epoll_ctl資料結構的存在,這個過程很快),如果是,則喚醒該process,並將其從single_epoll_wait_list中刪除,同時也將ready_list上的socket刪除。我們可以看到,通過對callback函式的變動,我們避免了對所有sockets的掃描,從而解決了第二個問題。
上述內容只是對於select/epoll的簡單認知。具體的細節還要檢視相應的官方文件。