注意
注意
現在も学習を進めている段階であり、とりわけ非同期処理については、試行錯誤を重ねながら理解を深めているところです。まだ完全に腑に落ちていない部分も多く、記事の内容には誤解や技術的に不正確な記述が含まれている可能性があります。
同期と非同期の違い
同期処理
プログラムに書かれた命令を、上から1つずつ順に実行していく方式。一つの処理が終了するまで、次の処理は実行されない。
このように、処理の完了を待っている間、プログラム全体の動きが止まってしまう。これをブロッキング状態という。
同期処理のメリット
- 処理の順序が明確
- プログラムが上から順に実行されるため、コードがシンプルで理解しやすい
デメリット
- 時間のかかる処理があると、その処理が終わるまで、次のタスクは実行されないため、全体との処理時間が長くなる。
- タスクの完了を待つ必要があり、パフォーマンスが低下する可能性がある
非同期処理
1つの処理を開始した後、その処理が終わるのを待たずに次の処理に進む方式。処理が完了すると、イベントループなどの仕組みで、結果を受け取って後続の処理を開始する。これをノンブロッキングという。
メリット
- I/Oの待ち時間に別の処理を実行するなどすることで、リソースを無駄なく活用できる
デメリット
- 処理の実行順序が、コードの記述順序が異なるため、コードが複雑になりやすい
同期、非同期処理の具体例
同期処理の例
同期処理を、銀行のATMでお金を10万円引き出す例で説明する。
-
振込操作の開始
- AさんがATMで「引き出し」を押す。
-
残高確認
- システムがデータベースに接続し、Aさんの口座残高を取得。
- 残高確認が完了するまで、ユーザー画面や次の処理はブロックされる。
-
引き落とし処理
- 残高が10万円以上であれば、口座から10万円を引き出すトランザクションを実行。
- トランザクションはACID特性を保ち、成功/失敗が確定するまで待機。
-
取引履歴の記録
- 引き出す結果を含む取引履歴をデータベースに保存。
- ここも完了するまで次の操作は一時停止。
-
ユーザーへの通知
- 引き出し処理がすべて正常終了したことを確認し、「引き出す」の画面や明細を表示。
もしこの処理が非同期処理であるとしたら、2番のデータベースに接続して残高確認が終わる前に、3番の引き出す処理が始まってしまう。その結果、残高が足りない場合(10万円未満)は、残高が足りないことを知らせるエラーが出るはずが、確認完了する前に引き出す処理がされてしまう。
非同期処理の例
- GoogleMapは、非同期で実装されている
-
ユーザーの操作
- 地図をドラッグ、スクロールする
- 地図を拡大、縮小する
- 検索欄に場所を入力する
-
内部での非同期処理
- ユーザーが操作した瞬間、Googleマップは同時に並行で実行している
- 地図の取得
- 画面に表示されていない新しいエリアをサーバーにリクエストし、ユーザーの操作を止めずに、ダウンロードをする。
- 周辺状況の取得
- 新しく表示されるエリアのレストラン、コンビニ、駅などのランドマーク情報を、サーバーにリクエストします。
- などなど
- 地図の取得
- ユーザーが操作した瞬間、Googleマップは同時に並行で実行している
-
処理完了後の画面更新
バックグラウンドで実行されていた各タスクは、完了したものから画面に結果を反映させる
- 地図のダウンロードが終われば、その部分の地図が表示される。
- 周辺情報の取得が終われば、ランドマークのアイコンがマップ上に表示される。
もしこれが同期処理であったら、地図を少しだけドラッグしただけで、まず画面がフリーズし、新しい地図データのダウンロードが終わるまで、他の処理は停止しているため、一切の操作を受け付けなくなる。
そして、地図のダウンロードができたとしても他のランドマークを取得するなど他の処理を順番に処理して完了するまで、地図の操作ができない状態になる。
ようやくすべてダウンロードが完了して、地図を動かした瞬間に、再度地図をダウンロードする…といった画面のフリーズ状態が繰り返される。
非同期処理で重要な概念
コールバック
コールバックは、ある処理が完了した後に呼び出される関数のこと。
- 時間のかかる処理(例:ファイルの読み込み)を開始する関数を呼び出す。
- その際、引数として「処理が終わった後に実行してほしい関数(コールバック関数)」を渡す。
- 本体のプログラムは、処理の完了を待たずに次のコードへ進む。
- 時間のかかる処理が完了すると、システムが裏で預かっていたコールバック関数を、処理結果と共に呼び出す。
Pythonコード例
import time
import threading
def heavy_task(callback):
"""重い処理を実行して、完了後にコールバックを呼ぶ"""
def task():
print(f"重い処理を開始 : {time.strftime('%X')}")
time.sleep(2) # 2秒かかる処理
result = "処理完了!"
# 処理が完了したらコールバックを呼ぶ
callback(result) # コールバック関数
# 別スレッドで実行
thread = threading.Thread(target=task)
thread.start()
def on_complete(result):
"""コールバック関数"""
print(f"コールバックが呼ばれました: {result}")
print(f"処理終了時刻: {time.strftime('%X')}")
# 使用例
print(f"開始時刻: {time.strftime('%X')}")
heavy_task(on_complete)
print("メイン処理は続行中...")
time.sleep(3) # メインスレッドを待機
print(f"メイン処理終了: {time.strftime('%X')}")
時刻 メインスレッド 別スレッド(heavy_task内)
------------------------------------------------------------
0秒 開始時刻: 14:30:00
heavy_task(on_complete)
→ スレッド作成・開始
メイン処理は続行中... 重い処理を開始 : 14:30:00
sleep(3)開始 sleep(2)開始
| |
1秒 | (sleeping) | (sleeping)
| |
2秒 | (sleeping) sleep(2)終了
| callback(result)実行
| → on_complete("処理完了!")
| コールバックが呼ばれました: 処理完了!
| 処理終了時刻: 14:30:02
| [スレッド終了]
3秒 sleep(3)終了
メイン処理終了: 14:30:03
同期処理だったら、2+3=5秒かかるところが、非同期処理だと3秒で終了する。
コールバック地獄
複数の非同期処理を連続して実行する際に、コールバック関数が複雑な入れ子構造になる。そうなると、コードが読みにくくなり、処理を変更する際にメンテナンスが大変になったりする。これをコールバック地獄と呼ぶ。
処理1(入力, lambda 結果1:
処理2(結果1, lambda 結果2:
処理3(結果2, lambda 結果3:
処理4(結果3, lambda 結果4:
最終処理(結果4)
)
)
)
)
Promise
コールバック地獄を解決する概念である。非同期処理の結果を表すオブジェクトであり、処理が完了していなくてもすぐに返される。
import concurrent.futures
import threading
# コールバック方式
def 非同期処理(callback):
# 処理が終わったらcallbackを呼ぶ
callback(結果)
# Future方式
def 非同期処理():
future = Future() # 空の箱を作る
# 処理が終わったら箱に結果を入れる
return future # すぐに箱を返す
async/await
-
非同期処理を同期処理のようにかけるため、読みやすい。Promiseのようにコールバック地獄を解消する。
-
async
: 非同期関数を定義するキーワード -
await
: 非同期処理の完了を待つキーワード(asyncの中でのみ使用可能)
import asyncio
# asyncで非同期関数を定義
async def hello():
print("Hello")
await asyncio.sleep(1) # 1秒待つ(非ブロッキング)
print("World")
# 実行
asyncio.run(hello())
まとめ
同期処理
プログラムに書かれた命令を、上から1つずつ順に実行していく方式。一つの処理が終了するまで、次の処理は実行されない
非同期処理
1つの処理を開始した後、その処理が終わるのを待たずに次の処理に進む方式。処理が完了すると、イベントループなどの仕組みで、結果を受け取って後続の処理を開始する
今後の課題
今回、同期処理と非同期処理の基本を自分なりに整理した。その実装方法については理解が不足しているため、理解していきたいと思う。