コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
非同期と並行
100 件の API を順に叩いたら 100 秒かかった
1 件 1 秒で返る API を 100 件呼びます。素直に for 文で書けば 100 秒です。その 100 秒間、CPU が何をしているかというと、ほとんど何もしていません。リクエストを送ってから返事が届くまで、やることが無いのです。
1 秒という待ちは、CPU にとって途方もない長さです。ナノ秒単位で動く側から見れば、10 億回分の仕事ができる時間を、ただ座って過ごしていることになります。
スレッドを 100 本立てる、では届かない
待っている間に他の仕事をさせればよい、という方針は正しいです。素直な実装は、100 件それぞれに 1 本ずつスレッドを割り当てることで、実際これで 100 秒は 1 秒あまりまで縮みます。
では 10,000 件ならどうか。ここで頭打ちになります。OS スレッドは 1 本ごとにスタック用の領域を抱え、既定では 8MB 分の番地が確保されます。加えて OS がスレッドを切り替えるたびに、レジスタの退避と復元が必要です。1 回あたりはわずかでも、待っているだけのスレッドが 1 万本あると、切り替えのために CPU を使う割合が無視できなくなります。
そもそも、やりたいのは「返事が来た人から順に処理する」だけです。それ専用の道具として、スレッドは重すぎます。
待ちに入るところで、自分から譲る
非同期処理は、待ちが発生する場所をコード上に明示させます。
Python
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text()
results = await asyncio.gather(*[fetch(s, u) for u in urls])await と書いた行で、この関数は「ここから先は返事待ちなので、他の人にどうぞ」と中断します。中断した関数の状態は数 KB のオブジェクトとして残るだけで、OS スレッドは 1 本のまま次に進める関数へ移ります。返事が届いた関数は、中断した行から再開します。
スレッドの切り替えを OS が勝手に行うのに対し、こちらは書いた本人が譲る場所を決めています。譲る場所が分かっているぶん切り替えは桁違いに軽く、同じ 1 スレッドで数万件を抱えられます。
同時に進むことと、同時に走ることは違う
ここまでの話に、CPU コアを増やす要素は 1 つも出てきません。1 コアでも 100 件を「進行中」にはできます。これが 並行 です。対して、複数のコアで物理的に同時に計算するのが 並列 です。
シェフが 1 人でフライパン 3 つを順に振るのが並行、シェフが 3 人いるのが並列、という例えがよく使われます。非同期処理が効くのは前者、つまり待ち時間が支配的な仕事です。
裏を返すと、待ちのない計算では逆効果になります。await を挟まない重いループを書くと、その関数は誰にも譲らないので、同じスレッドに乗っている全タスクがその間まったく動きません。画像の変換や巨大な集計は、別のスレッドかプロセスへ逃がすのが定石です。