【Python】非同期処理が何もわからなかったあの頃の自分に向けて

はじめに

こんにちは。GMO アドパートナーズ新卒の樋笠です。

最近業務で、Pythonの非同期処理を書いているのですが、初めて非同期処理を学んだときに苦悶したことを思い出しました。

そこで、過去の自分に「こう伝えたら理解できるんじゃないかな」と考えながら記事を書きました。

非同期処理について学んだことがない人でも、これを読めば、「非同期処理がやろうとしていること」や「Pythonの非同期処理の基本的な書き方」が分かるようになる、というものを目指しました。

ぜひ最後までお読みください🙇🏻‍♂️

※ わかりやすく説明するために、あえて言い切っている箇所があります。ご了承ください。

非同期処理ってなに?

まず、非同期処理ってなに?という話ですが、「非同期処理」を理解するために、その対になる「同期処理」を考えてみましょう。

同期処理

たとえして、こんな状況を考えてみましょう。

  1. AとBの2つのタスクがあり、あなたはできるだけ早く両方のタスクを済ませたいです。
  2. あなたは同時に1つのタスクしか処理できません。
  3. 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つのリクエストが来た場合は、どちらのリクエストから処理し始めても、処理し終えても良いですよね。

イメージとして、以下のような流れで処理を行うことで、素早くレスポンスを返せます。

  1. Aを処理し始めたが、データベースからの読み取りがあるので、その待ち時間でBの処理を始める。
  2. Bはデータベースとのやりとりもなく、簡単な計算を行なうだけでレスポンスを返せる。
  3. その間に、Aはデータベースから読み取りを終えるのでレスポンスを返せる。

非同期処理がどんなものかつかめてきたでしょうか?
では、実際にPythonで非同期処理を書いてみましょう。

基本的な非同期処理 in Python

コルーチン

Pythonには、非同期に実行する処理の単位としてコルーチン というものがあります。

コルーチンは、以下のようにasync defで定義することができます。

コルーチンの中では、他のコルーチンを呼び出すことができます。
awaitという単語を直前につけることで、呼び出し可能です。
「この処理の終了を待つ」というような命令ですね。

ちなみに、大本のコルーチンはasyncio.run()の中で呼び出すことで実行できます。

具体例

次の処理を考えてみます。

say_after関数は、delay秒間待機した後、whatを出力します。
この関数は、API呼び出しやDBの読み書きなど、先ほど挙げた「時間のかかる処理」の例えだと思ってください。

この一連の処理は同期的な処理となり、タスク1 → タスク2と順番に処理を行います。
2つのタスクを終えるのに合計で3秒ほどかかってしまいます。

では、これを非同期に実行できるように書き換えてみましょう。

非同期で実行

まず、main()関数とsay_after()関数をコルーチンに書き直し、
time.sleep()を非同期に行うために、asyncio.sleep()に書き直しました。
最後に、asyncio.gather()の中で複数のコルーチンを呼び出すことで、2つのタスクを非同期に実行できます。

計測してみるとわかりますが、この処理は2秒で終了します

先ほどの同期処理よりも1秒早く処理を終えられていますね。
これは、以下のような順番で処理が行えているからです。

  1. say_after(1, "task1")が呼び出される
  2. task1の中のasyncio.sleep()で待ち時間が発生するので、
    その間にsay_after(2, "task2")を先に実行する

time.sleep()だとだめなの?

良い質問です。その通りで、以下のようにtime.sleep()に書き換えると、実行はできますが
完了までに3秒かかってしまいます。

なぜかというと、await asyncio.sleep()を呼び出した場合は、CPUコアが他の処理に移れますが、time.sleep()は他の処理に移れません。

いくらコルーチンを非同期に実行したとしても、その中の「待ち時間が発生する処理」が非同期に対応していなければ意味がありません

非同期にも対応しているライブラリを使おう

ということで、アプリケーションのレベルで非同期処理を行いたい場合は基本的に、非同期処理に対応したライブラリを使用する必要があります。

ORMならSQLAlchemyや、APIクライアントならhttpxなどが非同期にも対応しています。
BigQueryのPythonクライアントライブラリにも、BigQueryWriteAsyncClientという非同期用のクライアントクラスがあったりします。

おわりに

非同期処理がどんなものなのか、Pythonではどのように書くのかを説明してきました。

ただ、「裏側では、どのように複数のタスクを管理しているか」という仕組みの部分については触れていません。その辺りを詳しく学びたい方は、以下の記事をお勧めします。骨太な記事ですが、非同期処理の歴史を実例とともに学べます。

本記事を書くにあたって、以下の記事とPythonの公式ドキュメントを大いに参考にさせていただきました。
理解がより深まると思いますので、ぜひ合わせてお読みください。