Chapter 13

リクエストを並列処理する

いったんリファクタリングする

さて、ではどんどん進んでいきましょう・・・と言いたいところですが、そろそろソースコードもそれなりの量になってきて見通しも悪くなってきました。

この辺りで、まずは一度リファクタリングをしておきます。

リファクタリングといっても、細かな処理になってしまっている部分をメソッドに切り出すだけで、難しいことは何もしません。

コラム: 自由に書いてください

本書では、前章のソースコードと比較がしやすいように・・・などといった執筆上の都合でリファクタリングのタイミングを決めています。
また、急にソースコードが変わっていると皆さんも混乱すると思いますので、リファクタリングする際には今回のようにリファクタリングの内容を都度書いていきます。

しかし、人によって「こんなにごちゃごちゃになって、もう我慢できない!」と怒り出すタイミングはそれぞれ違うでしょう。
私なんかは(ソースコードにおいてのみ)結構キレイ好きな方ですので、実はもうちょっと前からリファクタしたくてソワソワし始めていました。

皆さんが書くソースコードは、私のために書くのではなく皆さん自身の勉強のために書くものですから、自由なタイミングで自由な粒度でリファクタリングしていただいて構いません。
また、文字列を切ったり貼ったりするような細かな処理では、私の書き方よりもっと良い書き方もあるでしょう。
良い書き方を思いついたら、ぜひ実践してみてください。

私のソースコードはあくまで「このように書けば最低限動作は保証しますよ」という解答例にすぎませんので、皆さんは自分の心の赴くままに好きなように実装していただいて構いません。

やりたいことは同じでも、プログラミングの方法は常に人によって自由なのです。

ソースコード

細かい処理をメソッドに切り出してリファクタリングしたものがこちらになります。

study/webserver.py

https://github.com/bigen1925/introduction-to-web-application-with-python/blob/main/codes/chapter13/webserver.py

解説

本当にそのままそっくり切り出しているだけなので特に詳細な説明は不要でしょう。

ただし、関数の型注釈を行っているtype annotationpythonの歴史の中でも比較的新しい機能ですので、補足をしておきます。

型注釈(type annotation は、変数や関数の引数/戻り値の型についてヒントを与える記述で、例えば本書では下記のように利用しています。

def my_function(arg: str) -> int:
    # ...

これは、
「引数argstr型であることを 想定しているよ
「返り値はint型であることを 想定しているよ
というヒントを付け加える意味があります。

注意すべきは、これはあくまで「ヒント」であって この関数にstr型でない引数を渡して実行してもエラーにはならない ことです。
また、型注釈は必須ではないため全ての変数や関数を型付けする必要もありません。

しかし、エディタによってはこのヒントを元に警告を出してくれるものもあり、ミスを未然に防ぐ効果が期待できます。
例えば、筆者はPyCharmというエディタを利用していますが、型ヒントに反した呼び出しやreturnを行うと以下のように警告を出してくれます。


型注釈ではint型をreturnすることが想定されているのに、str型をreturnしてしまっている場合の警告


型注釈では、str型の引数で呼び出すことが想定されているのに、int型の引数で呼び出してしまっている場合の警告


本書では、筆者の主観で何のデータを扱っているのか分かりづらいなと思う箇所にきまぐれで注釈をつけていきます。

これらの型注釈は動作上必須ではありませんので省略しても構いませんし、逆にもっとたくさん注釈しても構いません。

型の記法は様々なバリエーションがありますが、まだまだ変化していっているもの[1]もありますので、詳細はご自身で調べてみてください。
python type annotationpython type hint などのワードで調べるとたくさんの情報が提供されています。

複数のリクエストを「並列処理」する

さて、では本筋に戻りましょう。

前章までで、私達のWebサーバーは「1件のリクエストを処理しては次の新しいリクエストを受け付ける」というのを繰り返すことで複数のリクエストを繰り返し処理できるようになりました。
最低限の機能であればこのままでも良いのですが、せっかくなので一般的なWebサーバーでは必ず実装されている 「並列処理」 にもチャレンジしてみましょう。

ミニマムな理解で先へ進みたい気持ちは山々なのですが、中級者から上級者へステップアップするにあたってサーバーの並列処理については必ず理解が必要です。

サーバーの設定値を変更したことのある人は、「ワーカーのプロセス数」とか「ワーカーのスレッド数」というワードを見たことがあるでしょう。
並列処理について簡単にでも抑えておくと、これらの設定値の意味がなんとなく分かるようになるでしょう。

現状の問題点

pythonはソースコードを上から順に読み込み、ある1行の処理が完了すれば次の1行、というふうに順番に実行していきます。

そのため、現状のソースコードだと、

  • クライアントからの接続を受け付ける(.accept())
  • クライアントからのリクエストを処理する(.handle_client()
  • (必要であれば例外処理をしたあと)クライアントとの通信を終了する(.close())
  • クライアントからの接続を受け付ける(.accept())
  • ...

必ずこの順序を守って実行されます。

言い換えると、
「1つのリクエストの処理が全て完了するまで、次のリクエストの受付が始まらない」
ということになります。

これはリクエストの数が増えてくると大きな問題になります。


例えば10件のリクエストが同時にきたとします。

このとき、サーバーは早いもの勝ちで最初に来たリクエストの処理を開始します。
そしてこの最初のリクエストの処理がとても時間がかかる処理の場合、処理が完了するまで後続の9件のリクエストは待たされることになってしまいます。

よくあるのは、複雑な検索条件のクエリをDBサーバーに投げて応答を30秒間ずっと待ってるなどのケースです。
DBからの応答を待つ30秒の間、マシンが他の9件のリクエストを処理してくれたら全体的なパフォーマンスはぐっと向上します。

このように、とあるプログラムがとある処理をしている途中に、その間に裏で別のプログラムを実行させることを 並列処理 または 並行処理と呼びます。

この並列処理は一般的なWebサーバーであれば当たり前に実装されていますので、私達も実装していくことにしましょう。

コラム: 「並列処理」と「並行処理」

「並列処理」と「並行処理」の違いについて説明しておきましょう。

世間一般では、複数のモノゴトを同時に処理することを「並列処理」と呼びます。

コンピュータの世界においては、実際に何か処理をするのは全てCPUが担うのですが、1つのCPUは常に1つの仕事しか同時にはこなせません。
ですので、厳密な意味では1つのCPUでは「並列処理」は実現できません。

しかし、人間の感覚からするとCPUの処理はあまりに高速なので、
「こっちの仕事をして"待ち"になったら別の仕事に切り替えて、また"待ち"になったら更にべつの仕事をする」
と次々と取り組む処理を切り替えることで、人間の目にはまるで同時にタスクをこなしているように見せることができます。
(OSにもよりますが、CPUは大体0.2秒以下で次々と仕事を切り替えています)

その昔は1つのコンピュータには1つのCPUというのが当たり前でしたので、コンピュータの世界ではこの「疑似並列処理」のこと単に「並列処理」と読んでも特に問題がありませんでした。
ところが、2000年ごろデュアルコアのCPUが発売/普及すると共に1つのコンピュータに2つ、4つのCPU(正確にはCPUコア)が搭載されるようになり、これらのCPUを同時に動かすことで「疑似並列処理」ではない「本当の並列処理」が可能になってしまいました。

そこで、プログラマたちは「これまで単に並列処理と読んでいた疑似並列処理」と「本当の並列処理」を区別するために、「疑似並列処理」のことを「 並行 処理」と呼ぶようになりました。

「並行処理」と「並列処理」を人間が表面上で見分けることは極めて困難ですし、マシンのパフォーマンスチューニングが必要になるまでプログラミング上もこの2つを区別して扱う必要はほとんど必要ありません。
そのため、本書ではこれらを特に区別せず統一して「並列処理」と呼ぶことにします。

どのような場合に並行処理と並列処理が使い分けられるのか、パフォーマンスにどのように影響するのかは非常に込み入った話になるため。本書では割愛します。
このトピックに関してはインターネット上でも非常にたくさんの情報が提供されていますので、興味がある方は調べてみてください。

ソースコード

並列処理を行うように改良したソースコードがこちらです。
ファイルが2つになっていますので、ご注意ください。

study/webserver.py

https://github.com/bigen1925/introduction-to-web-application-with-python/blob/main/codes/chapter13-2/webserver.py

study/workerthread.py

https://github.com/bigen1925/introduction-to-web-application-with-python/blob/main/codes/chapter13-2/workerthread.py

解説

共通

ファイルを分けたので、処理の記録を示すログにそれぞれServer: , Worker: という文字を出力ようにしています。
例)

print("=== Server: サーバーを起動します ===")
print("=== Worker: クライアントとの通信を終了します ===")

webserver.py

28-31行目

                # クライアントを処理するスレッドを作成
                thread = WorkerThread(client_socket)
                # スレッドを実行
                thread.start()

コネクションを確立したクライアントを処理する スレッド を作成し、スレッドの処理を開始させます。
前回まで.handle_client()というメソッドで行っていた処理と、前後の例外処理は全てこのスレッド内の処理にお引越ししました。

スレッドとはコンピュータが並列に処理を行うことが可能な処理系列のことで、後ほど詳細を説明します。

workerthread.py

9行目-67行目

class WorkerThread(Thread):
    # 実行ファイルのあるディレクトリ
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    # 静的配信するファイルを置くディレクトリ
    STATIC_ROOT = os.path.join(BASE_DIR, "static")

    # 拡張子とMIME Typeの対応
    MIME_TYPES = {
        "html": "text/html",
        "css": "text/css",
        "png": "image/png",
        "jpg": "image/jpg",
        "gif": "image/gif",
    }

    def __init__(self, client_socket: socket):
        super().__init__()

        self.client_socket = client_socket

    def run(self) -> None:
        """
        クライアントと接続済みのsocketを引数として受け取り、
        リクエストを処理してレスポンスを送信する
        """
        
        # クライアントへ対応する処理...

Threadはpythonの組み込みライブラリであるthreadingモジュールに含まれるクラスで、スレッドを簡単に作成するためのベースクラスです。

Threadを利用する際は、Threadを継承したクラスを作成し、.run()メソッドをオーバーライドします。
このクラスのインスタンスは.start()メソッドを呼び出すことで新しいスレッドを作成し、.run()メソッドにかかれた処理を開始します。


CPUは1つのスレッド内で実行されるプログラムは並列処理できませんが、複数のスレッドに分かれて実行されるプログラムは並列処理することが可能です。

これまでは、1つのスレッド内でWebServerクラスが全てのクライアントの対応を逐次に処理していました。

しかし今回からは、スレッドを利用することでWebServerクラスはクライアントとのコネクション確立までは行うものの、リクエスト内容への対応は別スレッドで並列処理するようになりました。

これにより、とあるリクエストの処理が長引いてるせいで他のリクエストが受け付けられない、という状況が回避されます。

コラム: スレッドを扱う際の注意点

基本的に別々のスレッドの処理は別々のプログラムとして動くことになるので、あるスレッドで例外が発生しても別スレッドには伝搬しないということです。

例えば、以前までは同一スレッド内でリクエストを処理していたため、リクエストの処理中に例外が発生した場合、例外のハンドリングを行わなければメインの処理(リクエストの受付処理)まで終了していました。
今回からは別々のスレッド内でリクエストを処理しているため、とあるスレッドでリクエストの処理中に例外が発生しても、例外ハンドリングがない場合はそのスレッドが終了するだけでメインスレッドには影響がありません。

一見ありがたいようにも見えますが、サーバー全体に影響のある異常事態が発生した場合にも処理が続行してしまうようではいけません。

スレッドを使う際は、例外処理に敏感になっておきましょう。


また、スレッドをどんどん分岐させれば、処理はどこまでも早くなるというわけでもありません。

CPUが同時に処理できる量には限界があります。
並列実行している処理の多くがCPUを大量に利用するような状況では、CPUの処理量が限界に達して並列実行が滞り、パフォーマンスが上がらないor却って下がるケースがあります。
このようにCPUの性能がネックになって処理速度の限界を迎えるような処理を、CPUバウンドな処理と言います。

CPUバウンドな処理が多いWebサービスでは、むやみにスレッドを増やしすぎるとCPUの処理量が限界を迎えて他のプログラムの実行速度に影響を与えてしまうこともあります。
Webサーバーの多くがスレッド数やプロセス数に上限を設定できるのは、これを防ぐためです。

自分のプログラムが、最悪のケースでどれぐらいのスレッド分岐が行われるのかには十分気をつけましょう。

本書でも、本来は分岐できるスレッド数に上限を設けるべきで、pythonにはそのためのThreadPoolExecutorというクラスが用意されています。
興味がある方は調べてみてください。

動かしてみる

では、実際に動かしてみましょう。

いつもどおりコンソールからWebServerを起動します。

$ python webserver.py
=== Server: サーバーを起動します ===
=== Server: クライアントからの接続を待ちます ===

その後、ブラウザからhttp://localhost:8080/index.htmlへアクセスします。


表示される画面自体は今までと変わりませんが、裏では並列処理が行われているはずです。
このことを確認するためにログを確認してみましょう。

サーバーを起動したコンソールを開いてください。

$ python webserver.py
=== Server: サーバーを起動します ===
=== Server: クライアントからの接続を待ちます ===
=== Server: クライアントとの接続が完了しました remote_address: ('127.0.0.1', 56249) ===
=== Server: クライアントからの接続を待ちます ===
=== Server: クライアントとの接続が完了しました remote_address: ('127.0.0.1', 56250) ===
=== Server: クライアントからの接続を待ちます ===
=== Worker: クライアントとの通信を終了します remote_address: ('127.0.0.1', 56249) ===
=== Worker: クライアントとの通信を終了します remote_address: ('127.0.0.1', 56250) ===
=== Server: クライアントとの接続が完了しました remote_address: ('127.0.0.1', 56254) ===
=== Server: クライアントからの接続を待ちます ===
=== Worker: クライアントとの通信を終了します remote_address: ('127.0.0.1', 56254) ===

環境によって正確なログの順番は前後すると思いますが、概ね上記のようになっているでしょう。
(実は本章から通信の切断時にもログが出るように仕込んでおきました)

ログをよく見てみると、('127.0.0.1', 56249)のクライアントとの通信が終了する前に、すでに('127.0.0.1', 56250)のクライアントと接続を完了させ、さらに次の接続を待ち始めていることがわかります。

並列処理が行われている証拠ですね。

脚注
  1. 例えば、現在の最新バージョンであるpython3.9からはList[T]の代わりにlist[T]と書くことができるようになりました。今後も型の記法は整備が続くのではないでしょうか。 ↩︎