我們調用的Java AIO底層也是要調用OS的AIO實現,而OS主要也就Windows和Linux這兩大類,當然還有Solaris和mac這些小眾的。
- 在 Windows 操作系統中,提供了一個叫做 I/O Completion Ports 的方案,通常簡稱為 IOCP,操作系統負責管理線程池,其性能非常優異,所以在 Windows 中 JDK 直接采用了 IOCP 的支持。
- 而在 Linux 中其實也是有AIO 的實現的,但是限制比較多,性能也一般,所以 JDK 采用了自建線程池的方式,也就是說JDK並沒有用Linux提供的AIO。但是本文主要想聊的,就是Linux中的AIO實現。
Linux AIO主要有三種實現:
- glibc 的 AIO 本質上是由多線程在用戶態下模擬出來的異步IO,但是glibc下的POSIX AIO的bug太多,而且找不到相關資料,所以我們這里不詳細講。(我查了一下相關資料大多數是11年左右的那幾篇文章,感興趣的可以自己去了解一下)
- 而libeio的實現和glibc很像,也是由多線程在用戶態下模擬出來的異步IO,是libev 的作者 Marc Alexander Lehmann大佬寫的,一會會提及它
- Linux 2.6以上的版本實現了內核級別的AIO,內核的AIO只能以 O_DIRECT(直接寫入磁盤) 的方式做直接 IO(使用了虛擬文件系統,其他OS不一定能用)
glibc 的 AIO
- 異步請求被提交到request_queue中;
- request_queue實際上是一個表結構,"行"是fd、"列"是具體的請求。也就是說,同一個fd的請求會被組織在一起;
- 異步請求有優先級概念,屬於同一個fd的請求會按優先級排序,並且最終被按優先級順序處理;
- 隨着異步請求的提交,一些異步處理線程被動態創建。這些線程要做的事情就是從request_queue中取出請求,然后處理之;
- 為避免異步處理線程之間的競爭,同一個fd所對應的請求只由一個線程來處理;
- 異步處理線程同步地處理每一個請求,處理完成后在對應的aiocb中填充結果,然后觸發可能的信號通知或回調函數(回調函數是需要創建新線程來調用的);
- 異步處理線程在完成某個fd的所有請求后,進入閑置狀態;
- 異步處理線程在閑置狀態時,如果request_queue中有新的fd加入,則重新投入工作,去處理這個新fd的請求(新fd和它上一次處理的fd可以不是同一個);
- 異步處理線程處於閑置狀態一段時間后(沒有新的請求),則會自動退出。等到再有新的請求時,再去動態創建;
更詳細的可以自己去看Linux里面的源碼是怎么寫的
libeio 的 AIO
1. 主線程調用eio_init函數,主要是初始化req_queue(請求隊列),res_queue(響應隊列)以及對應的mutex(互斥鎖)和cond(pthread,Linux多線程部分);
2-3. 所有的IO操作其實都是對eio_sumbit的調用,而eio_sumbit的職能是將IO操作封裝為request並插入到req_queue;並調用cond_signal向worker線程發出reqwait已經OK的信號;
4. worker線程被創建后執行的函數為etp_proc,etp_proc啟動后會一直等待reqwait條件的出現;
5-6. 當reqwait條件變量滿足時,etp_proc從req_queue中取得一個待處理的request;並調用eio_execute來同步執行該IO操作;
7-8. eio_execute完成后,將response插入到res_queue隊列中;同時調用want_poll來通知主線程request已經處理完畢;
9. 這里worker線程通知主線程的機制是通過向pipe[1]寫一個byte數據;
10. 當主線程發現pipe[0]可讀時,就調用eio_poll;
11. eio_poll從res_queue里取response,並調用該IO操作在init時設置的callback函數完成后續處理;
12. 在res_queue中沒有待處理response時,調用done_poll;
13-14. done_poll從pipe[0]讀出一個byte數據,該IO操作完成。
Linux libaio 內核級別AIO
- 首先是調用 io_setup 函數創建一個aio上下文 aio_context_t (對應內核中的kioctx),這個上下文包含等待隊列等內容和一個存放 io_event 的 aio_ring_buffer
- 調用 io_submit 提交異步請求,每一個請求都會創建一個 iocb 結構用於描述這個請求(對應內核中的kiocb)
- 調用 aio_rw_vect_retry 提交請求到虛擬文件系統,這個方法調用了 file->f_op(open)->aio_read 或 file->f_op->aio_write 提交到了虛擬文件系統,提交完后 IO 請求立即返回,而不等待虛擬文件系統完成相應操作(這也就是為什么內核級別實現的aio不一定兼容其他OS的原因,因為使用了自有的文件系統)
- 調用wake_up_process喚醒被阻塞的進程(io_getevents的調用者)
- 最后然后調用aio_complete 將處理結果寫回到對應的io_event中
- io_getevents返回結果
內核級AIO與用戶線程級別的AIO(glibc和libaio)的比較
- 從上面的流程可以看出,linux版本的異步IO實際上只是利用了CPU和IO設備可以異步工作的特性(IO請求提交的過程主要還是在調用者線程上同步完成的,請求提交后由於CPU與IO設備可以並行工作,所以調用流程可以返回,調用者可以繼續做其他事情)。相比同步IO,並不會占用額外的CPU資源。
- 而glibc版本的異步IO則是利用了線程與線程之間可以異步工作的特性,使用了新的線程來完成IO請求,這種做法會額外占用CPU資源(對線程的創建、銷毀、調度都存在CPU開銷,並且調用者線程和異步處理線程之間還存在線程間通信的開銷)。不過,IO請求提交的過程都由異步處理線程來完成了(而linux版本是調用者來完成的請求提交),調用者線程可以更快地響應其他事情。如果CPU資源很富足,這種實現倒也還不錯。
- 當調用者連續調用異步IO接口,提交多個異步IO請求時。在glibc版本的異步IO中,同一個fd的讀寫請求由同一個異步處理線程來完成。而異步處理線程又是同步地、一個一個地去處理這些請求。所以,對於底層的IO調度器來說,它一次只能看到一個請求。處理完這個請求,異步處理線程才會提交下一個。
- 而內核實現的異步IO,則是直接將所有請求都提交給了IO調度器,IO調度器能看到所有的請求。請求多了,IO調度器使用的類電梯算法就能發揮更大的功效。請求少了,極端情況下(比如系統中的IO請求都集中在同一個fd上,並且不使用預讀),IO調度器總是只能看到一個請求,那么電梯算法將退化成先來先服務算法,可能會極大的增加碰頭移動的開銷。
- glibc版本的異步IO支持非direct-io,可以利用內核提供的page cache來提高效率。而linux版本只支持direct-io,cache的工作就只能靠用戶程序來實現了。
順便提一嘴,沒有OS提供了本地文件的非阻塞IO(NIO),對於文件的讀寫,即使以O_NONBLOCK方式來打開一個文件,也會處於"阻塞"狀態。因為文件時時刻刻處於可讀狀態。
不得不說,一旦把一樣技術挖到比較深的地方的話,涉及到的就是各種OS的知識甚至C語言的東西了。而這對於我這種只會表面調包的碼畜來說非常的不友好,畢竟我沒有學過Linux內核相關的知識,沒有Linux編程的基礎(我甚至連C語言都不怎么熟悉)。希望以后能找時間補上。
(區區Linux AIO,可難不倒我名偵探野比大雄!)PS:如果有錯的話希望大家指正,畢竟之前就寫錯了,雖然我知道根本沒人看我blog。
參考資料: