エンジニアの鈴木(泰)です。
今回は、multiprocessingとthreadingとasyncioの違いとはなんだろう?という問に挑戦してみたいと思います。
この問の答えをグーグル先生に聞いてみると、非常にたくさんの情報がヒットします。しかしながら、どの情報も断片的なものばかりで(本記事もそうなのかもしれません)、色々と本を読んだりネットを漁ったりして、情報を補完しなければなりませんでした。
本記事は、僕が調べた限りの情報を集約し、この問に対する結論を1つの記事にまとめたものとなっています。
前提
本題に入る前に、いつくかの前提について認識を合わせておきます。
マルチプロセスとは
プロセスとは実行中のプログラムです。例えば、Pythonのソースコードを実行すると、ソースコードをインタプリターがバイトコードにコンパイルします。OSはこのバイトコードを実行し、ソースコードに書かれている通りに処理を開始します。この実行中の処理をプロセスと呼びます。
1つのプロセスは、OSから空いているCPUコアが割り当てられることにより、処理を進めることができます。当然、CPUのコアが1つだけである場合、1つのプロセスの処理だけしか進めることができません。しかし、CPUのコアが複数ある場合、それぞれのコアを複数のプロセスに対して同時に割り当てることができるため、複数のプロセスの処理を同時に進めることができます。
マルチプロセスとは、複数のプロセスが同時に処理を進めることを指します。マルチプロセスのメリットは、1つのプログラムの目的を達成するために複数のCPUのコアを利用することで、より速く目的を達成できるという点にあります。
マルチプロセス機構はOS毎に実装が異なります。OS毎の挙動の違いに注意する必要はありますが、プログラミング言語毎の挙動の違いはあまりないです。とはいえ、各プログラミング言語において、プロセスの作成をOSに対して直接に命令することは少なく、各言語毎に用意されているラッパー関数やクラスを通して行います。従って、各言語毎に、これらのラッパーの仕様の違いを知っておく必要はあります。
マルチスレッドとは
スレッドとは、プロセスの中における処理の流れのことです。「処理の流れ」という表現では曖昧でわかりにくいため、具体例で説明します。
以下のPythonのソースコードを実行すると、プロセスが作られます。このプロセスの中では、Hello
の出力から始まり、!
の出力で終わる処理の流れがあります。この処理の流れがスレッドです。このスレッドをメインスレッドと呼びます。このソースコードでは、プロセスが開始されたから終わるまで、処理の流れはずっとメインスレッド1つだけです。
hello.py
print('Hello')
print('world')
print('!')
以下のPythonのソースコードはthreadingライブラリを利用したマルチスレッドを実行するものです。job.start()
関数がスレッドを開始します。このソースコードではprint('Hello')
、print('world')
、print('!')
、そしてメインスレッドの4つの処理の流れがあります。job.join()
関数の実行後はスレッドが完了します。よって、print('done')
が実行される時点においては、スレッドはメインスレッドの1つだけです。
hello_threading.py
import threading
jobs = []
jobs.append(threading.Thread(target=lambda : print('Hello')))
jobs.append(threading.Thread(target=lambda : print('world')))
jobs.append(threading.Thread(target=lambda : print('!')))
for job in jobs:
job.start()
for job in jobs:
job.join()
print('done')
1つのスレッドは、プログラミング言語毎に実装されている機構(LinuxではPthread、JavaのThreadsライブラリ、Pythonではasyncioやthreadingライブラリ等)を通してCPUコアが割り当てられることにより、処理を進めることができます。プロセスのように、OSから直接CPUコアが割り当てられるのではありません。プロセスの場合と同様に、CPUのコアが複数ある場合、それぞれのコアを複数のスレッドに対して同時に割り当てることができれば、複数のスレッドの処理を同時に進めることができます。
マルチスレッドとは、複数のスレッドが同時に処理を進めることを指します。
一般的には、マルチスレッドのメリットもマルチプロセスのメリットと同様です。が、Pythonにおいては、CPythonがGILであるということに注意する必要があります。
Pythonにおけるマルチスレッド
Pythonにおいて、マルチスレッドなソースコードを書く場合、CPythonがGILがあることを考慮しなければなりません。スクリプト言語のインタプリターは、GILであるものとそうでないものがあります。CPythonはGILであり、JythonやIronPythonはGILではありません。ちなみにCRubyはGILです。
GILであるインタプリターにおいては、マルチスレッドなソースコードを書いたとしても、インタプリターが出力したバイトコードをOS上で実行する段階においてマルチスレッドでは実行されません。たとえば、上で掲載したhello_threading.pyは、OS上で実行される段階においてマルチスレッドでは実行されません。
本題
Pythonにおいて、マルチプロセスやマルチスレッドなソースコードを書く場合、multiprocessing、threading、asyncioのどれを利用すべきなのでしょうか?
マルチプロセス(multiprocessingライブラリ)を利用したほうが良い場合
CPU負荷の高い処理(いわゆるCPU bound)を達成するためのソースコードである場合、multiprocessingを利用し、マルチプロセスに書きましょう(Jython等のGILではないインタプリターを使うのであれば、この限りではありません)。
CPU負荷の高い処理するためにマルチスレッドなソースコードを書いたとしても、パフォーマンスは改善されません。なぜなら、「Pythonにおけるマルチスレッド」で説明した通り、Pythonのソースコードはインタプリターによりコンパイルされた後、OS上でシングルスレッドで実行されるからです。すなわち、利用できるCPUコアは1つだけに限定されます。
実際にやってみると、パフォーマンスの差が顕著に表れます。
検証環境
- 4 vCPUs, 16 GB memory
- CentOS, 8, x86_64 built on 20210701
- Python3.8
cpu_sec.py
CPU負荷の高い処理burden_cpu関数を1つのプロセス、1つのスレッドで処理するプログラムです。
def burden_cpu():
for i in range(10000):
for j in range(10000):
pass
for i in range(4):
burden_cpu()
実行結果
$ time python3.8 cpu_sec.py
real 0m9.518s
user 0m9.473s
sys 0m0.005s
CPU使用率。CPUのコアが4個あるうち、1つのコアだけを使用しているため、25%となります。
$ mpstat 1
...(省略)
16:08:49 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16:08:49 all 18.50 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 81.50
16:08:50 all 25.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.00
16:08:51 all 25.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.00
16:08:52 all 25.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.00
16:08:53 all 24.88 0.00 0.25 0.00 0.25 0.00 0.00 0.00 0.00 74.63
16:08:54 all 24.81 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.19
16:08:55 all 25.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.00
16:08:56 all 24.75 0.00 0.00 0.00 0.50 0.00 0.00 0.00 0.00 74.75
16:08:57 all 25.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.00
...(省略)
cpu_multiprocessing.py
CPU負荷の高い処理burden_cpu関数を4つのプロセス、各プロセス上では1つのスレッドで処理するプログラムです。
import multiprocessing as mp
def burden_cpu(_: any):
for i in range(10000):
for j in range(10000):
pass
pool = mp.Pool(4)
pool.map(burden_cpu, [i for i in range(4)])
pool.close()
実行結果。CPUを効率良く使用できている(下記参照)ため、cpu_sec.pyの実行時間よりも小さくなります。
$ time python3.8 cpu_multiprocessing.py
real 0m5.351s
user 0m21.062s
sys 0m0.028s
CPU使用率。CPUのコアが4個あるうち、4つプロセスに対して1つずつコアが割り当てられ、同時に4つのコアを使用しているためほぼ100%となります。
$ mpstat 1
...(省略)
16:12:08 all 99.50 0.00 0.00 0.00 0.50 0.00 0.00 0.00 0.00 0.00
16:12:09 all 99.01 0.00 0.00 0.00 0.99 0.00 0.00 0.00 0.00 0.00
16:12:10 all 99.75 0.00 0.00 0.00 0.25 0.00 0.00 0.00 0.00 0.00
16:12:11 all 99.25 0.00 0.00 0.00 0.75 0.00 0.00 0.00 0.00 0.00
...(省略)
cpu_threading.py
CPU負荷の高い処理burden_cpu関数を1つのプロセス、4つのスレッドで処理するプログラムです。
from concurrent.futures import ThreadPoolExecutor
def burden_cpu():
for i in range(10000):
for j in range(10000):
pass
pool = ThreadPoolExecutor(max_workers=4)
for i in range(4):
pool.submit(burden_cpu)
pool.shutdown()
実行結果。ソースコード上では4つのスレッドが同時に処理を進めていますが、バイトコード上では1つのスレッドだけが処理を実行しているだけの状態(下記参照)であるために、cpu_sec.pyの実行時間とほぼ同じです。
$ time python3.8 cpu_threading.py
real 0m9.812s
user 0m9.820s
sys 0m0.090s
CPU使用率。CPU使用率が25%程度であることから、CPUのコアが4個あるうち1つだけしか利用できていないことがわかります。
$ mpstat 1
...(省略)
16:16:46 all 25.00 0.00 0.25 0.00 0.00 0.25 0.00 0.00 0.00 74.50
16:16:47 all 24.88 0.00 0.25 0.00 0.50 0.00 0.00 0.00 0.00 74.38
16:16:48 all 24.75 0.00 0.00 0.00 0.25 0.00 0.25 0.00 0.00 74.75
16:16:49 all 24.81 0.00 0.25 0.00 0.00 0.25 0.00 0.00 0.00 74.69
16:16:50 all 25.00 0.00 0.25 0.00 0.50 0.00 0.00 0.00 0.00 74.25
16:16:51 all 24.75 0.00 0.00 0.00 0.25 0.00 0.25 0.00 0.00 74.75
16:16:52 all 24.94 0.00 0.25 0.00 0.25 0.00 0.00 0.00 0.00 74.56
16:16:53 all 24.69 0.00 0.25 0.00 0.00 0.00 0.00 0.00 0.00 75.06
16:16:54 all 25.31 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 74.69
...(省略)
cpu_asyncio.py
asyncioを使用した場合です。実行結果は、cpu_threading.pyのものと同じです。
import asyncio
running = 0
async def burden_cpu_async():
global running
for i in range(10000):
for j in range(10000):
pass
running-=1
async def main():
await asyncio.gather(*[
burden_cpu_async(),
burden_cpu_async(),
burden_cpu_async(),
burden_cpu_async(),
])
asyncio.run(main())
実行結果。
$ time python3.8 cpu_asyncio.py
real 0m9.433s
user 0m9.389s
sys 0m0.007s
CPU使用率。CPU使用率が25%程度であることから、CPUのコアが4個あるうち1つだけしか利用できていないことがわかります。
$ mpstat 1
...(省略)
01:10:20 all 24.94 0.00 0.00 0.00 0.25 0.00 0.00 0.00 0.00 74.81
01:10:21 all 24.81 0.00 0.00 0.00 0.25 0.00 0.00 0.00 0.00 74.94
01:10:22 all 25.00 0.00 0.00 0.00 0.25 0.00 0.00 0.00 0.00 74.75
01:10:23 all 24.81 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 75.19
01:10:24 all 24.88 0.00 0.00 0.00 0.50 0.00 0.00 0.00 0.00 74.63
01:10:25 all 24.81 0.00 0.25 0.00 0.00 0.00 0.00 0.00 0.00 74.94
01:10:26 all 24.94 0.00 0.00 0.00 0.00 0.00 0.25 0.00 0.00 74.81
01:10:27 all 24.75 0.00 0.00 0.00 0.25 0.00 0.00 0.00 0.00 75.00
...(省略)
ソースコード毎の実行結果まとめ表
ソースコード |
プロセス |
スレッド |
実行時間(秒) |
CPU使用率(%) |
cpu_sec.py |
1 |
1 |
9.518 |
25.0 |
cpu_multiprocessing.py |
4 |
1 |
5.351 |
99.75 |
cpu_threading.py |
1 |
4 |
9.812 |
25.0 |
cpu_asyncio.py |
1 |
1 |
9.433 |
25.0 |
threadingとasyncioを利用したほうが良い場合
I/O待ち時間が大きいもの(いわゆるI/O bound)を達成するためのソースコードである場合、threading(マルチスレッド)かasyncio(非同期I/O)を利用しましょう。
multiprocessing(マルチプロセス)を利用しない方が良い理由は、プロセスを作る際に発生するコストが大きいからです。プロセスを作るコストよりもスレッドを作るコストの方が小さいので、コストが小さい方を利用した方が良いということです。プロセスを新しく作ると、新しく作られたプロセスの数に比例してファイルディスクリプタ数、OSがCPUを切り替えるためのスイッチング回数が大きくなります。
同様にして、スレッドを作るコストという観点から言えば、スレッドを作るコストよりも非同期I/Oのイベントを発火するコストの方が小さいため、asyncioを利用する方が良いと言えそうです。
果たしてどうなのでしょうか?検証していきたいと思います。
threading vs asyncio
誤解を恐れずにいえば、threadingとasyncioは本質的にはどちらも、Pythonにおける「複数の処理を同時に進めるための仕組み」を提供するライブラリです。どちらにおいても、「Pythonにおけるマルチスレッド」にて述べた通り、インタプリターが出力したバイトコードはOS上で1つのスレッドでのみ実行されます。
両者の差異は次の点にあります。
- threading
- 昔からある。Python1.6(2000年)から標準ライブラリにあります。
- 昔からある、マルチスレッドプログラミングというパラダイムに属する。
- PythonのthreadingライブラリのAPIは、なんとなくですが、Javaのマルチスレッドに似ています。
- 複数のスレッドを作り、それぞれの処理を同時に進めることができる。
- 競合状態(Race Condition)に気をつけなければならない。
- asyncio
- 2015年(Python3.4)から導入された。
- ここ10年ぐらいで広まってきた非同期プログラミングというパラダイムに属する。
- ある処理がI/O待ちをしている間に他の処理を進めることができる。このことからわかるように、厳密に言えば、複数の処理を同時に進めているわけではなく、「待ち」が発生した時に、他に進めることのできる処理(待ちが発生していない処理)を進めているだけである(非同期I/Oについての詳細な説明は本記事では割愛します。詳しく知りたい方は、グーグル先生に聞いてみてください。)
- 競合状態(Race Condition)をあまり気にする必要はないが、程よく、非同期I/Oの「待ち」(例
asyncio.sleep
関数など)が入るようなプログラムを書かなければならない(「待ち」が入らない場合、コンテキストスイッチングが起こらない。)
一見するとthreadingよりもasyncioを利用する方が良さそうですが、実際のところどうなのでしょうか?I/O boundな処理をそれぞれのライブラリを利用して書き、比較してみましょう。
検証環境
- 4 vCPUs, 16 GB memory
- CentOS, 8, x86_64 built on 20210701
- Python3.8
比較方法
比較に用いられる検証用プログラムは2つあります。io_threading.pyとio_asyncio.pyです。
io_threading.py、io_asyncio.pyはそれぞれ、I/O boundなタスクを処理する常駐プログラムです。ウェブサーバーのようなプログラムを模倣しています。ウェブサーバーはポートに届いたリクエストを処理します。これを模倣し、検証用プログラムは標準入力に届いたタスクを処理します。ウェブサーバーには、リクエストの処理をスレッドに任せるもの(nginxのような)と、イベントループに任せるもの(node.jsのような)があります。io_threading.pyは標準入力に届いたタスクの処理をスレッドに任せます。一方、io_asyncio.pyはイベントループに任せます。
タスクは検証用プログラムの標準入力に入力されます。入力された文字列は数字でなければなりません。この数字は入力されたタスクの量を表します。max_weight_io_burdenが、検証用プログラムが処理しなければならないタスクの総量です。検証用プログラムが処理したタスクの量の和がタスクの総量を超えると、プログラムは終了します。
タスクはI/O boundなものです。タスクの量はI/O待ちの時間(秒)です。io_burden関数が、I/O boundなタスクを模倣します。
io_threading.pyとio_asyncio.pyは、タスクの総量をどれだけ速く終わらせることができるのか?を競います。
io_threading.py
I/O boundな処理をthreadingを用いて捌く実装です。
import threading
import fileinput
import time
import os
max_weight_io_burden = int(os.getenv('MAX_WEIGHT_IO_BURDEN'))
start = None
processed_weight = 0
processed_weight_lock = threading.Lock()
def io_burden(weight: int):
global processed_weight
global processed_lock
time.sleep(weight)
with processed_weight_lock:
processed_weight += weight
if processed_weight >= max_weight_io_burden:
print(time.time() - start, processed_weight)
def get_input():
global start
inputs = 0
for line in fileinput.input():
weight = int(line)
if inputs == 0:
start = time.time()
if inputs >= max_weight_io_burden:
break
t = threading.Thread(target=io_burden, args=(weight,))
t.start()
inputs += weight
while threading.active_count() > 1:
pass
get_input()
io_asyncio.py
I/O boundな処理をasyncioを用いて捌く実装です。上記のio_threading.pyのasyncio版です。
import threading
import fileinput
import time
import os
import asyncio
max_weight_io_burden = int(os.getenv('MAX_WEIGHT_IO_BURDEN'))
start = None
processed_weight = 0
async def io_burden(weight: int, loop):
global processed_weight
await asyncio.sleep(weight)
processed_weight += weight
if processed_weight >= max_weight_io_burden:
loop.stop()
print(time.time() - start, processed_weight)
def get_input(loop):
global start
inputs = 0
for line in fileinput.input():
weight = int(line)
if inputs == 0:
start = time.time()
if inputs >= max_weight_io_burden:
break
asyncio.run_coroutine_threadsafe(io_burden(weight, loop), loop=loop)
inputs += weight
loop = asyncio.get_event_loop()
thread_input = threading.Thread(target=get_input, args=(loop,))
thread_input.start()
loop.run_forever()
プログラムの実行方法
このプログラムは2つの端末により実行します。
1つ目の端末では、検証用プログラムを動かします。環境変数MAX_IO_BURDEN_TASKSはプログラムが処理するタスクの総量です。
# 実行例
# プログラムを起動。このプログラムはタスクを1000000だけ処理したら終了する。
$ tail -f a.txt | MAX_IO_BURDEN_TASKS=1000000 python3.8 io_threading.py
# プログラムを起動。このプログラムはタスクを1000だけ処理したら終了する。
$ tail -f a.txt | MAX_IO_BURDEN_TASKS=1000 python3.8 io_asyncio.py
2つ目の端末では、プログラムにタスクを投入します。
# 量1のタスクを投入し続ける
$ while true; do echo "1" >> a.txt; done
# 量10のタスクを投入し続ける
$ while true; do echo "10" >> a.txt; done
実行結果
io_threading.py
プログラム |
MAX_IO_BURDEN_TASKS |
単タスクの量(秒) |
処理時間(秒) |
備考 |
io_threading.py |
1,000,000 |
1 |
188.2251 |
(1) |
io_threading.py |
1,000,000 |
2 |
98.5299 |
(1) |
io_threading.py |
1,000,000 |
3 |
67.4113 |
(1) |
io_threading.py |
1,000,000 |
4 |
54.4028 |
(1) |
io_threading.py |
1,000,000 |
5 |
- |
(2) |
io_threading.py |
1,000,000 |
30 |
- |
(2) |
io_threading.py |
1,000,000 |
40 |
43.4900 |
(4) |
io_threading.py |
1,000,000 |
50 |
52.6634 |
(4) |
io_threading.py |
1,000,000 |
100 |
101.3432 |
(4) |
io_asyncio.py
プログラム |
MAX_IO_BURDEN_TASKS |
単タスクの量(秒) |
処理時間(秒) |
備考 |
io_asyncio.py |
1,000,000 |
1 |
127.0902 |
(1) |
io_asyncio.py |
1,000,000 |
2 |
71.9533 |
(1) |
io_asyncio.py |
1,000,000 |
3 |
50.1331 |
(1) |
io_asyncio.py |
1,000,000 |
4 |
36.5489 |
(1) |
io_asyncio.py |
1,000,000 |
5 |
29.2290 |
(1) |
io_asyncio.py |
1,000,000 |
6 |
25.2520 |
(1) |
io_asyncio.py |
1,000,000 |
7 |
22.3903 |
(1) |
io_asyncio.py |
1,000,000 |
8 |
20.4432 |
(1)(3) |
io_asyncio.py |
1,000,000 |
9 |
20.1911 |
(1)(3) |
io_asyncio.py |
1,000,000 |
10 |
19.8268 |
(3) |
io_asyncio.py |
1,000,000 |
20 |
24.8514 |
(4) |
io_asyncio.py |
1,000,000 |
30 |
33.0301 |
(4) |
io_asyncio.py |
1,000,000 |
40 |
42.3853 |
(4) |
io_asyncio.py |
1,000,000 |
50 |
51.8705 |
(4) |
io_asyncio.py |
1,000,000 |
100 |
100.7834 |
(4) |
実行結果の考察
(1)過度なタスク分割によるオーバーヘッド増大
threading、asyncio共に、最も処理時間が大きくなっています。これはスレッドやイベントループ、その他諸々のオーバーヘッドの影響が大きくなってしまったことが起因していると考えられます。マルチスレッドのメリットは大きなタスクを小さなタスクに分割し、複数のタスクを複数のスレッドが同時に処理することで、全てのタスクを速く終了させるための手法です。タスクを小さくすればするほどそれぞれのスレッドは速く終了しますが、よりたくさんのスレッドを生成・管理しなければなりません。非同期I/Oでも同様に、タスクを小さくすればするほどそれぞれのタスクは速く終了しますが、よりたくさんのタスクを非同期I/Oのイベントループに登録・管理しなければなりません。また、今回のプログラムの場合、タスクを小さくすればするほどタスクを標準入力から読み込む回数も大きくなります。
asyncioの方がthreadingよりも処理時間が小さいです。これは非同期I/Oのイベントループのタスクの登録・管理にかかる時間の方が、スレッドの生成・管理のそれよりも小さいからであると考えられます。非同期I/Oの方がマルチスレッドよりもコンテキストのスイッチングに関わるオーバーヘッドが小さいという一般論にも合致します。
(2)OSのスレッド数上限値が影響
OS上で稼働しているスレッド数が、実行環境の上限値に引っかかってしまい、エラー終了します。
$ tail -f a.txt | MAX_WEIGHT_IO_BURDEN=1000000 python3.8 io_threading.py
Traceback (most recent call last):
File "io_threading.py", line 43, in <module>
get_input()
File "io_threading.py", line 37, in get_input
t.start()
File "/usr/lib64/python3.8/threading.py", line 852, in start
_start_new_thread(self._bootstrap, ())
RuntimeError: can't start new thread
32702
libgcc_s.so.1 must be installed for pthread_cancel to work
実行環境OSはLinuxです。1プロセス毎に作ることのできるスレッド数には上限値があります。CPythonのスレッドはpthreadを用いて実装されているため、この上限値の影響を受けます。
$ cat /proc/sys/kernel/threads-max
126329
(3)パフォーマンス頭打ち
最も処理時間が小さいですが、(4)で述べる理想的なパフォーマンスの向上が頭打ちになっている状態です。(1)で述べたようなオーバーヘッドの影響が出始めてきたものと思われます。
(4)タスク分割数に比例してパフォーマンス向上
単タスクの量と処理時間がほぼ同じです。マルチスレッド、非同期I/O、共に、理想的なパフォーマンスの向上が実現できています。「理想的な」という所以は、大きなタスクを小さく分割した分だけ、処理時間が向上しているからです。
io_threading.pyとio_asyncio.pyの処理時間に差異がほとんどありません。これは(1)で述べたようなオーバーヘッドの影響が無視できるほど小さいからだと思われます。
まとめ
今回の試行錯誤から得られた結論は次です。
- CPU負荷の高い処理(いわゆるCPU bound)を達成したいのであれば、マルチプロセス(multiprocessing)を利用。
- I/Oの待ち時間が大きい処理(いわゆるI/O bound)を達成したいのであれば、マルチスレッド(threading)か非同期I/O(asyncio)を利用。
- 同時に実行しているスレッド数が大きい場合において、非同期I/Oのパフォーマンスの方が良い。ただし、同時に実行しているスレッド数が大きくない場合においては、マルチスレッドと非同期I/Oのパフォーマンスの差異はあまりない。
参考