curl_multiは確かに爆速だった(がcurl_multi_selectのバグでハマった)

前回の記事「PHPのfile_get_contentsをcURLへ置き換える」では下記のようなfile_get_contents_curlという関数を作り、既存のソース中にあるfile_get_contentsを置き換えてみました。

function file_get_contents_curl($url)
{
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, $url);
	curl_setopt($ch, CURLOPT_HEADER, false);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
	curl_setopt($ch, CURLOPT_TIMEOUT, 30);
	$html = curl_exec($ch);
	curl_close($ch);
	return $html;
}

速度自体はさほど変わらなかったものの、概ねミリ秒単位でcurlのほうが速いため、これはこれで良しとして今度はcurl_multiによる並列処理を試してみたいと思います。

curl_multiの初期処理

■curlの初期処理のコード例

//対象のURL
$urls = array (
    'http://example.com/1',
    'http://example.com/2',
    'http://example.com/3'
);
//マルチハンドル初期化
$mh = curl_multi_init();

//後で使うため個別ハンドル保管用の配列を準備
$ch_array = array();

//URLのセット
foreach($urls as $url) {
	$ch = curl_init();
	$ch_array[] = $ch;
	curl_setopt_array($ch, array(
		CURLOPT_URL            => $url,
		CURLOPT_HEADER         => false,
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_SSL_VERIFYPEER => false,
		CURLOPT_TIMEOUT        => 30
	));
	curl_multi_add_handle($mh, $ch);
}

URLごとのハンドル($ch)と、全体を管理するハンドル($mh)があるので少々ややこしいですが、順を追って見てみればけっこう単純だと思います。

  1. 接続対象のURLを連想配列に格納($urls)
  2. curl_multi_initでマルチハンドル初期化
  3. 個別ハンドル保管用の配列を準備($ch_array)
  4. curl_initで個別ハンドルを作成しcurl_multi_add_handleでマルチハンドルと関連付ける処理をURLの数だけ実行

概ねこんな感じでしょうか。

curl_init~curl_setopt_arrayの流れはfile_get_contents_curlを作ったときと同じなので説明不要ですね。

CURLOPTは同じものを使えるので、必要ならここでUserAgentなりREFERERなり設定できます。

curl_multiのリクエスト処理

ここからが問題なのですが、大きくわけて2つのやり方があります。

いや実際はもっと色々あると思いますけど、まぁとりあえずサンプルで語ったほうが手っ取り早いですよね。

■パターン1(PHPの公式マニュアルにあるヤツ)

$active = null;
// ハンドルを実行します
do {
    $mrc = curl_multi_exec($mh, $active);
} while ($mrc == CURLM_CALL_MULTI_PERFORM);

while ($active && $mrc == CURLM_OK) {
    if (curl_multi_select($mh) != -1) {
        do {
            $mrc = curl_multi_exec($mh, $active);
        } while ($mrc == CURLM_CALL_MULTI_PERFORM);
    }
}

出典:https://secure.php.net/manual/ja/function.curl-multi-exec.php

これね。動作は……するんですよ。環境によっては。

一応、動きをざっくりと説明します。

do {
    $mrc = curl_multi_exec($mh, $active);
} while ($mrc == CURLM_CALL_MULTI_PERFORM);

curl_multi_execからCURLM_CALL_MULTI_PERFORM(処理中)が返されたら、もう1度curl_multi_execをコールしなければならないルールなので、こんなループをしているのですが、これ実はlibcurl7.20.0以降では使われないそうです。

問題はここです。

$active = null;
while ($active && $mrc == CURLM_OK) {
    if (curl_multi_select($mh) != -1) {
        do {
            $mrc = curl_multi_exec($mh, $active);
        } while ($mrc == CURLM_CALL_MULTI_PERFORM);
    }
}

curl_multi_selectは応答があるまで待機してくれる関数のはずですが、環境によっては-1を返し続けます。

とりあえず3つの環境しか用意できなかった中で試した限りはこんな感じ。

結果サーバーPHPcURL
Apache2.27.2.67.19.7
Apache2.27.2.127.61.1
×LiteSpeed7.1.67.54.0

curl_multi_selectが-1を返し続けるのはcURLのバグという話なので、cURLのバージョン次第なのかなぁーと思いましたが、上の結果を見る限りそうでもないっぽい。Webサーバーの違いによるものか、PHP7.2系から大丈夫になったとか? PHP5の頃から議論されているバグっぽいし、まさかなぁ。ヒマになったらPHP5と7.1を引っ張り出してきてテストしてみるかなぁ。

まぁいずれにせよ、curl_multi_selectが-1を返し続ける環境だと上記のコードは無限ループに陥るのでマズイです。

というわけでこんな感じ。

■パターン2

do {
    curl_multi_exec($mh, $running);
    curl_multi_select($mh);
} while ($running > 0);

わお、シンプル…。

接続応答が返ってきたときにちょこまか処理して、少しでも並列実行しようというのは諦め、とりあえず全部にGET要求つきつけて、それらの用意ができたら受け取りましょう、っていう漢らしい感じ。

こんなのでも速度早くなるのかなぁ、と思ったのですが、結論から言うと同時接続先が増えれば増えるほど早くなります。

curlのデータ転送処理

先述のループを抜けてきたってことはすべての接続先で転送準備が整ったということなので、一気に頂きます。

foreach ($ch_array as $ch) {
	$html = curl_multi_getcontent($ch);
	//ここで$htmlを好きにする
	curl_multi_remove_handle($mh, $ch);
	curl_close($ch);
}
curl_multi_close($mu);

curl_multi_getcontentでHTMLを取得する際にハンドルが必要なので、あらかじめハンドルを保存しておいた連想配列($ch_array)をループして使う感じです。

ループの途中で、マルチハンドルから個別ハンドルを削除するのと閉じる処理もしています。

そして最後にマルチハンドルを閉じて終了。

curl_multiの全体ソース

小分けだとわかりづらかったかもなのでソースの全体も紹介。

//対象のURL
$urls = array (
    'http://example.com/1',
    'http://example.com/2',
    'http://example.com/3'
);
//マルチハンドル初期化
$mh = curl_multi_init();

//後で使うため個別ハンドル保管用の配列を準備
$ch_array = array();

//URLのセット
foreach($urls as $url) {
	$ch = curl_init();
	$ch_array[] = $ch;
	curl_setopt_array($ch, array(
		CURLOPT_URL            => $url,
		CURLOPT_HEADER         => false,
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_SSL_VERIFYPEER => false,
		CURLOPT_TIMEOUT        => 30
	));
	curl_multi_add_handle($mh, $ch);
}

//パターン2
do {
    curl_multi_exec($mh, $running);
    curl_multi_select($mh);
} while ($running > 0);

//HTML取得
foreach ($ch_array as $ch) {
	$html = curl_multi_getcontent($ch);
	//ここで$htmlを好きにする
	curl_multi_remove_handle($mh, $ch);
	curl_close($ch);
}
//終了処理
curl_multi_close($mh);

どうでしょ。

細かく説明すると長ったらしくて難しそうだったけど、全体見ると意外とシンプルじゃないですかね。

まぁ実際やってることはシンプルで、宅配便に例えると、個別に訪問して「(ぴんぽーん)お荷物受け取りに伺いました~」「……(待機中)……」「(家人)あ、はーい」というのをやめて、先に全部の家に「これからお荷物受け取りに伺いますので準備しておいてください~」と通達するようなイメージでしょうか。

あとは家をまわればスムーズに荷物が回収できるという寸法

ホントは「これからお荷物届けますから、準備ができた家から連絡してください」にしたほうが早くなるだろうし、それが最初に説明したcurl_multi_selectなのですが、常に-1が返ってくる環境があると使えないし、実際ぼくが使いたかった環境でそうなってしまったので、とりあえず今は諦めます。

curl_multiへ変更してどのくらい速度が上がったの?

■内容

約40サイト分のRSSフィードを取得する

■結果

file_get_contents6.78~7.10秒
curl_multi1.35~2.26秒

めちゃめちゃ早くなっとるやんけ

これはちょっとびっくりしました。

時間帯を変えて20回ほど試行しましたが、いずれも上記のような結果になり、データの取りこぼしとかもありませんでした。

並列処理とは名ばかりでデータ転送部分は待ち行列になるので大した効果はないだろうと思っていましたが、良い意味で期待を裏切られた感があります。

接続要求の部分だけでも非同期実行できるとこんなにも違うものなんですねえ。

adsbygoogle

フォロー