如何對 Rust web 應用程序做性能分析 - 1

在本篇文章中,我們將研究一種衡量 web 應用程序性能的方法,並使用一個工具來分析和改進 Rust 代碼。

首先,創建一個新的 Rust 項目:

cargo new rust-web-profiling-example

接下來,編輯 Cargo.toml 文件,添加你需要的依賴項:

[dependencies]
tokio = { version = "1.19", features = ["full"] }
warp = "0.3.2"
[profile.release]
debug = true

本教程所需要的是一個小型 web 服務,所以我們將使用 Warp 和 Tokio 來創建它。然而,本文中討論的技術將適用於任何其他 web 框架和庫。

注意,我們爲 release 構建模式設置了 debug=true,這意味着即使在 release 構建模式下,我們也會有調試信息。這樣做的原因是,我們總是希望在 release 模式下進行性能優化。我們也希望有儘可能多的關於正在運行的代碼的信息,這會使得性能分析變得更容易。

一個迷你的 web 服務

首先,我們創建一個非常基本的 Warp web 服務,它包含一個共享資源和幾個接口來測試。

我們首先在 main.rs 中定義一些類型:

 1use std::{collections::HashMap, sync::Arc};
 2
 3use tokio::sync::Mutex;
 4use warp::Rejection;
 5
 6type WebResult<T> = std::result::Result<T, Rejection>;
 7
 8#[derive(Debug, Clone)]
 9pub struct Client {
10    pub user_id: usize,
11    pub subscribed_topics: Vec<String>,
12}
13
14pub type Clients = Arc<Mutex<HashMap<String, Client>>>;

WebResult 是我們處理 web 程序返回結果的一個助手類型。Client 類型是我們的共享資源—用戶 id 到 Client 的 map。Client 有一個 user_id 和一個訂閱主題列表,但這與我們的示例無關。

重要的是,該資源將在整個應用程序中共享,多個接口將同時訪問它。爲此,我們將它封裝在互斥鎖中,以保護訪問,並將它放入 Arc 智能指針中,這樣我們就可以安全地傳遞它。

接下來,我們定義一些 helper 來初始化和傳播我們的 Clients:

 1fn with_clients(clients: Clients) -> impl Filter<Extract = (Clients,)Error = Infallible> + Clone {
 2    warp::any().map(move || clients.clone())
 3}
 4
 5async fn initialize_clients(clients: &Clients) {
 6    let mut clients_lock = clients.lock().await;
 7    clients_lock.insert(
 8        String::from("87-89-34"),
 9        Client {
10            user_id: 1,
11            subscribed_topics: vec![String::from("cats"), String::from("dogs")],
12        },
13    );
14    clients_lock.insert(
15        String::from("22-38-21"),
16        Client {
17            user_id: 2,
18            subscribed_topics: vec![String::from("cats"), String::from("reptiles")],
19        },
20    );
21    clients_lock.insert(
22        String::from("12-67-22"),
23        Client {
24            user_id: 3,
25            subscribed_topics: vec![
26                String::from("mice"),
27                String::from("birds"),
28                String::from("snakes"),
29            ],
30        },
31    );
32}

with_clients 是我們在 Warp web 框架中爲路由提供資源的簡單方法。在 initialize_clients 中,我們向共享 Clients map 添加了一些硬編碼的值,但實際值與示例無關。

然後,我們添加一個 handler 模塊,它將使用共享的 Clients,在 src 目錄下新建 handler.rs 文件:

 1use std::time::Duration;
 2use warp::{reply, Reply};
 3
 4use crate::{Clients, WebResult};
 5
 6pub async fn read_handler(clients: Clients) -> WebResult<impl Reply> {
 7    let clients_lock = clients.lock().await;
 8    let user_ids: Vec<String> = clients_lock
 9        .iter()
10        .map(|(_, client)| client.user_id.to_string())
11        .collect();
12    tokio::time::sleep(Duration::from_millis(50)).await;
13    let result = user_ids
14        .iter()
15        .rev()
16        .map(|user_id| user_id.parse::<usize>().expect("can be parsed to usize"))
17        .fold(0, |acc, x| acc + x);
18    Ok(reply::html(result.to_string()))
19}

這個 async web 處理函數,接收一個 Client 的共享引用,訪問它,並從 map 中獲取一個 user_ids 列表。

然後,我們使用 tokio::time::sleep 異步暫停這裏的執行。這只是爲了模擬在此請求中傳遞的時間—例如,這可能是一個數據庫調用,或對現實應用程序中的另一個服務的 HTTP 調用。

在處理程序從休眠狀態返回之後,我們對 user_ids 執行另一個操作,將它們解析爲數字,反轉它們,將它們加起來,並將它們返回給用戶。

現在讓我們來完善 main.rs:

 1#[tokio::main]
 2async fn main() {
 3    let clients: Clients = Clients::default();
 4    initialize_clients(&clients).await;
 5
 6    let read_route = warp::path!("read")
 7        .and(with_clients(clients.clone()))
 8        .and_then(handler::read_handler);
 9
10    println!("Started server at localhost:8080");
11    warp::serve(read_route)
12        .run(([0, 0, 0, 0], 8080))
13        .await;
14}

我們使用 cargo run 運行這個服務,訪問 http://localhost:8080/read,我們會得到響應。

到目前爲止,一切順利。讓我們看看它的性能如何。

負載測試

爲了測試 web 服務的性能,特別是 read handler 程序,我們將在本教程中使用 Locust。然而,任何其他負載測試應用程序 (如 Gatling) 或你自己的工具發送和測量大量請求到 web 服務器都可以。

安裝 Locust:

 pip3 install locust
 locust -V

現在,安裝 Locust 後,讓我們在項目中與 src 同級目錄下創建 locust 文件夾,新建一個 read.py 文件,我們可以在其中添加一些負載測試定義:

from locust import HttpUser, task, between
class Basic(HttpUser):
    wait_time = between(0.5, 0.5)
    @task
    def read(self):
        self.client.get("/read")

在上面的 read.py 示例中,我們基於 HttpUser 創建了一個名爲 Basic 的類,它將爲我們提供類中的所有 Locust helper。

然後定義一個名爲 read 的 @task,這個 Client 使用 Locust 提供的 HTTP Client 簡單地發出 GET 請求。我們還定義了 wait_time 屬性,用於控制請求之間等待的時間。如果目標是模擬真實的用戶行爲,這很有用,在本例中,我們將其設置爲 0.5 秒。

讓我們用下面的命令來運行它:

locust -f read.py --host=http://127.0.0.1:8080

現在我們在瀏覽器中輸入 http://localhost:8089,我們將看到 Locust 的 web 界面。在那裏,我們可以設置我們想要模擬的用戶數量以及他們應該以每秒多快的速度生成。

在本例中,我們希望以 100/s 的速度生成 3000 個用戶。然後,這些用戶將每 0.5 秒發出一個 / 讀請求,直到我們停止。

通過這種方式,我們可以在 web 服務上創建一些負載,這將幫助我們找到代碼中的性能瓶頸和熱路徑,我們稍後將看到。

當在 Rust 中優化性能時,有一件重要的事情需要注意,那就是總是在發佈模式下編譯。

所以我們運行 cargo build --release,然後使用./target/release/rust-web-profiling-example 啓動應用。現在我們的 locusts 可以啓動了!

你可能必須在運行 locust 的終端中使用 ulimit -n 200000 這樣的命令來增加允許 locust 進程打開的文件的數量。

如果我們運行負載測試一段時間,直到所有用戶生成,響應時間穩定,我們可能會在停止時看到這樣的情況:

我們看到我們每秒只能收到 18.7 個請求,請求平均花費了 18 + 秒。很明顯我們的代碼出了問題——但是我們並沒有做什麼花哨的事情,而 Rust,Warp 和 Tokio 都是超級快的。發生了什麼事?

改善鎖的性能

如果我們 review 一下 read_handler 中的代碼,我們可能會注意到,當涉及到互斥鎖時,我們正在做一些非常低效的事情。

我們獲得鎖,訪問數據,到那時,我們實際上已經不再需要 Client 了。然而,由於 clients_lock 停留在作用域中,特別是在我們的僞 DB 調用 (睡眠) 的整個期間,這意味着我們在這個處理程序的整個期間鎖定了資源!

此外,在這個應用程序中,除了初始化之外,我們只從共享資源中讀取數據,但是互斥鎖並不區分讀訪問和寫訪問,它只是始終鎖定。

因此這裏我們可以做兩個簡單的優化:

  1. 我們用完鎖之後就把它 drop 掉

  2. 我們使用 RwLock 而不是 Mutex,因爲它只在寫操作時鎖住資源。

在 main.rs 中添加如下代碼:

 1pub type FasterClients = Arc<RwLock<HashMap<String, Client>>>;
 2
 3#[tokio::main]
 4async fn main() {
 5    ...
 6    let faster_clients: FasterClients = FasterClients::default();
 7    initialize_faster_clients(&faster_clients).await;
 8    ...
 9    let fast_route = warp::path!("fast")
10        .and(with_faster_clients(faster_clients.clone()))
11        .and_then(handler::fast_read_handler);
12    ...
13    warp::serve(read_route.or(fast_route))
14        .run(([0, 0, 0, 0], 8080))
15        .await;
16}
17
18fn with_faster_clients(
19    clients: FasterClients,
20) -> impl Filter<Extract = (FasterClients,)Error = Infallible> + Clone {
21    warp::any().map(move || clients.clone())
22}
23
24async fn initialize_faster_clients(clients: &FasterClients) {
25    let mut clients_lock = clients.write().await;
26    clients_lock.insert(
27        String::from("87-89-34"),
28        Client {
29            user_id: 1,
30            subscribed_topics: vec![String::from("cats"), String::from("dogs")],
31        },
32    );
33    clients_lock.insert(
34        String::from("22-38-21"),
35        Client {
36            user_id: 2,
37            subscribed_topics: vec![String::from("cats"), String::from("reptiles")],
38        },
39    );
40    clients_lock.insert(
41        String::from("12-67-22"),
42        Client {
43            user_id: 3,
44            subscribed_topics: vec![
45                String::from("mice"),
46                String::from("birds"),
47                String::from("snakes"),
48            ],
49        },
50    );
51}

在 handler.rs 文件中添加如下代碼:

 1pub async fn fast_read_handler(clients: FasterClients) -> WebResult<impl Reply> {
 2    let clients_lock = clients.read().await;
 3    let user_ids: Vec<String> = clients_lock
 4        .iter()
 5        .map(|(_, client)| client.user_id.to_string())
 6        .collect();
 7    drop(clients_lock);
 8    tokio::time::sleep(Duration::from_millis(50)).await;
 9    let result = user_ids
10        .iter()
11        .rev()
12        .map(|user_id| user_id.parse::<usize>().expect("can be parsed to usize"))
13        .fold(0, |acc, x| acc + x);
14    Ok(reply::html(result.to_string()))
15}

我們現在使用了 fastclients,並且在使用完它之後立即 drop。這應該能大大提高我們的速度,我們來檢查一下。

在 read.py 的 Locust 文件中,可以註釋掉前面的 / read,並添加以下內容:

# @task
# def read(self):
#     self.client.get("/read")
@task
def read(self):
    self.client.get("/fast")

讓我們重新編譯並運行 Locust。

我們獲得了每秒 915.6 個請求,這是 48 倍的改進。

在下一篇文章中,我們將使用火焰圖做一些實際的分析,以更深入地瞭解 web 處理程序內部發生了什麼。

本文翻譯自:

https://blog.logrocket.com/an-introduction-to-profiling-a-rust-web-application/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mddcF7lGl9tDZ6UEFHxSQQ