在網路應用程式開發中,有效處理大量 HTTP 請求是提升效能的關鍵。本文將探討 Rust 中的多執行緒和非同步程式設計模型,比較它們的優劣,並提供程式碼範例說明如何應用於實際場景。同步模型下,程式會逐一處理請求,造成效能瓶頸。多執行緒模型允許多個請求同時處理,但需要管理執行緒間的同步和資源競爭。非同步模型則利用單執行緒高效地切換不同任務,避免多執行緒的開銷。我們將以檔案讀取和 HTTP 請求處理為例,展示如何在 Rust 中使用 std::thread 和 Tokio 進行多執行緒和非同步程式設計,並分析它們的效能差異。透過理解這些模型的特性,開發者可以根據應用場景選擇最合適的平行程式設計策略。
平行程式設計的挑戰與機會
設計和撰寫平行程式是一項複雜的工作。首先,開發者需要確定哪些任務可以平行執行。讓我們回到圖10.1,它展示了兩個待執行的任務:任務1和任務2。假設這些任務是Rust程式中的兩個函式。最簡單的視覺化方法是將任務1排程在CPU 1上,將任務2排程在CPU 2上。這正是平行處理範例所展示的。但是,這是利用可用CPU時間的最有效模型嗎?
CPU密集型任務與I/O密集型任務
為了更好地理解這個問題,讓我們將軟體程式執行的所有處理過程廣泛地分為兩類別:CPU密集型任務和I/O密集型任務,儘管現實世界中的大多數程式碼都涉及兩者的混合。CPU密集型任務的例子包括基因組排序、視訊編碼、圖形處理和計算區塊鏈中的密碼學證明。I/O密集型任務包括從檔案系統或資料庫存取資料以及處理網路TCP/HTTP請求。
CPU密集型任務中的平行性
在CPU密集型任務中,大部分工作涉及存取記憶體中的資料、將程式指令和資料載入堆積疊以及執行它們。這裡有什麼樣的平行性是可能的?讓我們考慮一個簡單的程式,它接受一個數字列表並計算每個數字的平方根。程式設計師可以寫一個單一函式來完成以下工作:
- 接受對記憶體中載入的數字列表的參照
- 按順序遍歷列表
- 計算每個數字的平方根
- 將結果寫回記憶體
這是順序處理的一個例子。在有多個處理器或核心的電腦中,程式設計師還有機會以這樣的方式構建程式:每個數字從記憶體中讀取並傳送到下一個可用的CPU或核心進行平方根處理,因為每個數字都可以獨立於其他數字進行處理。這是一個簡單的例子,但它說明瞭程式設計師可以在複雜的CPU密集型任務中利用多個處理器或核心的機會。
I/O密集型任務中的平行性
接下來,讓我們看看在I/O密集型任務中如何使用平行性。以Web服務和應用程式中常見的HTTP請求處理為例,這通常比CPU密集型任務更傾向於I/O密集型。在Web應用程式中,資料儲存在資料函式庫中,所有建立、讀取、更新和刪除操作(對應於HTTP POST、GET、PUT和DELETE請求)都需要Web應用程式將資料傳輸到和從資料函式庫。這需要處理器(CPU)等待資料被讀取或寫入磁碟。儘管磁碟技術有所進步,但磁碟存取仍然很慢(毫秒級,相對於記憶體存取的納秒級)。因此,如果應用程式嘗試從Postgres資料函式庫檢索10,000個使用者記錄,它會呼叫作業系統進行磁碟存取,而CPU在此期間等待。當程式碼的一部分使處理器等待時,程式設計師有什麼選擇?答案是處理器在此期間可以執行另一個任務。這是程式設計師可以設計平行程式的一個機會。
網路請求處理中的平行性
Web應用程式中另一個“延遲”或“等待”的來源是網路請求處理。HTTP模型相當簡單:客戶端與遠端伺服器建立連線並發出請求(以HTTP請求訊息傳送)。然後,伺服器處理請求,發出回應,並關閉連線。當新的請求到達,而處理器仍在處理前一個請求時,就會出現挑戰。例如,假設一個GET請求到達以檢索導師1的一組課程,而在處理過程中,一個新的請求到達以POST導師2的新課程。第二個請求是否應該在佇列中等待,直到第一個請求完全處理完畢?或者我們可以在下一個可用的核心或處理器上排程第二個請求?這時我們開始欣賞平行程式設計的必要性。
注意 HTTP/2引入了一些改進措施,以最小化請求-回應週期和握手的次數。有關HTTP/2的更多詳細資訊,請參閱Barry Pollard的《HTTP/2 in Action》(Manning,2019)。
平行程式設計工具
到目前為止,我們已經看到了在CPU密集型和I/O密集型任務中使用平行程式設計技術的機會範例。現在讓我們來看看程式設計師可用的用於編寫平行程式的工具。圖1.2顯示了程式設計師在CPU上執行程式碼的各種選項。它特別強調了同步處理與兩種平行處理模式(多執行緒和非同步處理)之間的差異。它透過一個需要執行三個任務(任務1、任務2和任務3)的範例來說明這些差異。
同步處理、多執行緒處理和非同步處理
假設任務1包含三個部分:
- 第1部分—處理輸入資料
- 第2部分—阻塞操作
- 第3部分—封裝要傳回的資料
注意阻塞操作。這意味著當前的執行緒被阻塞,等待某些外部操作完成,例如從大檔案或資料函式庫讀取。讓我們看看如何在三種不同的程式設計模式下處理這些任務:同步處理、多執行緒處理和非同步處理。
同步處理
在同步處理的情況下,處理器完成第1部分,等待阻塞操作的結果,然後繼續執行任務的第3部分。
多執行緒處理
如果在多執行緒模式下執行相同的任務,則包含阻塞操作的任務1可以在單獨的作業系統執行緒上產生,而處理器可以在另一個執行緒上執行其他任務。
非同步處理
如果使用非同步處理,非同步執行環境(例如Tokio)將管理處理器上的任務排程。在這種情況下,它將執行任務1,直到它被阻塞,等待I/O。此時,非同步執行環境將排程任務2。當任務1中的阻塞操作完成時,任務1將再次被排程在處理器上執行。
多執行緒方法
多執行緒方法涉及使用原生作業系統執行緒,如圖10.3所示。在這種情況下,在Web伺服器程式中啟動一個新執行緒來處理每個傳入的請求。Rust標準函式庫透過std::thread模組為多執行緒提供了良好的內建支援。
use std::thread;
use std::time::Duration;
fn main() {
// 建立一個新執行緒
thread::spawn(|| {
for i in 1..10 {
println!("來自新執行緒:{}", i);
thread::sleep(Duration::from_millis(1));
}
});
// 在主執行緒中繼續執行其他任務
for i in 1..5 {
println!("來自主執行緒:{}", i);
thread::sleep(Duration::from_millis(1));
}
}
內容解密:
此範例展示瞭如何使用Rust標準函式庫中的std::thread模組建立一個新執行緒。在main函式中,我們建立了一個新執行緒,該執行緒與主執行緒平行執行。每個執行緒都列印一系列數字,並使用thread::sleep暫停一段時間,以模擬實際工作負載。
非同步方法
非同步方法則涉及使用非同步執行環境(如Tokio)來管理任務排程。這種方法允許在單一執行緒內高效地切換任務,從而避免了多執行緒之間的上下文切換開銷。
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// 建立兩個非同步任務
tokio::spawn(async {
for i in 1..10 {
println!("來自非同步任務1:{}", i);
sleep(Duration::from_millis(1)).await;
}
});
tokio::spawn(async {
for i in 1..5 {
println!("來自非同步任務2:{}", i);
sleep(Duration::from_millis(1)).await;
}
});
// 等待一段時間,以確保非同步任務有足夠的時間完成
sleep(Duration::from_secs(1)).await;
}
內容解密:
此範例展示瞭如何使用Tokio非同步執行環境建立和管理非同步任務。在main函式中,我們使用tokio::spawn建立了兩個非同步任務,它們平行執行。每個任務都列印一系列數字,並使用sleep暫停一段時間,以模擬實際工作負載。主函式等待一段時間,以確保非同步任務有足夠的時間完成。
平行程式設計在HTTP請求處理中的應用
在現代的網路應用程式中,處理多個HTTP請求是一項基本需求。為了提高效能,開發者經常採用平行程式設計技術。在本章中,我們將探討三種主要的平行程式設計方法:同步、多執行緒和非同步程式設計。
多執行緒在HTTP請求處理中的應用
圖10.3展示了多執行緒在HTTP請求處理中的模型。在這個模型中,我們將程式(Web伺服器)的運算分配到多個執行緒上。這可以提高效能,因為執行緒可以同時執行。但是,這並不是簡單地實作平行運算。多執行緒增加了一層複雜性:
- 執行緒的執行順序是不可預測的。
- 當多個執行緒試圖存取記憶體中的同一資料時,可能會發生死鎖。
- 可能會出現競爭條件,例如一個執行緒讀取了記憶體中的某個資料並正在進行計算,而另一個執行緒更新了該資料的值。
內容解密:
多執行緒程式設計需要謹慎的設計,以避免上述問題。開發者需要考慮執行緒之間的同步和通訊。
Rust標準函式庫實作了1:1的執行緒模型,即每個語言執行緒對應一個作業系統執行緒。但是,這並不意味著我們可以建立無數個對應於新網路請求的執行緒。每個作業系統都有執行緒數量的限制,這也受到堆積疊大小和伺服器可用虛擬記憶體的影響。此外,多個執行緒之間存在上下文切換的成本。
因此,多執行緒雖然適合某些場景,但並不是所有需要平行處理的情況下的完美解決方案。
非同步程式設計在HTTP請求處理中的應用
圖10.4展示了非同步程式設計在HTTP請求處理中的模型。在這個模型中,當非同步Web伺服器接收到每個HTTP請求時,伺服器會產生一個新的非同步任務來處理它。非同步執行時的排程器負責將各種非同步任務分配到可用的CPU上。
內容解密:
非同步程式設計可以提高伺服器的平行處理能力,從而提高效能。開發者需要使用非同步程式設計框架和函式庫來實作非同步程式設計。
非同步程式設計在客戶端的應用
圖10.5展示了非同步程式設計在客戶端的應用。例如,在瀏覽器中執行的JavaScript應用程式試圖上傳檔案到伺服器。如果沒有平行處理,螢幕將會凍結,直到檔案上傳完成並收到伺服器的回應。使用客戶端的非同步程式設計,瀏覽器上的UI可以繼續處理使用者輸入,而不必等待伺服器的回應。
實作平行程式
在本文中,我們將使用Rust語言實作同步、多執行緒和非同步程式。首先,讓我們建立一個新的專案:
cargo new --bin async-hello
cd async-hello
在src/main.rs中新增以下程式碼:
fn main() {
println!("Hello before reading file!");
let file_contents = read_from_file();
println!("{:?}", file_contents);
println!("Hello after reading file!");
}
fn read_from_file() -> String {
String::from("Hello, there")
}
這個程式模擬了從檔案中讀取資料的過程。read_from_file函式傳回一個字串,main函式呼叫它並列印結果。
內容解密:
這個程式是同步的,main函式等待read_from_file函式完成後再繼續執行。
接下來,讓我們模擬一個延遲。修改src/main.rs如下:
use std::thread::sleep;
use std::time::Duration;
fn main() {
println!("Hello before reading file!");
let file_contents = read_from_file();
println!("{:?}", file_contents);
println!("Hello after reading file!");
}
fn read_from_file() -> String {
sleep(Duration::new(2, 0));
String::from("Hello, there")
}
這個程式使用了sleep函式來模擬延遲。sleep函式會阻塞目前的執行緒一段時間。
內容解密:
這個程式仍然是同步的,main函式等待read_from_file函式完成後再繼續執行。開發者需要考慮使用非同步程式設計或其他技術來提高效能。
平行程式設計:多執行緒與非同步程式設計的比較
在現代軟體開發中,處理多個任務的能力對於提高程式效能至關重要。Rust 語言提供了多執行緒和非同步程式設計兩種方法來實作平行處理。本文將探討這兩種技術,並透過例項展示如何在 Rust 中使用它們。
同步執行範例
首先,讓我們考慮一個簡單的範例,該範例模擬從兩個檔案中讀取資料。初始程式碼如下:
fn main() {
println!("Hello before reading file!");
let file1_contents = read_from_file1();
println!("{:?}", file1_contents);
println!("Hello after reading file1!");
let file2_contents = read_from_file2();
println!("{:?}", file2_contents);
println!("Hello after reading file2!");
}
// 模擬從檔案讀取的函式
fn read_from_file1() -> String {
sleep(Duration::new(4, 0));
String::from("Hello, there from file 1")
}
// 模擬從檔案讀取的函式
fn read_from_file2() -> String {
sleep(Duration::new(2, 0));
String::from("Hello, there from file 2")
}
內容解密:
main函式依序呼叫read_from_file1和read_from_file2,模擬從兩個檔案中讀取資料。- 每個函式呼叫都會導致程式暫停,分別暫停 4 秒和 2 秒,總共需要 6 秒才能完成兩個操作。
多執行緒範例
為了提高效率,我們可以使用多執行緒技術同時讀取兩個檔案。下面是修改後的程式碼:
use std::thread;
use std::thread::sleep;
use std::time::Duration;
fn main() {
println!("Hello before reading file!");
let handle1 = thread::spawn(|| {
let file1_contents = read_from_file1();
println!("{:?}", file1_contents);
});
let handle2 = thread::spawn(|| {
let file2_contents = read_from_file2();
println!("{:?}", file2_contents);
});
handle1.join().unwrap();
handle2.join().unwrap();
}
// 模擬從檔案讀取的函式
fn read_from_file1() -> String {
sleep(Duration::new(4, 0));
String::from("Hello, there from file 1")
}
// 模擬從檔案讀取的函式
fn read_from_file2() -> String {
sleep(Duration::new(2, 0));
String::from("Hello, there from file 2")
}
內容解密:
- 使用
thread::spawn建立兩個新的執行緒,分別執行read_from_file1和read_from_file2。 handle1.join().unwrap()和handle2.join().unwrap()確保主執行緒等待兩個子執行緒完成。
非同步程式設計範例
Rust 也支援非同步程式設計,可以使用 Tokio 這個函式庫來實作。以下是使用 Tokio 的範例:
use std::thread::sleep;
use std::time::Duration;
use tokio;
#[tokio::main]
async fn main() {
println!("Hello before reading file!");
let h1 = tokio::spawn(async {
let file1_contents = read_from_file1().await;
println!("{:?}", file1_contents);
});
let h2 = tokio::spawn(async {
let file2_contents = read_from_file2().await;
println!("{:?}", file2_contents);
});
let _ = tokio::join!(h1, h2);
}
// 模擬從檔案讀取的非同步函式
async fn read_from_file1() -> String {
sleep(Duration::new(4, 0));
println!("{:?}", "Processing file 1");
String::from("Hello, there from file 1")
}
// 模擬從檔案讀取的非同步函式
async fn read_from_file2() -> String {
sleep(Duration::new(2, 0));
println!("{:?}", "Processing file 2");
String::from("Hello, there from file 2")
}
內容解密:
- 使用
tokio::spawn建立兩個非同步任務,分別執行read_from_file1和read_from_file2。 tokio::join!(h1, h2)等待兩個非同步任務完成。