パイソンにおけるマルチスレッディング - DEV コミュニティ

今日の現代世界では、データはソーシャルメディアからスマート家電まで、私たちの生活の中心にあります。プログラムのパフォーマンスは、しばしばネットワークを介して、データの操作と計算能力によって決まります。大量のデータを処理することには問題が付き物で、特にプログラムの実行時間の増加によって「ブロック」や「ラグ」が発生します。

プログラムの効率的な実行と、ますます洗練されたマルチコアOS/ハードウェアアーキテクチャの必要性から、プログラミング言語はこの振る舞いをより良く利用しようと試みてきました。並行性という言葉の文字通りの意味は「同時発生」です。並行プログラムの実行時間は、コンピュータが複数の指示を同時に実行できるため、大幅に短縮される可能性があります。

Pythonには、その並行性モデルに密接に関連する3つの主要なオペレーティングシステムの概念があります。それはスレッドタスクプロセスです。

スレッドの解きほぐし

同じオペレーティングシステムプロセスのスレッドは、C++やJavaなどのプログラミング言語で見られるように、コンピューティングワークロードを複数のコアに分配します。一般的に、Pythonは単一のプロセスを使用し、そのプロセスから単一のメインスレッドが生成され、ランタイムを実行します。これはコンピュータのコア数や新しいスレッドがいくつ生成されたかに関係なく、単一のコアに留まります。これはグローバルインタプリタロック(GIL)と呼ばれるロッキングメカニズムによるもので、レースコンディションと呼ばれるものを防ぐために導入されました。

レースと聞くと、NASCARやフォーミュラワンを思い浮かべるかもしれません。その類似点を使って、すべてのフォーミュラ1のドライバーが同時に1台のレースカーでレースをすることを想像してみてください。無茶苦茶な話でしょ?これは、各ドライバーが自分の車を持っているか、もしくは1周ごとに交代してカーを次のドライバーに渡す場合にのみ可能です。

スレッドも非常に似たような状況にあります。スレッドは「メイン」スレッドから「生成」され、「次の」スレッドは前のスレッドのコピーです。これらのスレッドは同じプロセス「コンテキスト」(イベントまたは競技)内に存在するため、そのプロセスに割り当てられたリソース(例えばメモリ)は共有されます。例えば、典型的なPythonインタープリタセッションでは:

>>> a = 8

ここで、aはメモリ(RAM)のごくわずかな部分を消費しており、仮に割り当てられたメモリの位置で8の値を一時的に保持しています。

ここまでは良いですが、いくつかのスレッドを起動して、2つの数値 xy の加算時の挙動を観察してみましょう:

import time
import threading
from threading import Thread

a = 8

def threaded_add(x, y):
    # 時間が掛かることをシミュレートするために、
    # Pythonにスリープさせます。なぜなら加算はあまりにも素早く行われるからです!
    for i in range(2):
        global a
        print("別のスレッドでタスクを計算中!")
        time.sleep(1)
        # これは良くないですが、後でもっと詳しく説明しますが、
        # Pythonは同期を強います!
        a = 10
        print(a)

# 現在のスレッドはサブセットフォーク(派生スレッド)になります!
if __name__ != "__main__":
    current_thread = threading.current_thread()

# ここでPythonにメインスレッドから他のスレッドを
# 生成するよう指示します
if __name__ == "__main__":
    thread = Thread(target=threaded_add, args=(1, 2))
    thread.start()
    thread.join()
    print(a)
    print("メインスレッドが終了しました...終了します")

2つのスレッドが現在実行中です。これらを thread_onethread_two と呼びましょう。もし thread_one が変数 a を10に変更しようとしていて、同時に thread_two も同じ変数を更新しようとしていれば、問題が発生します!データレースが発生し、a の結果の値が一貫性を失うことになります。

見ていなかったNASCARレースで友達から矛盾する結果を2つ聞いたようなものです!thread_one が1つのことを教えてくれて、thread_two がそれを否定!疑似コードのスニペットは以下に示します:

a = 8
# 2つの異なるスレッド1と2を生成
# thread_one がaの値を10に更新する

if (a == 10):
  # aのチェック

# thread_two がaの値を15に更新する
a = 15
b = a * 2

# thread_one が最初に終了した場合、結果は20
# thread_two が最初に終了した場合、結果は30
# どちらが正しいのでしょうか?

実際に何が起きているのか?

Pythonはインタプリタ言語であり、他の言語のソースコードを解析するプログラムであるインタプリタが付属しています!Pythonには、cpython, pypy, Jpython, IronPythonといったインタプリタがいくつかありますが、cpythonがPythonのオリジナル実装です。

CPythonはインタプリタであり、Cや他のプログラミング言語でフォーリン関数インタフェースを提供し、Pythonソースコードを中間の バイトコード にコンパイルし、そのコードをCPythonバーチャルマシンで解釈します。ここまでの議論、およびこれから先の話はCPythonの環境内での挙動について理解することについてです。

メモリモデル及びロッキングメカニズム

プログラム言語は、動作を実行するプログラム内でオブジェクトを使用します。これらのオブジェクトには stringintegerboolean などのプリミティブデータ型があります。listclasses/objects といったより複雑なデータ構造も含まれます。プログラムのオブジェクトの値は、迅速なアクセスのためにメモリに格納されます。変数がプログラムで使用される時、プロセスはその値をメモリから読み取り、操作します。早い時代のプログラミング言語では、多くの開発者が自分のプログラムのメモリ管理を行う責任がありました。つまり、リストやオブジェクトを作成する前に、変数のためのメモリをまず割り当てる必要がありました。それを行った後で、そのメモリを解放「解放」することができました。

Pythonでは、オブジェクトは 参照 によってメモリに格納されます。参照とは、オブジェクトのラベルのようなものであり、1つのオブジェクトには多くの名前が付けられるように、名前とニックネームのように機能します。参照はオブジェクトの正確なメモリ位置です。参照カウンタはPythonにおけるガベージコレクション、つまり自動メモリ管理プロセスに使用されます。

Pythonは参照カウンタを使って各オブジェクトを追跡し、オブジェクトが作成または参照されるたびに参照カウンタを増やし、オブジェクトが参照解除されるたびに減らします。参照カウントが0になると、そのオブジェクトのメモリが解放されます。

import sys
import gc

hello = "world" # 'world'への参照は2回
print(sys.getrefcount(hello))

bye = "world"
other_bye = bye
print(sys.getrefcount(bye))
print(gc.get_referrers(other_bye))

これらの参照カウンタ変数は、レースコンディションやメモリリークから保護する必要があります。これらの変数を保護するために、スレッド間で共有されるすべてのデータ構造にロックを追加することができます。

CPythonのGILはPythonインタプリタを制御し、一度に1つのスレッドだけがインタプリタをコントロールできるようにします。これは、管理する必要のあるロックが1つだけのため、単一スレッドのプログラムのパフォーマンスが向上しますが、反面、特定の状況下でマルチスレッドのCPythonプログラムがマルチプロセッサシステムを完全に活用するのを妨げる可能性もあります。

💡 ユーザーがPythonプログラムを書く際には、CPU利用率が高いものとI/O利用率が高いもののパフォーマンスに違いがあることを覚えておいてください。CPU利用率の高いプログラムは多くの操作を同時に行おうとすることでプログラムを限界まで押し上げますが、I/O利用率の高いプログラムはI/Oの完了を待つ時間が必要になります。

したがって、CPythonのバイトコードを解釈する内で多くの時間を費やすマルチスレッドのプログラムにおいて、GILがボトルネックになるのはその場合です。GILは必要ない時にもパフォーマンスを低下させる可能性があります。たとえば、以下はI/OバウンドタスクとCPUバウンドタスクの両方を扱うPythonで書かれたプログラムです。

import time, os
from threading import Thread, current_thread
from multiprocessing import current_process

COUNT = 200000000
SLEEP = 10

def io_bound(sec):
   pid = os.getpid()
   threadName = current_thread().name
   processName = current_process().name
   print(f"{pid} * {processName} * {threadName} \
           ---> スリープを開始...")
   time.sleep(sec)
   print(f"{pid} * {processName} * {threadName} \
           ---> スリープ終了...")

def cpu_bound(n):
   pid = os.getpid()
   threadName = current_thread().name
   processName = current_process().name
   print(f"{pid} * {processName} * {threadName} \
           ---> カウント開始...")
   while n > 0:
       n -= 1
   print(f"{pid} * {processName} * {threadName} \
       ---> カウント終了...")

def timeit(function, args, threaded=False):
   start = time.time()
   if threaded:
       t1 = Thread(target = function, args = (args, ))
       t2 = Thread(target = function, args = (args, ))
       t1.start()
       t2.start()
       t1.join()
       t2.join()
   else:
       function(args)
   end = time.time()
   print('Time taken in seconds for running {} on Argument {} is {}s -{}'.format(function, args, end - start, "Threaded" if threaded else "None Threaded"))

if __name__ == "__main__":
   # I/Oバウンドタスクを実行
   print("I/Oバウンドタスク 非スレッド化")
   timeit(io_bound, SLEEP)

   print("I/Oバウンドタスク スレッド化")
   # スレッド化でI/Oバウンドタスクを実行
   timeit(io_bound, SLEEP, threaded=True)

   print("CPUバウンドタスク 非スレッド化")
   # CPUバウンドタスクを実行
   timeit(cpu_bound, COUNT)

   print("CPUバウンドタスク スレッド化")
   # スレッド化でCPUバウンドタスクを実行
   timeit(cpu_bound, COUNT, threaded=True)

結果からわかるように、マルチスレッディングはI/Oバウンドタスクに対して非常に優れたパフォーマンスを発揮し、実行時間が10秒間であることに対して、非スレッド化のアプローチでは20秒かかりました。CPUバウンドタスクの実行にも同じアプローチを使用しましたが、最終的にプログラム全体の実行には驚異の106秒かかります!一体何が起こったのでしょうか?これは Thread-1が開始すると、グローバルインタプリタロック(GIL)を取得し、Thread-2がCPUを使用するのを妨げるためです。したがって、Thread-2Thread-1がタスクを終了し、ロックを解放するのを待たな

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/derhnyel/multi-threading-in-python-5h80