rubyを使ってスレッドセーフなtokyo cabinetを並列処理してみる

最近仕事でtokyo cabinetを使う機会があって、せっかくrubyもはじめたのだからと思って、phpにはないマルチスレッドで並列処理させてみました。

あ、プログラミングの話です。眠くさせてすいません。
phpは主にWEBアプリ用の開発言語として特化しているので、ひとりのユーザがブラウザ経由で要求してきた内容を、WEBサーバを通じてひとつの独立したプロセスとして処理します。
そして、処理した内容をユーザに返却してプロセスは終了します。
複数のユーザが同時に要求してきた場合は、複数のプロセスが同時にたちあがってそれぞれ個別に要求を処理しますが、ひとりのユーザが複数の要求をおこなった場合に、ひとつのプロセスが同時に要求を処理するような言語仕様にはあんまりなっていません(「プロセス制御」を使って子プロセスに処理させたりはできますね)。
一回のHTTPアクセスがひとつの要求と考えれば自然でしょうか。
たとえば更新処理があります。
このサイトいいなーと思ってユーザ登録します。登録したらホーム画面が表示されます。
そこではそのサイトが提供するサービス一覧が表示されているので、自分の利用したいサービスを選択して変更します。変更したら、利用できるサービスが更新されます。
それらはひとつひとつの個別な要求です。
普通は1度の画面クリックで、ユーザ登録とサービス変更を同時に要求してきたりしません。しかも、自分だけでなく友達のぶんも登録お願いねー、とまとめて要求してきたりしません。
でもバッチ処理は違います。1度の要求で、ひとつのまとまった単位をいっぺんに処理することが求められます。まとまった単位とは、たとえば先月の1日から先月末までの1ヶ月単位とか、特定サービスを利用するユーザ1000人単位とかです。
夜中にクーロンを使って裏で処理させるようなケースならいいのかもしれませんが、ユーザの要求によってそういうバッチ処理をおこなわなければならないような重い処理の場合、上から順番に処理していたら遅くて、ユーザはしびれをきらして観にこなくなってしまうかもしれません。
そこで必要になってくるのが、ひとつのプロセスの中にスレッドという小さな処理領域を設けて、マルチスレッドという並列処理機能を利用して、同時平行して処理をおこなわせることで、できるだけ早くユーザに応答を返すことです。
例えると、太郎さん(php)ではひとつのメールの返信(プロセス)はひとつの指(スレッド)しか使えませんが、次郎さん(rubyとか)はひとつのメールの返信(プロセス)に全部の指(スレッド)を使います。
要は太郎さんは人差し指だけで一生懸命キーパンチしているのに比べて、次郎さんは10本の指全部を使ってキーパンチしているようなものです。
ごにょごにょと長くなりました。つまり僕は最近、次郎さんになろうと努めているのです。
勉強がてらにシングルスレッドとマルチスレッドの消費時間を比較してみました。
それぞれ100レコードを1000人ぶん登録するバッチ処理です。
わかりやすくするため、ひとりめの処理だけ10秒スリープをいれています(後述のResister.rb内)。

# simulate: first user has heavy process
sleep 10 if (cnt == 1)

最初のひとのレコードはずいぶん重い内容だったのですね。
シングルスレッドではひとりめの処理が終わらないとふたりめの処理には移れません(phpの挙動)。マルチスレッドでは、ひとりめの処理が終わろうが終わるまいが、後続の処理をメモリやスクリプトが許すかぎり進めていきます。
tokyo cabinet に登録をおこなうクラス
Resister.rb

require 'tokyocabinet'
include TokyoCabinet
class Resister
def self.store_user(cnt)
# create the object
bdb=BDB::new
user_id = "customer_" + cnt.to_s
db_file = "user_data/#{user_id}.tcb"
# open the database
bdb.open(db_file, BDB::OWRITER | BDB::OCREAT)
# store records
i = 1
while i <= MAX_DOC_CNT
now_date  = Time.now
now_stamp = now_date.to_i
doc_id = "doc_" + Digest::MD5.hexdigest((now_stamp.to_s + i.to_s))
key = doc_id
val = sprintf("user:%s\ndoc: %s\nrec: %s\n", user_id, doc_id, now_date)
bdb.put(key, val)
i += 1
end
# simulate: first user has heavy process
sleep 10 if (cnt == 1)
size = bdb.size()
print "#{user_id}: total record size= #{size}\n"
# close the database
bdb.close
end
end

シングルスレッドで登録するプログラム
single.rb

#!/usr/local/bin/ruby
require 'resister'
require 'digest/md5'
MAX_USER_CNT = 1000
MAX_DOC_CNT = 100
for i in 1..MAX_USER_CNT
Resister::store_user(i)
end

マルチスレッドで登録するプログラム
multi.rb

#!/usr/local/bin/ruby
require 'resister'
require 'digest/md5'
MAX_USER_CNT = 1000
MAX_DOC_CNT = 100
threads = []
for i in 1..MAX_USER_CNT
t = Thread.new(i) do |cnt|
Resister::store_user(cnt)
end
threads << t
end
for i in 1..MAX_USER_CNT
threads[i-1].join
end

シングルスレッドの方を実行

$ time ruby single.rb
customer_1: total record size= 100
customer_2: total record size= 100
customer_3: total record size= 100
...
customer_998: total record size= 100
customer_999: total record size= 100
customer_1000: total record size= 100
real    0m13.695s
user    0m2.769s
sys     0m0.924s

マルチスレッドの方を実行

$ time ruby multi.rb
customer_2: total record size= 100
customer_3: total record size= 100
customer_152: total record size= 100
...
customer_996: total record size= 100
customer_1000: total record size= 100
customer_1: total record size= 100
real    0m10.034s
user    0m3.959s
sys     0m1.110s

シングルスレッドはreal 0m13.695s、マルチスレッドではreal 0m10.034sという結果になりました。
シングルスレッドの方は、律儀にひとりめの処理にきっちり10秒以上かけてから残りの999人ぶんを処理したために13秒強かかってしまいましたが、マルチスレッドの方はひとりめの処理をおこなっているあいだに999人ぶん終わらせることができたため、ひとりめの処理が終了したのと同時に全員の処理が完了したので、ひとりめにかけた時間しか消費していません。いやー速いですね。
このあたりがおもしろくなってくると、性能要件にまで気を配ったコーディングができて、お客さんも幸せで僕も幸せで、いえに帰ってから飲むお酒がおいしいという、素敵な日々が送れるようになってくるわけですね、次郎さん?
rubyおもしろいです。tokyo cabinetもおもしろいし。すごいよ、日本人。すごい。
P.S. ところで、ブログにdp.SyntaxHighlighterをいれてソースを綺麗に見せたかったのですが、半日格闘して頓挫しました。既存のCMSやAPIは便利なのですが、ちょっと提供されている以上の機能をとりいれたいなあと思うと、途端になんだか敷居の高いものになりますね。。ていけー。