はじめに
こんにちは。GMO アドパートナーズ新卒の樋笠です。
最近業務で、Pythonの非同期処理を書いているのですが、初めて非同期処理を学んだときに苦悶したことを思い出しました。
そこで、過去の自分に「こう伝えたら理解できるんじゃないかな」と考えながら記事を書きました。
非同期処理について学んだことがない人でも、これを読めば、「非同期処理がやろうとしていること」や「Pythonの非同期処理の基本的な書き方」が分かるようになる、というものを目指しました。
ぜひ最後までお読みください🙇🏻♂️
※ わかりやすく説明するために、あえて言い切っている箇所があります。ご了承ください。
非同期処理ってなに?
まず、非同期処理ってなに?という話ですが、「非同期処理」を理解するために、その対になる「同期処理」を考えてみましょう。
同期処理
たとえして、こんな状況を考えてみましょう。
- AとBの2つのタスクがあり、あなたはできるだけ早く両方のタスクを済ませたいです。
- あなたは同時に1つのタスクしか処理できません。
- Aのタスクの途中には待ち時間があり、その間あなたは他のタスクができます。
同期処理というのは、「まずAに取り掛かり、Aを処理し終わったらBに移る」という処理の方法です。
これでは、Aの待ち時間で何もしないということになります。なんだか無駄があるように感じますね🤔
非同期処理
対して非同期処理とは、「Aの待ち時間にBの処理を行う」というものです。
Bの処理をしながら、たまにAの待ち時間が終わっているかどうかを確認することで、Aのタスクを再開できます。
こうすると、AとBの2つのタスクを早く終えられそうですよね。
卑近な例だと、「お風呂のお湯を入れている間に掃除をすると、両方のタスクが短時間に済ませられる」みたいな感覚でしょう。
プログラムでそんな状況ってある?
あります。
通常、1つのプログラムには複数の処理が含まれますが、できるだけ早く全ての処理を済ませたいですよね。
プログラムを実行すると、CPUのコアが演算処理を行いますが、このCPUコアは1つにつき1つの処理しか実行できません。
でも、プログラムの中に「WebAPIを呼び出す」という処理がある場合、レスポンスが返ってくるまでCPUコアは他の処理ができそうですよね。
他にも、「DBへの読み書き」などの処理の間もCPUコアは他の処理を行うことができます。
順序がある処理はどうするの?
AとBの2つの処理に順序がある場合は、Aが終わってからBを始めるというふうに、同期的に処理を行う必要があります。
したがって、非同期処理を行うことができるのは、処理したい複数のタスクに順序が無い場合のみに限られます。
順序が無いときってどんなの?
例えば、Webのサーバーに複数のリクエストが来た場合です。
サーバーにAとBの2つのリクエストが来た場合は、どちらのリクエストから処理し始めても、処理し終えても良いですよね。
イメージとして、以下のような流れで処理を行うことで、素早くレスポンスを返せます。
- Aを処理し始めたが、データベースからの読み取りがあるので、その待ち時間でBの処理を始める。
- Bはデータベースとのやりとりもなく、簡単な計算を行なうだけでレスポンスを返せる。
- その間に、Aはデータベースから読み取りを終えるのでレスポンスを返せる。
非同期処理がどんなものかつかめてきたでしょうか?
では、実際にPythonで非同期処理を書いてみましょう。
基本的な非同期処理 in Python
コルーチン
Pythonには、非同期に実行する処理の単位としてコルーチン というものがあります。
コルーチンは、以下のようにasync def
で定義することができます。
1 2 |
async def say_hello(): print("Hello!!") |
コルーチンの中では、他のコルーチンを呼び出すことができます。await
という単語を直前につけることで、呼び出し可能です。
「この処理の終了を待つ」というような命令ですね。
1 2 3 4 5 6 7 8 9 |
import asyncio async def say_hello(): print("Hello!!") async def main(): await say_hello() asyncio.run(main()) # コルーチンの実行 |
ちなみに、大本のコルーチンはasyncio.run()
の中で呼び出すことで実行できます。
具体例
次の処理を考えてみます。
1 2 3 4 5 6 7 8 9 10 11 |
import time def say_after(delay, what): time.sleep(delay) print(what) def main(): say_after(1, "task1") # タスク1 say_after(2, "task2") # タスク2 main() |
say_after
関数は、delay
秒間待機した後、what
を出力します。
この関数は、API呼び出しやDBの読み書きなど、先ほど挙げた「時間のかかる処理」の例えだと思ってください。
この一連の処理は同期的な処理となり、タスク1 → タスク2と順番に処理を行います。
2つのタスクを終えるのに合計で3秒ほどかかってしまいます。
では、これを非同期に実行できるように書き換えてみましょう。
非同期で実行
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import asyncio async def say_after(delay, what): await asyncio.sleep(delay) print(what) async def main(): await asyncio.gather( say_after(1, "task1"), say_after(2, "task2") ) asyncio.run(main()) |
まず、main()
関数とsay_after()
関数をコルーチンに書き直し、time.sleep()
を非同期に行うために、asyncio.sleep()
に書き直しました。
最後に、asyncio.gather()
の中で複数のコルーチンを呼び出すことで、2つのタスクを非同期に実行できます。
計測してみるとわかりますが、この処理は2秒で終了します。
先ほどの同期処理よりも1秒早く処理を終えられていますね。
これは、以下のような順番で処理が行えているからです。
say_after(1, "task1")
が呼び出される- task1の中の
asyncio.sleep()
で待ち時間が発生するので、
その間にsay_after(2, "task2")
を先に実行する
time.sleep()だとだめなの?
良い質問です。その通りで、以下のようにtime.sleep()
に書き換えると、実行はできますが
完了までに3秒かかってしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import time import asyncio async def say_after(delay, what): time.sleep(delay) print(what) async def main(): await asyncio.gather( say_after(1, "task1"), say_after(2, "task2") ) asyncio.run(main()) |
なぜかというと、await asyncio.sleep()
を呼び出した場合は、CPUコアが他の処理に移れますが、time.sleep()
は他の処理に移れません。
いくらコルーチンを非同期に実行したとしても、その中の「待ち時間が発生する処理」が非同期に対応していなければ意味がありません。
非同期にも対応しているライブラリを使おう
ということで、アプリケーションのレベルで非同期処理を行いたい場合は基本的に、非同期処理に対応したライブラリを使用する必要があります。
ORMならSQLAlchemyや、APIクライアントならhttpxなどが非同期にも対応しています。
BigQueryのPythonクライアントライブラリにも、BigQueryWriteAsyncClient
という非同期用のクライアントクラスがあったりします。
おわりに
非同期処理がどんなものなのか、Pythonではどのように書くのかを説明してきました。
ただ、「裏側では、どのように複数のタスクを管理しているか」という仕組みの部分については触れていません。その辺りを詳しく学びたい方は、以下の記事をお勧めします。骨太な記事ですが、非同期処理の歴史を実例とともに学べます。
本記事を書くにあたって、以下の記事とPythonの公式ドキュメントを大いに参考にさせていただきました。
理解がより深まると思いますので、ぜひ合わせてお読みください。