面試題:BIO,NIO,AIO 的區別是什么?說說 select 和 epoll 工作機制與差異?為何 epoll 如此高效
面試官:說說看你知道的IO模型有哪些?
先說說看什么是IO吧。IO表示輸入輸出,訪問外部設備讀取數據或者向外部設備寫數據。常見的文件讀寫操作、網絡通信操作,其實都是IO的過程。
這里以阻塞式讀取為例,分析IO操作所進行的工作:
- 當read/recv時,如果緩沖區中沒有數據,那就阻塞式等待數據就緒 -- 等待。
- 當read/recv時,如果緩沖區中有數據,那么就將數據從緩沖區拷貝到應用層 -- 拷貝。
因此,我們可以認為,IO = 等待 + 拷貝。只要執行流(進程/線程)參與了等待和拷貝其中之一,那么就認為這個執行流進行了IO操作。
下圖展示了在進行寫文件操作時系統所進行的工作,可分為三步:
再來說IO模型,經典的IO模型主要包括以下幾種:
一、阻塞IO(BIO)
定義:在BIO模型中,當程序進行IO操作時,它會持續等待直到操作完成。在此期間,線程被阻塞,不能執行其他任務。
特點:
- 傳統的IO模型,適用于連接請求較少且請求處理簡單的場景。
- 在這種模型中,服務器為每個客戶端連接創建一個線程,導致線程數量隨著客戶端的增加而急劇增加,消耗大量系統資源。
二、非阻塞IO(NIO)
定義:為了解決BIO模型的線程阻塞問題,NIO模型引入了非阻塞的概念。在NIO中,當一個線程進行IO操作時,它不會等待操作完成,而是繼續執行其他任務。當IO操作完成時,線程會收到通知。非阻塞式IO一般采用輪詢檢查的方法進行IO操作,即:通過循環,不斷檢查IO資源是否已經就緒,就緒就讀取,不就緒就執行其他的工作。
特點:
- 提高了線程的利用率,適用于連接請求多且單個請求處理耗時較長的場景。
- 需要不斷輪詢或者使用事件通知來檢查操作是否完成,可能會占用一定的CPU時間。
- Java的NIO庫提供了基于Reactor設計模式的非阻塞IO操作,也稱為IO多路復用。
三、異步IO(AIO)
定義:異步IO就是發起IO的執行流本身不參與IO工作,而是有另外一個執行流來進行IO操作,發起IO的線程只需要等待進行IO的執行流反饋回結果。這樣發起IO的執行流就不需要等到,可以處理其他的工作。當操作完成時,系統會通知該線程。
特點:
- 能夠顯著提高并發性能,因為多個操作可以同時進行。
- 適用于高并發、高吞吐量的場景,如網絡服務器、大規模并行計算等。
- Java的NIO2(即AIO)基于Proactor設計模式的異步非阻塞模型。
四、IO多路復用
定義:使用操作系統提供的select、poll或epoll等多路復用機制,允許應用程序同時監視多個IO事件。
特點:
- 應用程序可以將多個IO請求注冊到一個多路復用器上,然后通過輪詢或者阻塞等待多路復用器通知事件的發生。
- 適用于需要同時處理多個連接的場景,提高了系統的并發性能。
- 有效地管理多個IO操作,減少系統資源的消耗。
五、信號驅動IO
定義:通過自定義對SIGIO信號的處理函數來實現信號驅動式IO,當進程收到SIGIO信號的時候,就調用對應的處理函數來進行IO操作,這樣保證在調用IO接口的時候數據一定是就緒的,在沒有收到信號時不影響進程進行其他的工作,信號驅動式IO避免了阻塞等待資源就緒,提高了IO效率。
特點:
- 相比阻塞IO和非阻塞IO更為靈活。
- 適用于需要處理多個IO事件的場景。
面試官:阻塞IO(BIO)和非阻塞IO(NIO)的區別是什么?NIO和異步IO(AIO)的區別是什么?
一、以下是 BIO 和 NIO 的主要差異:
(1) 阻塞IO(Blocking IO)
- 等待操作完成:在阻塞IO模型中,當一個線程發起一個I/O請求(如讀取文件、網絡通信等)時,該線程會被阻塞,直到I/O操作完成并且數據已經準備好。
- 線程占用:在I/O操作進行期間,線程無法執行其他任務,導致線程的利用率降低。如果I/O操作耗時較長,線程可能會長時間處于空閑狀態。
- 簡單性:阻塞IO模型相對簡單,因為開發者不需要處理I/O操作的異步通知和狀態檢查。
- 資源消耗:在高并發場景下,阻塞IO模型可能導致大量的線程被創建和阻塞,從而消耗大量的系統資源(如內存和CPU)。
(2) 非阻塞IO(Non-blocking IO)
- 立即返回:在非阻塞IO模型中,當一個線程發起一個I/O請求時,該請求會立即返回,不會阻塞線程。線程可以繼續執行其他任務,而無需等待I/O操作完成。
- 輪詢或回調:非阻塞IO模型通常需要通過輪詢(polling)或回調(callback)機制來檢查I/O操作的狀態。輪詢是主動檢查I/O操作是否完成,而回調是當I/O操作完成時由系統通知應用程序。
- 復雜性:非阻塞IO模型相對復雜,因為開發者需要處理I/O操作的異步通知和狀態檢查,以及管理多個I/O請求的狀態。
- 資源效率:在高并發場景下,非阻塞IO模型可以更有效地利用系統資源,因為線程不會被阻塞,可以處理更多的并發請求。然而,由于需要頻繁地檢查I/O操作的狀態,可能會增加CPU的使用率。
(3) 選擇
- 阻塞IO適用于連接請求較少且請求處理簡單的場景,或者當I/O操作不會顯著影響程序性能時。
- 非阻塞IO適用于連接請求多且單個請求處理耗時較長的場景,或者當需要處理大量并發請求時。
二、以下是NIO和AIO的差異:
(1) 工作原理
NIO:
- 同步非阻塞I/O模型。
- 使用一個單獨的線程或線程池來處理所有的I/O操作。
- 當一個操作不能立即完成時,線程不會被阻塞,而是繼續執行其他任務。
- 使用緩沖區(Buffer)來存儲數據,并通過選擇器(Selector)來監聽多個通道(Channel)上的事件,從而實現并發處理多個I/O操作。
AIO:
- 異步非阻塞I/O模型。
- 不需要通過選擇器來監聽通道的事件,而是由操作系統在數據準備就緒時通知應用程序。
- 應用程序在數據讀寫操作時不會被阻塞,而是在操作完成時收到通知。
- 引入了異步通道的概念,簡化了程序編寫,并提高了并發處理能力。
(2) 性能與資源消耗
NIO:
- 通過并發處理多個I/O操作來提高性能。
- 需要合理地管理線程池和緩沖區資源,以避免資源耗盡或性能下降。
AIO:
- 通過異步操作來避免線程阻塞,從而提高了系統的并發處理能力。
- 在高并發場景下,AIO通常能夠表現出更好的性能,因為它能夠更有效地利用系統資源。
(3) 編程復雜度
NIO:
- 編程相對復雜,需要處理緩沖區、選擇器、通道等組件的交互。
AIO:
- 編程復雜度也較高,但相對于NIO來說,由于引入了異步操作的概念,可以更加靈活地處理I/O操作。
- 開發者需要熟悉AIO的編程模型,并能夠正確地處理異步通知和回調機制。
面試官:能詳細說一下多路復用中 select 和 epoll 的工作機制和性能差異嗎?
先說說多路復用。多路復用(Multiplexing)是一種能夠同時監控多個文件描述符(如網絡連接、文件、管道等),一旦某個描述符就緒(通常指有數據可讀或可寫),便能夠通知程序進行相應的讀寫操作的技術。
select和epoll是兩種常見的I/O多路復用機制,它們的工作機制和性能差異如下:
一、select的工作機制
基本原理:
- select函數允許一個進程同時監視多個文件描述符,以查看它們是否有I/O事件發生(如可讀、可寫或有異常)。
- 它通過監視一個文件描述符集合,在其中的文件描述符中有I/O事件發生時返回。
使用方法:
- 調用select時,需要指定三個文件描述符集合:讀集合、寫集合和異常集合,以及一個時間限制。
- select函數會阻塞,直到至少有一個文件描述符準備好進行I/O操作,或者超時發生。
- 當select返回時,它會通過修改這三個集合來指示哪些文件描述符已經準備好進行I/O操作。
性能瓶頸:
- 當監視的文件描述符數量很大時,select的性能會顯著下降,因為它需要遍歷整個文件描述符集合來查找哪些文件描述符已經準備好進行I/O操作。
- select有一個內置的文件描述符數量限制(通常是1024個),這在某些高并發場景下可能不夠用。
- select的超時參數是一個時間間隔,而不是一個精確的時間點,這可能導致在某些情況下時間控制不準確。
二、epoll的工作機制
基本原理:
- epoll是Linux內核提供的一種I/O多路復用機制,是select和poll機制的改進版。
- 它使用事件驅動的方式,只通知那些真正發生了I/O事件的文件描述符。
核心API:
- epoll_create():創建一個epoll實例,并返回一個文件描述符。
- epoll_ctl():將需要監控的文件描述符添加到epoll實例中,或從epoll實例中刪除,或對監聽事件進行修改。
- epoll_wait():等待注冊的事件發生,返回事件的數目,并將觸發的事件寫入事件數組中。
工作流程:
- 應用程序通過epoll_create()系統調用創建一個epoll實例。
- 應用程序通過epoll_ctl()系統調用將需要監控的文件描述符添加到epoll實例中。
- 內核會將這些文件描述符及其感興趣的事件類型(可讀、可寫、異常等)記錄在內核的事件表中。
- 應用程序通過epoll_wait()系統調用進入休眠狀態,等待內核通知有事件發生。
- 內核會不斷監控這些文件描述符的狀態變化,一旦有事件發生,就會將就緒的文件描述符添加到就緒隊列中。
- 當epoll_wait()被喚醒時,內核會返回就緒隊列中的文件描述符列表以及就緒的事件類型。
- 應用程序根據返回的信息來處理對應的I/O操作。
性能優勢:
- epoll的時間復雜度接近O(1),而select是O(n),因此在處理大量文件描述符時,epoll具有明顯的性能優勢。
- epoll沒有文件描述符數量的限制,可以支持海量的文件描述符。
- epoll支持邊緣觸發(Edge Triggered)和水平觸發(Level Triggered)兩種工作模式,提供了更靈活的事件通知機制。
三、select與epoll的性能比較
時間復雜度:
- select的時間復雜度為O(n),需要遍歷整個文件描述符集合來查找就緒的文件描述符。
- epoll的時間復雜度接近O(1),因為它使用內核事件表來存儲需要監控的文件描述符,并只返回就緒的文件描述符。
文件描述符數量限制:
- select有文件描述符數量的限制(通常是1024個),這在某些高并發場景下可能不夠用。
- epoll沒有文件描述符數量的限制,可以支持海量的文件描述符。
系統資源消耗:
- 當監視的文件描述符數量很大時,select會消耗大量的系統資源,因為它需要復制整個文件描述符集合到用戶空間。
- epoll則不會消耗大量的系統資源,因為它只需要在文件描述符狀態發生變化時才更新內核事件表。
可擴展性:
- 由于select的性能瓶頸和文件描述符數量限制,它在高并發場景下可能無法提供足夠的可擴展性。
- epoll則具有良好的可擴展性,可以支持大量的并發連接和文件描述符。
面試官:epoll底層的數據結構什么,請結合epoll的底層結構說說epoll的工作機制。以及為什么epoll如此高效?
epoll底層主要使用了紅黑樹和就緒隊列(雙鏈表)這兩種數據結構,其高效性主要源于以下幾個方面:
一、epoll底層數據結構
紅黑樹:
- 用于存儲所有添加到epoll中的需要監控的事件。
- 紅黑樹的插入、刪除和查找操作的時間復雜度都是O(log n),其中n是樹中節點的數量。這使得epoll能夠高效地管理大量的文件描述符。
就緒隊列(雙鏈表):
- 存放著將要通過epoll_wait返回給用戶的滿足條件的事件。
- 當有I/O事件發生時,內核會將對應的事件添加到就緒隊列中。
- epoll_wait在調用時只需要檢查就緒隊列中是否有事件即可,無需遍歷整個紅黑樹。
再聊聊epoll的具體實現機制。
如下圖所示:
當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。
struct eventpoll{
....
/*紅黑樹的根節點,這顆樹中存儲著所有添加到epoll中的需要監控的事件*/
struct rb_root rbr;
/*雙鏈表中則存放著將要通過epoll_wait返回給用戶的滿足條件的事件*/
struct list_head rdlist;
....
};
每一個epoll對象都有一個獨立的eventpoll結構體,用于存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,重復添加的事件就可以通過紅黑樹log(n)的查找復雜度高效的識別出來(其中n為樹的高度)。
所有添加到epoll中的事件都會與設備(如網卡)驅動程序建立回調關系。這個回調方法會將就緒的事件添加到rdlist雙鏈表(就緒鏈表)中。
每一個事件對應一個epitem結構體,如下所示。
struct epitem{
struct rb_node rbn;//紅黑樹節點
struct list_head rdllink;//雙向鏈表節點
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所屬的eventpoll對象
struct epoll_event event; //期待發生的事件類型
}
當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的就緒隊列rdlist中是否有epitem元素即可。如果rdlist不為空,則把發生的事件復制到用戶態,同時將事件數量返回給用戶進程,最后用戶進程根據事件執行相應的讀寫處理。
二、epoll高效性原因
事件通知機制:
- epoll使用事件通知的方式,當監控的文件描述符有事件發生時才會返回。
- 這減少了不必要的系統調用和開銷,因為只有在真正有事件發生時才會進行后續的處理。
避免數據拷貝:
- 在使用epoll時,可以將要監聽的文件描述符注冊到內核中。
- 之后,用戶程序只需等待內核通知就可以,不必在每次調用時傳遞文件描述符列表。
- 這降低了用戶空間和內核空間之間的數據拷貝,提高了效率。
支持兩種工作模式:
- epoll支持水平觸發(Level Triggered)和邊緣觸發(Edge Triggered)兩種工作模式。
- 邊緣觸發模式僅在狀態變化時通知應用程序,這允許開發者實現更高效的處理邏輯,因為可以避免重復檢測已處理過的事件。
動態管理文件描述符:
- epoll沒有文件描述符數量的限制,能夠動態管理大量的文件描述符。
- 這使得epoll在需要高并發、高效能I/O操作的網絡編程場景中表現出色。
面試官:你剛剛有提到epoll的兩種觸發模式,請解釋一下邊緣觸發(ET)和水平觸發(LT)的區別?
一、邊緣觸發(ET)與水平觸發(LT)的區別
觸發機制:
- 邊緣觸發(ET):只在文件描述符的狀態從不可讀/不可寫變為可讀/可寫時觸發一次。也就是說,它在狀態發生變化的時候通知應用程序,且通知僅發送一次。
- 水平觸發(LT):當文件描述符處于可讀或可寫狀態時,會持續通知應用程序,直到文件描述符不再處于該狀態。即,只要文件描述符保持在可讀/可寫狀態,epoll就會不斷通知應用程序。
應用程序響應:
- 邊緣觸發(ET):要求應用程序在接收到通知后立即且徹底地處理所有可讀/可寫的數據,否則可能會錯過后續數據,導致數據丟失。因此,使用ET模式時,應用程序需要具備更高的復雜性和更精細的控制。
- 水平觸發(LT):應用程序可以按需處理數據,不必立即處理完所有數據。即使只處理部分數據,下次調用epoll_wait時,epoll仍會繼續通知應用程序該文件描述符仍然可讀/可寫。
適用場景:
- 邊緣觸發(ET):適用于高性能場景,因為它減少了不必要的系統調用。同時,它也要求應用程序能夠高效、徹底地處理數據。
- 水平觸發(LT):適用于大多數情況,特別是當應用程序需要持續處理就緒事件或處理多個相關事件時。它提供了更簡單、直觀的通知機制。
以下是一個使用Python的epoll模塊來演示ET和LT觸發模式的示例:
import os
import socket
import select
import epoll
# 創建一個TCP/IP套接字
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(('localhost', 12345))
server_sock.listen(5)
server_sock.setblocking(0) # 設置為非阻塞模式
# 創建一個epoll實例
epoller = epoll.epoll()
# 為服務器套接字注冊事件,LT模式
epoller.register(server_sock.fileno(), epoll.EPOLLIN | epoll.EPOLLET if False else epoll.EPOLLIN) # 將if False改為True以使用ET模式
# 客戶端連接處理函數
def handle_connection(client_sock):
data = client_sock.recv(1024)
if data:
print(f"Received: {data.decode()}")
client_sock.sendall(data) # Echo the data back to the client
else:
# 沒有數據表示客戶端已經關閉連接
client_sock.close()
print("Client disconnected.")
# 主循環
try:
while True:
events = epoller.poll(1000) # 超時時間為1000毫秒
for fileno, event in events:
if fileno == server_sock.fileno():
# 服務器套接字上有新的連接請求
client_sock, client_addr = server_sock.accept()
client_sock.setblocking(0) # 設置為非阻塞模式
print(f"Accepted connection from {client_addr}")
# 為客戶端套接字注冊事件,這里我們根據之前的條件選擇ET或LT模式
epoller.register(client_sock.fileno(), epoll.EPOLLIN | epoll.EPOLLET if False else epoll.EPOLLIN)
else:
# 客戶端套接字上有數據可讀或連接關閉等事件
client_sock = socket.socket(fileno=fileno) # 從文件描述符恢復套接字對象
# 根據ET或LT模式的不同,處理數據的方式也會有所不同
if False: # 如果為True,則表示使用ET模式
# ET模式下,需要確保一次讀取所有可用數據
while True:
try:
data = client_sock.recv(1024)
if not data:
break # 沒有數據表示連接已關閉
print(f"Received (ET): {data.decode()}")
client_sock.sendall(data) # Echo the data back to the client
except BlockingIOError:
# 在ET模式下,如果沒有更多數據可讀,會拋出BlockingIOError異常
break
else:
# LT模式下,每次有數據可讀時都會觸發事件
handle_connection(client_sock)
# 如果客戶端連接已關閉,則注銷該套接字
if not client_sock.fileno() in [fd for fd, _ in epoller.poll(0)]: # 使用非阻塞poll檢查連接是否仍然活躍
epoller.unregister(client_sock.fileno())
client_sock.close()
except KeyboardInterrupt:
print("Server stopped by user.")
finally:
epoller.close()
server_sock.close()
代碼中通過epoll.EPOLLIN | epoll.EPOLLET來選擇ET模式,而僅通過epoll.EPOLLIN來選擇LT模式。你可以通過修改條件(即if False改為if True)來切換模式。
- 在ET模式下,由于只有在狀態變化時才會觸發事件,因此我們需要使用循環來讀取所有可用的數據,直到recv拋出BlockingIOError異常,表示當前沒有更多數據可讀。
- 在LT模式下,每次有數據可讀時都會觸發事件,因此我們可以直接調用handle_connection函數來處理連接。
由于epoll模塊是Linux特有的,因此這段代碼只能在Linux環境下運行。
為了簡化示例,錯誤處理和資源清理可能不是最完善的。在實際應用中,你需要更仔細地處理這些情況。
使用epoll.poll(0)來檢查連接是否仍然活躍是一種非阻塞的檢查方式,但它可能不是最優雅或最高效的方法。在實際應用中,你可能需要設計更復雜的邏輯來處理連接的狀態。面試官:說說看你知道的IO模型有哪些?