Perl Hackers Hub

第22回 Coroを使ったやさしいクローラの作り方(3)

この記事を読むのに必要な時間:およそ 6.5 分

(1)こちら⁠2)こちらから。

並列・分散処理のためのクローラ

ある程度大規模なクローラを作る場合は,一定時間内になるべく多くのURLから効率的にデータを収集する必要があります。接続先のホストごとに同時接続数やウェイトなどの配慮をしつつ,全体としては大量のリクエストを同時に処理する必要があります。検索エンジンのためのクローラ,定期的に同じURLにアクセスする必要がある更新チェックやフィードアグリゲータなどは,特に該当するでしょう。

Perlで高性能なクローラを書く場合の選択肢は2つあります。一つはforkによるマルチプロセスによるもので,Parallel::ForkManagerやParalell::Preforkを使うケースや,ジョブキューのためのフレームワークを使ったワーカがこれに該当します。もう一つはAnyEventやCoroを使ったI/O多重化によるアプローチです。筆者が好んで使うのはこれら2つを組み合わせた,I/Oを多重化しつつ,CPUのコア数に合わせた少数のプロセスを立ち上げるという方式です。

以降では,1プロセス内で複数のリクエストを同時に実行するI/O多重化によるアプローチでのクローラの作り方を紹介します。PerlでI/O多重化を実現するモジュールは数多くあり,それぞれ長い歴史がありますが,筆者がお勧めするのはAnyEventとCoroです。いずれもlibevの作者であるMark Lehmannによるものです。AnyEventは環境によって利用可能なイベントループのライブラリを自動で判別し,統一したインタフェースで利用可能にするモジュールです。Coroはコルーチンによる協調スレッドを実現するためのモジュールです。

AnyEventとCoroの違い

Coroを使うと複雑な非同期処理を,いわゆる「コールバック地獄」に陥ることなくシンプルに記述できます。たとえば1秒ごとに何か処理を実行したいというコードは,AnyEventとCoroだと次のような違いがあります。

AnyEventの場合

use AE;
my $i = 0;
my $cv = AE::cv;

# 1秒ごとに,指定した関数を実行せよ
my $watcher = AE::timer 0, 1, sub {
  warn $i++;
  $cv->send("done") if $i >= 10;
};
# CV = condition variable が利用可能になるまで待機
warn $cv->recv;

Coroの場合

use Coro;
my $main = Coro::current;
async {
    my $i = 0;
    while (1) {
        # async で囲まれた部分だけがsleep によって1 秒止まる
        Coro::Timer::sleep 1;
        warn $i++;
        last if ( $i > 10 );
    }
    # 次の切り替え時にメインスレッドに切り替わるようにスケジューラに指示
    $main->ready;
};
schedule;

Coroで「定期的に何かを実行」したければ,単にwhile文やfor文によるループを使って,その中でsleepを呼びます。Coro::Timer::sleepが呼ばれたタイミングでCoroのスレッドが停止し,別のスレッドが実行可能な状態になり,1秒後にもとのスレッドが実行可能な状態になり戻ってきます。

AnyEventとCoroの使い分け

Coroは黒魔術ですので,汎用的なモジュールや複雑にならない程度の非同期処理であればAnyEventで書いたほうが無難です。ただ,実際にプロダクションで動くコードで,一度でもCoroを使うことを選択したのであれば,Coroを積極的に使ってしまったほうがよいでしょう。癖はありますが,Coroを使わないと実現できないようなことが多くあり,AnyEventを使うよりもコードを短く,すっきりさせることができるでしょう。

Coro::Timerの内部は次のようになっています。

sub sleep($) {
   my $w = AE::timer $_[0], 0, Coro::rouse_cb;
   Coro::rouse_wait;
}

内部ではAE::timer を使って指定秒数後にCoro::rouse_cbが呼び出されるようにして,実行されるまで現在のcoroを停止させる(別のcoroが実行されるようにする)ということになります。この例を見てわかるように,AnyEventのcallback方式で記述可能なものは,すべてCoro::rouse_cb とCoro::rouse_wait を使ってCoroの流儀に変換できます。逆に,一度Coroを使用することを前提にして書かれたコードを,Coroを使わずにAnyEventだけで書き直す作業は非常に困難です。

そのためAnyEventとCoroの使い分けは,汎用的なライブラリにはAnyEventを使い,複雑な処理内容を含む実際のプロダクトやCoroを使わないとできないことがある場合はCoroを使う,というのがお勧めです。

Coroを使った典型的なクローラのひな型

Coroを使ってホストごとの接続数制限やウェイトを実装したクローラのサンプルはリスト1のようになります。実際には各処理は複数のクラスに分割したほうがよいですが,見通しが良いように1つのファイルにまとめてあります。

Coroを使うメリットとして,SemaphoreやChannelによって1プロセス内での排他制御やメッセージングが高速に行えるというものがあります。サンプルコードと共に見ていきましょう。

ホストごとの並列数を制限する

クローラを書くうえで,相手先のサーバに過大な負荷をかけないように1ホストあたりの同時接続数を制限したい場合,Coro::Semaphoreを使うのがよいでしょう。セマフォSemaphoreは共有リソースへのロックの獲得と解放を制御するためのしくみです。

たとえば同時接続数を4件に制限したい場合,典型的には次のようなコードになります。

my $hosts = {};
sub task {
    my $url = shift;
    my $host = URI->new($url)->host;
    # ホストごとのセマフォを呼び出す,なければ新しく作る
    my $semaphore = $hosts->{$host} ||= Coro::Semaphore->new(4);
    # 4 件以上呼ばれたら,その時点で別スレッドに切り替わる
    my $guard = $semaphore->guard;
    # 何らかの処理
    ...
}

上記のコードでは,$semaphore->guardを使って「自動解放されるロック」を作っています。guardを使うと,guardオブジェクトが格納されている変数の参照カウンタがゼロになり,そのオブジェクトが解放されたタイミングで何らかの処理が実行されるという機構を作ることができます。CoroやAnyEventを使う場合,よくこの「Guard」という概念が出てきます。

$semaphore->guardを使わずに,$semaphore->downでロックの確保,$semaphore->upでロックの解放と明示的に行うこともできますが,個人的にはguardを使うことをお勧めします。最初のうちは処理の開始時にdown,処理の終了時にupと呼び出したほうがわかりやすく感じるかもしれませんが,downだけ呼ばれてupが呼ばれないと,ロックされたままプログラムが停止してしまいます。しかしguardを使うと,特定のスレッドがエラーで終了してしまっても,guardオブジェクトが破棄されれば確実に$semaphore->upが呼び出されます。非同期タスクの寿命とguardオブジェクトの寿命を一致させるようにすることで,ロックの解放忘れがなくなります。

詳しい使い方はperldoc Coro::Semaphoreを参照してください。

著者プロフィール

mala(マラ)

NHN Japan所属。livedoor Readerの開発で知られる。JavaScriptを使ったUI,非同期処理,Webアプリケーションセキュリティなどに携わる。

Twitter:@bulkneets

コメント

コメントの記入