1. 程式人生 > 實用技巧 >Linux的常用IO模型

Linux的常用IO模型

核心kernel

作業系統負責整個系統執行的排程管理,包括管理各個硬體(如:cpu, 記憶體,磁碟,網絡卡等)以及在系統的上執行的各個應用程式。當計算機從關機狀態啟動,啟動的第一個程式是作業系統核心,核心啟動,將會註冊GDT表(記憶體的分段資訊),表中會記錄作業系統單獨擁有的一段記憶體空間,這部分空間只有作業系統核心可以操作,無法被其他的使用者程式更改資料,從而來保證作業系統的穩定執行。由此記憶體被劃分成使用者空間和核心空間。

主機上連線的所有的硬體裝置,都將被作業系統管理。在linux系統中,這些硬體裝置被抽象為檔案,所以作業系統在處理一個硬體裝置時,其硬體裝置對應為一個檔案描述符,通過對裝置的檔案描述符進行讀寫操作,來表示該對裝置中資料的讀寫操作。為了使用者的應用程式可以對硬體進行呼叫,kernel提供了系統呼叫介面,使用者應用可以通過呼叫這些介面來實現呼叫硬體操作。

獲取程式系統呼叫

使用strace工具可以在啟動某個程式時候,將該程序執行過程中的系統呼叫記錄並記錄這裡將呼叫日誌寫入到檔案中。

strace -ff -o ./record  python 檔名.py 

該命令將使用python直譯器執行該py程式,並將該程序執行過程中的使用到的系統呼叫指令,按照每個程序id進行分類,並儲存到當前目錄中以record為字首的檔案中。該程序啟動後可能會有其他的輔助程序,所以檔案可能不止一個。

該python程式內容如下:

import socket
import threading

def worker(sock):
    # 阻塞接受客戶端的資料。
data = sock.recv(1024) print(data) sock = socket.socket() ip_addr = ("127.0.0.1", 8000) sock.bind(ip_addr) sock.listen() print("開始監聽127.0.0.1:8000") # 每接受一個請求,開啟新的執行緒與客戶端進行通訊,主執行緒阻塞等待新的連線 while True: s, addr = sock.accept() t = threading.Thread(target=worker, args=(s, )) t.start()
print("end-------")

通過strace產生的檔案。找到該主程序執行檔案,然後可以看到上面的python程式執行過程中執行的幾個核心的系統呼叫,包括socket, bind, listen, write等。

可以簡單的理解為在socket物件例項化的過程中,作業系統會建立一個socket ,然後將該socket關聯上一個檔案描述符,在之後執行,sock.bind()或者sock.listen()的語句時,實際上是將該檔案描述符繫結到本地8000埠並監聽該檔案描述符。之後通過sock。accept()將會阻塞等待在該埠也就是該檔案描述符新的連線,使用偽程式碼表示為。

socket fd4          # 建立一個socket時,關聯一個檔案描述符fd4
bind  8000          # socket繫結埠 
listen fd4          # 監聽該socket,即fd4檔案描述符

while True:
    accept fd4 = fd5    # accept會阻塞等待,當新的連線到來,建立一個新的socket關聯fd5檔案描述符,該socket於客戶端建立了連線
    new Thread ->  send fd5, recv fd5    # 開啟一個新的執行緒來與客戶端send或recv資料,主執行緒繼續accept 監聽fd4等待新的連線。

由於recv和accpet都是阻塞的,所以要想伺服器能同時處理多個客戶端的連線,就只能開闢新的執行緒來實現每個客戶端的資料通訊。這種IO的實現方式就是BIO(Blocking IO 阻塞IO)模型。

這種模型的問題有:
1. 執行緒太多:每個socket都需要開啟一個執行緒,大量客戶端同時連線將耗費服務端大量的資源,這些執行緒在記憶體中有獨立的棧空間,堆空間是共享的。同時增加cpu的排程。
2. 系統呼叫太多:開啟和關閉執行緒也是系統呼叫。在原send 和 recv系統呼叫的基礎上,增加了開閉執行緒的系統呼叫開銷。

因為作業系統獨立使用一份記憶體空間,所以每次發生系統呼叫時,實際上會程式會由使用者態轉變為核心態執行,如果需要使用者空間中的資料,在核心態中需要使用使用者態的資料,需要從使用者空間拷貝資料到核心空間才能使用,因此執行一次系統呼叫的開銷會比程式正常執行運算更耗費資源。

BIO模型的優點是延時低,因為每個執行緒的單獨處理這個socket,當有資料到來時候,該執行緒被調起即可獲取內部的資料。這還需要和下面NIO的方式進行對比更容易理解。

NIO模型

BIO模型由於每個socket都使用一個單獨的執行緒進行執行,耗費了大量的資源,想要避免開啟過多的執行緒,而是使用單執行緒去實時的監視多個socket是否有資料到來,只有將這些socket全部以非阻塞的方式執行,然後輪詢執行accpet和recv他們。於是就誕生了NIO的模型(NonBlocking IO)

虛擬碼:

socket fd4 
bind 127.0.0.1:8000
listen fd4

list = []         # 建立一個容器。
while True:
    accept fd4 = new fd       # accept 是非阻塞,如果有新的連線,得到新的fd,否則直接跳過即可,系統呼叫通過一個引數即可指定為非阻塞。
    append fd to list         # 如果得到了新的fd,將他新增到列表中,遍歷列表,對每個socket執行send 和 recv操作即可
    for fd in list:
        send  fd           # 非阻塞的執行send和recv (有資料操作,沒有資料則跳過)
        recv  fd   

在NIO(非阻塞IO) 的模型下,可以使用單執行緒去管理所有的sokcet,相比於BIO節約了執行緒的開銷,但是同樣存在問題。
1. 使用遍歷的方式對每一個socket 執行send 和 recv,這兩個方法都會執行系統呼叫,並且在大多數的情況下,大部分的socket是沒有資料的,也就是,我們對所有的socket輪詢一次,可能只有1%的socket需要接收資料,其餘的系統呼叫屬於浪費。
2. 有延遲,相對於BIO中某個socket收到資料,對應的執行緒將會被啟用,然後排程執行,獲取資料。而在NIO的模式下,只有遍歷到該socket,才能從中獲取資料,如果列表很大,例如10000個socket。遍歷到第 10000 個socket時第9999個socket來資料了,只能等待下一輪將前面所有的9998個都執行一遍send或者recv操作之後,才能處理這個資料,因此時效性較差。

IO多路複用-selector

在NIO的模型中,通過遍歷列表,對每一個socket呼叫send或者recv系統呼叫,這樣產生了大量的系統呼叫,由於核心態和使用者態的原因,會更加的浪費資源。於是核心提供了一個系統呼叫函式,select, 該函式要求提供需要被監視的socket集合,然後由核心對這個socket進行一次遍歷,再將有資料的socket集合返回給使用者應用端。通過select,只進行了一次系統呼叫,完成了對所有socket的管理,相比NIO有效能的提高。但在系統內部,仍然使用的是遍歷的方式來處理這些socket,然後將有資料的socket放回交給應用端處理。

socket fd4 
bind 127.0.0.1:8000
listen fd4

list = [ fd4 ]         # 建立集合
while True:
    select list  =>  use_list       # 將socket集合交給select,返回一個有資料的socket集合
    for fd in  use_list:      # 遍歷這些可用的sokcet,執行send 或 recv獲取資料即可
        send  fd       
        recv  fd  

基於上面的執行方式,所以select被稱為多路複用器,即執行一個系統呼叫,可以同時監聽了多個socket IO,然後將可用的socket IO返回。
這樣的方式同樣存在問題:
1. 核心態中執行時,每次為了獲取可用的IO,需要對整個集合進行一次遍歷,這是O(N)複雜度的操作。
2. 每次呼叫select 都需要傳入全部的socket。

epoll

epoll是在select基礎上,針對上述的selector的兩個問題來進行了優化。

為了解決的每次傳入所有的sokcet物件,epoll在核心中建立一個緩衝區空間,存放所有被監聽的socket物件,並綁定了一個檔案描述符epfd。

epoll提供了4個相關的系統呼叫:關於epoll的詳細請看https://blog.csdn.net/petershina/article/details/50614877,這裡只做簡單的機制說明

epool_create  == >  int epoll_create(int size) 開闢一個空間,返回與該空間關聯的檔案描述符epfd,    
 
epoll_ctl     == >   int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);   管理epfd中的資料
       第一個引數是epoll_create()的返回值,也就是核心中用來儲存sokcet空間的檔案描述符。
       第二個引數表示動作,用三個巨集來表示:
        EPOLL_CTL_ADD:註冊新的fd到epfd中;
        EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;
        EPOLL_CTL_DEL:從epfd中刪除一個fd
      第三個引數是需要監聽的fd。
      第四個引數是告訴核心需要監聽什麼事件, 可以是以下幾個巨集的集合。
          EPOLLIN:表示對應的檔案描述符可以讀(包括對端SOCKET正常關閉);
          EPOLLOUT:表示對應的檔案描述符可以寫;
          EPOLLPRI:表示對應的檔案描述符有緊急的資料可讀(這裡應該表示有帶外資料到來);
          EPOLLERR:表示對應的檔案描述符發生錯誤;
          EPOLLHUP:表示對應的檔案描述符被結束通話;
    
epoll_wait    == > int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout); 
    epoll通過wait該epfd,得知該epfd是否被喚醒,而喚醒的條件是,該epfd內部的任意一個socket被啟用。
epoll_close   == > 關閉epfd檔案描述符的方法 

使用epoll過程的虛擬碼

epoll_create() => epfd

socket  fd4
bind 127.0.0.1:8000
listen fd4 epoll_ctl(epfd, fd4, epoll_add)
# 將fd4新增到epfd對應的核心空間中 whiel True: epoll_wati() => fd_list for fd in fd_list: recv fd send fd epoll_ctl(epfd, fd4, epoll_add) # 如果是新的fd,新增到epfd中

這個過程可以簡單的理解為:首先使用epool_create在核心空間中開啟一個epfd檔案描述符對應的空間,然後將需要監聽socketd物件通過EPOLL_CTL的ADD操作將fd新增到epfd空間中,當空間中有sokcet被啟用時,通過wait可返回被啟用的socket物件的集合,然後分別呼叫這些socket的recv或者send方法操作即可,如果要對空間中的socket操作,在應用端呼叫EPOLL_CTL的MOD 和 DEL 操作進行修改刪除即可。否則這些socket始終處於核心態中由核心管理。

select中使用遍歷的方式來獲取那些socket被啟用,而epoll基於事件驅動模型,事件驅動簡單描述為網絡卡(本列監聽socket進行外部通訊,所以相關硬體為網絡卡,也可以是其他的硬體裝置)接受到一個訊息,並將訊息複製到對應的socket,併產生一個訊息事件,該事件會通知作業系統,併產生一箇中斷,此時作業系統會中斷正在執行的其他操作,找到這個網絡卡對應的中斷號以及初始繫結的回撥函式,執行該回調操作,該回調在epoll中就是將socket從未啟用區域呼叫到啟用的佇列中去,這樣實現了哪個socket有訊息,將會被中斷的回撥呼叫到啟用區域中。應用程式將可以讀取這個資料。

通過這種方式將可以不用的持續遍歷核心空間中的socket,而是有訊息的socket自動觸發,通過回撥事件,進入啟用區域。而在應用端,可以通過阻塞或者非阻塞的方式從這個佇列中獲取啟用的socket。

當然wait方法同樣指定為阻塞或者非阻塞模型,同樣詳細說明:https://blog.csdn.net/petershina/article/details/50614877