[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

uWSGI caching frameworkを試してみる(PythonのWEBアプリケーションでのプロセス間情報共有)

今回はuWSGIのワーカプロセス間でキャッシュを共有できる、cache frameworkを試してみます。ISUCONをPythonで戦うとなると、大抵はRedisあたりだと思うのですが、それだと1プロセスで動作するGoやNode.jsに対してディスアドバンテージをもつことになるので、どうにかできないかというのが本記事の主題です。

PythonのWEBアプリケーションサーバ

PythonはGILがある関係上、CPUバウンドな処理はマルチプロセスにしなければ性能が出にくいです。そのためWEBアプリケーション(ここではwsgi)も複数のワーカプロセスを用意して振り分けるような実装になっているものが多いです。

よく名前が上がるgunicornuWSGIもワーカプロセスを複数用意する利用方法が一般的です。

プロセスが複数に別れるということは、それらの間で共有すべき情報がある場合は何らかの共通レイヤが必要になります。ざっくり考えると以下のような形式になります。

方法 メリット デメリット
RDBMSを背後に配置する もっとも基本的な方法であり、多種多様なデータを共有できます 他の方法に比べるとパフォーマンス面で劣る可能性があります
Redis等を背後に配置する RDBMSよりパフォーマンスが優れます。データの形式によってはよく取られる方法の一つです 複雑なデータ処理等は実装が面倒です。
ソースコードに必要な情報を事前にべた書きする 更新が一切かからないデータであれば最速です。 更新がかかる場合は、プロセス間の整合性が保てません

The uWSGI caching framework

uWSGIには、ワーカ間で情報を共有するためのキャッシュフレームワークがあります。この機能がRedisを利用する場合よりもパフォーマンス的に優れるかを確認していきます。

uWSGIのキャッシュフレームワークではuwsgi.cache_set(key, value)uwsgi.cache_get(key)でDictライクに扱うことができます。

用意されているメソッドはこちらから確認できます。なお、uwsgi起動時に--cache2 name=mycacheといった形でキャッシュを明示する必要があります。

環境情報

今回は以下の環境で試しました。試験用のアプリはbottleで用意したのでそちらもいれておきます。

  • Ubuntu 16.04
  • Python 3.6.2
  • uWSGI 2.0.15
  • redis-py 2.10.6
  • hiredis 0.2.0
  • bottle 0.12.13
(uwsgi) denzow@denzow-ubuntu:~$ pip install uwsgi redis hiredis bottle

検証(uwsgiキャッシュフレームワーク)

以下のようなコードを用意しました。

# coding: utf-8
# bottle.pyから使う関数をインポート
from bottle import Bottle, route, run
import uwsgi
import pickle
import os
app = Bottle()

@app.route('/')
def index():
    # キャッシュがなければ0で初期化
    if not uwsgi.cache_exists("access_count"):
        uwsgi.cache_set("access_count", pickle.dumps(0))
    # キャッシュからアクセス数の情報を取得し、1を加算
    count_num = pickle.loads(uwsgi.cache_get("access_count"))
    count_num += 1
    # 新しい値でキャッシュを更新
    uwsgi.cache_update("access_count", pickle.dumps(count_num))
    # ワーカのプロセスIDとアクセス数をレスポンスとして返却
    return 'process:{pid}, count:{count_num}\n'.format(count_num=count_num, pid=os.getpid())

if __name__ == "__main__":
    # テスト用のサーバをlocalhost:8080で起動する
    run(app, host='localhost', port=8080)

以下のようなコマンドで本アプリを起動します。

(uwsgi) denzow@denzow-ubuntu:~/work$ uwsgi --http :8080 -w app:app --workers=2 --cache2 name=mycache,items=1000  --chmod-socket 666

これで、ワーカプロセス2本が起動されます。それに対してアクセスを繰り返すと以下のようにプロセスが異なっても一貫性のとれた値が戻っていることが確認できます。

:
process:30555, count:27
process:30555, count:28
process:30555, count:29
process:30553, count:30 # プロセスが違っても正しくインクリメントされている
process:30555, count:31
process:30555, count:32
process:30555, count:33
:

ではパフォーマンスを確認します。100アクセスをシリアルで行った場合の合計時間を測定します。

$ for x in `seq 1 100`; do curl localhost:8080 ; done

結果は以下のようになりました。

real 0m1.426s
user    0m0.446s
sys 0m0.417s

検証(redis-py)

同じ処理をredis-pyでも実装します。redisへの接続が追加されている以外はほぼメソッドの置き換えのみです。なお、この処理であればredisのincr等でもっと効率的に実装できますが、あくまで比較のためそのままとしています。

# coding: utf-8
# bottle.pyから使う関数をインポート
from bottle import Bottle, route, run
import redis
import pickle
import os
app = Bottle()

POOL = redis.ConnectionPool(host='localhost', port=6379, db=0)

@app.route('/')
def index():
    r = redis.StrictRedis(connection_pool=POOL)
    # キャッシュがなければ0で初期化
    if not r.exists("access_count"):
        r.set("access_count", pickle.dumps(0))
    # キャッシュからアクセス数の情報を取得し、1を加算
    count_num = pickle.loads(r.get("access_count"))
    count_num += 1
    # 新しい値でキャッシュを更新
    r.set("access_count", pickle.dumps(count_num))
    # ワーカのプロセスIDとアクセス数をレスポンスとして返却
    return 'process:{pid}, count:{count_num}\n'.format(count_num=count_num, pid=os.getpid())

if __name__ == "__main__":
    # テスト用のサーバをlocalhost:8080で起動する
    run(app, host='localhost', port=8080)

uwsgiでこのアプリケーションを起動した上で、同じように結果を測定してみます。

real 0m1.483s
user    0m0.444s
sys 0m0.445s

若干ではありますがuwsgi のキャッシュフレームワークのほうが早いようです。

まとめ

uWSGI caching frameworkのパフォーマンスを試してみました。Redisをつかうよりも僅かではありますがパフォーマンスがあがりました。ただし、今回は処理の流れを揃えていましたがRedisのほうが改善できる箇所が多いため最終的には逆転し得る結果だと思います。もう少し試して自分の中で落とし込みたいと思います。