はじめに
みなさん様々な言語でAPIサーバーを立てて負荷試験を実施したことはありますか。
私自身、業務でPythonのアプリケーションに対して負荷試験を実施した経験があります。
その際にPythonの速度観点の不安定さを目の当たりにしたと同時に、別の言語ではどのような違いが生まれるのだろうか、という疑問を持ちました。
そこで今回は、簡単ではありますがGoとRustとPythonでそれぞれAPIサーバーを立てて負荷試験をしてみます。
誤解を生んでしまっていたので、補足すると今回は言語間の厳密なベンチマークテストを行いたいわけではありません。
負荷試験対象のAPIサーバー
今回は(1) Hello, World!を返すAPI(2)ファイル読み込みAPI(3)1秒待ってから応答するAPIの3つを実装します。
(1)はAPIサーバー自体の応答速度の計測、(2)はメモリを消費する処理が生じた場合のAPIの応答速度の計測、(3)は待ち時間発生している時のAPIの応答速度の計測することが目的です。
(2)について、ファイル読み込みAPIとはAPIが叩かれた際に特定のファイルを読み込んで完了メッセージを送るAPIを意図しています。(POSTではなく、 GET)
読み込む対象のファイルは以下のTitanic dataset(29.47 KB)としました。
またPythonはFastAPIを用いて実装を行いました。Go、Rust、Pythonのバージョンは以下です。
% go version
go1.20.6 darwin/arm64
% rustc --version
rustc 1.68.2 (9eb3afe9e 2023-03-27) (built from a source tarball)
% python --version
Python 3.11.2
負荷試験のプログラム
Go、Rust、Pythonの実装は以下です。
ファイル読み込み実装が厳密に一致しておりません。
Goの実装
package main
import (
"encoding/csv"
"encoding/json"
"net/http"
"os"
"time"
)
type Response struct {
Message string `json:"message"`
}
func writeJSONResponse(w http.ResponseWriter, message string) {
response := Response{Message: message}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func root(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, "Hello, World!")
}
func fileRead(w http.ResponseWriter, r *http.Request) {
file, err := os.Open("../data/tested.csv")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
csvReader := csv.NewReader(file)
for {
_, err := csvReader.Read()
if err != nil {
break
}
}
writeJSONResponse(w, "file_read")
}
func waitTime(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second)
writeJSONResponse(w, "time_sleep")
}
func main() {
http.HandleFunc("/", root)
http.HandleFunc("/file_read", fileRead)
http.HandleFunc("/wait_time", waitTime)
http.ListenAndServe(":8000", nil)
}
Rustの実装
use std::fs::File;
use std::io::{BufRead, BufReader};
use tokio::time::Duration;
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
use serde::Serialize;
#[derive(Serialize)]
struct Message {
message: String,
}
#[get("/")]
async fn root() -> impl Responder {
HttpResponse::Ok().json(Message {
message: "Hello, World!".to_string(),
})
}
#[get("/file_read")]
async fn file_read() -> impl Responder {
let file = File::open("../data/tested.csv").unwrap();
let reader = BufReader::new(file);
for line in reader.lines() {
if let Ok(_row) = line {
}
}
HttpResponse::Ok().json(Message {
message: "file_read".to_string(),
})
}
#[get("/wait_time")]
async fn wait_time() -> impl Responder {
tokio::time::sleep(Duration::from_secs(1)).await;
HttpResponse::Ok().json(Message {
message: "time_sleep".to_string(),
})
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.service(root)
.service(file_read)
.service(wait_time)
})
.bind(("0.0.0.0", 8000))?
.run()
.await
}
Pythonの実装
from fastapi import FastAPI
import time
import csv
from asyncio import sleep
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello, World!"}
@app.get("/file_read")
def file_read():
with open('../data/tested.csv', 'r') as file:
csv_reader = csv.reader(file)
last_row = None
for row in csv_reader:
pass
return {"message": "file_read"}
@app.get("/wait_time")
async def wait_time():
await sleep(1)
return {"message": "time_sleep"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
負荷試験方法
負荷試験ツール
今回はお手軽に行いたかった&興味本意でPostmanを使って負荷試験を行いました。(最近どこかの記事で、Postmanで負荷試験ができるというのを確認した)
デスクトップアプリのPostmanで負荷試験を行うということで、負荷をかけるクライアントサーバーはローカルPCです。
負荷試験シナリオ
負荷試験の具体的なシナリオは以下です。
Postmanで設定できる最大のVirtual usersが100だったので、100としています。負荷試験は2分間行い、最初の1分間は負荷を線型に上昇させます。
PCのスペックによってかけ得る負荷の最大値が変わる点、注意してください。
負荷試験結果
Hello, World!を返すAPIの負荷試験結果
Hello, World!を返すAPIの負荷試験結果は以下です。
言語 | Avg(ms) | Min(ms) | Max(ms) | 90th(ms) |
---|---|---|---|---|
Go | 1 | 1 | 122 | 2 |
Rust | 1 | 1 | 124 | 2 |
Python | 2 | 1 | 130 | 3 |
GoとRustはPythonに比べて処理速度が平均1ms速いことがわかりました。
またどの言語においても最大応答速度が平均応答速度と比べると非常に大きいことがわかり、最大時は100msほど時間がかかっています。
Rustの99%タイルを見てみると、処理時間のグラフにスパイクが現れています。(Go、Pythonでも同様の結果)
ファイル読み込みAPIの負荷試験結果
ファイル読み込み実装が厳密に一致しておりません。
Hello, Worldを返す例と比較して、メモリを消費した場合の負荷試験の結果の一例としてご覧ください。
ファイル読み込みAPIの負荷試験結果は以下です。
言語 | Avg(ms) | Min(ms) | Max(ms) | 90th(ms) |
---|---|---|---|---|
Go | 2 | 1 | 124 | 3 |
Rust | 3 | 2 | 125 | 4 |
Python | 4 | 2 | 135 | 6 |
Go、Rust、Pythonの順で処理速度が速いことがわかりました。
Hello, World!を返すAPIの負荷試験結果と比較すると、平均の処理時間自体は伸びたものの、傾向としては概ね変わらないという結果でした。
先ほどと同様、Rustの99%タイルを見てみると、処理時間のグラフにスパイクが現れています。(Go、Pythonでも同様の結果)
1秒待ってから応答するAPIの負荷試験結果
1秒待ってから応答するAPIの負荷試験結果は以下です。
言語 | Avg(ms) | Min(ms) | Max(ms) | 90th(ms) |
---|---|---|---|---|
Go | 1003 | 1001 | 1172 | 1004 |
Rust | 1005 | 1002 | 1149 | 1006 |
Python | 1005 | 1001 | 1193 | 1005 |
上記よりGoが最も速いことがわかりました。(1秒=1000ms待つ処理が挟まっているので、待ち時間を除いた純粋な処理時間はGoがRustとPythonよりも1.5倍以上速いと言えます。)
また上記2つの結果と傾向は同じですが、最大応答速度が平均応答速度と比べると150-200msほど時間がかかっていることがわかりました。
おわりに
いかがだったでしょうか。
記事は応答時間にのみ着目していますが、CPU使用率やメモリ使用率の差に着目しても面白いと思います。また今回はAPIサーバーをローカルPCで立てていますが、Dockerなどを使って限られたリソースで負荷試験をしてみるのも面白いと思います。
今回はごくごく簡単な例の紹介にとどまりましたが、もう少し込み入った負荷試験の知見については、別途発信していきたいと思います。(弊社テックブログで執筆する可能性が高いですが。)