SE_BOKUのまとめノート的ブログ

SE_BOKUが知ってること・勉強したこと・考えたことetc

PythonのThread(並列処理)は速度改善効果がないので「concurrent.futures」を使う

f:id:arakan_no_boku:20190427205325j:plain

目次

PythonのThread(並列処理)は速度改善効果がないのか確認

 PythonのThreadで並列性による速度改善効果を期待しても無駄だと教えてもらったので試してみました。

同じ重たい処理をシーケンシャルに実行するのと、PythonのThreadを使って並列処理するので、どのくらい速度があがるのか?を確認してみます。

シーケンシャル実行ケースの計測

シーケンシャルの処理です。

import time


# 単に時間がかかるだけの処理
def killing_time(number):
    return_list = []
    for i in range(1, number + 1):
        if number % i == 1:
            if i <= 9999:
                return_list.append(i)
    return return_list


start = time.time()
num_list = [25000000, 20000000, 20076000, 14500000]
for n in num_list:
    result_list = list(killing_time(n))
stop = time.time()
print('%.3f seconds' % (stop - start))

killing_time(時間潰し)という名前通りの処理を4回ループで実行して、time()で時間計測しています。 

ちなみに。

ループの中で、

if number % i == 1:
    if i <= 9999:

などと、妙に意味ありげなことをしてますが、これにも何となく時間をかけてそうに見える以上の意味はありません。

気になる方は、コメントにいただいたように、このif文をとって実行しても、結果は大して変わらないと思います(笑)

自分のPCでは平均「4.547秒」かかりました。

 

PythonのThreadを使うケースの時間計測

今度は、これをスレッドで処理してみます。

まずは、ソースから。

import time
import threading


class MyThread(threading.Thread):
    def __init__(self, number):
        super().__init__()
        self.number = number

    def killing_time(self, number):
        return_list = []
        for i in range(1, number + 1):
            if number % i == 1:
                if i <= 9999:
                    return_list.append(i)
        return return_list

    def run(self):
        self.factors = list(self.killing_time(self.number))


start = time.time()
threads = []
num_list = [25000000, 20000000, 20076000, 14500000]
for n in num_list:
    thread = MyThread(n)
    thread.start()
    threads.append(thread)
for th in threads:
    th.join()
stop = time.time()
print('%.3f seconds' % (stop - start))

Threading.Threadクラスを継承してMyThreadクラスを作ってます。

Threading.Threadを継承したクラスで、コンストラクタ「__init__()」で、親クラスのコンストラクタ「super().__init__()」を実行し、run()メソッドに、スレッド内で実行したい処理を書けば、Thread化可能なクラスになります。

MyThreadのインスタンスを生成し、start()すれば処理が各スレッド内で処理します。

各Thread内の処理がすべて終了するのを待つのは、この部分です。

for th in threads:
    th.join() 

動かしてみました。

普通に考えれば、シーケンシャルに実行していた4つの処理を、Threadで並列処理したわけなので、多少は実行時間が短くなるはずです。

 ところが、どっこい。

平均の実行時間は「4.493秒」・・・。

なるほど・・全く速くなっていません(笑)

 

Threadで速度改善しないのがPythonの仕様 

納得いかない結果ですが、調べてみると、これがPythonの仕様です。

Pythonにはプログラム実行の一貫性を保証する仕組みとして「Global Interpreter Lock(GIL)」を採用しています。

ja.wikipedia.org

これが採用されるとどうなるかを、Wikipediaから引用すると。

複数のスレッドを持つインタプリタプロセスの並行性を制限してしまう。

プロセスをマルチプロセッサのマシンで実行させた場合、ほとんどあるいはまったく速度の向上が見られない。

そうです。

もう少し補足すると、GILは、優先度の高いスレッドが実行中のスレッドに割り込んで制御を奪ってしまい一貫性が崩れることを防ぐ「相互排他ロック(mutex)」機能で、その副作用として、同時に1つのスレッドしか進行できないようにしてしまうみたいです。

つまり。

GILが動いている限り、マルチスレッドで動かしても、結局ループを回してシーケンシャルに処理するのと、変わらない動きをするから、今回のようにほぼ同じか、もしくはThread起動のオーバーヘッド分遅くなるのが関の山・・という、実に(;´д`)トホホな話でした。

どうも、PythonのThreadはシステムコールを行う際の「ブロッキングI/O」を扱うためにサポートされているとか・・、そもそも速度向上を狙うようににはなってないみたいなのですね・・。

 

Pythonの並列処理で速度向上できる方法と計測結果 

Pythonで並列処理の効果で速度向上をはかるための機能は、他のところにありました。

concurrent.futures.ProcessPoolExecutor() です。

docs.python.org

これは上記の「Global Interpreter Lock(GIL)」を回避して、マルチプロセスの実行ができるようになっています。

とりあえず、やってみます。

まず、ソースです。

import time
import concurrent.futures


# 単に時間がかかるだけの処理
def killing_time(number):
    return_list = []
    for i in range(1, number + 1):
        if number % i == 1:
            if i <= 9999:
                return_list.append(i)
    return return_list


def main():
    start = time.time()
    num_list = [25000000, 20000000, 20076000, 14500000]
    with concurrent.futures.ProcessPoolExecutor(max_workers=4) as excuter:
        result_list = list(excuter.map(killing_time, num_list))
    stop = time.time()
    print('%.3f seconds' % (stop - start))


if __name__ == '__main__':
    main()

いろいろ、こまかいところで違いがあります。 

まず、「max_worker」の指定。

concurrent.futures.ProcessPoolExecutor(max_workers=4)

これは、CPUコア数とかを指定しとけばよいみたいです。

自分のPCは4コアなので、4にしました。

あと、マルチプロセスで処理を動かすところですが、Threadのように、ループで回したりする必要はないです。

excuter.map(killing_time, num_list)

 このように、ファンクション名と、引数になるリストを渡してやって、マルチプロセスに振り分けるのはおまかせって感じです。

あと注意点としては。

  • pickle 化できるオブジェクトしか実行したり返したりすることができない。
  • 対話的インタープリタでは動かない。

というのがあります。

なので、前の2つのソースのように横着に書くと、動きませんので、上記のソースでも「main()」でくくって、__name__のチェックとかをやってます。

などなど・・癖はありますが、実行結果は見事なもので。

1.769秒

きちっと並列処理されて、パフォーマンスアップしています。

よかった・・よかった(笑)

ではでは。