1. 程式人生 > >linux非同步IO的兩種方式

linux非同步IO的兩種方式

知道非同步IO已經很久了,但是直到最近,才真正用它來解決一下實際問題(在一個CPU密集型的應用中,有一些需要處理的資料可能放在磁碟上。預先知道這些資料的位置,所以預先發起非同步IO讀請求。等到真正需要用到這些資料的時候,再等待非同步IO完成。使用了非同步IO,在發起IO請求到實際使用資料這段時間內,程式還可以繼續做其他事情)。
假此機會,也順便研究了一下linux下的非同步IO的實現。

linux下主要有兩套非同步IO,一套是由glibc實現的(以下稱之為glibc版本)、一套是由linux核心實現,並由libaio來封裝呼叫介面(以下稱之為linux版本)。


glibc版本

介面
glibc版本主要包含如下介面:
int aio_read(struct aiocb *aiocbp);  /* 提交一個非同步讀 */
int aio_write(struct aiocb *aiocbp); /* 提交一個非同步寫 */
int aio_cancel(int fildes, struct aiocb *aiocbp); /* 取消一個非同步請求(或基於一個fd的所有非同步請求,aiocbp==NULL) */
int aio_error(const struct aiocb *aiocbp);        /* 檢視一個非同步請求的狀態(進行中EINPROGRESS?還是已經結束或出錯?) */
ssize_t aio_return(struct aiocb *aiocbp);         /* 檢視一個非同步請求的返回值(跟同步讀寫定義的一樣) */
int aio_suspend(const struct aiocb * const list[], int nent, const struct timespec *timeout); /* 阻塞等待請求完成 */

其中,struct aiocb主要包含以下欄位:
int                 aio_fildes;                 /* 要被讀寫的fd */
void *            aio_buf;                 /* 讀寫操作對應的記憶體buffer */
__off64_t      aio_offset;           /* 讀寫操作對應的檔案偏移 */
size_t             aio_nbytes;             /* 需要讀寫的位元組長度 */
int                 aio_reqprio;               /* 請求的優先順序 */


struct sigevent   aio_sigevent;      /* 非同步事件,定義非同步操作完成時的通知訊號或回撥函式 */

實現
glibc的aio實現是比較通俗易懂的:
1、非同步請求被提交到request_queue中;
2、request_queue實際上是一個表結構,"行"是fd、"列"是具體的請求。也就是說,同一個fd的請求會被組織在一起;
3、非同步請求有優先順序概念,屬於同一個fd的請求會按優先順序排序,並且最終被按優先順序順序處理;
4、隨著非同步請求的提交,一些非同步處理執行緒被動態建立。這些執行緒要做的事情就是從request_queue中取出請求,然後處理之;
5、為避免非同步處理執行緒之間的競爭,同一個fd所對應的請求只由一個執行緒來處理;
6、非同步處理執行緒同步地處理每一個請求,處理完成後在對應的aiocb中填充結果,然後觸發可能的訊號通知或回撥函式(回撥函式是需要建立新執行緒來呼叫的);
7、非同步處理執行緒在完成某個fd的所有請求後,進入閒置狀態;
8、非同步處理執行緒在閒置狀態時,如果request_queue中有新的fd加入,則重新投入工作,去處理這個新fd的請求(新fd和它上一次處理的fd可以不是同一個);
9、非同步處理執行緒處於閒置狀態一段時間後(沒有新的請求),則會自動退出。等到再有新的請求時,再去動態建立;

看起來,換作是我們,要在使用者態實現一個非同步IO,似乎大概也會設計成類似的樣子……


linux版本

介面
下面再來看看linux版本的非同步IO。它主要包含如下系統呼叫介面:
int io_setup(int maxevents, io_context_t *ctxp);  /* 建立一個非同步IO上下文(io_context_t是一個控制代碼) */
int io_destroy(io_context_t ctx);  /* 銷燬一個非同步IO上下文(如果有正在進行的非同步IO,取消並等待它們完成) */
long io_submit(aio_context_t ctx_id, long nr, struct iocb **iocbpp);  /* 提交非同步IO請求 */
long io_cancel(aio_context_t ctx_id, struct iocb *iocb, struct io_event *result);  /* 取消一個非同步IO請求 */
long io_getevents(aio_context_t ctx_id, long min_nr, long nr, struct io_event *events, struct timespec *timeout)  /* 等待並獲取非同步IO請求的事件(也就是非同步請求的處理結果) */

其中,struct iocb主要包含以下欄位:
__u16     aio_lio_opcode;     /* 請求型別(如:IOCB_CMD_PREAD=讀、IOCB_CMD_PWRITE=寫、等) */
__u32     aio_fildes;         /* 要被操作的fd */
__u64     aio_buf;            /* 讀寫操作對應的記憶體buffer */
__u64     aio_nbytes;         /* 需要讀寫的位元組長度 */
__s64     aio_offset;         /* 讀寫操作對應的檔案偏移 */
__u64     aio_data;           /* 請求可攜帶的私有資料(在io_getevents時能夠從io_event結果中取得) */
__u32     aio_flags;          /* 可選IOCB_FLAG_RESFD標記,表示非同步請求處理完成時使用eventfd進行通知(百度一下) */
__u32     aio_resfd;          /* 有IOCB_FLAG_RESFD標記時,接收通知的eventfd */

其中,struct io_event主要包含以下欄位:
__u64     data;               /* 對應iocb的aio_data的值 */
__u64     obj;                /* 指向對應iocb的指標 */
__s64     res;                /* 對應IO請求的結果(>=0: 相當於對應的同步呼叫的返回值;<0: -errno) */

實現
io_context_t控制代碼在核心中對應一個struct kioctx結構,用來給一組非同步IO請求提供一個上下文。其主要包含以下欄位:
struct mm_struct*     mm;             /* 呼叫者程序對應的記憶體管理結構(代表了呼叫者的虛擬地址空間) */
unsigned long         user_id;        /* 上下文ID,也就是io_context_t控制代碼的值(等於ring_info.mmap_base) */
struct hlist_node     list;           /* 屬於同一地址空間的所有kioctx結構通過這個list串連起來,連結串列頭是mm->ioctx_list */
wait_queue_head_t     wait;           /* 等待佇列(io_getevents系統呼叫可能需要等待,呼叫者就在該等待佇列上睡眠) */
int                   reqs_active;    /* 進行中的請求數目 */
struct list_head      active_reqs;    /* 進行中的請求佇列 */
unsigned              max_reqs;       /* 最大請求數(對應io_setup呼叫的int maxevents引數) */
struct list_head      run_list;       /* 需要aio執行緒處理的請求列表(某些情況下,IO請求可能交給aio執行緒來提交) */
struct delayed_work   wq;             /* 延遲任務佇列(當需要aio執行緒處理請求時,將wq掛入aio執行緒對應的請求佇列) */
struct aio_ring_info  ring_info;      /* 存放請求結果io_event結構的ring buffer */

其中,這個aio_ring_info結構比較值得一提,它是用於存放請求結果io_event結構的ring buffer。它主要包含了如下欄位:
unsigned long   mmap_base;       /* ring buffer的地始地址 */
unsigned long   mmap_size;       /* ring buffer分配空間的大小 */
struct page**   ring_pages;      /* ring buffer對應的page陣列 */
long            nr_pages;        /* 分配空間對應的頁面數目(nr_pages * PAGE_SIZE = mmap_size) */
unsigned        nr, tail;        /* 包含io_event的數目及存取遊標 */

這個資料結構看起來有些奇怪,直接弄一個io_event陣列不就完事了麼?為什麼要維護mmap_base、mmap_size、ring_pages、nr_pages這麼複雜的一組資訊,而又把io_event結構隱藏起來呢?
這裡的奇妙之處就在於,io_event結構的buffer是在使用者態地址空間上分配的。注意,我們在核心裡面看到了諸多資料結構都是在核心地址空間上分配的,因為這些結構都是核心專有的,沒必要給使用者程式看到,更不能讓使用者程式去修改。而這裡的io_event卻是有意讓使用者程式看到,而且使用者就算修改了也不會對核心的正確性造成影響。於是這裡使用了這樣一個有些取巧的辦法,由核心在使用者態地址空間上分配buffer。(如果換一個保守點的做法,核心態可以維護io_event的buffer,然後io_getevents的時候,將對應的io_event複製一份到使用者空間。)
按照這樣的思路,io_setup時,核心會通過mmap在對應的使用者空間分配一段記憶體,mmap_base、mmap_size就是這個記憶體對映對應的位置和大小。然後,光有對映還不行,還必須立馬分配實體記憶體,ring_pages、nr_pages就是分配好的物理頁面。(因為這些記憶體是要被核心直接訪問的,核心會將非同步IO的結果寫入其中。如果物理頁面延遲分配,那麼核心訪問這些記憶體的時候會發生缺頁異常。而處理核心態的缺頁異常又很麻煩,所以還不如直接分配實體記憶體的好。其二,核心在訪問這個buffer裡的資訊時,也並不是通過mmap_base這個虛擬地址去直接訪問的。既然是非同步,那麼結果寫回的時候可能是在另一個上下文上面,虛擬地址空間都不同。為了避免進行虛擬地址空間的切換,核心乾脆直接通過kmap將ring_pages對映到高階記憶體上去訪問好了。)

然後,在mmap_base指向的使用者空間的地址上,會存放著一個struct aio_ring結構,用來管理這個ring buffer。其主要包含了如下欄位:
unsigned         id;                /* 等於aio_ring_info中的user_id */
unsigned         nr;                /* 等於aio_ring_info中的nr */
unsigned         head,tail;         /* io_events陣列的遊標 */
unsigned         magic,compat_features,incompat_features;
unsigned         header_length;     /* aio_ring結構的大小 */
struct io_event  io_events[0];      /* io_event的buffer */
終於,我們期待的io_event陣列出現了。

看到這裡,如果前面的內容你已經理解清楚了,你一定會有個疑問:既然整個aio_ring結構及其中的io_event緩衝都是放在使用者空間的,核心還提供io_getevents系統呼叫幹什麼?使用者程式不是直接就可以取用io_event,並且修改遊標了麼(核心作為生產者,修改aio_ring->tail;使用者作為消費者,修改aio_ring->head)?我想,aio_ring之所以要放在使用者空間,其原本用意應該就是這樣的。
那麼,使用者空間如何知道aio_ring結構的地址(aio_ring_info->mmap_base)呢?其實kioctx結構中的user_id,也就是io_setup返回給使用者的io_context_t,就等於aio_ring_info->mmap_base。
然後,aio_ring結構中還有諸如magic、compat_features、incompat_features這樣的欄位,使用者空間可以讀這些magic,以確定資料結構沒有被異常篡改。如果一切可控,那麼就自己動手、豐衣足食;否則就還是走io_getevents系統呼叫。而io_getevents系統呼叫通過aio_ring_info->ring_pages得到aio_ring結構,再將相應的io_event拷貝到使用者空間。
下面貼一段libaio中的io_getevents的程式碼(前面提到過,linux版本的非同步IO是由使用者態的libaio來封裝的):
int io_getevents_0_4(io_context_t ctx, long min_nr, long nr, struct io_event * events, struct timespec * timeout){
    struct aio_ring *ring;
    ring = (struct aio_ring*)ctx;
    if (ring==NULL || ring->magic != AIO_RING_MAGIC)
        goto do_syscall;
    if (timeout!=NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {
        if (ring->head == ring->tail)
            return 0;
    }
do_syscall:
    return __io_getevents_0_4(ctx, min_nr, nr, events, timeout);
}
其中確實用到了使用者空間上的aio_ring結構的資訊,不過尺度還是不夠大。

以上就是非同步IO的context的結構。那麼,為什麼linux版本的非同步IO需要“上下文”這麼個概念,而glibc版本則不需要呢?
在glibc版本中,非同步處理執行緒是glibc在呼叫者程序中動態建立的執行緒,它和呼叫者必定是在同一個虛擬地址空間中的。這裡已經隱含了“同一上下文”這麼個關係。
而對於核心來說,要面對的是任意的程序,任意的虛擬地址空間。當處理一個非同步請求時,核心需要在呼叫者對應的地址空間中存取資料,必須知道這個虛擬地址空間是什麼。不過當然,如果設計上要想把“上下文”這個概念隱藏了也是肯定可以的(比如讓每個mm隱含一個非同步IO上下文)。具體如何選擇,只是設計上的問題。

struct iocb在核心中又對應到struct kiocb結構,主要包含以下欄位:
struct kioctx*       ki_ctx;           /* 請求對應的kioctx(上下文結構) */
struct list_head     ki_run_list;      /* 需要aio執行緒處理的請求,通過該欄位鏈入ki_ctx->run_list */
struct list_head     ki_list;          /* 鏈入ki_ctx->active_reqs */
struct file*         ki_filp;          /* 對應的檔案指標 */
void __user*         ki_obj.user;      /* 指向使用者態的iocb結構 */
__u64                ki_user_data;     /* 等於iocb->aio_data */
loff_t               ki_pos;           /* 等於iocb->aio_offset */
unsigned short       ki_opcode;        /* 等於iocb->aio_lio_opcode */
size_t               ki_nbytes;        /* 等於iocb->aio_nbytes */
char __user *        ki_buf;           /* 等於iocb->aio_buf */
size_t               ki_left;          /* 該請求剩餘位元組數(初值等於iocb->aio_nbytes) */
struct eventfd_ctx*  ki_eventfd;       /* 由iocb->aio_resfd對應的eventfd物件 */
ssize_t (*ki_retry)(struct kiocb *);   /*由ki_opcode選擇的請求提交函式*/

呼叫io_submit後,對應於使用者傳遞的每一個iocb結構,會在核心態生成一個與之對應的kiocb結構,並且在對應kioctx結構的ring_info中預留一個io_events的空間。之後,請求的處理結果就被寫到這個io_event中。
然後,對應的非同步讀寫(或其他)請求就被提交到了虛擬檔案系統,實際上就是呼叫了file->f_op->aio_read或file->f_op->aio_write(或其他)。也就是,在經歷磁碟快取記憶體層、通用塊層之後,請求被提交到IO排程層,等待被處理。這個跟普通的檔案讀寫請求是類似的。
在《linux檔案讀寫淺析》中可以看到,對於非direct-io的讀請求來說,如果page cache不命中,那麼IO請求會被提交到底層。之後,do_generic_file_read會通過lock_page操作,等待資料最終讀完。這一點跟非同步IO是背道而馳的,因為非同步就意味著請求提交後不能等待,必須馬上返回。而對於非direct-io的寫請求,寫操作一般僅僅是將資料更新作用到page cache上,並不需要真正的寫磁碟。page cache寫回磁碟本身是一個非同步的過程。可見,對於非direct-io的檔案讀寫,使用linux版本的非同步IO介面完全沒有意義(就跟使用同步介面效果一樣)。
為什麼會有這樣的設計呢?因為非direct-io的檔案讀寫是隻跟page cache打交道的。而page cache是記憶體,跟記憶體打交道又不會存在阻塞,那麼也就沒有什麼非同步的概念了。至於讀寫磁碟時發生的阻塞,那是page cache跟磁碟打交道時發生的事情,跟應用程式又沒有直接關係。
然而,對於direct-io來說,非同步則是有意義的。因為direct-io是應用程式的buffer跟磁碟的直接互動(不使用page cache)。

這裡,在使用direct-io的情況下,file->f_op->aio_{read,write}提交完IO請求就直接返回了,然後io_submit系統呼叫返回。(見後面的執行流程。)
通過linux核心非同步觸發的IO排程(如:被時鐘中斷觸發、被其他的IO請求觸發、等),已經提交的IO請求被排程,由對應的裝置驅動程式提交給具體的裝置。對於磁碟,一般來說,驅動程式會發起一次DMA。然後又經過若干時間,讀寫請求被磁碟處理完成,CPU將收到表示DMA完成的中斷訊號,裝置驅動程式註冊的處理函式將在中斷上下文中被呼叫。這個處理函式會呼叫end_request函式來結束這次請求。這個流程跟《linux檔案讀寫淺析》中所說的非direct-io讀操作的情況是一樣的。
不同的是,對於同步非direct-io,end_request將通過清除page結構的PG_locked標記來喚醒被阻塞的讀操作流程,非同步IO和同步IO效果一樣。而對於direct-io,除了喚醒被阻塞的讀操作流程(同步IO)或io_getevents流程(非同步IO)之外,還需要將IO請求的處理結果填回對應的io_event中。
最後,等到呼叫者呼叫io_getevents的時候,就能獲取到請求對應的結果(io_event)。而如果呼叫io_getevents的時候結果還沒出來,流程也會被阻塞,並且會在direct-io的end_request過程中得到喚醒。

linux版本的非同步IO也有aio執行緒(每CPU一個),但是跟glibc版本中的非同步處理執行緒不同,這裡的aio執行緒是用來處理請求重試的。某些情況下,file->f_op->aio_{read,write}可能會返回-EIOCBRETRY,表示需要重試(只有一些特殊的IO裝置會這樣)。而呼叫者既然使用的是非同步IO介面,肯定不希望裡面會有等待/重試的邏輯。所以,如果遇到-EIOCBRETRY,核心就在當前CPU對應的aio執行緒新增一個任務,讓aio執行緒來完成請求的重新提交。而呼叫流程可以直接返回,不需要阻塞。
請求在aio執行緒中提交和在呼叫者程序中提交相比,有一個最大的不同,就是aio執行緒使用的地址空間可能跟呼叫者執行緒不一樣。需要利用kioctx->mm切換到正確的地址空間,然後才能發請求。(參見《淺嘗非同步IO》中的討論。)
 
核心處理流程
最後,整理一下direct-io非同步讀操作的處理流程:
io_submit。對於提交的iocbpp陣列中的每一個iocb(非同步請求),呼叫io_submit_one來提交它們;
io_submit_one。為請求分配一個kiocb結構,並且在對應的kioctx的ring_info中為它預留一個對應的io_event。然後呼叫aio_rw_vect_retry來提交這個讀請求;
aio_rw_vect_retry。呼叫file->f_op->aio_read。這個函式通常是由generic_file_aio_read或者其封裝來實現的;
generic_file_aio_read。對於非direct-io,會呼叫do_generic_file_read來處理請求(見《linux檔案讀寫淺析》)。而對於direct-io,則是呼叫mapping->a_ops->direct_IO。這個函式通常就是blkdev_direct_IO;
blkdev_direct_IO。呼叫filemap_write_and_wait_range將相應位置可能存在的page cache廢棄掉或刷回磁碟(避免產生不一致),然後呼叫direct_io_worker來處理請求;
direct_io_worker。一次讀可能包含多個讀操作(對應於類readv系統呼叫),對於其中的每一個,呼叫do_direct_IO;
do_direct_IO。呼叫submit_page_section;
submit_page_section。呼叫dio_new_bio分配對應的bio結構,然後呼叫dio_bio_submit來提交bio;
dio_bio_submit。呼叫submit_bio提交請求。後面的流程就跟非direct-io是一樣的了,然後等到請求完成,驅動程式將呼叫 bio->bi_end_io來結束這次請求。對於direct-io下的非同步IO,bio->bi_end_io等於dio_bio_end_aio;
dio_bio_end_aio。呼叫wake_up_process喚醒被阻塞的程序(非同步IO下,主要是io_getevents的呼叫者)。然後呼叫aio_complete;
aio_complete。將處理結果寫回到對應的io_event中;


比較

從上面的流程可以看出,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的工作就只能靠使用者程式來實現了。