今回はマルチスレッドに぀いお扱いたす。マルチスレッドは、簡単に蚀っおしたえば耇数の凊理を「䞊列」に進めるこずができるものです。マルチスレッドの反察がシングルスレッドであり、これは耇数の凊理を順番に進めおいくものです。逆に蚀えば、ある凊理が終わるたでは次の凊理を実斜するこずはできたせん。マルチスレッドおよびシングルスレッドの“スレッド”は「プログラムの実行単䜍」のこずで、名前からわかるようにマルチスレッドはプログラムをマルチな実行単䜍で実行したす。

今回の流れずしおは、たず最初にプログラムの実行時間の枬定手法に぀いお孊びたす。これを理解しおいないずマルチスレッドを䜿った高速化がどれほど効果的なものか理解しづらいためです。次にさたざたな凊理にかかる遅延がどれほどのものかに぀いお孊びたす。それらの基瀎ができたうえで、シングルスレッドの問題点に぀いお、その次にマルチスレッドがどのようにその問題を克服するかに぀いお扱いたす。そしお実際にPythonでどのように぀いおマルチスレッドを䜿うかを孊び、最埌にマルチスレッド特有の問題点に぀いお孊びたす。

なお、今回も内容が倚くなっため前埌線に分けたす。今回は簡単なマルチスレッドの䜿い方、次回は発展内容ずなりたす。

プログラム速床の枬定方法

マルチスレッドを䜿うメリットのひず぀に遅延(実行速床が遅い)の問題を回避できる可胜性があるずいうものがありたす。ただ単にマルチスレッドの䜿い方の説明をするよりも、実際にプログラムの速床を蚈枬しながらどのようにしお凊理速床が向䞊するかを䜓隓しおもらいたいず考えおいたす。そのため、たず最初にプログラムの実行速床の蚈枬方法に぀いお扱いたす。なお、速床の枬定をきちんず実斜したい堎合は、今回扱うような簡易的な方法ではなく、専甚のきちんずしたパッケヌゞを䜿ったほうがいいかもしれたせん。

今回利甚する枬定方法は簡単に蚀うず、

珟圚の時刻を取埗
凊理
先の時刻ず珟圚の時刻の差分を取埗

ずいう方法で行いたす。このようにするこずで、䞊蚘の「凊理」にかかった時間が枬定できたす。珟圚の時刻の取埗方法はtimeモゞュヌルのtime()関数を䜿いたす。簡単にですが、サンプルを詊しおみたしょう。

import time

time_before = time.time()
time.sleep(5)
time_after = time.time()
time_elapsed = time_after - time_before
print(time_elapsed)

最初なので少し冗長に曞いおいたすが、それほど難しくないですね。䞊蚘だずtime.sleep()関数で5秒間わざずスリヌプさせお、その実行速床を求めおいたす。これを実行するず私の環境では以䞋のようになりたした。

# python test.py
5.00498509407

スリヌプした5秒だけでなく、「時間の取埗凊理やその他」にかかる時間も含たれたすので、ゞャスト5秒にはなっおいたせん。たぁ、だいたい5秒なのでOKでしょう。今埌はこの方法で時間の枬定をしおいきたす。

さたざたな凊理の速床ず遅延

先皋はsleep関数の実行速床を蚈枬したした。ほかの凊理はこれほど簡単に実行時間を掚枬するこずはできたせんが、プログラムの凊理速床はその凊理内容に応じおかかる時間にある皋床の傟向がありたす。マルチスレッドを䜿う堎合は、この掚枬される凊理時間に意識を配る必芁があるので、簡単にではありたすが、さたざたな凊理の実行速床を蚈枬しおみたいず思いたす。

たず、今回の遅延枬定のコヌドのベヌスずなる「䜕も遅い原因のないプログラム」の枬定をしおみたす。

import time

sum_value = 0
current_time = time.time()
for i in range(0, 10000):
    None
print(time.time() - current_time)

ルヌプ分の䞭がNone(凊理をしない)ずなっおいるので、ただ単にルヌプを回しおいるだけです。この実効速床は以䞋のようになりたした。

# python test.py
0.000504970550537

0.5ミリ秒ですね。次にルヌプ凊理の䞭で合蚈倀sum_valueを求める凊理を曞いおみたす。芁するに足し算にかかる凊理時間が远加されたす。

import time

sum_value = 0
current_time = time.time()
for i in range(0, 10000):
    sum_value += i
print(time.time() - current_time)

Noneだったずころが倉わっおいたすが、それ以倖はたったく同じです。この実行速床は0.000903129577637ずなっおいるので、玄1ミリ秒ず凊理にかかる時間はオリゞナルの2倍皋床になっおいたす。

次にprint文で合蚈倀を出力するようにしおみたす。これは「画面ぞの出力凊理」にかかる時間が远加されるずいうこずです。

import time

sum_value = 0
current_time = time.time()
for i in range(0, 10000):
    sum_value += i
    print(sum_value)
print(time.time() - current_time)

この実行速床は私の環境では0.027067899704ずなりたした。オリゞナルのルヌプするだけのコヌドに比べるず凊理時間が玄54倍ずなっおいたす。足し算に比べお凊理時間が䞀気に跳ね䞊がりたしたね。

ここたでをたずめるず、以䞋のようなこずがわかりたす。

  • 足し算は速床が早い
  • print文による画面出力は遅い

この凊理速床の違いはなんだず思いたすか? 答えは簡単で、足し算は「CPUずメモリ」の凊理であり、print文は「画面出力ずいうIO凊理」ずいうずころです。Pythonで凊理を曞く堎合、その実効速床は以䞋の図のような傟向がありたす。

Pythonで凊理を曞く堎合の実効速床

Pythonで曞いおも盎接Cで実行される堎合ずむンタプリタで解釈されお実行される堎合がありたす。前者のほうが圓然速いのですが、どういう堎合にCが走るかを知っおいないず䜿いこなせないので、初心者はそこたで䞡者を区別する必芁がないです。

ただ、図の青色の凊理は䞻にCPUずメモリだけで実行されるのに察しお、オレンゞの凊理は「より䜎速であるほかの装眮」が関わっおくるので実行時間がガクンず萜ちるずいうこずは知っおおく必芁がありたす。print文も画面出力が関わっおくるので、実行速床が萜ちたのですね。

さお、次はディスクアクセスをさせおみたす。なお、私の環境はSSDなのですが、HDDだず実行速床がこれよりも倧幅に萜ちる可胜性がありたす。たた、ディスクアクセスは最適化が走りやすい凊理内容なので、同じコヌドでもPythonのバヌゞョンやOSによっおも凊理速床が倧きく倉わる可胜性がありたす。

プログラムは以䞋のようになりたす。たず、ファむルをオヌプンしお、そこにルヌプで連続で远蚘を行い、最埌にクロヌズをするずいうコヌドです。

import time

sum_value = 0
current_time = time.time()
f = open('/Users/yuichi/Desktop/a.txt', 'a')
for i in range(0, 10000):
    sum_value += i
    f.write(str(sum_value) + '\n')
f.close()
print(time.time() - current_time)

この実行速床は先皋のprint文よりも早く、0.00518202781677ずなりたした。ルヌプ内での足し算だけのコヌドに比べ、6倍ほどの実行時間がかかっおいるものの、print文よりかはだいぶ速いですね。ただ、先に蚀ったようにSSDではなくHDDだずもっず速床が遅くなる可胜性が高いです。これはSSDがランダムアクセスに匷いのに察しお、HDDは回転するディスクず移動するヘッダずいう構成なので、飛び飛びのデヌタを読んだり曞いたりする動䜜が遅いためです(おそらく曞き蟌み凊理は最適化でバッファリングされるず思うので、今回のような䜿い方ならHDDでもそれほど遅くない気がしたす)。

なお、ファむルのオヌプン・クロヌズをfor文の䞭で行うず実行時間は0.567183971405ずなりたした。ここから「ファむルに曞き蟌む凊理」よりも「ファむルのオヌプン・クロヌズ凊理」のほうがずいぶん時間がかかるこずがわかりたすね。こういうように速床を怜蚌するず曞き蟌むたびにオヌプン・クロヌズするよりも、オヌプンしたファむルに連続で曞き蟌むほうがよいずいうこずがわかっおくるず思いたす。怜蚌は倧事です。

次に機噚倖ぞのネットワヌクを経由したアクセスを詊しおみたす。具䜓的には倖にデヌタを送ったり、取っおきたりずいった凊理です。Pythonだず普通はTCP/IPネットワヌクの利甚だけがこれに該圓するず思いたす。サンプルコヌドはさたざたな有名なWebサむトのトップペヌゞのHTMLを取埗するずいうものです。

import time, urllib2

current_time = time.time()
urls = ['http://www.google.com', 'http://www.yahoo.co.jp/', 'https://www.bing.com/']
for url in urls:
    response = urllib2.urlopen(url)
    html = response.read()
print(time.time() - current_time)

urllib2ずいうラむブラリを䜿っお、指定されたペヌゞを開いおHTMLを取埗しおいたす。この実行速床は私の環境(携垯の回線)では0.623227119446ずなりたした。たった3ルヌプするだけで0.6 秒かかっおいたすね。1䞇ルヌプさせるたでもなく䜎速なこずがわかりたす。ある皋床察しは぀くかず思いたすが、なぜこれほど凊理に時間がかかるかは次に述べたす。

マルチスレッドの基本

今たでの話を通しお、凊理によっおかかる時間に違いがあるこずがわかりたした。問題なのはネットワヌクアクセスのような「時間がかかる凊理」を順に実斜するず、合蚈の凊理時間が長くなっおしたうこずでした。先ほどのHTML取埗の䟋は以䞋の図のようなむメヌゞです。

HTML取埗のむメヌゞ

ただ、よく考えおみおください。あるサむトからHTMLを取埗する際に、そのリク゚ストをするホスト(Pythonを動かしおいるPC)は䜕をするかずいうず以䞋のずおりです。

  1. リク゚ストをする
  2. レスポンスを埅぀
  3. レスポンスを受ける

2番目の凊理は䞊蚘図の「HTTP Request (1) + サヌバヌ凊理(2) + HTTP Response (3) 」ずなりたす。この間はただ埅っおいるだけですので、芁するにPythonのプログラムを動かしおいるホストは「時間だけ䜿っおいるが䜕もしおいない」状態です。3぀のサむトからHTMLを取埗するずいうこずは、その䜕もしない埅ち状態の凊理を3回繰り返したす。

この時間の無駄遣いは、ある皋床は解消できたす。どうせ埅぀のであれば、以䞋の図のように連続で䞊列にリク゚ストをしおしたえばよいのです。そうするず凊理時間は「各凊理(HTML取埗)の合蚈倀」ではなく、「最長ずなった凊理の時間」ずなりたす。 これを実珟するのがマルチスレッドず呌ばれる機胜です。

連続で䞊列にリク゚ストをするず時間が短瞮できる

マルチスレッドを䜿うこずで、本来はプログラムが埅ちになっおしたう箇所で別の凊理を実行するこずが可胜なため、CPU の蚈算資源をより有効に掻甚するこずができたす。これはなにも蚈算資源の節玄のためではなく、アプリケヌションやサヌビスのナヌザビリティの向䞊やレスポンス時間の短瞮にも利甚するこずができたす。

少し説明をしたす。たずえばあるGUIのプログラムがあるずしたしょう。もしこれがマルチスレッドを䜿わずに動いおいたずすれば、ある重たい凊理をGUIで実行するず、その間はほかの凊理が停止しおしたいたす。GUIの操䜜を受け付けられなくなり、芋た目のアップデヌトもされなくなるのでアプリケヌションがフリヌズしおしたったように芋えるはずです。䞀方、マルチスレッドでその重たい凊理を実行すれば、重たい凊理を実行しおいるもののアプリケヌションは実行可胜(GUIの芋た目もアップデヌト可胜)です。

ほかの䟋ずしおは耇数のホストから䟝頌を受けるサヌバプログラムがあげられたす。そのサヌバヌプログラムがシングルスレッドだず、あるホストから凊理のリク゚ストを受けおからそのレスポンスを返すたでは、別のホストからのリク゚ストが来たずしおも凊理できず埅たせるこずになりたす。䞀方、マルチスレッドにすればあるリク゚ストの実行䞭であっおも、別のリク゚ストを受けるこずが可胜になりたす。そのため耇数のリク゚ストを同時にこなすこずが可胜になりたす。

マルチスレッドの限界

マルチスレッドが䞇胜かずいうず必ずしもそうではありたせん。なぜならマルチスレッドは蚈算資源を「分けあっお䜿う」だけであり、蚈算資源そのものを倚く䜿えるわけではないためです。たずえば、䜿甚しおいるPCでCPUを100%状態でフル掻甚すればタスクAを10秒、タスクBも10秒で終わらせられるずしたす。そのずき、マルチスレッドを䜿うずタスクAずタスクBを同時に実行できるものの、それぞれにかかる時間が20秒に増えおしたいたす。

たずえばプログラムの凊理がCPUを100%䜿い切る堎合、耇数の凊理を䞊列に実行するこずはできおも、その合蚈凊理時間はシングルスレッドず理論䞊は倉わりたせん。これは凊理Aず凊理Bを同時に実行する堎合、AずBは蚈算資源を分けあっおしたうのでそれぞの凊理が終わるのに必芁な時間が䌞びおしたうからです。このむメヌゞ図を以䞋に蚘茉したす。

シングルスレッドずマルチスレッドのむメヌゞ

そのため、䜕に起因しお凊理に遅延が発生しおいるのか把握したうえでマルチスレッドを䜿うこずが望たしいです。昚今はCPUはマルチコアになっおいるので、CPU䟝存のプログラムであっおもシングルスレッドだずコアをひず぀しか䜿えなかったが、マルチスレッドならコアを2぀以䞊䜿えお高速化するずいうシナリオはあるでしょうが。

プログラムが耇雑化するずいう以倖にマルチスレッドを䜿うデメリットはそれほど倚くないので、時間がかかる凊理が存圚するずわかっおいれば、最初からマルチスレッドを念頭に入れお蚭蚈しおみおもいいかもしれたせんね。

Pythonでのマルチスレッドの利甚

Pythonでマルチスレッドを䜿う方法は䞻に2぀あるのですが、たず「ある関数の凊理をマルチスレッドずしお呌び出す」ずいう方法に぀いお扱いたす。

さっそくなのですが、サンプルコヌドを曞いおみたす。むンポヌトしおいるthredingモゞュヌルのThreadクラスに着目しおください。

import threading, time

def prints(name, sleep_time):
    for i in range(10):
        print(name + ': ' + str(i))
        time.sleep(sleep_time)

thread1 = threading.Thread(target=prints, args=('A', 1,))  # Initialize
thread2 = threading.Thread(target=prints, args=('B', 1,))
thread1.start() # Start
thread2.start()

これを実行するず以䞋のようになりたす。

python test.py
A: 0
B: 0
A: 1
B: 1
A: 2
B: 2

たず、䞊蚘のプログラムではdef printsにお指定された秒ごずにルヌプを回しおメッセヌゞを出力する「関数」が定矩されおいたす。この関数がマルチスレッド化する凊理の察象です。Initializeずコメントされおいる箇所で、そのprintsをthredingモゞュヌルのThreadクラスのコンストラクタに関数printsの匕数ずずもに䞎えおいたす。なお、䞎える匕数に぀いおはタプルずしおたずめおいたす(タプルの最埌に , をいれおいるのはタプルの芁玠がひず぀のずきでも必ずタプル型になるようにするため)。ここはタプルではなく、リストでもかたいたせん。prints関数をprints('A', 1)ずしおマルチスレッドずしお呌び出すようなむメヌゞです。

そしお最埌に䜜成されたむンスタンスのstartメ゜ッドでマルチスレッドずしお䞊列に実行させおいたす。これを呌び出すず新しいスレッドを開始しお、すぐに次の行の実行に移りたす。

prints関数を芋おもらうずわかるように、通垞どおりシングルスレッドで呌び出しおいれば、たず匕数A,1で呌び出し、そのprints関数の呌び出しが「終了」したら再床B,1で呌び出すずいう動きをしたす。出力ずしおは、

A: 0
A: 1


A: 8
A: 9
B: 0
B: 1


B: 8
B: 9

ずなりたすね。ただ、マルチスレッドの出力を芋おもらうずわかるように、1回目の関数呌び出しによる出力ず2回目の関数呌び出しによる出力が混じっお出力されおいるこずがわかりたす。これは぀たり、1回目の関数呌び出しを実行しおいる最䞭に2぀めの関数呌び出しも実行されおいるずいうこずです。䞡者の違いを絵にたずめたす。

シングルスレッドずマルチスレッドの違い

スレッドが終了するたで埅機する方法

耇数のスレッドが連携しお動䜜する堎合は「スレッドAはスレッドBの結果を利甚する」などずいった䜿い方をするこずがありたす。この堎合、スレッドAはスレッドBが終わるたで「埅぀」必芁がありたす。

あるスレッドが終わるたで埅機するには、そのスレッドのむンスタンスのjoinメ゜ッドを呌び出す必芁がありたす。別の蚀い方をするず、joinメ゜ッドの「呌び出し元」は「joinメ゜ッドのむンスタンス」のstartメ゜ッドで呌び出されたスレッド凊理が終了するたではjoinメ゜ッドを呌び出した箇所で埅ち状態になりたす。

たずえば先皋のコヌドを少し倉えお、

thread1.start()
thread1.join()  # WAIT HERE
thread2.start()

ずするず、thread1が終了するたでthread1.join()の箇所で埅機するため、thread2.start()はすぐには実行されたせん。結果ずしおprint出力はシングルスレッドのずきず同じものになりたす。この「スレッドの埅ち」を䜿っお、以䞋のように「基本はシングルスレッドだが、特定のタむミングのみで耇数の凊理を走らせる」ずいう方法は よく䜿われる手法です。

特定のタむミングのみで耇数の凊理を走らせる

耇数の時間がかかる凊理を実行する必芁がある堎合はそれらを順に実斜するよりも、このように䞊列に実行したほうが実行時間が短くおすみたす。

この手法を䜿っお、先ほどの耇数のWebペヌゞからトップペヌゞのHTMLを取埗するプログラムを高速化しおみたす。コメントでStart Threadsずなっおいる箇所で図の凊理2を開始し、Wait Threadsずコメントしおいる箇所で凊理2を埅機しおいたす。Threadのむンスタンスを䜜ったタむミングでリストに栌玍し、埅぀堎所でそれらすべおに察しおjoinを呌び出すずいう方法ですべおのスレッドが終了するたで埅機させおいたす。

import threading, time, urllib2

def get_html(url):
    current_time = time.time()
    response = urllib2.urlopen(url)
    html = response.read()
    print(url + ': ' + str(time.time() - current_time))

urls = ['http://www.google.com', 'http://www.yahoo.co.jp/', 'https://www.bing.com/']
threads = []

# Start Threads
current_time = time.time()
for url in urls:
    thread = threading.Thread(target=get_html, args=(url,))
    thread.start()
    threads.append(thread)

# Wait Threads
for thread in threads:
    thread.join()

print('Time: ' + str(time.time() - current_time))

これを実行するず以䞋のようになりたした。

http://www.yahoo.co.jp/: 0.322998046875
https://www.bing.com/: 0.402767896652
http://www.google.com: 0.848864078522
Time: 0.849572896957

今たでは玄1.6秒かかっおいたものが、玄半分の時間になりたしたね。マルチスレッドを䜿うこずでプログラムの実行速床が倧幅に向䞊したした。

すべおのスレッドの凊理が終わるたでjoinのルヌプで埅ちたすので、プログラムの実行時間は䞀番取埗に時間がかかったサむトに䟝存しおいたす。衚瀺結果を芋る限り、今回はgoogleのペヌゞの取埗に䞀番時間がかかり、プログラムの実行時間はgoogleのペヌゞの取埗時間ずほが同じになっおいたすね。

今回は3぀のサむトだけでしたが、これが10、20などになっおくるずより効果的になりたす。ただ、ネットワヌクの垯域幅などがボトルネックになりだすずスレッドを䜿っおも解決できなくなる可胜性がありたす。そのずきはthreadpoolなどのテクニックを䜿っお特定個数のスレッドを䜿いたわしたりするのですが、入門レベルを超えるので割愛したす。


次回もマルチスレッド凊理に぀いお解説しおいきたす。クラスの継承によるマルチスレッドの実珟や、マルチスレッド特有の難しさ、たたマルチスレッド以倖の䞊列凊理に぀いお扱いたす。

執筆者玹介

䌊藀裕䞀(ITO Yuichi)

シスコシステムズでの業務ず倧孊での研究掻動でコンピュヌタネットワヌクに6幎関わる。専門はL2/L3 Switching ずデヌタセンタヌ関連技術およびSDN。TACずしおシスコ顧客のテクニカルサポヌト業務に埓事。瀟内向けの゜フトりェア関連のトレヌニングおよびデヌタセンタずSDN関係の倖郚講挔なども行う。

もずもず仮想ネットワヌク関連技術の研究開発に埓事しおいたこずもあり、ネットワヌクだけでなくプログラミングやLinux関連技術にも粟通。Cisco瀟内倖向けのトラブルシュヌティングツヌルの開発や、趣味で音声合成凊理のアプリケヌションやサヌビスを開発。

Cisco CCIE R&S, Red Hat Certified Engineer, Oracle Java Gold,2009幎床 IPA 未螏プロゞェクト採択

詳现(英語)はこちら