手把手教Linux驅(qū)動8-Linux IO模型
什么是IO?
IO模型中,先討論下什么是IO?
在計算機系統(tǒng)中I/O就是輸入(Input)和輸出(Output)的意思,針對不同的操作對象,可以劃分為磁盤I/O模型,網(wǎng)絡(luò)I/O模型,內(nèi)存映射I/O, Direct I/O、數(shù)據(jù)庫I/O等,只要具有輸入輸出類型的交互系統(tǒng)都可以認為是I/O系統(tǒng),也可以說I/O是整個操作系統(tǒng)數(shù)據(jù)交換與人機交互的通道,這個概念與選用的開發(fā)語言沒有關(guān)系,是一個通用的概念。
在如今的系統(tǒng)中I/O卻擁有很重要的位置,現(xiàn)在系統(tǒng)都有可能處理大量文件,大量數(shù)據(jù)庫操作,而這些操作都依賴于系統(tǒng)的I/O性能,也就造成了現(xiàn)在系統(tǒng)的瓶頸往往都是由于I/O性能造成的。因此,為了解決磁盤I/O性能慢的問題,系統(tǒng)架構(gòu)中添加了緩存來提高響應(yīng)速度;或者有些高端服務(wù)器從硬件級入手,使用了固態(tài)硬盤(SSD)來替換傳統(tǒng)機械硬盤;一個系統(tǒng)的優(yōu)化空間,往往都在低效率的I/O環(huán)節(jié)上,很少看到一個系統(tǒng)CPU、內(nèi)存的性能是其整個系統(tǒng)的瓶頸。
那么數(shù)據(jù)被Input到哪,Output到哪呢?
Input(輸入)數(shù)據(jù)到內(nèi)存中,Output(輸出)數(shù)據(jù)到IO設(shè)備(磁盤、網(wǎng)絡(luò)等需要與內(nèi)存進行數(shù)據(jù)交互的設(shè)備)中;
主存(通常時DRAM)的一塊區(qū)域,用來緩存文件系統(tǒng)的內(nèi)容,包含各種數(shù)據(jù)和元數(shù)據(jù)。
IO接口
IO設(shè)備與內(nèi)存直接的數(shù)據(jù)傳輸通過IO接口,操作系統(tǒng)封裝了IO接口,我們編程時可以直接使用;
對于用來講,如果要和外設(shè)通信,只需要通過這些系統(tǒng)調(diào)用即可實現(xiàn)。
無處不在的緩存
- 如圖,當(dāng)程序調(diào)用各類文件操作函數(shù)后,用戶數(shù)據(jù)(User Data)到達磁盤(Disk)的流程如圖所示。圖中描述了Linux下文件操作函數(shù)的層級關(guān)系和內(nèi)存緩存層的存在位置。中間的黑色實線是用戶態(tài)和內(nèi)核態(tài)的分界線。
- 從上往下分析這張圖,首先是C語言stdio庫定義的相關(guān)文件操作函數(shù),這些都是用戶態(tài)實現(xiàn)的跨平臺封裝函數(shù)。stdio中實現(xiàn)的文件操作函數(shù)有自己的stdio buffer,這是在用戶態(tài)實現(xiàn)的緩存。此處使用緩存的原因很簡單——系統(tǒng)調(diào)用總是昂貴的。如果用戶代碼以較小的size不斷的讀或?qū)懳募脑?,stdio庫將多次的讀或者寫操作通過buffer進行聚合是可以提高程序運行效率的。stdio庫同時也支持fflush(3)函數(shù)來主動的刷新buffer,主動的調(diào)用底層的系統(tǒng)調(diào)用立即更新buffer里的數(shù)據(jù)。特別地,setbuf(3)函數(shù)可以對stdio庫的用戶態(tài)buffer進行設(shè)置,甚至取消buffer的使用。
- 系統(tǒng)調(diào)用的read(2)/write(2)和真實的磁盤讀寫之間也存在一層buffer,這里用術(shù)語Kernel buffer cache來指代這一層緩存。在Linux下,文件的緩存習(xí)慣性的稱之為Page Cache,而更低一級的設(shè)備的緩存稱之為Buffer Cache. 這兩個概念很容易混淆,這里簡單的介紹下概念上的區(qū)別:Page Cache用于緩存文件的內(nèi)容,和文件系統(tǒng)比較相關(guān)。文件的內(nèi)容需要映射到實際的物理磁盤,這種映射關(guān)系由文件系統(tǒng)來完成;Buffer Cache用于緩存存儲設(shè)備塊(比如磁盤扇區(qū))的數(shù)據(jù),而不關(guān)心是否有文件系統(tǒng)的存在(文件系統(tǒng)的元數(shù)據(jù)緩存在Buffer Cache中)。
- 綜上,既然討論Linux下的IO操作,自然是跳過stdio庫的用戶態(tài)這一堆東西,直接討論系統(tǒng)調(diào)用層面的概念了。對stdio庫的IO層有興趣的同學(xué)可以自行去了解。從上文的描述中也介紹了文件的內(nèi)核級緩存是保存在文件系統(tǒng)的Page Cache中的。所以后面的討論基本上是討論IO相關(guān)的系統(tǒng)調(diào)用和文件系統(tǒng)Page Cache的一些機制。
Linux IO棧
雖然我們通過系統(tǒng)調(diào)用就可以簡單的實現(xiàn)對外設(shè)的數(shù)據(jù)讀取,實際上這得益于Linux完整的IO棧架構(gòu)。
由圖可見,從系統(tǒng)調(diào)用的接口再往下,Linux下的IO棧致大致有三個層次:
- 文件系統(tǒng)層,以 write(2) 為例,內(nèi)核拷貝了write(2)參數(shù)指定的用戶態(tài)數(shù)據(jù)到文件系統(tǒng)Cache中,并適時向下層同步
- 塊層,管理塊設(shè)備的IO隊列,對IO請求進行合并、排序(還記得操作系統(tǒng)課程學(xué)習(xí)過的IO調(diào)度算法嗎?)
- 設(shè)備層,通過DMA與內(nèi)存直接交互,完成數(shù)據(jù)和具體設(shè)備之間的交互
結(jié)合這個圖,想想Linux系統(tǒng)編程里用到的Buffered IO、mmap(2)、Direct IO。
這些機制怎么和Linux IO棧聯(lián)系起來呢?
上面的圖有點復(fù)雜,畫一幅簡圖,把這些機制所在的位置添加進去:
傳統(tǒng)的Buffered IO使用read(2)讀取文件的過程什么樣的?
假設(shè)要去讀一個冷文件(Cache中不存在),open(2)打開文件內(nèi)核后建立了一系列的數(shù)據(jù)結(jié)構(gòu),接下來調(diào)用read(2),到達文件系統(tǒng)這一層,發(fā)現(xiàn)Page Cache中不存在該位置的磁盤映射,然后創(chuàng)建相應(yīng)的Page Cache并和相關(guān)的扇區(qū)關(guān)聯(lián)。然后請求繼續(xù)到達塊設(shè)備層,在IO隊列里排隊,接受一系列的調(diào)度后到達設(shè)備驅(qū)動層,此時一般使用DMA方式讀取相應(yīng)的磁盤扇區(qū)到Cache中,然后read(2)拷貝數(shù)據(jù)到用戶提供的用戶態(tài)buffer中去(read(2)的參數(shù)指出的)。
整個過程有幾次拷貝?
從磁盤到Page Cache算第一次的話,從Page Cache到用戶態(tài)buffer就是第二次了。
而mmap(2)做了什么?
mmap(2)直接把Page Cache映射到了用戶態(tài)的地址空間里了,所以mmap(2)的方式讀文件是沒有第二次拷貝過程的。
那Direct IO做了什么?
這個機制更狠,直接讓用戶態(tài)和塊IO層對接,直接放棄Page Cache,從磁盤直接和用戶態(tài)拷貝數(shù)據(jù)。
好處是什么?
寫操作直接映射進程的buffer到磁盤扇區(qū),以DMA的方式傳輸數(shù)據(jù),減少了原本需要到Page Cache層的一次拷貝,提升了寫的效率。
對于讀而言,第一次肯定也是快于傳統(tǒng)的方式的,但是之后的讀就不如傳統(tǒng)方式了(當(dāng)然也可以在用戶態(tài)自己做Cache,有些商用數(shù)據(jù)庫就是這么做的)。
除了傳統(tǒng)的Buffered IO可以比較自由的用偏移+長度的方式讀寫文件之外,mmap(2)和Direct IO均有數(shù)據(jù)按頁對齊的要求,Direct IO還限制讀寫必須是底層存儲設(shè)備塊大小的整數(shù)倍(甚至Linux 2.4還要求是文件系統(tǒng)邏輯塊的整數(shù)倍)。所以接口越來越底層,換來表面上的效率提升的背后,需要在應(yīng)用程序這一層做更多的事情。所以想用好這些高級特性,除了深刻理解其背后的機制之外,也要在系統(tǒng)設(shè)計上下一番功夫。
阻塞/非阻塞與同步/異步
了解了IO的概念,現(xiàn)在我們來講解什么是阻塞、非阻塞、同步、異步。
阻塞/非阻塞
針對的對象是調(diào)用者自己本身的情況
阻塞
指調(diào)用者在調(diào)用某一個函數(shù)后,一直在等待該函數(shù)的返回值,線程處于掛起狀態(tài)。
非阻塞
指調(diào)用者在調(diào)用某一個函數(shù)后,不等待該函數(shù)的返回值,線程繼續(xù)運行其他程序(執(zhí)行其他操作或者一直遍歷該函數(shù)是否返回了值)
同步/異步
針對的對象是被調(diào)用者的情況
同步
指的是被調(diào)用者在被調(diào)用后,操作完函數(shù)所包含的所有動作后,再返回返回值
異步
指的是被調(diào)用者在被調(diào)用后,先返回返回值,然后再進行函數(shù)所包含的其他動作。
五種IO模型
下面以recvfrom/recv函數(shù)為例,這兩個函數(shù)都是操作系統(tǒng)的內(nèi)核函數(shù),用于從(已連接)socket上接收數(shù)據(jù),并捕獲數(shù)據(jù)發(fā)送源的地址。
recv函數(shù)原型:
- ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags)
- sockfd:接收端套接字描述符
- buff:用來存放recv函數(shù)接收到的數(shù)據(jù)的緩沖區(qū)
- nbytes:指明buff的長度
- flags:一般置為0
網(wǎng)絡(luò)IO的本質(zhì)是socket的讀取,socket在linux系統(tǒng)被抽象為流,IO可以理解為對流的操作。對于一次IO訪問(以read舉例),數(shù)據(jù)會先被拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)中,然后才會從操作系統(tǒng)內(nèi)核的緩沖區(qū)拷貝到應(yīng)用程序的地址空間。
所以說,當(dāng)一個recv操作發(fā)生時,它會經(jīng)歷兩個階段:
- 第一階段:等待數(shù)據(jù)準備 (Waiting for the data to be ready)。
- 第二階段:將數(shù)據(jù)從內(nèi)核拷貝到進程中 (Copying the data from the kernel to the process)。
對于socket流而言:
- 第一步:通常涉及等待網(wǎng)絡(luò)上的數(shù)據(jù)分組到達,然后被復(fù)制到內(nèi)核的某個緩沖區(qū)。
- 第二步:把數(shù)據(jù)從內(nèi)核緩沖區(qū)復(fù)制到應(yīng)用進程緩沖區(qū)。
阻塞IO(Blocking IO)
指調(diào)用者在調(diào)用某一個函數(shù)后,一直在等待該函數(shù)的返回值,線程處于掛起狀態(tài)。好比你去商場試衣間,里面有人,那你就一直在門外等著。(全程阻塞)
BIO程序流
當(dāng)用戶進程調(diào)用了recv()/recvfrom()這個系統(tǒng)調(diào)用,kernel就開始了IO的第一個階段:準備數(shù)據(jù)(對于網(wǎng)絡(luò)IO來說,很多時候數(shù)據(jù)在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數(shù)據(jù)到來)。這個過程需要等待,也就是說數(shù)據(jù)被拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當(dāng)然,是進程自己選擇的阻塞)。
第二個階段:當(dāng)kernel一直等到數(shù)據(jù)準備好了,它就會將數(shù)據(jù)從kernel中拷貝到用戶內(nèi)存,然后kernel返回結(jié)果,用戶進程才解除block的狀態(tài),重新運行起來。
所以,blocking IO的特點就是在IO執(zhí)行的兩個階段都被block了。
優(yōu)點:
1. 能夠及時返回數(shù)據(jù),無延遲;
2. 對內(nèi)核開發(fā)者來說這是省事了;
缺點:
對用戶來說處于等待就要付出性能的代價了;
非阻塞IO
指調(diào)用者在調(diào)用某一個函數(shù)后,不等待該函數(shù)的返回值,線程繼續(xù)運行其他程序(執(zhí)行其他操作或者一直遍歷該函數(shù)是否返回了值)。好比你要喝水,水還沒燒開,你就隔段時間去看一下飲水機,直到水燒開為止。(復(fù)制數(shù)據(jù)時阻塞)
非阻塞IO程序流
當(dāng)用戶進程發(fā)出read操作時,如果kernel中的數(shù)據(jù)還沒有準備好,那么它并不會block用戶進程,而是立刻返回一個error。從用戶進程角度講,它發(fā)起一個read操作后,并不需要等待,而是馬上就得到了一個結(jié)果。用戶進程判斷結(jié)果是一個error時,它就知道數(shù)據(jù)還沒有準備好,于是它可以再次發(fā)送read操作。一旦kernel中的數(shù)據(jù)準備好了,并且又再次收到了用戶進程的system call,那么它馬上就將數(shù)據(jù)拷貝到了用戶內(nèi)存,然后返回。
所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數(shù)據(jù)好了沒有。
同步非阻塞方式相比同步阻塞方式:
優(yōu)點:
能夠在等待任務(wù)完成的時間里干其他活了(包括提交其他任務(wù),也就是 “后臺” 可以有多個任務(wù)在同時執(zhí)行)。
缺點:
任務(wù)完成的響應(yīng)延遲增大了,因為每過一段時間才去輪詢一次read操作,而任務(wù)可能在兩次輪詢之間的任意時間完成。這會導(dǎo)致整體數(shù)據(jù)吞吐量的降低。
IO多路復(fù)用
I/O是指網(wǎng)絡(luò)I/O,多路指多個TCP連接(即socket或者channel),復(fù)用指復(fù)用一個或幾個線程。意思說一個或一組線程處理多個連接。比如課堂上學(xué)生做完了作業(yè)就舉手,老師就下去檢查作業(yè)。(對一個IO端口,兩次調(diào)用,兩次返回,比阻塞IO并沒有什么優(yōu)越性;關(guān)鍵是能實現(xiàn)同時對多個IO端口進行監(jiān)聽,可以同時對多個讀/寫操作的IO函數(shù)進行輪詢檢測,直到有數(shù)據(jù)可讀或可寫時,才真正調(diào)用IO操作函數(shù)。)
IO多路復(fù)用程序流
這種模型其實和BIO是一模一樣的,都是阻塞的,只不過在socket上加了一層代理select,select可以通過監(jiān)控多個socekt是否有數(shù)據(jù),通過這種方式來提高性能。
一旦檢測到一個或多個文件描述有數(shù)據(jù)到來,select函數(shù)就返回,這時再調(diào)用recv函數(shù)(這塊也是阻塞的),數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間,recv函數(shù)返回。
多路復(fù)用的特點是通過一種機制一個進程能同時等待IO文件描述符,內(nèi)核監(jiān)視這些文件描述符(套接字描述符),其中的任意一個進入讀就緒狀態(tài),select, poll,epoll函數(shù)就可以返回。對于監(jiān)視的方式,又可以分為 select, poll, epoll三種方式。
IO多路復(fù)用是阻塞在select,epoll這樣的系統(tǒng)調(diào)用之上,而沒有阻塞在真正的I/O系統(tǒng)調(diào)用如recvfrom之上。
在I/O編程過程中,當(dāng)需要同時處理多個客戶端接入請求時,可以利用多線程或者I/O多路復(fù)用技術(shù)進行處理。I/O多路復(fù)用技術(shù)通過把多個I/O的阻塞復(fù)用到同一個select的阻塞上,從而使得系統(tǒng)在單線程的情況下可以同時處理多個客戶端請求。與傳統(tǒng)的多線程/多進程模型比,I/O多路復(fù)用的最大優(yōu)勢是系統(tǒng)開銷小,系統(tǒng)不需要創(chuàng)建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降底了系統(tǒng)的維護工作量,節(jié)省了系統(tǒng)資源,I/O多路復(fù)用的主要應(yīng)用場景如下:
1. 服務(wù)器需要同時處理多個處于監(jiān)聽狀態(tài)或者多個連接狀態(tài)的套接字。
2. 服務(wù)器需要同時處理多種網(wǎng)絡(luò)協(xié)議的套接字。
在用戶進程進行系統(tǒng)調(diào)用的時候,他們在等待數(shù)據(jù)到來的時候,處理的方式不一樣,直接等待,輪詢,select或poll輪詢,兩個階段過程:
第一個階段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。
第二個階段都是阻塞的。
從整個IO過程來看,他們都是順序執(zhí)行的,因此可以歸為同步模型(synchronous)。都是進程主動等待且向內(nèi)核檢查狀態(tài)。
信號驅(qū)動IO
信號驅(qū)動IO程序流
在用戶態(tài)程序安裝SIGIO信號處理函數(shù)(用sigaction函數(shù)或者signal函數(shù)來安裝自定義的信號處理函數(shù)),即recv函數(shù)。然后用戶態(tài)程序可以執(zhí)行其他操作不會被阻塞。
一旦有數(shù)據(jù)到來,操作系統(tǒng)以信號的方式來通知用戶態(tài)程序,用戶態(tài)程序跳轉(zhuǎn)到自定義的信號處理函數(shù)。
在信號處理函數(shù)中調(diào)用recv函數(shù),接收數(shù)據(jù)。數(shù)據(jù)從內(nèi)核空間拷貝到用戶態(tài)空間后,recv函數(shù)返回。recv函數(shù)不會因為等待數(shù)據(jù)到來而阻塞。
這種方式使異步處理成為可能,信號是異步處理的基礎(chǔ)。
在 Linux 中,通知的方式是信號:
如果這個進程正在用戶態(tài)忙著做別的事,那就強行打斷之,調(diào)用事先注冊的信號處理函數(shù),這個函數(shù)可以決定何時以及如何處理這個異步任務(wù)。由于信號處理函數(shù)是突然闖進來的,因此跟中斷處理程序一樣,有很多事情是不能做的,因此保險起見,一般是把事件 “登記” 一下放進隊列,然后返回該進程原來在做的事。
如果這個進程正在內(nèi)核態(tài)忙著做別的事,例如以同步阻塞方式讀寫磁盤,那就只好把這個通知掛起來了,等到內(nèi)核態(tài)的事情忙完了,快要回到用戶態(tài)的時候,再觸發(fā)信號通知。
如果這個進程現(xiàn)在被掛起了,例如無事可做 sleep 了,那就把這個進程喚醒,下次有 CPU 空閑的時候,就會調(diào)度到這個進程,觸發(fā)信號通知。
異步 API 說來輕巧,做來難,這主要是對 API 的實現(xiàn)者而言的。Linux 的異步 IO(AIO)支持是 2.6.22 才引入的,還有很多系統(tǒng)調(diào)用不支持異步 IO。Linux 的異步 IO 最初是為數(shù)據(jù)庫設(shè)計的,因此通過異步 IO 的讀寫操作不會被緩存或緩沖,這就無法利用操作系統(tǒng)的緩存與緩沖機制。
很多人把 Linux 的 O_NONBLOCK 認為是異步方式,但事實上這是前面講的同步非阻塞方式。需要指出的是,雖然 Linux 上的 IO API 略顯粗糙,但每種編程框架都有封裝好的異步 IO 實現(xiàn)。操作系統(tǒng)少做事,把更多的自由留給用戶,正是 UNIX 的設(shè)計哲學(xué),也是 Linux 上編程框架百花齊放的一個原因。
異步IO
異步IO程序流
異步IO的效率是最高的。
異步IO通過aio_read函數(shù)實現(xiàn),aio_read提交請求,并遞交一個用戶態(tài)空間下的緩沖區(qū)。即使內(nèi)核中沒有數(shù)據(jù)到來,aio_read函數(shù)也立刻返回,應(yīng)用程序就可以處理其他的事情。
當(dāng)數(shù)據(jù)到來后,操作系統(tǒng)自動把數(shù)據(jù)從內(nèi)核空間拷貝到aio_read函數(shù)遞交的用戶態(tài)緩沖區(qū)。拷貝完成以信號的方式通知用戶態(tài)程序,用戶態(tài)程序拿到數(shù)據(jù)后就可以執(zhí)行后續(xù)操作。
異步IO和信號驅(qū)動IO的不同?
在于信號通知用戶態(tài)程序時數(shù)據(jù)所處的位置。異步IO已經(jīng)把數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間了;而信號驅(qū)動IO的數(shù)據(jù)還在內(nèi)核空間,等著recv函數(shù)把數(shù)據(jù)拷貝到用戶態(tài)空間。
異步IO主動把數(shù)據(jù)拷貝到用戶態(tài)空間,主動推送數(shù)據(jù)到用戶態(tài)空間,不需要調(diào)用recv方法把數(shù)據(jù)從內(nèi)核空間拉取到用戶態(tài)空間。異步IO是一種推數(shù)據(jù)的機制,相比于信號處理IO拉數(shù)據(jù)的機制效率更高。
推數(shù)據(jù)是直接完成的,而拉數(shù)據(jù)是需要調(diào)用recv函數(shù),調(diào)用函數(shù)會產(chǎn)生額外的開銷,故效率低。
本文轉(zhuǎn)載自微信公眾號「一口Linux」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系一口Linux公眾號。