編輯附註: 本文收錄自 Latency(作者 Pekka Engberg)的獨家摘錄,該書幫助讀者診斷延遲問題,並掌握過去多為「部落知識」的低延遲技術。您可從 ScyllaDB 免費下載三章內容。若您想深入探討延遲議題,歡迎加入 P99 CONF(免費且線上) 社群。

非同步處理是一種強大的技術,能在延遲已無法進一步降低的系統中提升並行能力並隱藏延遲。透過非同步執行 I/O 操作並延後非關鍵工作,您可大幅改善使用者感知的回應性,因為終端使用者不會察覺到延遲。雖然分割、快取及其他延遲優化技術能降低絕對延遲,但非同步處理提供了一種互補策略——它透過讓系統即使在 某些操作需要時間 時仍保持回應,來隱藏延遲。

「非同步處理提供了一種互補策略——它透過讓系統即使在某些操作需要時間時仍保持回應,來隱藏延遲。」

在本文中,我們將探討同步與非同步處理的根本差異,檢視事件迴圈如何實現高效的非同步執行,並探討非同步系統必須面對的主要挑戰與權衡。

非同步處理與同步處理

非同步處理 允許任務獨立且在重疊的時間區間內執行,而同步處理則是任務依序執行。換句話說,在同步系統中,一個任務必須執行完成後,下一個任務才能開始。例如,假設單執行緒的同步伺服器正在讀取與處理訊息。它會先從 socket 讀取訊息,此時執行緒可能需要阻塞直到訊息完全到達。接著伺服器處理該訊息,最後送出回應,然後才開始處理下一個訊息。如果請求處理耗時或阻塞,系統就會同步等待。

非同步處理移除了此限制,允許多個任務同時進行。例如,使用非同步處理的伺服器可透過 I/O 多工(operating system interface)輪詢各連線狀態,同時處理多個獨立的請求。伺服器可對事件(如 socket 變為可讀或可寫)作出反應來處理請求。同樣地,非同步伺服器可啟動透過網路送出回應,然後在不等待回應的情況下繼續其他工作。

非同步處理與 並行程式設計 相似。然而,非同步處理與並行程式設計不同之處在於它具有明確的介面。例如,使用執行緒的並行程式設計允許伺服器同步處理請求,同時透過執行緒間的上下文切換保留並行性。伺服器執行 send()recv() 系統呼叫,若 socket 無資料可讀或不可寫,這些呼叫會阻塞。當伺服器阻塞時,作業系統會切換至另一執行緒以實現並行執行。相對地,在非同步處理中,伺服器使用 I/O 多工介面輪詢 socket 狀態。I/O 多工介面會告知伺服器哪些 socket 可讀,伺服器即可在不阻塞執行緒的情況下讀取資料。同樣地,當伺服器送出回應時,它使用非同步介面送出回應,之後可立即繼續工作而不阻塞,讓作業系統在背景送出回應。

圖 1 說明同步與非同步處理的差異(也總結於標題為「非同步、並行與平行處理的差異」的側邊欄)。在此範例中,我們有兩個任務 A 與 B,必須執行完成才能完成全部工作。假設後端系統需要與外部系統 A 和 B 溝通才能完成收到的請求。在同步處理中,每個任務必須完成後才能開始下一個任務。我們執行任務 A 直到完成(包含其提交的 I/O),然後執行任務 B。所需總時間為所有任務時間加總。若後端服務需執行所有這些任務,使用者必須等待此總時間才能收到回應。

A timeline diagram comparing synchronous and asynchronous processing.
圖 1 同步處理與非同步處理。同步處理(上方)依序從一個任務處理至下一個任務。在此範例中,我們有任務 A 與 B,其中 A 提交 I/O。I/O 會同步執行完成後,任務 B 才能執行。相對地,非同步處理(下方)可與任務 B 同時執行 I/O。也就是說,任務 A 執行、提交 I/O 後,立即開始執行任務 B。當任務 A 與任務 B 的 I/O 完成時,我們就完成了工作,比同步處理更快。

然而,在非同步處理中,我們可同時執行任務 A 的 I/O 與任務 B,當兩者都完成時我們就結束。若 I/O 同時執行,使用者等待的時間會大幅縮短,即使每個任務仍需相同時間。這在後端服務進行資料庫呼叫或其他服務呼叫時效果良好,因為這些任務可獨立執行而不互相阻塞。但有一個注意事項:若您的 I/O 無法平行執行,使用非同步處理不會有幫助,反而可能因管理非同步任務而增加系統額外負擔,使執行變慢。

非同步處理也是隱藏延遲的關鍵技術。有些操作即使您已盡力降低延遲,仍需較長時間完成,因此必須在不讓所有人等待的情況下執行這些操作。例如,後端系統通常會與第三方服務、資料庫伺服器及訊息佇列等外部系統互動,每次互動都會增加一些延遲。在同步處理中,您常建立無法利用內建平行性的系統,並造成等待系統完成工作的閒置時間。相對地,非同步處理允許您透過非同步啟動操作並在完成時作出反應,來最小化等待時間。

在同步處理中,您將程式碼結構化為彼此相依的操作序列。例如,同步伺服器的請求處理函式可能如下所示。

清單 1 同步系統的簡單範例

fn process_requests(socket: &Socket) {
  loop {
    process_request(socket);
  }
}

fn process_request(socket: &Socket) {
  let msg = socket.recv();
  let request = parse_message(msg);
  let resp = match request {
    Request::GetUserInfo(id) => get_user_info(id);    
  };
  let resp = format_response(resp);
  socket.send(resp);
}

在高層次上,我們有 process_requests 函式,處理來自 socket 的任何傳入請求。在 process_request 函式中,每個步驟必須執行完成後才能開始下一個步驟。我們從 socket 讀取訊息、解析訊息以判斷請求內容、處理請求,最後透過 socket 送出回應。更重要的是,我們在送出回應前不會開始另一個 process_request,也不允許從多個 socket 處理請求。

雖然協程與 futures 等並行原語可實現平行執行,但對有效率的非同步處理(特別是 I/O)而言仍不足。若伺服器要處理數千個並行連線,您必須以不同方式建構應用程式。事件迴圈是有效率地跨多個連線多工 I/O 操作的基礎。

事件迴圈

事件迴圈是所有輸入與輸出操作的中央協調者——它是非同步系統的核心。傳統同步程式一次處理一個連線——如同單一工作者依序處理任務——而事件迴圈則作為分派器,同時管理數千個 I/O 操作。這種有時稱為 I/O 迴圈或 I/O 分派器的架構模式,正是非同步處理有效率地處理並行操作的方式。事件迴圈不是為每個連線配置獨立資源,而是透過追蹤狀態並在準備就緒時處理,來多工處理各種 I/O 來源——網路連線、檔案操作、計時器等。

「事件迴圈是所有輸入與輸出操作的中央協調者——它是非同步系統的核心。」

事件迴圈遵循一個簡單但強大的模式:

  1. 輪詢事件。
  2. 處理事件。
  3. 執行排定的任務。
  4. 重複。

事件迴圈使用作業系統特定的 I/O 多工介面(如 Linux 上的 io_uringepoll、macOS 上的 kqueue,以及 Windows 上的 IOCP)輪詢事件,例如來自 socket 的傳入資料、過期的計時器或 I/O 完成。這些介面讓您可註冊對事件來源的興趣,並在事件發生時收到通知。例如,應用程式不是直接從 socket 讀取資料,而是表示對 socket 變為可讀的興趣。當資料從網路到達 socket 時,作業系統會透過 I/O 多工介面通知應用程式 socket 現在可讀。事件迴圈透過輪詢發現這一點,並呼叫應用程式的事件處理邏輯來處理從 socket 新到達的資料。

讓我們在 Rust 中實作一個基本的事件迴圈,以更好地理解其結構:

struct EventLoop {
    // Holds registered event sources like sockets, files, timers
    sources: Vec<EventSource>,    
}

impl EventLoop {
    fn run(&mut self) {
        loop {
            // Create a new collection to store events
            let mut events = Events::new();

            // Poll for new events with a timeout
            self.poll(&mut events, Duration::from_millis(100));

            // Process each event that was found
            for event in events.iter() {
                self.process_event(&event);
            }

            // Run any scheduled tasks
            self.run_scheduled_tasks();
        }
    }
}

EventLoop::run() 方法展示了事件驅動程式設計的核心功能:持續輪詢並處理事件。poll() 方法使用作業系統特定的 I/O 多工介面(如 io_uring)針對事件來源輪詢事件。如範例程式碼所示,我們也為事件輪詢指定了逾時時間。逾時時間是必要的,因為事件迴圈中的 I/O 輪詢通常是唯一會阻塞執行緒直到事件發生的同步程式碼。若系統閒置且沒有事件發生,輪詢可能會阻塞,而這種阻塞可減少無事可做時浪費的 CPU 週期。然而,為確保事件迴圈不會永久阻塞,逾時時間確保我們會從 poll() 返回。這也允許事件迴圈執行不依賴事件的作業,例如執行背景工作。然而,在某些情況下,您可能會使用忙碌輪詢來避免某些延遲敏感事件迴圈的睡眠/喚醒週期延遲。

process_event 函式負責處理輪詢期間發現的任何事件。例如,若應用程式已註冊對來自網路的資料到達(例如 socket 變為可讀)的興趣,process_event 函式會從 socket 讀取資料,並將資料轉送給應用程式處理。簡單的 process_event 函式可能如下所示:

struct EventLoop {
    // Holds registered event sources like sockets, files, timers
    sources: Vec<EventSource>,    
}

impl EventLoop {
    fn run(&mut self) {
        loop {
            // Create a new collection to store events
            let mut events = Events::new();

            // Poll for new events with a timeout
            self.poll(&mut events, Duration::from_millis(100));

            // Process each event that was found
            for event in events.iter() {
                self.process_event(&event);
            }

            // Run any scheduled tasks
            self.run_scheduled_tasks();
        }
    }
}

如您所見,每個事件由 Event 列舉表示,其中包含不同事件的變體。事件處理邏輯取決於事件迴圈的結構方式。例如,若事件迴圈使用回呼進行事件處理,它會呼叫這些回呼,將工作委派給應用程式。應用程式接著可在回呼中執行工作,或將工作提交給另一執行緒處理。

圖 10.2 視覺化了事件迴圈如何執行工作。在此範例中,工作被分成三個獨立任務:

  1. 接受連線
  2. 處理請求
  3. 送出回應
Diagram of the event loop broken down into three separate tasks.
圖 2 事件迴圈將工作分解為個別任務,在事件發生時執行。在此範例中,事件迴圈處理三個不同任務——接受連線、處理請求與送出回應——作為處理來自網路請求的一部分。每個任務在事件(如 socket 變為可讀)發生時執行。

當 I/O 多工器通知事件迴圈有傳入連線時,第一個任務會執行。應用程式對此事件作出反應,接受連線,然後註冊對已接受 socket 何時變為可讀的興趣。當資料從網路到達時,作業系統會通知事件迴圈 socket 可讀。應用程式對此作出反應,從 socket 讀取資料並處理傳入的請求。最後,應用程式註冊對 socket 變為可寫的興趣。當作業系統有足夠的緩衝記憶體用於傳出回應時,它會通知應用程式,應用程式會將回應寫入 socket。

「雖然事件迴圈是非同步處理的低階基礎設施,您仍需要一些並行原語來指定個別任務之間的相依關係。」

若您將事件迴圈與同步伺服器(您在清單 1 中看到的)進行比較,會發現這兩種方法有兩個關鍵差異:

  • 非阻塞操作——事件迴圈不會阻塞執行緒,而是註冊對事件(如 socket 變為可讀)的興趣,並在該條件成立前延後從 socket 讀取,同時處理其他事件。
  • 資源效率——單一執行緒執行事件迴圈可處理數千個並行連線,因為它不需要等待 I/O 操作完成。相反地,I/O 多工作業系統介面允許事件迴圈同時輪詢多個事件來源(如 socket)的狀態,執行基於事件的處理。

雖然事件迴圈是非同步處理的低階基礎設施,您仍需要一些並行原語,如回呼或 futures,來指定個別任務之間的相依關係。

挑戰

雖然非同步處理可大幅改善應用程式效能,但它也伴隨著幾個需要考慮的重要陷阱:

  • 複雜度——非同步程式碼通常比同步程式碼更複雜。您需要仔細 管理任務相依關係、跨多個操作處理錯誤,並處理競爭條件。
  • 資源管理——同時執行許多任務可能會消耗大量記憶體與系統資源。您需要實作適當的節流與資源管理。
  • 可除錯性——當非同步程式碼發生問題時,可能較難追蹤問題,因為執行順序不一定明顯,且堆疊追蹤可能無法說明完整故事。
  • 錯誤處理——由於多個操作獨立執行,錯誤處理變得更複雜。您需要決定一個任務的失敗應如何影響其他正在執行的任務。

YOUTUBE.COM/THENEWSTACK

科技發展迅速,別錯過任何一集。訂閱我們的 YouTube 頻道,觀看所有 podcast、訪談、示範等內容。

Group Created with Sketch.