聊聊BIO,NIO和AIO (2)

本文從操作系統(tǒng)的角度來解釋BIO,NIO,AIO的概念,含義和背后的那些事。本文主要分為3篇。

  • 第一篇 講解BIO和NIO以及IO多路復(fù)用
  • 第二篇 講解磁盤IO和AIO
  • 第三篇 講解在這些機(jī)制上的一些應(yīng)用的實(shí)現(xiàn)方式,比如nginx,nodejs,Java NIO等

磁盤IO

磁盤IO,簡單來說就是讀取硬盤一類設(shè)備的IO。這類設(shè)備包括傳統(tǒng)的磁盤、SSD、閃存、CD等。操作系統(tǒng)將其統(tǒng)一抽象為”塊設(shè)備“。所以磁盤IO又可以叫做”塊IO“。這些設(shè)備上的數(shù)據(jù)一般用文件系統(tǒng)來組織,所以又可以成為”文件IO“。本文統(tǒng)一用”磁盤IO“這個(gè)術(shù)語。

簇(sector)和塊(block)

對(duì)于磁盤的驅(qū)動(dòng)來說,存在一個(gè)最小的操作單位。這個(gè)單位被稱為“簇”(sector)。對(duì)磁盤的操作不可以小于這個(gè)單位,比如整簇讀取/整簇寫入。比如硬盤的簇很多都是512Byte,而CD上的簇是2KB。

對(duì)于Linux來說,虛擬文件系統(tǒng)(VFS)抽象了磁盤設(shè)備,統(tǒng)一稱為“塊設(shè)備”(block device)。數(shù)據(jù)是按照一塊塊來組織的。操作系統(tǒng)可以隨機(jī)的定位到某個(gè)“塊”,讀寫某個(gè)“塊”。

很不巧,“阻塞”和“塊”的英文單詞都是“block”,請(qǐng)讀者留意區(qū)分。

塊到簇的轉(zhuǎn)換,是由設(shè)備驅(qū)動(dòng)來完成的。一個(gè)“塊”的大小必須大于等于一個(gè)“簇”。并且塊的大小必須是簇的整倍數(shù)(否則轉(zhuǎn)換起來就太麻煩了)。塊的大小一般有512Byte,1KB,2KB等。

在VFS上層的應(yīng)用是感受不到“簇”的,他們只能感受到“塊”。同時(shí),對(duì)于操作系統(tǒng)在驅(qū)動(dòng)程序之上的層次來說,訪問磁盤數(shù)據(jù)的最小單位是“塊”。即,即使你只想讀取1個(gè)Byte,磁盤也至少要讀取1個(gè)塊;要寫入1個(gè)Byte,磁盤也至少要寫入一個(gè)塊。

這里簡單介紹“簇”和“塊”,是因?yàn)樽x寫磁盤數(shù)據(jù)要求“塊”對(duì)齊。下文中會(huì)提到。

Page Cache

在虛擬文件系統(tǒng)層之上,是內(nèi)存。這一層被稱為Page Cache。詳見下圖。

Page Cache和塊設(shè)備

這個(gè)層次是用頁面(Page)來組織的。一般來講一個(gè)頁面是4KB,一個(gè)頁面對(duì)應(yīng)若干個(gè)“塊”。

Page Cache對(duì)于磁盤IO的性能表現(xiàn)極度重要。比如,當(dāng)通過write API寫入數(shù)據(jù)到磁盤時(shí),數(shù)據(jù)先會(huì)被寫入到Page Cache。此時(shí),這個(gè)Page被稱為“dirty page”。dirty page會(huì)最終被寫入到磁盤上,這個(gè)過程為稱之為“寫回”(writeback)。寫回往往不會(huì)立刻發(fā)生。寫回可能由于調(diào)用者直接使用類似于fsync這樣的API,也有可能因?yàn)椴僮飨到y(tǒng)根據(jù)某種策略和算法決定自動(dòng)寫回。寫回發(fā)生之前,如果機(jī)器掛了,就有可能丟失數(shù)據(jù)。這也是為什么有持久性要求的程序都需要用fsync來保證數(shù)據(jù)落地的原因。

當(dāng)讀取數(shù)據(jù)時(shí),操作系統(tǒng)會(huì)先嘗試從Page Cache里找,如果找到了就會(huì)直接返回給應(yīng)用程序。如果找不到,就會(huì)觸發(fā)“頁錯(cuò)誤”(Page Fault),迫使操作系統(tǒng)去讀取磁盤數(shù)據(jù),在Page Cache里進(jìn)行緩存,然后將數(shù)據(jù)返回給上層應(yīng)用程序。

Page Cache的基本維護(hù)算法是基于“時(shí)間局部性”(Temporal Locality)。下面是wiki的解釋:

Temporal locality refers to the reuse of specific data, and/or resources, within a relatively small time duration.

用人類語言解釋就是假設(shè)“被訪問的數(shù)據(jù)在短時(shí)間內(nèi)再次被訪問的幾率會(huì)很大”。具體算法一般就是基于Least Recent Use,LRU——即把最不經(jīng)常訪問的Cache刪除掉。

“時(shí)間局部性“作為通用規(guī)則,可以應(yīng)付大部分情況。但是凡事總有特殊。比如把一個(gè)巨大的文件從頭讀到尾。此時(shí)“時(shí)間局部性”肯定是不起作用的(已經(jīng)讀取過的數(shù)據(jù)反而不需要了)。這時(shí)就要用一些定制的手段來定制“如何做Cache”。比如可以預(yù)取——預(yù)先把即將訪問的數(shù)據(jù)讀取到Cache;可以強(qiáng)制一個(gè)Page常駐——手工管理一個(gè)Page的存活等。這些工作可以由fadvise等api來完成。

大家都知道內(nèi)存的讀寫延遲要比磁盤高2~3個(gè)數(shù)量級(jí)。對(duì)于磁盤數(shù)據(jù),就可以長期的保存在Cache中。這樣可以極大的提升磁盤IO讀取的效率。

應(yīng)用程序

Page Cache的上層是應(yīng)用程序,就是我們平時(shí)寫的程序了。

磁盤IO的應(yīng)用程序大概長這樣:

char buffer[BUF_SIZE];      /* buffer */
int fd1 = /* ... 打開一個(gè)文件并獲得fd */
int fd2 = /* ... 打開另一個(gè)文件并獲得fd */
read (fd1, &buffer, BUF_SIZE); /* 讀文件數(shù)據(jù)到buffer */
/* processing buffer ... */
write (fd2, &buffer, ret_in); /* 將buffer數(shù)據(jù)寫入文件 */
/* 如果需要,可以調(diào)用fsync(fd2); 將數(shù)據(jù)刷到磁盤*/
/* close fd */

在處理IO數(shù)據(jù)時(shí),應(yīng)用程序總是需要在用戶態(tài)分配一段內(nèi)存空間作為buffer,然后將Page Cache中的數(shù)據(jù)copy出來進(jìn)行處理。處理完成后,將數(shù)據(jù)寫回(copy回)到Page Cache。

應(yīng)用和Page Cache

如果你留意這個(gè)圖,就會(huì)發(fā)現(xiàn),這里會(huì)多額外兩次數(shù)據(jù)的copy(并且是CPU copy)。但是有兩種方法可以避免這兩次copy,分別是mmapsendfile。

mmap

mmap可以將Page Cache中的內(nèi)核空間內(nèi)存地址直接映射到用戶空間中,于是應(yīng)用程序可以直接對(duì)Page Cache中的數(shù)據(jù)進(jìn)行讀寫操作。

內(nèi)存映射

mmap的一個(gè)巨大好處是可以讓開發(fā)人員像是訪問常規(guī)變量那樣隨機(jī)訪問文件中的數(shù)據(jù)。如果不用mmap,開發(fā)人員就得自己用lseek去頻繁定位文件的位置。這樣一來是非常麻煩,代碼寫的會(huì)相當(dāng)臃腫啰嗦;二是lseek也是系統(tǒng)調(diào)用,頻繁使用的話會(huì)造成大量上下文切換,帶來性能上的無謂損耗。

現(xiàn)實(shí)當(dāng)中,mmap有相當(dāng)花樣的玩法,可以實(shí)現(xiàn)多進(jìn)程數(shù)據(jù)共享和通訊,實(shí)現(xiàn)跨進(jìn)程鎖等。但這些功能不是本文的重點(diǎn)就不展開了。

sendfile

sendfile可以直接將Page Cache中某個(gè)fd的一部分?jǐn)?shù)據(jù)傳遞給另外一個(gè)fd,而不用經(jīng)過到應(yīng)用層的兩次copy。值得注意的是,sendfile的原始fd必須是一個(gè)磁盤文件對(duì)應(yīng)的fd;而其目標(biāo)fd可以是磁盤文件,也可以是socket。當(dāng)為socket時(shí),sendfile就非常高效的實(shí)現(xiàn)了一個(gè)功能——通過網(wǎng)絡(luò)serving文件。一般稱這種實(shí)現(xiàn)為“Zero Copy”。

其實(shí)這里還是會(huì)copy,只不過只有DMA copy,沒有CPU copy,不浪費(fèi)CPU

sendfile實(shí)現(xiàn)Zero Copy

上圖是一個(gè)使用sendfile將一個(gè)文件直接發(fā)到網(wǎng)絡(luò)的示意圖。調(diào)用sendfile使得文件數(shù)據(jù)進(jìn)入到Page Cache,然后讓網(wǎng)卡直接從Page Cache中獲取數(shù)據(jù)發(fā)送給網(wǎng)絡(luò)。期間不出現(xiàn)任何CPU Copy。如果調(diào)用sendfile時(shí),數(shù)據(jù)已經(jīng)在Page Cache了,就會(huì)被直接使用。

但是sendfile也有一個(gè)嚴(yán)重的缺點(diǎn)。因?yàn)閿?shù)據(jù)是兩個(gè)fd在內(nèi)核直接傳輸?shù)?,所以無法做任何修改。你只能原封不動(dòng)的傳輸原始的數(shù)據(jù)文件。一旦你想在數(shù)據(jù)上做一些額外的加工,就無法使用sendfile。比如磁盤上存儲(chǔ)的是原始的文件,而你想壓縮文件后再傳輸給socket,就必須放棄sendfile,老老實(shí)實(shí)的把文件讀到用戶態(tài)buffer,然后做壓縮處理,再寫回到內(nèi)核態(tài)的socket buffer。

Direct IO

上面介紹的是用了Page Cache的IO一般被稱為Buffered IO。之所以不叫Cached IO,是因?yàn)樵缒闘inux的磁盤iOS設(shè)計(jì)中在Page Cache 里還有一個(gè)內(nèi)部的”內(nèi)核buffer“。在Linux 2.6之后,這個(gè)設(shè)計(jì)被統(tǒng)一到了只使用Page。然而,Buffered IO的名字被保留了下來。

與Buffered IO相對(duì)的,是Direct IO。即應(yīng)用程序直接讀寫塊設(shè)備,不再經(jīng)過Page Cache。

Direct IO

要使用這種IO,只要在打開文件時(shí),增加一個(gè)O_DIRECT標(biāo)記。

int fd = open("path/to/the/file", O_DIRECT | O_RDWR);

相比“Buffered IO”,Direct IO必然會(huì)帶來性能上的降低。所以Direct IO有特定的應(yīng)用場景。比如,在數(shù)據(jù)庫的實(shí)現(xiàn)中,為了保證數(shù)據(jù)持久,寫入新數(shù)據(jù)到WAL(Write Ahead Log)必須直接寫入到磁盤,不能等待。這里用Direct IO來實(shí)現(xiàn)WAL就非常理想。

使用Direct IO的另外一種場景是,應(yīng)用程序?qū)Υ疟P數(shù)據(jù)緩存有特別定制的需要,而常規(guī)的Page Cache的各種策略并不能滿足這種需要。于是開發(fā)人員可以自己設(shè)計(jì)和實(shí)現(xiàn)一套“Cache”,配合Direct IO。畢竟最熟悉數(shù)據(jù)訪問場景的,是應(yīng)用程序自己的需求。

塊對(duì)齊

然而,Direct IO有一個(gè)很大的問題是要求如果是寫入到磁盤,開發(fā)者必須自行保證“塊對(duì)齊”。即write時(shí)給的buffer的offset和size要?jiǎng)偤门cVFS中的“塊”對(duì)應(yīng),不然就會(huì)得到EINVAL錯(cuò)誤。如果用了“Buffered IO”,Page Cache內(nèi)部就可以自動(dòng)搞定對(duì)齊這件事情了。沒有Page Cache,對(duì)齊要就得自己做。比如,需要手工調(diào)用posix_memalign分配塊對(duì)齊的內(nèi)存地址。

磁盤IO的優(yōu)化

除非用Direct IO,對(duì)于磁盤IO的優(yōu)化主要在讀取操作上。這是因?yàn)閷懭霑r(shí)總是寫到Page Cache,而寫內(nèi)存比寫磁盤要高效的多。從業(yè)務(wù)上講,一般來講上傳文件的請(qǐng)求量要遠(yuǎn)遠(yuǎn)小于獲取文件(圖片、html、js、css……),所以在Web場景下,對(duì)磁盤IO的優(yōu)化的主要思路其實(shí)很簡單——盡量保證要讀取的文件在內(nèi)存里,而不是取磁盤上讀取。如果數(shù)據(jù)已經(jīng)到了Page Cache,你可以

  • 選擇用read將其從Page Cache讀取到應(yīng)用程序的buffer,然后做后續(xù)處理。
  • 選擇用sendfile直接將數(shù)據(jù)復(fù)制到另外一個(gè)fd里(另外一個(gè)文件或者socket)
  • 選擇用mmap直接讀寫操作

但如果數(shù)據(jù)沒有到Page Cache,read可能就會(huì)“卡”一下(雖然操作系統(tǒng)并不認(rèn)為這是阻塞)。對(duì)于高性能服務(wù),這可能是無法接受的的。我們需要一種不會(huì)“卡”當(dāng)前線程的磁盤數(shù)據(jù)讀取方式。

正如第一篇文章所說,在Linux中,磁盤IO不支持NON_BLOCKING模式。但是Linux提供了磁盤的異步IO接口(Asynchronous IO,AIO)。

AIO

Linux中有兩套“AIO”接口。這兩套接口都只支持磁盤IO,不支持網(wǎng)絡(luò)IO。

POSIX AIO

第一套被稱作POSIX AIO。顧名思義,這套接口是POSIX標(biāo)準(zhǔn)規(guī)定的。這套AIO的接口的定義可以參考這里。其大致的使用方式是:

  1. POSIX AIO用信號(hào)(signal)來通知進(jìn)程IO完成了。所以要先注冊(cè)一個(gè)IO完成時(shí)對(duì)應(yīng)的信號(hào)的handler。
  2. aio_read或者aio_write來發(fā)起要讀/寫的操作。這個(gè)接口會(huì)立刻返回。
  3. IO完成后,信號(hào)被觸發(fā),相應(yīng)的handler會(huì)執(zhí)行。
  4. 你也可以選擇不使用信號(hào),而主動(dòng)調(diào)用aio_suspend來主動(dòng)等待IO的完成,就像第一篇文章中的select那樣。

POSIX AIO還支持用線程來做通知,但這需要額外處理線程協(xié)作的問題。

這套接口沒有得到廣泛的使用,原因是其有很大的局限性——這套接口并不能算是"真?AIO"。這套接口是完全在用戶態(tài)實(shí)現(xiàn)的(libc),完全沒有深入到操作系統(tǒng)內(nèi)核中。

此外,用信號(hào)做AIO的觸發(fā)在工程中有很多問題。信號(hào)是一個(gè)“數(shù)字”,而且是全局有效的。所以比如你用POSIX AIO實(shí)現(xiàn)了一個(gè)lib,選用數(shù)字M做信號(hào);但是你無法阻止其他人用POSIX AIO實(shí)現(xiàn)另外一個(gè)lib,也選用數(shù)字M做信號(hào)。這樣如果一個(gè)程序同時(shí)用了兩套lib,就會(huì)彼此干擾。POSIX AIO無法實(shí)現(xiàn)類似于epoll中可以創(chuàng)建多個(gè)epoll fd,彼此隔離的使用方式。

如果用aio_suspend,就不滿足使用AIO的最初目標(biāo)。你還是得讓程序主動(dòng)“等”一下。并且aio_suspend并不支持eventfd(下文會(huì)講到為什么eventfd很重要)。

此外POSIX AIO因?yàn)槭荘OSIX指定的標(biāo)準(zhǔn),所以其存在的一個(gè)重要意義是不同操作系統(tǒng)的實(shí)現(xiàn)要一致,便于跨平臺(tái)使用。但實(shí)際上各個(gè)操作系統(tǒng)對(duì)此標(biāo)準(zhǔn)實(shí)現(xiàn)的相當(dāng)不一致(尤其是MacOS),所以這個(gè)標(biāo)準(zhǔn)實(shí)際上沒有什么用處。可以參考這篇吐槽。

所以,對(duì)于POSIX AIO大家看看就好。Linux下實(shí)際使用比較多的是Linux AIO。

Linux AIO

Linux中的另外一套AIO接口被稱為Linux AIO,是Linux在內(nèi)核實(shí)現(xiàn)的一套AIO接口。這套是"真?AIO"。接口的詳細(xì)用法可以參考這里。我這里給出一個(gè)極度精簡版的例子,里面所有的錯(cuò)誤處理都被我忽略了,只是想體現(xiàn)一下Linux AIO的使用方式:

aio_context_t ctx;
struct iocb cb;
struct iocb *cbs[1];
char data[4096];
struct io_event events[1];
int ret;
int fd = /* 打開一個(gè)文件,獲得fd */;
ctx = 0;

ret = io_setup(128, &ctx); // 初始化一個(gè)同時(shí)處理最大128個(gè)fd的aio ctx
    
/* 初始化 IO control block */
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_lio_opcode = IOCB_CMD_PWRITE; // 設(shè)置要“寫入”
cb.aio_buf = (uint64_t)data; // aio用的buffer
cb.aio_offset = 0; // aio要寫入的offset
cb.aio_nbytes = 4096; // aio要寫入的字節(jié)個(gè)數(shù)

cbs[0] = &cb;
ret = io_submit(ctx, 1, cbs); // 提交io進(jìn)行異步處理

/* 等待aio完成 */
ret = io_getevents(ctx, 1, 1, events, NULL);
/* 對(duì)events進(jìn)行處理 */
io_destroy(ctx);

大意是:

  • 使用io_setup創(chuàng)建一個(gè)AIO的上下文aio_context_t(就像epoll會(huì)有一個(gè)fd)
  • 初始化iocb結(jié)構(gòu)體(io control block),每一個(gè)要進(jìn)行AIO的操作都要一個(gè)對(duì)應(yīng)的iocb數(shù)據(jù)
  • io_submitiocb提交(支持提交多個(gè))。接口會(huì)立刻返回。然后,你的程序就可以做其他事情了。
  • 希望處理IO事件時(shí),調(diào)用io_getevents。該接口會(huì)阻塞。如果IO事件完成了,就能拿到events,于是可以后續(xù)處理數(shù)據(jù)了。
  • 最終調(diào)用io_destroy把ctx清理掉。

這套接口在Linux內(nèi)核中實(shí)現(xiàn),看上去靠譜多了。但是這套接口有三個(gè)比較令人郁悶的問題。

第一個(gè)問題是,它只支持Direct IO的IO操作。也許這套接口是專門給數(shù)據(jù)庫領(lǐng)域?qū)iT定制的(更多的人會(huì)吐槽這個(gè)接口的作者腦筋有問題)。盡管Linux社區(qū)有很多爭論和提案(比如這里)。但是現(xiàn)狀就是只有Direct IO被支持。這就意味著,選擇使用了Linux AIO就無法享受Page Cache帶來的好處;此外,只要使用Linux AIO,就意味著必須自己做塊對(duì)齊(見上文Direct IO的介紹)。

BSD系統(tǒng)的AIO接口是支持buffered IO的

第二個(gè)問題是,這套接口支持的功能有限,比如對(duì)于fsync,stat等API,壓根就不能真的做到異步。此外Linux AIO對(duì)一些特定的文件系統(tǒng)支持不好(比如ext4,在這種操作系統(tǒng)上調(diào)用io_submit,還是會(huì)“卡”)。

第三個(gè)問題是io_getevents,它和epoll一起使用會(huì)讓程序有兩個(gè)阻塞點(diǎn)。這樣程序就沒法寫了。Linux提供了eventfd解決這個(gè)問題。

使用eventfd協(xié)調(diào)epoll和Linux AIO

如果在Linux下編寫一個(gè)高性能文件服務(wù)器,就需要同時(shí)用到epoll和Linux AIO。但是epoll_waitio_getevents就會(huì)引入兩個(gè)阻塞點(diǎn),這樣,等待文件IO的時(shí)候,網(wǎng)絡(luò)請(qǐng)求就會(huì)被延遲。這可不是我們希望的。

eventfd可以幫助把兩個(gè)阻塞點(diǎn)二合為一。eventfd,顧名思義,就是表達(dá)事件的fd。它的本意是利用fd來簡化跨進(jìn)程的通訊——比如AB兩個(gè)進(jìn)程共享同一個(gè)eventfd,A進(jìn)程對(duì)eventfd寫入,B進(jìn)程就能感知到。當(dāng)然,eventfd也能在同一個(gè)進(jìn)程里用。eventfd能協(xié)調(diào)epoll和Linux AIO是因?yàn)椋?/p>

  • epoll支持監(jiān)聽eventfd,并且
  • Linux AIO中被提交的events如果完成,就會(huì)觸發(fā)eventfd,于是監(jiān)聽該eventfd的epoll就能察覺到

這樣對(duì)于同時(shí)使用eventfd和Linux AIO的程序就可以把阻塞點(diǎn)統(tǒng)一到epoll_wait上。下面是一個(gè)例子(我依然忽略所有錯(cuò)誤處理,盡量簡化):

int efd, fd, epfd;
io_context_t ctx;
struct timespec tms;
struct io_event events[NUM_EVENTS];
struct iocb iocbs[NUM_EVENTS];
struct iocb *iocb;

int i, j, r;
void *buf;
struct epoll_event epevent;
  
efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); // 使用“eventfd”系統(tǒng)調(diào)用創(chuàng)建一個(gè)eventfd實(shí)例

fd = open(TEST_FILE, O_RDWR | O_CREAT | O_DIRECT, 0644); // 打開一個(gè)要真實(shí)操作的文件
    
ctx = 0;
io_setup(128, &ctx);
posix_memalign(&buf, 512, 1024); // 對(duì)齊buffer
  
for (i = 0, iocb = iocbs; i < NUM_EVENTS; ++i, ++iocb) {
    io_prep_pread(&iocb, fd, buf, RD_WR_SIZE, i * RD_WR_SIZE);
    io_set_eventfd(&iocb, efd); // 對(duì)于這個(gè)AIO注冊(cè)eventfd
    io_set_callback(&iocb, aio_callback); // 假設(shè)有這么個(gè)回調(diào)函數(shù)
}

io_submit(ctx, NUM_EVENTS, iocbps); // 提交所有的iocb

epfd = epoll_create(1); // 創(chuàng)建epoll fd
epevent.events = EPOLLIN | EPOLLET;
epevent.data.ptr = NULL;
epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &epevent); // 讓epoll監(jiān)聽eventfd
  
i = 0;
while (i < NUM_EVENTS) {
    uint64_t finished_aio;
    
    epoll_wait(epfd, &epevent, 1, -1); // epoll阻塞,開始等待

    /* epoll_wait 返回了,說明eventfd有事發(fā)生了 */
    read(efd, &finished_aio, sizeof(finished_aio)); // eventfd的值是這次AIO完成事件的個(gè)數(shù)

    printf("已經(jīng)完成的IO個(gè)數(shù): %"PRIu64"\n", finished_aio);

    while (finished_aio > 0) {
        tms.tv_sec = 0;
        tms.tv_nsec = 0;
        /* 既然AIO已經(jīng)完事了,調(diào)用io_gevents就會(huì)立刻返回了,不會(huì)阻塞 */
        r = io_getevents(ctx, 1, NUM_EVENTS, events, &tms); 
        if (r > 0) {
            // 處理AIO的事件
            i += r;
            finished_aio -= r;
        }
    }
}

上面的例子首先創(chuàng)建了一個(gè)eventfd,并且掛到了AIO上下文中。然后epoll_wait監(jiān)聽這個(gè)eventfd。在現(xiàn)實(shí)中,epoll可以同時(shí)監(jiān)聽此eventfd和所有其他socket的fd。一旦IO完成,eventfd被觸發(fā),epoll_wait返回。程序就可以調(diào)用io_getevents,這時(shí)鐵定是不會(huì)阻塞的,所以可以立刻拿到返回的事件,并作處理。

反思AIO

上面討論了這么多操作系統(tǒng)接口層面上的AIO,有很多細(xì)節(jié)和不完善的。但是,AIO在概念上卻很簡單,意思是通過一個(gè)回調(diào)處理數(shù)據(jù)。比如在nodejs中,讀取文件的用法可以非常清晰的反映出什么才是AIO。

const fs = require('fs'); // 引入fs這個(gè)包
fs.read('/path/to/file', function(data) {
    // 處理文件的數(shù)據(jù)
    console.log('文件數(shù)據(jù)處理完成');
});
console.log('開始讀取文件');

程序員可以指定要讀取一個(gè)文件,并且指定當(dāng)讀取完成后要處理的函數(shù)。這個(gè)指定立刻執(zhí)行,不會(huì)等待文件的讀取。這個(gè)模式可以清晰的反映出我們腦海中那個(gè)理想的AIO的樣子。

但是現(xiàn)實(shí)是很悲催的,因?yàn)椴僮飨到y(tǒng)層面的AIO沒法變成理想中的樣子。

  • 操作系統(tǒng)的AIO接口只支持文件操作。對(duì)于網(wǎng)絡(luò),需要用epoll這樣的IO多路復(fù)用技術(shù)。如果要統(tǒng)一網(wǎng)絡(luò)和磁盤IO都可以AIO就必須在上層進(jìn)行封裝,屏蔽掉操作系統(tǒng)這么不一致的細(xì)節(jié)(比如libuv就是這么干的)。
  • 由于系統(tǒng)調(diào)用并不只直接支持”回調(diào)”(“信號(hào)”在工程上難以應(yīng)用于IO回調(diào)這個(gè)場景,不算數(shù)),程序員需要自行使用io_getevents這樣的API來主動(dòng)等事件。在操作系統(tǒng)層面上,能做的最舒服的就是統(tǒng)一用epoll_wait做這個(gè)“等事件”的核心。這時(shí)需要借助eventfd。POSIX AIO并不支持eventfd,所以雖然有這么套接口,但是一般沒機(jī)會(huì)用。
  • Linux AIO只支持Direct IO,所以無法利用Page Cache。所以現(xiàn)實(shí)當(dāng)中,用不用是要做取舍的(nginx有一個(gè)選項(xiàng)aio就是配置這個(gè)功能的,見這里)。
  • Linux AIO不能100%實(shí)現(xiàn)所有文件操作api都能“異步”。

所以在操作系統(tǒng)上這個(gè)級(jí)別上,AIO非常的“別扭”

基于以上的這些問題,一般上層(nodejs,Java NIO)都會(huì)選擇用線程池+BIO來模擬文件AIO。好處是:

  • BIO這一套接口非常完備,文件IO除了read,write,還有stat,fsync,rename等接口在現(xiàn)實(shí)中也是經(jīng)常需要”異步“的;
  • 編程容易。看看上面的例子,是不是非常容易暈。而這些已經(jīng)是非常簡化的例子了,現(xiàn)實(shí)中的代碼要處理相當(dāng)多的細(xì)節(jié);
  • 不用在AIO和Buffered IO中做取舍。BIO天然可以利用Page Cache來提高性能;
  • 容易跨平臺(tái)。不同操作系統(tǒng)的線程實(shí)現(xiàn)和BIO的實(shí)現(xiàn)基本上完備一致,不會(huì)像AIO那樣細(xì)節(jié)差異相當(dāng)巨大。

再下一篇文章中,會(huì)介紹上層系統(tǒng)的高性能IO部分是如何使用操作系統(tǒng)API的。


本文來自大寬寬的碎碎念。如果覺得本文有戳到你,請(qǐng)關(guān)注/點(diǎn)贊哦。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容