今回はマルチスレッドについて扱います。マルチスレッドは、簡単に言ってしまえば複数の処理を「並列」に進めることができるものです。マルチスレッドの反対がシングルスレッドであり、これは複数の処理を順番に進めていくものです。逆に言えば、ある処理が終わるまでは次の処理を実施することはできません。マルチスレッドおよびシングルスレッドの“スレッド”は「プログラムの実行単位」のことで、名前からわかるようにマルチスレッドはプログラムをマルチな実行単位で実行します。

今回の流れとしては、まず最初にプログラムの実行時間の測定手法について学びます。これを理解していないとマルチスレッドを使った高速化がどれほど効果的なものか理解しづらいためです。次にさまざまな処理にかかる遅延がどれほどのものかについて学びます。それらの基礎ができたうえで、シングルスレッドの問題点について、その次にマルチスレッドがどのようにその問題を克服するかについて扱います。そして実際に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 未踏プロジェクト採択

詳細(英語)はこちら