Inside of LOVOT

GROOVE X 技術ブログ

PythonとGoとRustで複数のHTTPリクエストを同時にやってみた

こんにちは!ソフトウェアチームのエンジニアのふくだです。
この記事は、GROOVE X Advent Calendar 2023の21日目の記事です。

qiita.com

並行処理が大好きです

私は並行処理が大好きです。ただとても難しいです。

私はPythonの asyncio という非同期処理の標準ライブラリ、その中でも最近のわかりやすく使いやすくなった高レベルAPIで好きになった新参者で、日々勉強しています。

弊社GROOVE Xには、さまざまな技術を持ったエンジニアが活躍しています。ソフトウェア領域であれば、CやC++、Go、PythonやRust、TypeScriptなどさまざまなプログラミング言語が使われています。*1

そうなんです。並行処理といえば!と言われている言語が使われているではありませんか!そう、GoとRustです。 ただ、私はGoもRustもあまり書いたことがありません。今回の記事では、PythonのコードをベースにGoとRustでたくさんのHTTPリクエストを行う処理を書いてみました。

簡単な仕様

今回のコードの簡単な仕様は以下の2つです。

  • 特定のエンドポイントに対して複数のHTTPリクエストを同時に行い出力する
  • エラー処理を書く

そんなことしないって?確かにしませんね。ただみなさんのお使いのプロダクトなどに置き換えてみてください。 DBアクセスや外部APIの実行だったり、I/Oの塊だったりしませんか?弊社LOVOTもセンサー情報をたくさんあるため、I/Oがたくさんです。

実行環境

試した環境としては、以下のとおりです。

  • MacBook Air M2

また、外部の公開されているAPIを利用するため、コードをお試しいただく場合には、 計画的 にお願いします。 今回利用するのはPokemon APIです。

pokeapi.co

書いてみた

まずはPython

各種バージョンは以下のとおりです。

  • Python 3.12.0
  • HTTPX 0.25.2
  • trio 0.23.2

Python コード

asyncio版

import asyncio
from dataclasses import dataclass
from time import time

import httpx

URL = "https://pokeapi.co/api/v2/pokemon/"


@dataclass(slots=True)
class PokemonResponse:
    """PokemonResponse はAPIレスポンスのデータクラス"""
    name: str


async def fetch_pokemon(client: httpx.AsyncClient, num: int) -> PokemonResponse:
    """fetch_pokemon は指定された番号のポケモンの名前を取得"""
    response = await client.get(f"{URL}{num}")
    data = response.json()
    return PokemonResponse(name=data["name"])


async def main():
    start = time()
    try:
        async with httpx.AsyncClient() as client:
            async with asyncio.TaskGroup() as tg:
                tasks: [asyncio.Task] = [
                    tg.create_task(fetch_pokemon(client, number))
                    for number in range(1, 2)
                ]
        pokemon_names = [task.result().name for task in tasks]
        print(pokemon_names)
        print(f"Time: {time() - start}s")
    except* BaseException as e:
        print(f"Error: {e.exceptions}")


if __name__ == "__main__":
    asyncio.run(main())

Trio版

弊社では、意思決定エンジンであるneodmにTrioを利用しています。そのため、Trioでも書いてみましょう。

from dataclasses import dataclass
from time import time

import trio
import httpx

URL = "https://pokeapi.co/api/v2/pokemon/"


@dataclass(slots=True)
class PokemonResponse:
    """PokemonResponse はAPIレスポンスのデータクラス"""
    name: str


async def fetch_pokemon(client: httpx.AsyncClient, num: int, results: list) -> None:
    """fetch_pokemon は指定された番号のポケモンの名前を取得"""
    response = await client.get(f"https://pokeapi.co/api/v2/pokemon/{num}")
    pokemon = response.json()
    results.append(PokemonResponse(name=data["name"]))


async def main():
    start = time()
    results = []
    try:
        async with httpx.AsyncClient() as client:
            async with trio.open_nursery() as nursery:
                for number in range(1, 151):
                    nursery.start_soon(fetch_pokemon, client, number, results)
        print(results)
        print(f"Time: {time() - start}s")
    except* BaseException as e:
        print(f"Error: {e.exceptions}")


if __name__ == "__main__":
    trio.run(main)

Python 実行結果

それぞれ実行してみます。

$ python asyncio_pokemon.py
['bulbasaur', 'ivysaur', 'venusaur', ...
Time: 0.9101917743682861s

$ python trio_pokemon.py
['moltres', 'kabuto', 'magikarp', ...
Time: 0.8739862442016602s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 0.7011699676513672s
Time: 1.0189759731292725s
Time: 0.5281181335449219s

続いてGo

各種バージョンは以下のとおりです。

  • Go 1.21.1

Go のコード

Goのコードです。goroutinesとchannelを使用して、複数のHTTPリクエストを同時に実行します。

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "sync"
    "time"
)

const baseURL = "https://pokeapi.co/api/v2/pokemon/"

// PokemonResponse はAPIレスポンスの構造体
type PokemonResponse struct {
    Name string `json:"name"`
}

// fetchPokemon は指定された番号のポケモンの名前を取得
func fetchPokemon(wg *sync.WaitGroup, number int, results chan<- string, errors chan<- error) {
    defer wg.Done()
    url := fmt.Sprintf("%s%d", baseURL, number)

    resp, err := http.Get(url)
    if err != nil {
        errors <- err
        return
    }
    defer resp.Body.Close()

    var pokemon PokemonResponse
    if err := json.NewDecoder(resp.Body).Decode(&pokemon); err != nil {
        errors <- err
        return
    }

    results <- pokemon.Name
}

func main() {
    var wg sync.WaitGroup
    results := make(chan string, 150)
    errors := make(chan error, 150)

    start := time.Now()

    for i := 1; i <= 150; i++ {
        wg.Add(1)
        go fetchPokemon(&wg, i, results, errors)
    }

    wg.Wait()
    close(results)
    close(errors)

    for err := range errors {
        fmt.Println("Error:", err)
    }

    for result := range results {
        fmt.Println(result)
    }

    fmt.Printf("Time: %v\n", time.Since(start))
}

Go 実行結果

それぞれ実行してみます。

$ go run pokemon.go
starmie
clefable
geodude
haunter
...
Time: 1.124114875s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 1.124114875s
Time: 381.089875ms
Time: 199.999333ms

最後にRust

各種バージョンは以下のとおりです。

  • Rust 1.74.1
  • reqwest 0.11
  • tokio 1

Rust のコード

Rustのコードです。HTTPクライアントのreqwestクレートと並行処理を行うtokioを利用します。

/// `PokemonResponse`は、PokeAPIからの応答を表す構造体
#[derive(Deserialize, Debug)]
struct PokemonResponse {
    name: String,
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();

    let client = reqwest::Client::new();
    let mut handles = vec![];

    for number in 1..=150 {
        let client = client.clone();
        handles.push(tokio::spawn(
            async move { fetch_pokemon(&client, number).await },
        ));
    }

    let mut pokemons = Vec::new();
    for handle in handles {
        match handle.await {
            Ok(Ok(pokemon)) => pokemons.push(pokemon.name),
            Ok(Err(e)) => eprintln!("Error: {}", e),
            Err(e) => eprintln!("Error: {:?}", e),
        }
    }

    println!("{:?}", pokemons);
    println!("Time: {:?}", start.elapsed());
}

/// fetchPokemon は指定された番号のポケモンの名前を取得
async fn fetch_pokemon(client: &reqwest::Client, number: u32) -> Result<PokemonResponse, reqwest::Error> {
    let url = format!("https://pokeapi.co/api/v2/pokemon/{}", number);
    let resp = client.get(&url).send().await?;
    let pokemon = resp.json::<PokemonResponse>().await?;
    Ok(pokemon)
}

Cargo.tomlは以下の通りです。

[package]
name = "poke"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.11.22", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Rust 実行結果

それぞれ実行してみます。

$ cargo run --release
❯ cargo run --release
    Finished release [optimized] target(s) in 0.16s
     Running `target/release/poke`
["bulbasaur", "ivysaur", ...
Time: 1.5475795s

時間をおいて3回実行してみました。結果は次のとおりです。

Time: 1.412308208s
Time: 1.120167541s
Time: 1.589511041s

結果

速度としては、次のとおりの結果でした。気にはなるのですが、速度については処理系がマルチコアが使えるか、とか、ネットワークの状況もあるかと思うので、あくまでご参考で :pray:

|Python|約0.749秒| |Go|約0.568秒| |*Rust|約1.374秒|

感想

それよりも気になるのは、それぞれの言語の特徴ですね。気になるところを箇条書きにしてみました。

  • Go/Rustでやっぱり型をカッチリできるのはいい
    • Pythonでかっちりしたい場合には、Pydantic の出番
  • Goの sync.WaitGroup 書きやすい
    • Add() Done() Wait() でのハンドリング。async/awaitを採用していないのもおもしろい!
    • errgroup も気になる〜
  • Pythonのwalrus演算子 := が、Goでは型推論 (walrusはセイウチ。横から見るとセイウチの顔っぽく見える)
  • Rustのコンパイラ優しい ありがたい
  • Rustのキモは tokio::spawn での非同期タスクの生成と async move
    • 特に move での所有権をクロージャに移動して安全に使うところとか
      • タスクの生成は、Pythonの create_task のイメージ
  • Rustの .await?; ?って!?
    • エラーのハンドリングのためのもの
      • Result型 はOkとErrを持つ。 ? でハンドリングする
        • これもおもしろい〜!

な、印象を持ちました。みなさまはどうでしょうか?普段使っていない言語での似たコードはとても刺激的でした。

まとめ

個人的にはGoのファイル名、pokemon.goで優勝ですね。 それぞれの言語にそれぞれの特徴があってとてもおもしろかったです。コードを書いてみて、それぞれのフォーマッターやコードスタイルも気になりました!また色々な比較がかけそうですね。今回は紹介できませんでしたが、もちろんJavaScriptでも書けますし、C++にも std::async があったりC++ 20から co_await が追加になったりという情報もキャッチアップでき、まだまだ広がる並行処理の世界にワクワクが止まりません!

また弊社Slackには、トゲピーやタマザラシ、モルペコがいます!ぜひ会いにきてください!

わちゃついて!とお願いしたら、わちゃついてくれたやさしいポケモンのみなさん

*1:詳しくは、アドベントカレンダー1日目の LOVOTech Night 初開催報告!! - Inside of LOVOT をご参照ください。