はじめに
C#で経験ありでpythonを始めたときにpythonにもasync/awaitあるじゃんと喜び勇んで使ってみたわけですが、ドキュメント読んだりしてると微妙に挙動が違うような?
C#に思考が引き摺られていると妙に混乱するので書き出してみます。
結論
・pythonの非同期は単一スレッドのイベントループが基本です。
・c#と等価な処理を実現しようとするとpython的に泥臭くなります。(pythonとしては低位レイヤの機能により実現されるので)
・await
で待機する場合はcreate_task
であらかじめタスク登録しておきます。
・本当に並列に動かしたければrun_in_executer
を使いましょう。ただし排他に注意です。
基本的な違い
簡単に言うと
・C#
スレッドベースの非同期
・python
(基本)単一スレッドのイベントループベースの非同期
です。
C#ではasync
関数作ってawait
で待てば別スレッドが動いてくれます。
pythonではひとつのスレッドでイベントループ機構を用い、非同期っぽく動くのが基本です。ただ、普通に別スレッドな動作もやろうと思えばできます。低位レイヤとして分類されているのでなるべく使わないでねという感じでしょう。
低位レイヤはasyncio.get_running_loop
等で取得したイベントループインスタンス使って直接loopのメソッドを呼び出す場合です。
上位レイヤはasyncio.create_task
等のメソッドを呼び出す場合です。
イベントループのメリット
シングルスレッドなので排他不要という点ですね。
ただawait
挿入しつつも排他したい長いブロックがあるときもあります。その場合はasyncio.Lock
とかで非同期ロックもできます。
イベントループのデメリット
・本当に並列に実行するわけではない
重い処理をcreate_task
でTask化しても後回しになるだけですね。
C#ではawait
指定せずにコルーチンを呼び出すだけでいい感じに別スレッドで動いてくれます。
pythonではawait
指定していないコルーチンは実行時に警告がでて実行されない、という挙動になります。
例:C#とpythonの挙動の違い
同じように書いた両方のコードで実際の動きを比較します。末尾にスレッドIDを表示しています。
C#の場合
Console.WriteLine("base entry");
static async Task waitAsync()
{
Console.WriteLine(" async start:" + Environment.CurrentManagedThreadId);
await Task.Delay(1000);
Console.WriteLine(" async end:" + Environment.CurrentManagedThreadId);
}
async Task main()
{
Console.WriteLine(" main entry start:" + Environment.CurrentManagedThreadId);
await waitAsync();
Console.WriteLine(" main entry end:" + Environment.CurrentManagedThreadId);
}
Console.WriteLine("base pre:" + Environment.CurrentManagedThreadId);
await main();
Console.WriteLine("base over:" + Environment.CurrentManagedThreadId);
Thread.Sleep(5000);
Console.WriteLine("base finish:" + Environment.CurrentManagedThreadId);
base entry
base pre:1
main entry start:1
async start:1・・・★
async end:5・・・別スレッドで実行
main entry end:5
base over:5・・・await待機後も別スレッドのまま
base finish:5
pythonの場合
import asyncio
import threading
from time import sleep
print(f'base entry:{threading.get_native_id()}')
async def base():
async def wait_async():
print(f' async start:{threading.get_native_id()}')
await asyncio.sleep(1)
print(f' async end:{threading.get_native_id()}')
async def main():
print(f' main entry start:{threading.get_native_id()}')
await wait_async()
print(f' main entry end:{threading.get_native_id()}')
print(f'base pre:{threading.get_native_id()}')
await main()
print(f'base over:{threading.get_native_id()}')
sleep(5)
print(f'base finish:{threading.get_native_id()}')
asyncio.run(base())
sleep(5)
base entry:14308
base pre:14308
main entry start:14308
async start:14308
async end:14308
main entry end:14308
base over:14308
base finish:14308
注目は★ですね。
C#ではawait
で待機処理が呼びされると別スレッドとして実行されて、完了すると最後のスレッドのまま動き続けます。(元のスレッドに戻す方法もあります)
pythonでは基本的な流れは変わりませんが、すべて単一スレッドで実行されています。
C#での並列実行:awaitなしでのコルーチン呼び出し
C#でasync
関数をawait
せずに呼び出すと、async
関数内部のawait
発生時点で呼び出し元は動き出します。完全に並列実行ですね。排他を考慮する必要がでてきます。
//await main();
main();
base entry
base pre:1
main entry start:1
async start:1
base over:1・・・☆呼び出し元は動き出す
async end:8・・・別スレッド
main entry end:8
base finish:1
☆の部分のとおり、awaitの完了を待たずにmainが動き出します。
Pythonでの疑似並列実行:awaitなしでのTask呼び出し
pythonではコルーチン(async
)をawait
無しで呼ぶことはできません。
asyncio.create_task
でタスクを立ち上げ、sleep
ではなくasyncio.sleep
のawait
にすると同じような動きができます(シングルスレッドですが)。
疑似並列ですね。
#await main()
asyncio.create_task(main())
print(f'base over:{threading.get_native_id()}')
#sleep(5)
await asyncio.sleep(5) # 非同期待機によりmainが動く
base entry:30444
base pre:30444
base over:30444・・・☆呼び出し元は動き出す
main entry start:30444・・・タスクも動いてくれる
async start:30444
async end:30444
main entry end:30444
base finish:30444
Pythonでの並列実行:awaitなしでの別スレッド呼び出し
ではC#側と同じように別スレッドにやってもらう場合はと言うと、run_in_executer
を使用します。これを使うと別スレッドで実行してくれます。
run_in_executor
は渡された関数を内部でawait
で呼び出すわけではないので、コルーチンのままだと実行されません。main
のコルーチン(async
)指定は解除します。
普通の関数内ではawait
は呼べないので、wait_async
はコルーチンのままで無理やり呼んでみました。
print(f'base entry:{threading.get_native_id()}')
async def base():
async def wait_async():
print(f' async start:{threading.get_native_id()}')
await asyncio.sleep(1)
print(f' async end:{threading.get_native_id()}')
def main(loop):#asyncを外し、引数でloopを受け取る
print(f' main entry start:{threading.get_native_id()}')
future = asyncio.run_coroutine_threadsafe(wait_async(), loop) # もとループに合流
future.result()# 同期待ち
print(f' main entry end:{threading.get_native_id()}')
print(f'base pre:{threading.get_native_id()}')
loop = asyncio.get_running_loop() # 現在のloopを取得
loop.run_in_executor(None, main, loop) # 別スレッド呼び出し
print(f'base over:{threading.get_native_id()}')
await asyncio.sleep(5)
print(f'base finish:{threading.get_native_id()}')
asyncio.run(base())
sleep(5)
base entry:18640
base pre:18640
base over:18640・・・☆呼び出し元は動き出す
main entry start:7028・・・別スレッドでmain実行
async start:18640・・・・元スレッドで非同期ループで実行
async end:18640
main entry end:7028
base finish:18640
別スレッドなので排他を考慮する必要があります。
また、main
関数は別スレッドなので元のループに触れません。asyncio
のメソッドを呼ぶとエラーになります。(派生したスレッドではループを所持していないためです)
別スレッドから元スレッドのコルーチンを呼んで同期をとりたいときはasyncio.run_coroutine_threadsafe
で安全に非同期呼び出しできます。
future = asyncio.run_coroutine_threadsafe(wait_async(), loop) # 元スレッド上で実行される
future.result()# 完了待ち
run_in_executor
はawait
することで完了待機もできます。
await loop.run_in_executor(None, main, loop)
重い処理があるからasyncioを使う、のは注意
どこまで非同期でやりたいか、が大事です。
後回しにしたいだけなのか、並列で動かしたいのか。
重い(長い)処理がasync/await対応していれば喜ばしいことです。
しかしやむを得ない理由でasync/await対応していない待機処理(socketとか)を使う場合はやはりrun_in_executorの出番になります。