bottleとgeventによる高速軽量非同期ウェブアプリ

By | 2016年1月4日

最近bottleとgeventを使ってみて、とても便利でメカニズムも興味深いと思ったのだが、あまり日本語の解説がないようなのでここにまとめてみる。Pythonによるウェブアプリ開発で、レスポンス速度が重要なときに参考になるかと思う。

bottleとは?

Pythonの軽量ウェブフレームワークである。使い方はとてもシンプルで、独自のテンプレートエンジンを持っている。詳細は本家のドキュメントを参照だが、その本家のドキュメントの最初のこの例を示せば大体の雰囲気はつかめるであろう。

from bottle import route, run, template

@route('/hello/<name>')
def index(name):
    return template('<b>Hello {{name}}</b>!', name=name)

run(host='localhost', port=8080)

このプログラムを実行して、ブラウザのURLにhttp://localhost:8080/hello/hamukazuと入れれば、「Hello hamukazu!」と表示される。

geventとは?

非同期処理をベースとしたネットワーク処理のライブラリである。「pip install gevent」でインストールできる。geventでは軽量な擬似スレッドによってIOの待ち時間に他の処理を行うことができる非同期なメカニズムが用意されている。ここでは、bottleとの連携についてのみ説明するので、geventについて深い話はしない。詳細は本家ドキュメントを参照されたい。

WSGIとは?

bottleが採用しているWSGIという仕組みでは、リクエスト・レスポンスのサイクルが仕様書(PEP 3333)により定められている。サーバへのリクエストがくると、アプリケーションは「レスポンスのチャンクを返すイテレータ」を返す。サーバはそのイテレータを繰り返し呼び出すことでチャンクを少しずつ受け取り、ソケットに書き込むことでレスポンスをクライアントに返す。つまりアプリケーション側は、レスポンスをいくつかに刻んで、呼ばれるたびに少しずつ返せばよい。上記のサンプルコードではわかりづらいと思うので、明示的にイテレータを使った例を次に示す。

from bottle import route, run

def body_iter():
    yield "<p>Spam!</p>"
    yield "<p>Ham!</p>"
    raise StopIteration

@route("/")
def index():
    return body_iter()

run(host="localhost", port=8080)

これを起動して、ブラウザで「http://localhost:8080/」にアクセスすると、「Spam!Ham!」(途中改行あり)と表示される。このプログラムでは、レスポンスとして返すHTML文書をそのまま返すのではなく、2つの部分に分割してyieldしている。そしてPythonのイテレータのお作法により最後にStopIterationを返す。サーバは、StopIterationが出てくるまで繰り返し呼び出すことでクライアントに返すべきHTML文書を取得している。

bottleとgeventを使った開発

bottleにかぎらず、多くのウェブサーバ(ウェブアプリ)は複数のスレッドでサーバへのリクエストを処理する。一方でスレッド間のコンテキストスイッチ切り替えのコストを考え、同時に動くスレッド数は20個程度に抑えられているのが通常である。一方で、ウェブアプリではその内部処理でデータベースアクセスや他サーバへのアクセスを伴うことが多いので入出力の処理待ちがネックになり、スレッド数とその待ち時間の長さによりシステムの処理能力が決まってしまう。

その待ち時間を有効活用するために、新たなスレッドを立ち上げることなく擬似的なスレッドで他のリクエストを処理しようというのがbottleとgeventを使うときの考え方である。擬似スレッドへのコンテキストスイッチのコストは通常のスレッドと比べるとかなり低くなっている。つまり入出力待ち状態のスレッドが、ソケットに溜まっているクライアントからのリクエストを見に行きもしあればそれを処理するというメカニズムになっている。

モンキーパッチ

geventではモンキーパッチという仕組みにより、既存のネットワーク関連ライブラリの中身をgevent版に置き換えることで、手軽な非同期化を実現している。コードの一番最初に(他のimport文の前に)

from gevent import monkey
mokey.patch_all()

とすれば、その後にインポートされるライブラリのしかるべき部分がgevent版に置き換わる。例えば先程のイテレータを使った例を少し書き換えて次のようにしてみる。

from gevent import monkey
monkey.patch_all()
from bottle import route, run
import time

def body_iter():
    yield "<p>Spam!</p>"
    time.sleep(1)
    yield "<p>Ham!</p>"
    time.sleep(1)
    raise StopIteration

@route("/")
def index():
    return body_iter()

run(server="gevent", host="localhost", port=8080)

ここでの変更点は、1)モンキーパッチのためのオマジナイを最初に入れた、2)イテレータのyieldの間にsleepを入れた、3)runの引数に「server=”gevent”」を加えた、という点だけである。ここで、モンキーパッチによりtime.sleepはgevent版のsleepに置き変えられている。つまり、このsleep中に同じスレッドないで他のリクエストを処理することも可能になっている。サーバ側にもgeventを使っていることを知らさなければいけないので、runの引数に「server=”gevent”」を忘れてはいけない。

他のライブラリの関数の中身を勝手に書き換えてしまうのはとても気持ち悪いし悪魔的な印象を受けるのだが、これはよく黒魔術と呼ばれるものである。

bottleとgeventを使ったデザインパターン

ここではよくあるデザインパターンとして一つだけ紹介する。次のようなコードがそのパターンである。

from gevent import monkey
monkey.patch_all()
import gevent
from gevent.queue import Queue
from bottle import route, run

@route("/")
def index():
    body = Queue()
    def worker():
        response1 = do_something1()
        body.put(response1)
        response2 = do_something2()
        body.put(response2)
        body.put(StopIteration)
    gevent.spawn(worker)
    return body

run(server="gevent", host="localhost", port=8080)

いうまでもなくdo_something1()とdo_something2()は実際に何らかの処理が入る。index()の中で関数worker()が定義されていて、bodyという変数に格納されたキューが共有されている。このキューにより非同期な処理が実現されている。gevent.spawnは関数workerを非同期で呼び出しており、呼出し後すぐに次の行に制御が移されて、変数bodyが返される。サーバはこのbodyをイテレータとみなして逐次呼び出すが、worker内でputされるまでは次の値が取り出せないようになっている。この仕組みにより、もしdo_something1()などが入出力に時間がかかる処理で待ち時間が発生したとしても、その間にgeventが他の擬似スレッドに他のリクエストに対する処理を割り当てることができる。

ベンチマーク

簡単のため与えられた値をキーにデータベースにアクセスしてその値を返すだけのウェブアプリを考え、geventと使わない場合と使った場合について処理能力を比較してみる。データベースにはキーバリュー型のlmdbを使うが、lmdbを使う理由は設定が簡単だからである。(「pip lmdb」でインストールしておく必要はある)

まずは準備としてカレントディレクトリにdbというディレクトリを作って、データを保存するコードを1回だけ実行する。なお、以下pythonのバージョンは3系を仮定するが、データベース保存・読み込みのエンコード部分だけ気をつければ2系でも動くはずである。

import lmdb

env = lmdb.Environment("./db")
with env.begin(write=True) as txn:
    for i in range(1000):
        k = str(i)
        v = "{:06}".format(i)
        txn.put(k.encode("utf-8"), v.encode("utf-8"))

これはただ、”0″というキーに”000000″、”1″というキーに”000001″、というふうに999まで保存するだけである。

これを読み込んで保存するウェブアプリを作る。まずはgeventを使わない版の方から。

from bottle import route, run, template
import lmdb

@route("/<num>")
def index(num):
    env = lmdb.Environment("./db")
    with env.begin() as txn:
        value = txn.get(num.encode("utf-8"))
    return template("<p>{{value}}</p>", value=value)

run(host="localhost", port=8080)

これを実行して、ブラウザで「http://localhost:8080/99」を開くと「00099」が表示されるはずである。これはただデータベースから取り出した値を表示しているだけである。

これを先ほどのデザインパターンの通りにgevent化すると次のようになる

from gevent import monkey
monkey.patch_all()
import gevent
from gevent.queue import Queue
from bottle import route, run, template
import lmdb


@route("/<num>")
def index(num):
    body = Queue()
    def worker():
        env = lmdb.Environment("./db")
        with env.begin() as txn:
            value = txn.get(num.encode("utf-8"))
        body.put(template("<p>{{value}}</p>", value=value))
        body.put(StopIteration)
    gevent.spawn(worker)
    return body

run(server="gevent", host="localhost", port=8080)

ここでウェブアプリのテストツールであるlocustを使ってベンチマークをとってみた。locustの使い方やここで用いたテストコードは説明が長くなるので省略するが、1秒間に1000アクセスする負荷をかけてみると、geventなし版の方は処理エラーが多発し全く処理ができなくなったが、gevent版の方は問題なく処理できた。もちろん環境に依存するだろうが、手元の環境ではそうなった。

まとめ

  • bottleとgeventを組み合わせると、入出力がネックな処理でも高速レスポンスなシステムを作ることができる
  • そのためにはデザインパターンを一つだけ覚えておくと、かなり応用が効く
  • geventのモンキーパッチの黒魔術は強力

参考文献

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です