在Linux內核4.18版本中新添加了一種新的內核輪詢接口Linux AIO 方法IOCB_CMD_POLL。該補丁由Christoph Hellwig,還提議將Linux AIO接口配合網絡套接字等一起使用。
Linux的AIO是最初是設計用於磁碟異步IO的接口。文件與網絡套接字是大相逕庭的東西,可以用Linux AIO 接口,將其統一起來呢?
在本文中,我們介紹如何使用Linux AIO API的優勢來編寫更好,更快的網絡伺服器。
Linux AIO簡介
我們先來介紹一下Linux AIO。
Linux AIO用於將異步磁碟IO暴露給用戶空間。在Linux上所有磁碟操作都是阻塞的。無論是open(),read(),write()還是fsync(),如果所需要的數據和元數據還沒有在磁碟緩存準備好,則線程就會掛住。通常這不是問題。如果做少量的IO操作或者內存足夠大,則磁碟syscall會逐漸填充高速緩存,這樣整體來說速度還是會很快。
但是對於IO繁重的工作負載(例如資料庫或緩存Web代理),IO操作性能會下降很多。在這類應用中,很可能由於一些read()系統調用等待磁碟導致卡頓那是致命的。
要解決此類問題,通常變通使用如下方法:
使用線程池並將卸載的系統調用到工作線程。這就是glibc POSIX AIO(不要與Linux AIO混淆)包裝器的作用。
posix_fadvise使用預熱磁碟緩存,並希望達到最佳效果。
將Linux AIO與XFS文件系統一起使用,使用O_DIRECT打開文件,並避免出現未說明的陷阱。
這些變通的方法都不是完美的。
即使不小心使用Linux AIO,也可能會阻塞io_submit()調用。
Linux異步I/O(AIO)自從產生以來爭議就很多,大多數人希望的至少是異步的,實際上也沒有實現。但是由於種種原因,AIO操作可能會在內核中阻塞,從而使AIO在調用線程確實無法承受的情況下難以使用。
最簡單的AIO示例
要使用Linux AIO,首先需要定所需的系統調用。glibc不提供包裝函數。要使用Linux AIO,需要:
首先調用io_setup()以設置aio_context數據結構。內核提供了一個不透明的指針。
然後調用io_submit()提交一個"I/O控制塊"向量結構體 iocb進行處理。
最後,調用io_getevents() 塊並等待一個向量結構體 io_event-iocb的完成通知。
一個iocb中可以提交8個命令。4.18內核中引入了兩個讀取,兩個寫入,兩個fsync變體和一個POLL命令:
IOCB_CMD_PREAD = 0,
IOCB_CMD_PWRITE = 1,
IOCB_CMD_FSYNC = 2,
IOCB_CMD_FDSYNC = 3,
IOCB_CMD_POLL = 5, /* 4.18 */
IOCB_CMD_NOOP = 6,
IOCB_CMD_PREADV = 7,
IOCB_CMD_PWRITEV = 8,
在iocb結構體傳遞到io_submit,調整為磁碟IO。這是一個簡化的版本:
struct iocb {
__u64 data; /* 用戶數據 */
...
__u16 aio_lio_opcode; /* IOCB_CMD_ */
...
__u32 aio_fildes; /* 文件描述符 */
__u64 aio_buf; /* 緩衝指針 */
__u64 aio_nbytes; /* 緩衝大小*/
...
}
從io_getevents以下位置檢索完成通知:
struct io_event {
__u64 data; /* 用戶數據 */
__u64 obj; /* iocb請求指針 */
__s64 res; /* 事件結果碼*/
__s64 res2; /* 第二結果*/
};
讓我們舉一個簡單的例子,使用Linux AIO API讀取/etc/passwd文件:
fd = open("/etc/passwd", O_RDONLY);
aio_context_t ctx = 0;
r = io_setup(128, &ctx);
char buf[4096];
struct iocb cb = {.aio_fildes = fd,
.aio_lio_opcode = IOCB_CMD_PREAD,
.aio_buf = (uint64_t)buf,
.aio_nbytes = sizeof(buf)};
struct iocb *list_of_iocb[1] = {&cb};
r = io_submit(ctx, 1, list_of_iocb);
struct io_event events[1] = {{0}};
r = io_getevents(ctx, 1, 1, events, NULL);
bytes_read = events[0].res;
printf("read %lld bytes from /etc/passwd\n", bytes_read);
我們用strace追蹤程序的執行
這一切都OK!但磁碟讀取並非異步的,io_submit系統調用被阻止並完成了所有工作!io_getevents通話瞬間完成。
我們可以嘗試使磁碟異步讀取,但這需要O_DIRECT標誌來跳過緩存。
讓舉另一個更好能說明io_submit普通文件的阻止性質。從文件中讀取大的1GiB塊時顯示strace /dev/zero:
io_submit(0x7fe1e800a000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7fe1a79f4000, aio_nbytes=1073741824, aio_offset=0}]) = 1 <0.738380>
io_getevents(0x7fe1e800a000, 1, 1, [{data=0, obj=0x7fffb9588910, res=1073741824, res2=0}], NULL) = 1 <0.000015>
內核中io_submit花費738ms,只有15us io_getevents消耗的。內核的行為與網絡套接字相同,所有工作都在io_submit中完成。
用Linux AIO處理Socket
io_submit的處理相當保守。除非傳遞的描述符是O_DIRECT文件,否則它將阻塞並執行請求的操作。對於網絡套接字:
對於阻塞套接字,IOCB_CMD_PREAD將掛起,直到數據包到達為止。
對於非阻塞套接字,IOCB_CMD_PREAD返回-11(EAGAIN)。
這些語法與read()系統調用完全相同。對於網絡套接字而言,io_submit並不比舊式讀/寫調用更好用。
重要的是要注意iocb傳遞給內核的請求是按順序進行的。
儘管Linux AIO不能幫助異步操作,但它絕對可以用於系統調用批處理。如果有一個Web伺服器需要從數百個網絡套接字發送和接收數據,那麼使用io_submit是個好主意。這可以避免不必的send和recv數百次調用,將提高性能。從用戶空間和內核之間來回跳轉是需要耗時的。
為了說明的io_submit批處理,我們創建一個小程序,將數據從一個TCP套接字轉發到另一個。最簡單的形式,如果沒有Linux AIO,該程序將像下面這樣簡單:
while True:
d = sd1.read(4096)
sd2.write(d)
我們可以使用Linux AIO表達相同的邏輯。該代碼將如下:
struct iocb cb[2] = {{.aio_fildes = sd2,
.aio_lio_opcode = IOCB_CMD_PWRITE,
.aio_buf = (uint64_t)&buf[0],
.aio_nbytes = 0},
{.aio_fildes = sd1,
.aio_lio_opcode = IOCB_CMD_PREAD,
.aio_buf = (uint64_t)&buf[0],
.aio_nbytes = BUF_SZ}};
struct iocb *list_of_iocb[2] = {&cb[0], &cb[1]};
while(1) {
r = io_submit(ctx, 2, list_of_iocb);
struct io_event events[2] = {};
r = io_getevents(ctx, 2, 2, events, NULL);
cb[0].aio_nbytes = events[1].res;
}
以上代碼向io_submit提交兩個作業。首先,請求將數據寫入sd2,然後從sd1中讀取數據。讀取完成後,代碼將確定寫緩衝區的大小並再次循環。該代碼用了一個很酷的技巧:第一次寫入大小為0。之所以這樣做,是因為我們可以在一個io_submit中融合寫入+讀取(但不能讀取+寫)。讀取完成後,我們必須修複寫入緩衝區的大小。
這代碼比簡單的讀/寫版本快嗎?還不是。
兩個版本都有兩個系統調用:read + write和io_submit + io_getevents。
但是我們改善它。
擺脫io_getevents
當運行io_setup()時,內核為該進程分配幾頁內存。這是此內存塊在/proc/<pid> /maps中的樣子:
cat /proc/`pidof -s aio_passwd`/maps
...
7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted)
...
內存區域由io_setup分配。內存範圍用於存儲完成事件的環形緩衝區。在大多數情況下,沒有任何理由對io_geteventssyscall真正的調用。可以從環形緩衝區輕鬆檢索完成數據,而無需請求內核。下面是一個無需內核調用的修訂版本:
int io_getevents(aio_context_t ctx, long min_nr, long max_nr,
struct io_event *events, struct timespec *timeout)
{
int i = 0;
struct aio_ring *ring = (struct aio_ring*)ctx;
if (ring == NULL || ring->magic != AIO_RING_MAGIC) {
goto do_syscall;
}
while (i < max_nr) {
unsigned head = ring->head;
if (head == ring->tail) {
break;
} else {
/* There is another completion to reap */
events[i] = ring->events[head];
read_barrier();
ring->head = (head + 1) % ring->nr;
i++;
}
}
if (i == 0 && timeout != NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {
return 0;
}
if (i && i >= min_nr) {
return i;
}
do_syscall:
return syscall(__NR_io_getevents, ctx, min_nr-i, max_nr-i, &events[i], timeout);
}
通過此代碼修復了該io_getevents功能, Linux AIO版本的TCP代理每個循環僅只需要一個系統調用,並且讀寫版本代碼快一點。
替代Epoll
通過在內核4.18中添加IOCB_CMD_POLL,io_submit還可以用於替代select/poll/epoll。例如,以下代碼在Socket監聽等待數據:
struct iocb cb = {.aio_fildes = sd,
.aio_lio_opcode = IOCB_CMD_POLL,
.aio_buf = POLLIN};
struct iocb *list_of_iocb[1] = {&cb};
r = io_submit(ctx, 1, list_of_iocb);
r = io_getevents(ctx, 1, 1, events, NULL);
strace追蹤顯示:
io_submit(0x7fe44bddd000, 1, [{aio_lio_opcode=IOCB_CMD_POLL, aio_fildes=3}]) = 1 <0.000015>
io_getevents(0x7fe44bddd000, 1, 1, [{data=0, obj=0x7ffef65c11a8, res=1, res2=0}], NULL) = 1 <1.000377>
如上,該程序"異步"部分運行良好,io_submit立即完成,io_getevents等待數據成功阻塞了1秒鐘。這是非常強大的功能,可以代替epoll_wait()系統調用使用。
另外,通常情況下,epoll雜項處理需要epoll_ctl系統調用。應用程式開發人員竭盡全力避免過多地調用調用。使用io_submit輪詢可以解決整個的複雜工作,並且過程中不需要任何虛假的系統調用。只需將套接字推給iocb請求向量,只調用io_submit一次並等待完成即可。
總結
本文中,我們回顧了Linux AIO接口。儘管最初被認為是僅用於磁碟的接口API,但它似乎與網絡套接字上的常規讀/寫系統調用的工作方式相同。但是與讀/寫不同的是,它允許系統調用批處理io_submit,從而可以提高性能。從4.18版內核開始io_submit,io_getevents可用於等待網絡套接字上的事件如POLLIN和POLLOUT,用於替代epoll()事件循環。