本を更新しました
チャプター「リクエストを並列処理する」 を更新しました。
続きを読みたい方は、ぜひBookの「いいね」か「筆者フォロー」をお願いします ;-)
以下、書籍の内容の抜粋です。
現状の問題点
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/WebServer2.py
study/WorkerThread.py
https://github.com/bigen1925/introduction-to-web-application-with-python/blob/main/codes/chapter13/WebServer2.py
解説
共通
ファイルを分けたので、処理の記録を示すログにそれぞれServer:
, Worker:
という文字を出力ようにしています。
例)
print("=== Server: サーバーを起動します ===")
print("=== Worker: クライアントとの通信を終了します ===")
WebServer.py
28-31行目
# クライアントを処理するスレッドを作成
thread = WorkerThread(client_socket)
# スレッドを実行
thread.start()
コネクションを確立したクライアントを処理するスレッドを作成し、スレッドの処理を開始させます。
前回まで.handle_client()
というメソッドで行っていた処理と、前後の例外処理は全てこのスレッド内の処理にお引越ししました。
スレッドとはコンピュータが並列に処理を行うことが可能な処理系列のことで、後ほど詳細を説明します。
WorkerThread.py
9行目-57行目
class WorkerThread(Thread):
# 実行ファイルのあるディレクトリ
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 静的配信するファイルを置くディレクトリ
DOCUMENT_ROOT = os.path.join(BASE_DIR, "static")
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
というクラスが用意されています。
興味がある方は調べてみてください。
動かしてみる
では、実際に動かしてみましょう。