phpのcurl_multi, phpのPCNTL, perlのithread, rubyのthreadで、並列処理パフォーマンス比較

phpのcurl_multiを使うとHTTP通信を並列でおこなってくれます。
最初に思ったのは、実は内部的にプロセス制御をおこなって子プロセスに通信処理をさせているのではないかということです。であればPCNTL関数から冗長性を削いだもの、かと思ったのですが、もしそうなら機能をHTTP通信に特化する必要はないですね。
むしろ内部的にはバックグラウンドで非同期通信をおこなって並列処理を再現しているのでしょうか。curlだし、そっちのような気がしてきました。
スクリプトを書いて試してみました。
それと、ついでに他のスクリプト言語の並列処理とパフォーマンス比較もしてみました。
測定したパフォーマンスは、消費時間とそのプロセスの消費メモリ(RSS)です。phpの場合の消費メモリは子プロセスが消費したぶんも加えています。
また、通信先ではスリープをいれて、取得に1秒かかる程度のデータ、を再現しています。これを10本同時におこなっているので、直列処理でおこなえばそれだけで10秒かかる内容です。

HTTP通信を並列処理
0. 共通の通信先データ (sleep.php)

<?php
header('HTTP1/0 200 OK', false, 200);
sleep(1);
echo 'OK: code=' . (isset($_GET['code']) ? intval($_GET['code']) : 0);

1. php curl_multi版 (chk.php)

<?php
$term   = microtime(true);
$domain = 'localhost';
$path   = '/sleep.php';
$count  = 10;
$rets   = array();
$conns  = array();
$cpid_mem  = 0;
$selfpid = intval(chop(`ps alx | grep chk.php | grep -v grep | awk '{print $3}'`));
$mh = curl_multi_init();
for ($i = 0; $i < $count; $i ++) {
$conns[$i] = curl_init("http://${domain}${path}?code=${i}");
curl_setopt($conns[$i], CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $conns[$i]);
}
$running = null;
do {curl_multi_exec($mh, $running);} while ($running);
foreach (explode("\n", chop(
`ps alx | grep chk.php | grep -v grep | grep ${selfpid} | awk '{print $4, $8}'`
)) as $ps) {
$childs = explode(' ', $ps);
if ($childs[0] == $selfpid) $cpid_mem += intval($childs[1]);
}
foreach ($conns as $conn) {
array_push($rets, curl_multi_getcontent($conn));
curl_multi_remove_handle($mh, $conn);
curl_close($conn);
}
curl_multi_close($mh);
print_r($rets);
echo 'time: ' . round(microtime(true) - $term, 3) . "sec\n";
$ppid_mem = intval(chop(`ps alx | grep chk.php | grep -v grep | awk '{print $8}'`));
echo 'RSS:  ' . $ppid_mem . 'KB(parent) + ' . $cpid_mem . "(childs)KB\n";

実行結果:

php -v | head -n 1
PHP 5.3.1 (cli) (built: Feb  6 2010 01:50:38)
php ./chk.php
Array
(
[0] => OK: code=0
[1] => OK: code=1
[2] => OK: code=2
[3] => OK: code=3
[4] => OK: code=4
[5] => OK: code=5
[6] => OK: code=6
[7] => OK: code=7
[8] => OK: code=8
[9] => OK: code=9
)
time: 1.048sec
RSS:  11412KB(parent) + 0(childs)KB

2. php PCNTL版 (chk2.php)

<?php
$term   = microtime(true);
$domain = 'localhost';
$path   = '/sleep.php';
$count  = 10;
$rets   = array();
$conns  = array();
$cpid_mem  = 0;
$cpid_cnt = 0;
declare(ticks=1);
$selfpid = intval(chop(`ps alx | grep chk2.php | grep -v grep | awk '{print $3}'`));
for ($i = 0; $i < $count; $i ++) {
$cpid = pcntl_fork();
if ($cpid) {
$cpid_cnt ++;
continue;
}
$ret = file_get_contents("http://${domain}${path}?code=${i}");
$fp = fopen('ret.txt', "a");
flock($fp, LOCK_EX);
fwrite($fp, $ret . "\n");
flock($fp, LOCK_UN);
fclose($fp);
exit;
}
foreach (explode("\n", chop(
`ps alx | grep chk2.php | grep -v grep | grep ${selfpid} | awk '{print \$4, \$8}'`
)) as $ps) {
$childs = explode(' ', $ps);
if ($childs[0] == $selfpid) $cpid_mem += intval($childs[1]);
}
while($cpid_cnt > 0) {
$cpid = pcntl_wait($stat);
$cpid_cnt--;
}
print_r(file_get_contents('ret.txt'));
echo 'time: ' . round(microtime(true) - $term, 3) . "sec\n";
$ppid_mem = intval(chop(`ps alx | grep chk2.php | grep -v grep | awk '{print $8}'`));
echo 'RSS:  ' . $ppid_mem . 'KB(parent) + ' . $cpid_mem . "KB(childs)\n";
unlink('ret.txt');

実行結果:

php ./chk2.php
OK: code=3
OK: code=0
OK: code=1
OK: code=2
OK: code=4
OK: code=6
OK: code=7
OK: code=8
OK: code=5
OK: code=9
time: 1.149sec
RSS:  10752KB(parent) + 51876KB(childs)

3. perl スレッド版 (chk.pl)

use strict;
use threads;
use LWP::UserAgent;
use Data::Dump qw(dump);
use Time::HiRes qw(gettimeofday);
my $term = Time::HiRes::time;
my $domain = 'localhost';
my $path   = '/sleep.php';
my $count  = 10;
my @rets = ();
my @ts   = ();
for (my $i = 0; $i < $count; $i ++) {
push(@ts, threads->new(sub {
my $req = new HTTP::Request GET => "http://$domain$path?code=$i";
my $res = new LWP::UserAgent->request($req);
threads->yield();
return $res->content;
}));
}
foreach (@ts) {push(@rets, $_->join);}
print dump(@rets) . "\n";
printf("time: %1.3fsec\n", Time::HiRes::time - $term);
print 'RSS:  ' . `ps alx | grep chk.pl | grep -v grep | awk '{print \$8}'`;

実行結果:

perl -v | head -n 2
This is perl, v5.8.8 built for i386-linux-thread-multi
perl ./chk.pl
(
"OK: code=0",
"OK: code=1",
"OK: code=2",
"OK: code=3",
"OK: code=4",
"OK: code=5",
"OK: code=6",
"OK: code=7",
"OK: code=8",
"OK: code=9",
)
time: 1.313sec
RSS:  31024

4. ruby スレッド版 (chk.rb)

require 'net/http'
term   = Time.now
domain = 'localhost'
path   = '/sleep.php'
count  = 10
rets   = []
ts     = []
count.times do |i|
ts << Thread.new(domain,"#{path}?code=#{i}") do |domain, path|
http = Net::HTTP.new(domain, 80)
req = Net::HTTP::Get.new(path)
rets << http.request(req).body
end
end
ts.each {|t|t.join}
p rets
printf("time: %1.3fsec\n", Time.now - term)
print 'RSS:  ' + `ps alx | grep chk.rb | grep -v grep | awk '{print $8}'`

実行結果:

ruby -v
ruby 1.9.1p378 (2010-01-10 revision 26273) [i686-linux]
ruby ./chk.rb
["OK: code=3", "OK: code=0", "OK: code=2", "OK: code=4", "OK: code=9", "OK: code=5", "OK: code=6", "OK: code=1", "OK: code=8", "OK: code=7"]
time: 1.010sec
RSS:  6388

まとめ:

1. php curl_multi版 = 消費時間: 1.048秒 メモリ消費量: 11412KB+0KB = 11.1MB
2. php PCNTL版 = 消費時間: 1.149秒 メモリ消費量: 10752KB+51876KB=62628KB = 61.2MB
3. perl スレッド版 = 消費時間: 1.313秒 メモリ消費量: 31024KB = 30.3MB
4. ruby スレッド版 =消費時間: 1.010秒 メモリ消費量: 6388KB = 6.2MB

となりました。
1.のphp curl_multi版では子プロセスは使用されませんでした。やはり内部的にプロセス制御をおこなって並列処理を再現しているわけではありませんでした。
かといってcurlをバックグラウンドで並列処理させているのでは?といった検証はできていないので、推測の域を出ません。ただ、他の検証結果と比べても割と遜色ない結果が出ているので使い勝手はよさそうです。
でもphpはそもそもがHTMLの動的なレンダリングをサポートするために進化してきた言語なので、裏でもしかしたらトリッキーなことをしているのかも?という懸念もあります。curl_multiのソースを見てみたいですね。
代わりに、2. のphp PCNTL版ではちゃんと子プロセスを立てて、各々がHTTP通信をおこなうことで並列処理を実現していることが分かります。動きとしては見えやすく、素直ですが、しかしプロセスのメモリ消費量は61.2MBと、膨大です。並列処理をおこなう本数だけプロセスを立てるので当然ですが、多用したり並列処理の本数をあまり増やすのは怖いですね。
3. のperlはithreadという「スレッドにあってスレッドにあらず」というアプリケーションスレッドで並列処理を実現しているためでしょうか、実行時間もメモリ消費量も一番よくない成績となりました。perlでマルチスレッド、というのがどの程度現実的な手段として考えられているのか、によっては検証するまでもないのかもしれませんが。本当ならここでjavaが登場するべきですが、実行環境ができていないので(^^; またいずれ機会があったらにします。
4. のrubyはバージョン1.9以降のネイティヴスレッドの恩恵の現れでしょうか、いずれも一番よい成績です。逆にOSのリソースはどのくらい消費しているのか、見てみればよかったですが、これもおいおいということで。
ともあれ外部のAPIから通信結果を利用しているようなサービスから、ちょっと大きめなシステムで機能種別サーバ間でリクエストの受け渡しをしているようなサービスでもこのあたりの需要は多いと思うので、curl_multiは実行時間からみても、プロセスのメモリ消費量からみても、使える機能だなという感想です。