我對軟體開發充滿熱情,特別是按照人體工學創建軟體系統的難題,該系統可以解決最廣泛的問題,同時盡可能減少妥協。我還喜歡將自己視為系統開發人員,根據 Andrew Kelley 的定義,這意味著有興趣完全理解他們正在使用的系統的開發人員。在這篇部落格中,我與您分享我解決以下問題的想法:建立可靠且高效能的全端企業應用程式。這是一個很大的挑戰,不是嗎?在部落格中,我專注於「高效能網頁伺服器」部分 - 我覺得我可以在這方面提供一個新鮮的視角,因為其餘部分要么是眾所周知的,要么我沒有什麼可補充的。
一個重要的警告 - 將會有沒有程式碼範例,我還沒有實際測試過這一點。是的,這是一個重大缺陷,但實際上實現這一點需要花費很多時間,而我沒有時間,在發布有缺陷的部落格和根本不發布它之間,我堅持前者。我們已警告您。
我們將用哪些部分來組裝我們的應用程式?
確定了我們的工具,讓我們開始吧!
Zig 沒有對協程的語言等級支援:( 而協程是建構每個高效能 Web 伺服器的基礎。那麼,嘗試沒有意義嗎?
等一下,讓我們先戴上系統程式設計師的帽子。協程不是靈丹妙藥,沒有什麼是靈丹妙藥。涉及的實際好處和缺點是什麼?
眾所周知,協程(使用者空間執行緒)更加輕量級且速度更快。但具體是透過什麼方式呢? (這裡的答案大部分都是猜測,請持保留態度並親自測試一下)
例如,Go 運行時將 goroutine 重複使用到作業系統執行緒上。執行緒共享頁表以及其他行程擁有的資源。如果我們在混合中引入CPU 隔離和親和性- 線程將在各自的CPU 核心上持續運行,所有作業系統資料結構將保留在記憶體中,無需換出,用戶空間調度程序將CPU 時間分配給goroutine精度,因為它使用協作多任務模型。競爭還可能嗎?
效能的提升是透過將執行緒的作業系統級抽象放在一邊並用 goroutine 的抽象來代替來實現的。但翻譯中沒有遺失任何內容嗎?
我認為獨立執行單元的「真正」作業系統級抽象甚至不是線程 - 它實際上是作業系統進程。實際上,這裡的差異並不那麼明顯——區分執行緒和進程的只是不同的 PID 和 TID 值。至於檔案描述子、虛擬記憶體、訊號處理程序、追蹤資源 - 這些是否對於子進程是獨立的,在「複製」系統呼叫的參數中指定。因此,我將使用術語「進程」來表示擁有自己的系統資源的執行線程 - 主要是 CPU 時間、記憶體、開啟的檔案描述符。
為什麼這很重要?每個執行單元對系統資源都有自己的需求。每個複雜的任務都可以分解為多個單元,每個單元都可以提出自己的、可預測的資源請求 - 記憶體和 CPU 時間。沿著子任務樹越往上走,您就會看到更一般的任務 - 系統資源圖形成一條帶有長尾的鐘形曲線。您有責任確保尾部不會超出系統資源限制。但這是如何做到的呢?如果實際上超出了該限制會發生什麼?
如果我們使用單一進程和多個協程的模型來執行獨立任務,當一個協程超出記憶體限制時 - 因為記憶體使用情況是在進程層級追蹤的,整個進程將被終止。這是最好的情況 - 如果您使用 cgroup(Kubernetes 中的 pod 會自動出現這種情況,每個 pod 都有一個 cgroup) - 整個 cgroup 都會被殺死。建立一個可靠的系統需要考慮到這一點。那麼CPU時間呢?如果我們的服務同時受到許多運算密集型請求的影響,它將變得無回應。然後是截止日期、取消、重試、重新開始。
對於大多數主流軟體堆疊來說,處理這些場景的唯一現實方法是在系統中保留「脂肪」 - 一些未使用的資源用於鐘形曲線的尾部- 並限制並發請求的數量- 這再次導致未使用的資源。即使如此,我們偶爾也會被 OOM 殺死或變得無響應 - 包括恰好與異常值處於同一進程中的「無辜」請求。這種妥協對許多人來說是可以接受的,並且在實踐中足以很好地服務於軟體系統。但我們能做得更好嗎?
由於每個進程都會追蹤資源使用情況,因此理想情況下我們會為每個小的、可預測的執行單元產生一個新進程。然後我們設定 CPU 時間和記憶體的 ulimit - 我們就可以開始了! ulimit 具有軟限制和硬限制,這將允許進程在達到軟限制時優雅終止,如果沒有發生,可能是由於錯誤 - 在達到硬限制時強制終止。不幸的是,在 Linux 上產生新進程的速度很慢,許多 Web 框架以及其他系統(例如 Temporal)不支援每個請求產生新進程。此外,進程切換的成本更高——這可以透過 CoW 和 CPU 固定來緩解,但仍然不理想。不幸的是,長時間運行的進程是不可避免的現實。
我們離短期行程的乾淨抽象越遠,我們需要處理的作業系統層級工作就越多。但也有一些好處 - 例如利用 io_uring 在許多執行緒之間進行批次 IO。事實上,如果一個大任務是由子任務組成的——我們真的關心它們各自的資源利用率嗎?僅用於分析。但如果對於大型任務,我們可以管理(切斷)資源鐘形曲線的尾部,那就足夠了。因此,我們可以產生與我們希望同時處理的請求一樣多的進程,讓它們長期存在,並且只需為每個新請求重新調整 ulimit 即可。因此,當請求超出其資源限制時,它會收到作業系統訊號並能夠正常終止,而不影響其他請求。或者,如果高資源使用率是故意的,我們可以告訴客戶端支付更高的資源配額。我覺得聽起來不錯。
但是與每個請求的協程方法相比,效能仍然會受到影響。首先,複製進程記憶體表的成本很高。由於該表包含對記憶體頁的引用,因此我們可以使用大頁,從而限制要複製的資料的大小。這只能透過低階語言直接實現,例如 Zig。此外,作業系統層級多工處理是搶佔式的而不是協作式的,這總是效率較低的。或者是嗎?
系統呼叫 sched_yield,它允許執行緒在完成其部分工作後放棄 CPU。看來還蠻合作的。有沒有辦法也要求給定大小的時間片?實際上,調度策略 SCHED_DEADLINE 是有的。這是一個即時策略,這意味著對於請求的CPU時間片,執行緒不間斷地運行。但是,如果切片溢位 - 搶佔就會啟動,並且您的執行緒將被換出並取消優先權。如果切片運作不足 - 執行緒可以呼叫 sched_yield 來發出提前完成的訊號,從而允許其他執行緒運行。這看起來是兩全其美——合作和先發制人的模式。
一個限制是 SCHED_DEADLINE 執行緒無法分叉。這給我們留下了兩種並發模型- 要么每個請求一個進程,它為自己設置最後期限,並運行一個事件循環以實現高效的IO,要么一個從一開始就為每個微任務生成一個線程的進程,每個微任務設定自己的截止日期,並利用佇列相互通訊。前者較直接,但需要使用者空間中的事件循環,後者則較多利用核心。
兩種策略都達到了與協程模型相同的目的 - 透過與核心合作,可以讓應用程式任務以最小的中斷運行。
這一切都是為了高效能、低延遲、低階的方面,而這正是 Zig 的閃光點。但當涉及到應用程式的實際業務時,靈活性比延遲更有價值。如果一個流程涉及真人簽署文檔,則電腦的延遲可以忽略不計。此外,儘管效能受到影響,物件導向語言為開發人員提供了更好的原語來對業務領域進行建模。在最遠的一端,像 Flowable 和 Camunda 這樣的系統允許管理和營運人員以更大的靈活性和更低的進入門檻對業務邏輯進行程式設計。像 Zig 這樣的語言對此沒有幫助,只會阻礙你。
另一方面,Python 是最具活力的語言之一。類別、物件——它們都是底層的字典,可以在運行時按照你喜歡的方式進行操作。這會降低效能,但使得使用類別和物件以及許多巧妙的技巧對業務進行建模變得實用。 Zig 則相反 - Zig 中故意添加了一些巧妙的技巧,為您提供最大程度的控制。我們可以透過讓它們互通來結合它們的力量嗎?
確實可以,因為兩者都支援 C ABI。我們可以讓 Python 解釋器在 Zig 進程內運行,而不是作為單獨的進程運行,從而減少運行時成本和黏合程式碼的開銷。這進一步允許我們在 Python 中使用 Zig 的自訂分配器 - 設定一個區域來處理單一請求,從而減少(如果沒有消除)垃圾收集器的開銷,並設定記憶體上限。一個主要限制是 CPython 運行時產生用於垃圾收集和 IO 的線程,但我沒有發現任何證據表明它確實如此。透過使用 AbstractMemoryLoop 中的「context」字段,我們可以將 Python 連接到 Zig 中的自訂事件循環中,並進行每個協程記憶體追蹤。可能性是無限的。
我們討論了並行性、並行性以及與作業系統核心的各種形式整合的優點。這項探索缺乏基準和程式碼,我希望它能透過提供的想法的品質來彌補。你嘗試過類似的事情嗎?你有什麼想法?歡迎回饋:)
以上是使用 Zig 和 Python 的高效能且可擴展的 Web 伺服器的詳細內容。更多資訊請關注PHP中文網其他相關文章!