作者: 王璞
長期以來,計算機系統IO的速度一直沒能跟上CPU速度的提升,相比而言IO往往成為系統的性能瓶頸,計算任務等待IO存取數據,成為高性能系統的一大性能瓶頸。本文先剖析IO性能瓶頸的根源,然後舉例説明如何解決IO瓶頸,最後簡要介紹我們在高性能IO方面的嘗試。
IO性能瓶頸
當用户程序執行IO操作時,絕大多數情況下是調用操作系統內核提供的系統調用來執行IO操作,最常見的IO系統調用是read和write。在現代計算機體系結構和操作系統的架構下,導致程序IO性能瓶頸主要有三大因素:阻塞、上下文切換、內存拷貝。下面分別簡述為什麼這三個因素會導致程序性能下降。
阻塞
阻塞比較好理解,比如用户程序調用read系統調用來讀取數據,如果要讀取的數據沒有準備好(沒有命中緩存),那用户程序就會被阻塞,導致用户程序休眠。等要讀的數據加載到系統態的內存之後,內核再喚醒用户程序來讀取數據。
阻塞對用户程序性能最大的影響在於,用户程序會被強制休眠,而且用户程序什麼時候被喚醒也無法控制,程序休眠期間什麼都不能做。於是阻塞帶來了大量的休眠等待時間。如果程序把大量時間花在阻塞等待IO上,自然IO效率低下,進而導致程序性能受影響。
上下文切換
上下文切換是操作系統的基本概念。內核運行在系統態,用户程序運行在用户態,這麼做主要是處於安全的考慮,限制用户程序的權限。用户程序調用系統調用執行IO操作,會發生上下文切換,比如用户程序調用read系統調用來讀取數據,用户程序的上下文被保存起來,然後切換到內核態執行read系統調用,當read系統調用執行完畢,再切換回用户程序。
上下文切換的代價不小,一方面內核要保存上下文現場,另一方面CPU的流水線也會被上下文切換打斷,需要重新加載指令。上下文切換等同一次中斷操作,於是系統調用也被稱軟中斷。頻繁的上下文操作對計算機系統帶來很大的開銷,導致程序執行效率大大降低,進而極大影響程序的性能。
此外,阻塞的時候,一定會發生上下文切換。還是沿用read操作的例子,用户程序在調用read系統調用後,內核發現要讀的數據沒有命中緩存,那用户程序會被強制休眠導致阻塞,內核調度其他程序來運行。但是上下文切換的時候不一定有阻塞。比如read操作的時候,如果用户程序在調用read系統調用之後,緩存命中,則read系統調用成功返回,把該用户程序的上下文切換回來繼續運行。
內存拷貝
內存拷貝對程序性能的影響主要源於內存的訪問速度跟不上CPU的速度,加之內存的訪問帶寬有限,亦稱內存牆(Memory Wall)。
現在的CPU頻率都是幾個GHz,每個CPU指令的執行時間在納秒量級。但是CPU訪問內存要花很多時間,因為CPU發出讀取內存的指令後,不是馬上能拿到數據,要等一段時間。CPU訪問內存的延遲大約在幾十納秒量級,比CPU指令的執行時間差不多慢一個數量級。
再者,內存的訪問帶寬也是有限的,DDR4內存總的帶寬大約幾十GB每秒。雖然看着不小,但是每個程序在運行時都要訪問內存,不論是加載程序指令,執行計算操作,還是執行IO操作,都需要訪問內存。
當發生內存拷貝時,CPU把數據從內存讀出來,再寫到內存另外的地方。由於內存訪問延遲比CPU指令執行時間慢很多,再加上內存帶寬有限,於是CPU也不是隨時能訪問內存,CPU的訪存指令會在DDR控制器的隊列裏排隊。因此內存拷貝對於CPU來説是很花時間的操作,數據沒有從內存讀出來就不能執行後續寫入操作,導致大量CPU等待,使得程序性能下降。
如何實現高性能IO
針對上面提到的三種影響IO性能的因素,下面舉三個例子,Rust Async,io_uring和RDMA,分別來介紹如何解決這三種影響程序性能的IO問題。
Rust Async
Rust Async異步編程通過協程、waker機制,部分解決了阻塞和上下文切換的問題。
首先,Rust Async採用協程機制,在某個異步任務被阻塞後,自行切換執行下一個異步任務,一方面避免了工作線程被阻塞,另一方面也避免了工作線程被內核上下文切換。Rust Async底層依靠操作系統的異步機制,比如Linux的epoll機制,來通知IO是否完成,進而喚醒waker來調度異步任務。
但是,Rust Async仍然有阻塞。Rust Async裏工作線程沒有被阻塞,不過被阻塞的是waker,所以Rust Async是把阻塞從工作線程搬到了waker身上。
此外,Rust Async無法避免上下文切換。Rust Async採用Reactor的IO方式:比如用户程序要讀取數據,發起read異步任務,假定該任務被阻塞放到等待隊列,當該任務要讀取的數據被內核準備好之後,該任務被喚醒,繼續調用read系統調用把數據從內核裏讀到用户內存空間,這次read系統調用因為要讀的數據已經被內核加載到系統態內存裏,所以不會發生阻塞,但是read系統調用還會有上下文切換。
Rust Async運行在用户態,而阻塞和上下文切換是操作系統內核決定的。要想進一步避免阻塞和上下文切換,就得在內核上做文章。
io_uring
io_uring是Linux提供的原生異步接口,不僅支持異步IO,還可以支持異步系統調用。io_uring在內核與用户程序之間建立發送隊列SQ和完成隊列CQ,用户程序把IO請求通過SQ發給內核,然後內核把IO執行完畢的消息通過CQ發給用户程序。採用io_uring,一方面避免了阻塞,另一方面也避免了上下文切換。
io_uring採用Proactor的IO方式,Proactor是相對Reactor而言。比如用户程序採用io_uring來讀取數據,先把read請求放到發送隊列SQ,然後用户程序可以去執行其他任務,或者定期輪詢完成隊列CQ(當然用户程序也可以選擇休眠被異步喚醒,但這樣就會有上下文切換,不過這個上下文切換是用户程序自行選擇的)。IO完成的時候,io_uring會把用户程序要讀的數據加載到read請求裏的buffer,然後io_uring在CQ裏放入完成消息,通知用户程序IO完成。這樣當用户程序收到CQ裏的完成消息後,可以直接使用read請求buffer裏的數據,而不需要再調用read系統調用來加載數據。
所以io_uring通過內核的支持,可以實現無阻塞和無上下文切換,進一步提升了IO的性能。但是io_uring還無法避免內存拷貝,比如read操作的時候,數據是先從IO設備讀到內核空間的內存裏,然後內核空間的數據再在複製到用户空間的內存。內核這麼做是出於安全和簡化IO的考慮。但是要想避免內存拷貝,那就得實現內核旁路(kernel bypass),避免內核參與IO。
RDMA
RDMA是常用於超算中心、高端存儲等領域的高性能網絡方案。RDMA需要特殊的網卡支持,RDMA網卡具有DMA功能,可以實現RDMA網卡直接訪問用户態內存空間。在RDMA網卡和用户態內存之間的數據傳輸(即數據通路),完全不需要CPU參與,更無需內核參與。用户程序通過RDMA傳輸數據時,是調用RDMA的用户態library接口,然後直接和RDMA網卡打交道。所以RDMA傳輸數據的整個數據通路是在用户態完成,沒有內核參與,既沒有阻塞也沒有上下文切換,更沒有內存拷貝,因此採用RDMA可以獲得非常好的網絡IO性能。
雖然RDMA通過內核旁路避免了阻塞、上下文切換和內存拷貝,實現了高性能網絡IO,但是RDMA也是有代價的。首先,RDMA編程複雜度大大提高,RDMA有自己的編程接口,不再採用內核提供的socket接口,RDMA的接口偏底層,而且調試不夠友好。另外,用户程序採用RDMA之後要自行管理內存,保證內存安全,避免競爭訪問:比如用户要通過RDMA網路發送數據,在數據沒有發送完成前,用户程序要保證存放待發送數據的用户內存空間不能被修改,不然會導致發送的數據錯誤,而且即便用户程序在已經開始發送但還沒有發送完成前修改了待發送的數據,RDMA也不會報錯,因為RDMA的用户態library也無法控制用户程序的內存空間來保證數據一致性。這極大地增加了用户程序的開發難度。對比內核執行寫操作,用户程序調用write系統調用之後,內核把待寫的數據先緩存在內核空間的內存,然後就可以通知用户程序寫操作完成,回頭內核再把寫數據寫入設備。雖然內核有做內存拷貝,但是保證了數據一致性,,也降低了用户程序執行IO操作的開發複雜度。
我們的嘗試
DatenLord的目標是打造高性能分佈式存儲系統,我們在開發DatenLord的過程中,一直在探索如何實現高性能IO。
雖然Rust Async異步編程理念非常不錯,用類似同步IO語意實現異步IO。但是我們認為Rust Async更多是異步IO的編程框架,還稱不上是高性能IO框架。於是我們嘗試把Rust Async跟io_uring和RDMA結合,以實現高性能IO。
首先,Rust Async與io_uring的結合工作,雖然Rust社區在這方面也有不少類似的嘗試,但是我們的重點是如何在io_uring執行異步IO的時候避免內存拷貝,這方面Rust社區的工作還很少。我們嘗試採用Rust的ownership機制來防止用户程序修改提交給io_uring用於執行IO操作的用户態內存,一方面避免內存拷貝,一方面保證內存安全。感興趣的朋友可以看下ring-io。
另外,我們也在嘗試結合Rust Async與RDMA,這方面Rust社區的工作不多。RDMA性能雖好,但是開發複雜度很大,而且調試不友好。我們嘗試採用Rust friendly的方式來實現RDMA接口的異步封裝,同時解決RDMA程序需要開發者自行管理內存的問題,從而大大降低採用Rust開發RDMA程序的難度。感興趣的朋友可以看下async-rdma。
最後,歡迎對高性能IO感興趣的朋友們聯繫我們,跟我們一起交流探討。
我們的聯繫方式:dev@datenlord.io