使用Seastar進行異步(Asynchronout)編程
介紹
我們在本文中介紹的Seastar,是一個用于在現代多核機器上,編寫高效復雜的服務器應用程序的C++庫。
傳統上,用于編寫服務器應用程序的編程語言庫和框架已經分為兩個不同的陣營:那些注重效率的陣營和側重于復雜性的陣營。一些框架是非常高效的,但是只允許構建簡單的應用程序(例如,DPDK允許單獨處理數據包的應用程序),而其他框架允許構建極其復雜的應用程序,代價是運行時效率。 Seastar是我們努力獲得兩全其美的方法:創建一個允許構建高度復雜的服務器應用程序,但實現最佳性能的庫。
Scylladb第一個使用了Seastar,它是對Apache Cassandra的重寫。 Cassandra是一個非常復雜的應用程序,然而,通過Seastar,我們能夠讓吞吐量提高10倍,同時顯著的降低一致性的延遲。
Seastar提供了一個完整的異步編程框架,它使用了 futrues 和 continuations 的概念,來統一表示和處理每種類型的異步事件,包括網絡IO,磁盤IO以及其他事件的復雜事件。
由于現代多核和多插槽機器在核心之間共享數據(原子指令,高速緩存行反彈和內存隔離)具有很高的代價,Seastar程序使用無共享編程模型,即可用內存隔離,每個核心都在自己的內存部分進行數據處理,內核之間的通信通過顯式的消息傳遞(當然這發生在使用SMP的共享內存硬件的情況下)
異步編程
用于網絡協議的服務器(如經典的HTTP(Web)或SMTP(電子郵件)服務器)處理是并行的:多個客戶端并行發送請求,在完成處理一個請求之前,我們無法開始處理下一個請求:因為各種原因,一個請求可能并經常需要阻塞磁盤IO,例如一個完整的TCP窗口(即一個慢速連接),甚至是持有非活躍連接的客戶端。服務器需要處理其他連接也是如此。
經典網絡服務器(如Inetd,Apache Httpd和Sendmail)處理這種并行連接所采用的最直接的方法是對每個連接使用單獨的操作系統進程。這種技術經過多年的發展,以提高其性能:剛開始,使用一個新的進程來處理每個新的連接;后來,使用進程池,每一個新的連接都被分配到池中的一進程。最后,進程被線程所取代。但是,所有這些實現背后的共同思想是,在每一個時刻,每個進程只處理一個連接。因此,服務器代碼可以自由使用帶阻塞的系統調用,例如讀取或寫入連接,或者從磁盤讀取數據,如果這個過程阻塞了,也沒關系,因為我們有許多額外的進程可以處理其他連接。
對每個連接使用一個進程(或一個線程)的服務器編程稱為同步編程,因為代碼是線性執行的,在上一行完成后,下一行代碼開始運行。例如,代碼可能會從套接字讀取請求,解析請求,然后零碎地從磁盤讀取文件并將其寫回套接字。這樣的代碼很容易編寫,就像傳統的非并行程序一樣。事實上,甚至可以運行一個外部的非并行程序來處理每個請求 - 例如Apache HTTPd如何運行“CGI”程序,生成第一個動態Web頁面。
>注意:盡管同步服務器應用程序是以線性,非并行的方式編寫的,但內核可以幫助確保所有事情都是并行發生的,并且可以充分利用機器的資源(CPU,磁盤和網絡)。除了進程并行(我們有多個進程并行處理多個連接)之外,內核甚至可以并行化一個單獨連接的工作 - 例如處理一個未完成的磁盤請求(例如,從磁盤文件中讀取)并行處理網絡連接(發送緩沖區中尚未發送的數據,并緩沖新接收的數據,直到應用程序準備好讀取它)。
但是同步的,每個連接的過程,服務器編程并不是沒有缺點或成本的。服務器作者慢慢地但是確定地意識到,開始一個新的進程是緩慢的,上下文切換很慢,每個進程都伴隨著大量的開銷,最顯著的是它的堆棧大小。服務器和內核作者努力減輕這些開銷:他們從進程切換到線程,從創建新線程到線程池,降低了每個線程的默認堆棧大小,增加了虛擬內存大小以允許部分利用的堆棧。但是,同步設計的服務器的性能并不理想,隨著并發連接數量的增長,服務器的性能也不盡人意。 1999年,Dan Kigel普及了“C10K問題”,需要一臺服務器來高效地處理10,000個并發連接,其中大部分是緩慢甚至不活動的。
該解決方案在接下來的十年中變得流行,它放棄了舒適但低效的同步服務器設計,轉而采用新型服務器設計 - 異步服務器或事件驅動服務器。事件驅動的服務器只有一個線程,或者更準確地說,每個CPU有一個線程。這個單線程運行一個緊密的循環,在每次迭代時,使用poll()(或更高效的epoll)檢查許多打開文件描述符(例如套接字)上的新事件。例如,一個事件可以是一個套接字變得可讀(新的數據已經從遠端到達)或變得可寫(我們可以在這個連接上發送更多的數據)。應用程序通過做一些非阻塞操作來處理這個事件,修改一個或多個文件描述符,并且保持它對這個連接狀態的知識。
然而,異步服務器應用程序的作者面臨著今天仍面臨的兩大挑戰:
*?復雜性:編寫簡單的異步服務器非常簡單。但是編寫一個復雜的異步服務器是非常困難的。單個連接的處理,而不是一個簡單易讀的函數調用,現在涉及大量的小型回調函數和一個復雜的狀態機,以記住每個事件發生時需要調用哪個函數。
*?非阻塞:每個內核只有一個線程,對于服務器應用程序性能很重要,因為上下文切換很慢。但是,如果我們每個內核只有一個線程,則事件處理函數不能阻塞,否則內核將保持空閑狀態。但是一些現有的編程語言和框架讓服務器作者別無選擇,只能使用阻塞函數,因此也不能使用多線程。例如,Cassandra被編寫為一個異步服務器應用程序;但是因為磁盤I/O是用mmaped文件實現的,所以在訪問時可以不受控制地阻塞整個線程,它們被迫在每個CPU上運行多個線程。
而且,當需要盡可能好的性能時,服務器應用程序及其編程框架不得不考慮以下因素:
*?現代機器:現代機器與十年前的機器非常不同。它們具有許多內核和深層內存層次結構(從L1緩存到NUMA),這些有利于某些編程實踐,卻帶來一些問題:不可擴展的編程實踐(如鎖定)會破壞許多內核的性能;共享內存和無鎖同步原語是可用的(即,原子操作和內存排序的屏障),但是比僅涉及單個內核的緩存中的數據的操作慢得多,并且也阻止應用程序擴展到多個內核。
*?編程語言:諸如Java,Javascript和類似的“現代”語言的高級語言是很方便的,但是每一種語言都有自己的一套假設,與上面列出的要求相沖突。這些旨在可移植的語言也使編程人員無法控制關鍵代碼的性能。為了獲得最佳的性能,我們需要一種編程語言,使程序員能夠完全控制,零運行時間的開銷,另一方面, 編譯時代碼的復雜性和優化。
Seastar是一個用于編寫異步服務器應用程序的框架,旨在解決上述所有四個難題:這是一個用于編寫復雜的異步應用程序(包括網絡和磁盤I / O)的框架。該框架的高效運行途徑完全是單線程(每核心),并可擴展到多個內核,并最小化在內核之間使用昂貴的內存共享。它是一個C ++ 14庫,為用戶提供了復雜的編譯時功能和對性能的全面控制,而無需運行時間的開銷。
Seastar
Seastar是一個事件驅動的框架,允許您以相對直接的方式編寫非阻塞的異步代碼。它的API基于 futures(c++11新特性)。 Seastar利用以下概念來實現卓越的性能:
*?Cooperative micro-task scheduler(合作的微任務調度器):每個核心運行一個合作任務調度器,而不是運行線程。每個任務通常都是非常輕量級的,只要處理最后一次I/O操作的結果并提交一個新的結果就可以運行。
*?Share-nothing SMP architecture(無共享SMP架構):每個內核獨立于SMP系統中的其他的內核運行。內存,數據結構和CPU時間不共享;相反,核心間通信使用顯示的消息傳遞。 Seastar core 通常被稱為 shard。
TODO:更多在資料訪問?scylladb/seastar
*?Future based APIs(基于future的API):futures允許您提交I/O操作,并鏈接完成I/O操作時要執行的任務。這是很容易并行運行多個I/O操作。例如,在響應TCP連接請求時,可以發出多個磁盤I/O請求,或發送相同的系統上的消息給其他核,或者發送請求到集群中的其他節點,等待一些或全部結果完成,匯總結果并發送響應。
*?Share-nothing TCP stack(無共享TCP堆棧):雖然Seastar可以使用主機操作系統的TCP堆棧,但它還提供了自己的高性能TCP/IP堆棧,該堆棧構建在任務調度器和無共享架構之上。堆棧在兩個方向上提供零拷貝:您可以直接從TCP堆棧的緩沖區處理數據,并將您自己的數據結構的內容作為消息的一部分發送,而不會產生副本。
*?DMA-based storage APIs(基于DMA的存儲API):與網絡堆棧一樣,Seastar提供零拷貝存儲API,允許您將數據存入存儲設備。
本教程面向已經熟悉C ++語言的開發人員,并將介紹如何使用Seastar創建新的應用程序。
參考鏈接:
scylladb/seastar