LoginSignup
255
238

ペペロンチーノで学ぶ非同期プログラミングによる並行処理

Last updated at Posted at 2023-10-05

image.png

非同期プログラミングについて、イメージだけを超速で掴むための記事を書きました。非同期プログラミングが全くわからない人、具体的には、「async await ってなに……?」「for 文で実行していくのと何が違うの……?」レベルの人を想定しています。

非同期プログラングって何?

 同期的じゃないプログラミングです。同期的ということは、プログラムが上から下に順々に実行されるということです。つまり、普通のプログラムはだいたい同期的です。言い換えれば、非同期プログラミングは順番が入れ替わる(可能性)のあるプログラムです。なぜそんなことをするかについては後述します。

ペペロンチーノを作りたい

 あなたはペペロンチーノを作りたいとします。以下のタスクが必要です。

  • パスタを茹でる(5 分)
  • ニンニクを切る(1 分)
  • ソースを作る(4 分)、ただしニンニクを切っている必要がある
  • 盛り付けをする(0 分)、ただしパスタを茹でていてソースを作っている必要がある

 ネットワーク図にすると以下のようになります。
image.png
 人間の体はひとつなので、作業をひとつずつこなしていく必要があります。時系列は以下のようになります。
image.png
 つまり、合計 10 分かかります。プログラムにすると以下のようになります。(以下、簡便のために、プログラム上での秒=分とします)

import time

def boil_pasta():
    print("パスタを茹でます...")
    time.sleep(5)
    print("パスタが茹で上がりました!")
    return "パスタ"

def slice_garlic():
    print("ニンニクを切ります...")
    time.sleep(1)
    print("ニンニクを切りました!")
    return "ニンニク"

def make_sauce():
    slice_garlic()
    print("ソースを作ります...")
    time.sleep(4)
    print("ソースができました!")
    return "ソース"

def serve_dish():
    pasta = boil_pasta()
    sauce = make_sauce()
    print(f"{pasta}{sauce}を盛り付けます...")
    print("盛り付けが完了しました!")
    return f"{pasta}{sauce}の盛り付け"

def main():
    serve_dish()

start_time = time.time()
main()
end_time = time.time()

print(f"実測時間: {end_time - start_time:.2f}")
output
パスタを茹でます...
パスタが茹で上がりました!
ニンニクを切ります...
ニンニクを切りました!
ソースを作ります...
ソースができました!
パスタとソースを盛り付けます...
盛り付けが完了しました!
実測時間: 10.02 分

茹でている間、ヒマでは?

 しかし、この計画は大事な点をひとつ見逃しています。パスタを茹でている間、わざわざ鍋を注視し続けている必要はないことです。放置していようが茹で加減に変わりはないので、その間に別の作業をしていた方が効率的です。これが非同期プログラミングです。

 しかし、通常の、上から下に実行されるプログラムでは、「待ち時間に別のタスクが入るかもしれない」 という書き方をすることはできません。そこで、あらかじめ「待ち時間が発生するタスク」を設定することにより、このようなスケジューリングを行うのが非同期処理です。

 これをふまえて、チャートを書き直します。
image.png
 こうすれば、効率的に調理をすることが可能です。

 Python だと async (asynchronous=非同期的)を使うことにより、このような管理が可能です。

import asyncio
import time


async def boil_pasta():
    print("パスタを茹でます...")
    await asyncio.sleep(5) # 他の作業ができる
    print("パスタが茹で上がりました!")
    return "パスタ"


async def slice_garlic():
    print("ニンニクを切ります...")
    time.sleep(1) # 他の作業ができない
    print("ニンニクを切りました!")
    return "ニンニク"


async def make_sauce():
    await slice_garlic()
    print("ソースを作ります...")
    time.sleep(4) # 他の作業ができない
    print("ソースができました!")
    return "ソース"


async def serve_dish():
    pasta, sauce = await asyncio.gather(boil_pasta(),make_sauce())
    print(f"{pasta}{sauce}を盛り付けます...")
    print("盛り付けが完了しました!")
    return f"{pasta}{sauce}の盛り付け"


async def main():
    await serve_dish()

start_time = time.time()
asyncio.run(main())
end_time = time.time()
print(f"実測時間: {end_time - start_time:.2f}")
output
パスタを茹でます...
ニンニクを切ります...
ニンニクを切りました!
ソースを作ります...
ソースができました!
パスタが茹で上がりました!
パスタとソースを盛り付けます...
盛り付けが完了しました!
実測時間: 5.02 分

 5 分で調理で完了しました!

 なお、このような、待ち時間に他の作業を詰め込む効率化を並行処理といいます。これはあくまで作業を詰め込んでいるだけで、作業者は一人(シングルタスク)です。並「列」処理やマルチスレッディングといった場合、作業者自体が影分身するようなイメージですが、並行処理はそのようなものではないです。

await ってなによ

 正確な説明ではないと思いますが、「非同期処理の中でも、順番を追って処理してね」という意思表示です。なので、await で単体のタスクを呼び出し続けるならば、普通の同期的プログラミングとあまり変わりません。非同期処理が力を発揮するのは、「これらのタスクは待ち時間があるし、それぞれうまくやってね」という時です。プログラムでは以下の部分がそれになります。

pasta, sauce = await asyncio.gather(boil_pasta(),make_sauce())

 これを日本語に訳すと「パスタとソースができあがるまでこっちは動けないから早めに頼むよ。ただ、パスタを茹でる作業とソースを作る作業については、スキマ時間を活用してうまくやってくれ」ということになります。(ここで、boil_pasta()await で個別に呼び出すと、まったく意味がありません。茹で上がるパスタを 5 分間注視し続けることになります。)

じゃあ全部並行処理にすればいいのか

 このように、並行処理は時にボトルネックを大きく解消してくれますが、当然デメリットもあります。まず、asyncawait などの、非同期特有の書き方をしなければいけないこと。ただ、これは本質ではなくて、「上から下に順にコードが実行される」とは限らないことが本当に大変な部分です。

 ここがわかりやすいですが、

async def boil_pasta():
    print("パスタを茹でます...")
    await asyncio.sleep(5) # 他の作業ができる
    print("パスタが茹で上がりました!")
    return "パスタ"
output
パスタを茹でます...
ニンニクを切ります...
ニンニクを切りました!
ソースを作ります...
ソースができました!
パスタが茹で上がりました!

 asyncio.sleep している間に、他の処理が進んでいます。同期プログラミングにおいても、GOTO や関数呼び出しによって実際の命令が上下することはままありますが、それでもコードを一行ずつ読んでいけば必ず手がかりがあります。しかし、非同期プログラミングでは、そもそも手がかりが近くにないことがありえます。このように色々な処理が並行するようになると、実際の処理順序がわからなくなり、難読化やバグの温床を産むことは予想に難くないです。

 では await でしっかり順序の管理を……とやると、並行処理の効率化がはたらきづらいことは上に述べた通りです。

スキマ時間って実際には何

 では、実際のプログラムにおける「茹でる」に相当する作業は何か?

 多くの場合、それは I/O 待ちになります。より具体的に言うと、Web サーバーやデータベースにクエリを投げて返事を待っている時です。サーバーからの返事を待っている間は、たとえそれが数秒間であっても、CPU にとっては死ぬほど遅い時間です。

 例えば、多くの Web サービスはネットワーク上でデータをやりとりすることで成り立っているので、そのような待ち時間がボトルネックになります(I/O バウンドなタスク)。また、Web サービスは不定期に多数のリクエストが飛んでくるのが普通です。そんな状態ですべての待ち時間を律儀に守っていたらサービスが成り立ちません。そこで、スキマ時間に仕事を詰め込む効率化が必要になります。Web の主戦場である JavaScript に高度な並行処理が求められる理由はそこにあります。

 逆に、科学計算のような、外部とのやり取りがほぼ発生せず、純粋な計算量がボトルネックになるような処理(CPU バウンドなタスク)では、並行処理のメリットはあまりないです。(マルチスレッドによる並列計算とか、そういうのはまた話が別です)

結論

  • 非同期プログラミングは、複数のタスク間で、スキマ時間を活かすことができる
  • そのような効率化を並行処理といい、Web 開発をはじめとした I/O バウンドなタスクで役に立つ
255
238
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
255
238