こんにちは!ソフトウェアチームのエンジニアのふくだです。
この記事は、GROOVE X Advent Calendar 2023の21日目の記事です。
並行処理が大好きです
私は並行処理が大好きです。ただとても難しいです。
私は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です。
書いてみた
まずは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
のイメージ
- タスクの生成は、Pythonの
- 特に move での所有権をクロージャに移動して安全に使うところとか
- Rustの
.await?;
?って!?- エラーのハンドリングのためのもの
- Result型 はOkとErrを持つ。
?
でハンドリングする- これもおもしろい〜!
- Result型 はOkとErrを持つ。
- エラーのハンドリングのためのもの
な、印象を持ちました。みなさまはどうでしょうか?普段使っていない言語での似たコードはとても刺激的でした。
まとめ
個人的にはGoのファイル名、pokemon.goで優勝ですね。
それぞれの言語にそれぞれの特徴があってとてもおもしろかったです。コードを書いてみて、それぞれのフォーマッターやコードスタイルも気になりました!また色々な比較がかけそうですね。今回は紹介できませんでしたが、もちろんJavaScriptでも書けますし、C++にも std::async
があったりC++ 20から co_await
が追加になったりという情報もキャッチアップでき、まだまだ広がる並行処理の世界にワクワクが止まりません!
また弊社Slackには、トゲピーやタマザラシ、モルペコがいます!ぜひ会いにきてください!
*1:詳しくは、アドベントカレンダー1日目の LOVOTech Night 初開催報告!! - Inside of LOVOT をご参照ください。