CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 組み込みのソケットサーバをマルチスレッド化する

今回は小ネタ。 Python の標準ライブラリには、いくつか組み込みで提供されるソケットサーバの実装がある。

例えば WSGI サーバのリファレンス実装とか。

21.4. wsgiref — WSGI ユーティリティとリファレンス実装 — Python 3.6.1 ドキュメント

HTTP サーバを動かすためのやつとか。

21.22. http.server — HTTP サーバ — Python 3.6.1 ドキュメント

ただ、上記には弱点があって、それはシングルスレッドの実装ということ。 そのため、デフォルトでは同時に複数のアクセスをさばくことができない。 これが要するにどういうことなのか、というのは次の記事なんかに書いた。

blog.amedama.jp

今回は、そのままだとシングルスレッドで動くソケットサーバをマルチスレッドにする方法について書く。

動作確認に使った環境は次の通り。 ただし、一応 Python 2.7 で動くことも確認はしている。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.2

wsgiref.simple_server.WSGIServer

WSGIServer クラスは WSGI サーバのリファレンス実装で、簡単なテストなんかするのに便利。 ただ、実装がシングルスレッドなので、そのままでは同時に複数のアクセスをさばくことができない。 とはいえ、まずは本当に同時に複数のアクセスをさばけないのか確認してみよう。

確認には次のサンプルコードを用いる。 ここで登場する application() 関数が WSGI アプリケーションとなっている。 これは、一つのアクセスに対してレスポンスを返すのに 5 秒もかかるように意図的に作ってある。 それを wsgiref.simple_server.make_server() 関数で作成した WSGI サーバで動かすコードとなっている。 この関数はデフォルトで組み込みで用意されているシングルスレッド実装の WSGIServer クラスを使って起動する。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time

from wsgiref.simple_server import make_server


def application(_environ, start_response):
    """レスポンスを返すまでに大きくディレイが入る WSGI アプリケーション"""
    start_response('200 OK', [('Content-Type', 'text/plain')])

    # レスポンスが返るまで 5 秒かかる
    time.sleep(5)

    yield b'Hello, World!\n'


def main():
    # デフォルトではシングルスレッドの WSGIServer で立ち上がる
    server = make_server('localhost', 8000, application)
    server.serve_forever()


if __name__ == '__main__':
    main()

動作確認

シングルスレッド云々の前に、まずは上記がちゃんと動くか、というところから確認してみよう。 ファイルとして保存したら Python で実行する。

$ python wsgisinglethread.py

次に、別の端末から curl なんかを使ってアクセスすると、ちゃんと動くことが分かる。 もちろん、ディレイを入れた分だけレスポンスが返ってくるのにたっぷり時間がかかる。

$ curl http://localhost:8000
Hello, World!

実行時間を測る

ちゃんと動くことが分かったので、次は同時に複数のアクセスをさばけないことを確認してみよう。 今回は、時間を測るのにも Python を使うことにした。

Python の標準ライブラリにある HTTP クライアントは使うのがだるいので requests をインストールする。

$ pip install requests

次のようなベンチマーク用のファイルを用意する。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time
from concurrent.futures import ThreadPoolExecutor

import requests


def main():
    # HTTP リクエスト 2 件を並行実行するための準備をする
    executor = ThreadPoolExecutor(max_workers=2)
    parameters = ['http://localhost:8000' for _ in range(2)]

    # 並行実行に要した時間を測る
    t0 = time.time()
    list(executor.map(requests.get, parameters))
    t1 = time.time()

    print('duration: {sec} sec'.format(sec=(t1 - t0)))


if __name__ == '__main__':
    main()

先ほどのサーバを起動したまま、別の端末で実行する。 すると、処理が終わるのに 10 秒かかっており各リクエストが直列で処理されていることが分かる。

$ python benchmark.py
duration: 10.021423101425171 sec

ちなみに Jupyter Notebook を使っていれば %%time マジックコマンドが使えるので、時間を測るのにもっと楽ができる。

%%time
"""time マジックコマンドを使って実行時間を測る"""

# マルチスレッドで処理を実行するエグゼキュータを用意する
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=2)

# HTTP リクエスト 2 件を並行実行する
import requests
parameters = ['http://localhost:8000' for _ in range(2)]
list(executor.map(requests.get, parameters))

実行すると、こんな感じ。

CPU times: user 7.46 ms, sys: 2.65 ms, total: 10.1 ms
Wall time: 10 s

測り終わったらサーバを動かしている端末は Ctrl-C で止めよう。

マルチスレッド化する

次にシングルスレッドの実装だった WSGIServer をマルチスレッド化してみる。 これには ThreadingMixIn を用いる。 詳しい原理については後ほど紹介する。

次のサンプルコードでは ThreadingMixIn を使って WSGIServer をマルチスレッド化している。 具体的には ThreadingMixInWSGIServer を多重継承したクラス ThreadedWSGIServer を作っている。 動かす WSGI アプリケーションについては先ほどと変わらない。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time

from wsgiref.simple_server import make_server
from wsgiref.simple_server import WSGIServer

try:
    # Python 3
    from socketserver import ThreadingMixIn
except ImportError:
    # Python 2
    from SocketServer import ThreadingMixIn


class ThreadedWSGIServer(ThreadingMixIn, WSGIServer):
    """マルチスレッド化した WSGIServer"""
    pass


def application(_environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])

    # レスポンスが返るまで 5 秒かかる
    time.sleep(5)

    yield b'Hello, World!\n'


def main():
    server = make_server('localhost', 8000, application,
                         # マルチスレッド化した WSGIServer を使って起動する
                         server_class=ThreadedWSGIServer)
    server.serve_forever()


if __name__ == '__main__':
    main()

先ほどと同じように、起動したら処理時間を測ってみよう。

$ python wsgimultithread.py

結果は次の通り。 先ほどとは異なって処理が 5 秒で終わっている。 これはリクエストが直列ではなく並行で処理されたことを示している。

CPU times: user 6.81 ms, sys: 2.63 ms, total: 9.43 ms
Wall time: 5.01 s

このように ThreadingMixIn を多重継承することで WSGIServer はマルチスレッド化できる。

測定が終わったら、先ほどと同じように Ctrl-C でサーバを止める。

http.server.HTTPServer

同じように HTTPServer についても考えてみよう。 次のサンプルコードでは、先ほどと同じようにレスポンスまで 5 秒かかるハンドラをシングルスレッドの HTTPServer で動かしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time

try:
    # Python 3
    from http.server import BaseHTTPRequestHandler
    from http.server import HTTPServer
except ImportError:
    # Python 2
    from BaseHTTPServer import BaseHTTPRequestHandler
    from BaseHTTPServer import HTTPServer


class GreetingHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()

        # Content-Body の送信まで 5 秒のディレイが入る
        time.sleep(5)

        self.wfile.write(b'Hello, World!\r\n')


def main():
    server_address = ('localhost', 8000)
    # シングルスレッドな HTTPServer を使って起動する
    httpd = HTTPServer(server_address, GreetingHandler)
    httpd.serve_forever()


if __name__ == '__main__':
    main()

これまでと同じように、起動したら実行時間を測ってみよう。

$ python httpsinglethread.py

次のように 10 秒かかることが分かった。 やはり、リクエストが直列に処理されてしまっている。

CPU times: user 7.93 ms, sys: 3.16 ms, total: 11.1 ms
Wall time: 10 s

マルチスレッド化する

次に HTTPServer クラスをマルチスレッド化する。 やり方は、先ほどの WSGIServer と変わらない。 ThreadingMixIn と一緒に多重継承するだけ。

次のサンプルコードではマルチスレッド化した ThreadedHTTPServer で時間のかかるハンドラを動かしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time

try:
    # Python 3
    from http.server import BaseHTTPRequestHandler
    from http.server import HTTPServer
    from socketserver import ThreadingMixIn
except ImportError:
    # Python 2
    from BaseHTTPServer import BaseHTTPRequestHandler
    from BaseHTTPServer import HTTPServer
    from SocketServer import ThreadingMixIn


class GreetingHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.end_headers()

        # Content-Body の送信まで 5 秒のディレイが入る
        time.sleep(5)

        self.wfile.write(b'Hello, World!\r\n')


class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    """マルチスレッド化した HTTPServer"""
    pass


def main():
    server_address = ('localhost', 8000)
    # マルチスレッド化した HTTP サーバを使う
    httpd = ThreadedHTTPServer(server_address, GreetingHandler)
    httpd.serve_forever()


if __name__ == '__main__':
    main()

起動して、処理にかかる時間を測ってみよう。

$ python httpmultithread.py

次のように 5 秒で終わったことから、リクエストが並行処理されたことが分かる。

CPU times: user 7.1 ms, sys: 2.92 ms, total: 10 ms
Wall time: 5.01 s

ThreadingMixIn を使ったマルチスレッド化の原理について

それでは、次にどうして ThreadingMixIn を多重継承することで、前述した WSGIServerHTTPServer がマルチスレッド化できたのかについて見ていく。

まずは ThreadingMixIn のソースコードを確認すると process_request() というメソッドを実装していることが分かる。

https://github.com/python/cpython/blob/v3.6.2/Lib/socketserver.py#L645

上記のメソッドでは受け取った request を、新たに生成した threading.Thread で処理していることが読み取れる。 これが WSGIServerHTTPServerprocess_request() メソッドをオーバーライドしていたわけだ。

では process_request() は、どこで定義されているかというと、以下の BaseServer クラス内に見つかる。

https://github.com/python/cpython/blob/v3.6.2/Lib/socketserver.py#L157

次の場所に process_request() があって docstring にも、これは ForkingMixInThreadingMixIn で上書きされる、とある。

https://github.com/python/cpython/blob/v3.6.2/Lib/socketserver.py#L342

ForkingMixIn というのは初めて登場したけど、マルチプロセスを使った並列化をするのに使われるクラスのこと。

そして WSGIServerHTTPServerBaseServer を継承して作られている。 それぞれの process_request() メソッドを ThreadingMixIn がオーバーライドすることでマルチスレッド化されたわけだ。

>>> from socketserver import BaseServer
>>> from wsgiref.simple_server import WSGIServer
>>> issubclass(WSGIServer, BaseServer)
True
>>> from http.server import HTTPServer
>>> issubclass(HTTPServer, BaseServer)
True

これで ThreadingMixIn を使ってマルチスレッド化ができる理由がわかった。

まとめ

BaseServer を継承して作られた Python 組み込みのソケットサーバは ThreadingMixIn を多重継承することでマルチスレッド化できる。