BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル JavaScriptへのマルチスレッド・プログラミングの導入

JavaScriptへのマルチスレッド・プログラミングの導入

ブックマーク

多くのWebサイトが全面的に、あるいは部分的にAjaxを利用して実装されるようになってきました。しかし、依然として複雑なAjaxアプリケー ションを開発することが困難であるという状況は変わってはいません。いったいAjax開発のなにがそんなに難しいのでしょうか?サーバとの非同期通信でしょうか?それともGUIプログラミング?しかしこれらはデスクトップ上のウィンドウアプリケーションが日常的に行っている仕事です。では、どうして Ajaxアプリケーションの開発は特別に難しいというのでしょうか?

Ajaxアプリケーションの難しさ

簡単な例を見ながら考えてみましょう。Ajaxでツリー型の掲示板を作ることを考えてみます。この掲示板ではすべての記事を一度に読み込むのではなく、ユーザの要求に応じてサーバと通信を行い各記事のデータを読み込むことにします。各記事は掲示板の中でユニークなIDと、投稿者の名前、記事の内容、 そして子記事のIDの配列という4つの情報を持つものとします。説明の便宜上、1つの記事のデータを読み込む関数getArticleがあるとしましょう。これは記事のID(整数値)を引数に取り、サーバからIDに対応する記事のデータを取得して、記事の情報に対応する4つのプロパティ、id、 name、content、childrenを持つオブジェクトを返します。これを使うと記事のIDを受け取ってその内容を表示する関数は、例えば次のように書くことができます。

function ( id ) {
    var a = getArticle(id);
    document.writeln(a.name + "
" + a.content);
}

しかしこの関数を同じIDに対して何度も呼び出すと、すでに読み込んである記事について何度もサーバと通信を行うため無駄が多いことが分かります。 これを改善するため、getArticleにキャッシュ機能を追加したgetArticleWithCacheを考えてみましょう。ここでは単純に、 getArticleで一度読み込んだ記事のデータをグローバル変数で覚えておくことにします。

var cache = {};
function getArticleWithCache ( id ) {
    if ( !cache[id] ) {
        cache[id] = getArticle(id);
    }
    return cache[id];
}

さて、これで一度読み込んだ記事をキャッシュできるようになりました。今度はこれを利用して、すべての記事のデータを読み込む関数backgroundLoadを考えてみましょう。ユーザが記事を読んでいる間にバックグラウンドで記事の先読みを行うのが目的です。記事のデータはツリー型になっているので、次のように再帰的にツリーを辿ることですべての記事を読み込むことができると考えられます。

function backgroundLoad ( ids ) {
    for ( var i=0; i < ids.length; i++ ) {
        var a = getArticleWithCache(ids[i]);
        backgroundLoad(a.children);
    }
}

このbackgroundLoadはIDの配列を引数に取り、その各IDに対して上で定義したgetArticleWithCacheを呼び出します。これでIDに対応する記事のデータがキャッシュされます。そして読み込んだ記事の子記事のIDに対してbackgroundLoadを再帰的に呼び出すことで、ツリー全体をキャッシュすることができます。

ここまですべてうまくいっているように見えます。しかし、一度でもAjax開発を経験したことのある方ならば、これではうまくいかないということはすでにおわかりだと思います。これまでの例ではgetArticleの中で同期通信を利用していると暗黙に仮定して話を進めてきました。しかし、 JavaScriptでサーバと通信を行う場合には原則的に非同期通信を用いなければなりません。これはJavaScriptには1つしかスレッドがないことに起因しています。1つのスレッドの上でGUIを含むすべての処理を行うということは、単純さの上では良いプログラミングモデルです。同期に関わる複雑な問題を考えなくてすむからです。しかし一方で、ユーザ応答性の高いアプリケーションを作るうえでは大きな問題でもあります。なぜならば1つしかスレッドがない環境では、スレッドが仕事をしている間はユーザがマウスをクリックしたりキーボードを叩いたりしてもそれに対する応答をすることができないからで す。

このような環境で同期通信を行ったらなにが起こるでしょうか?同期通信というのは、通信の結果が得られるまでプログラムの実行がそこで止まる方式のことです。通信を待っている間も計算が終わったわけではないので、スレッドはユーザ応答に使うことができません。getArticleはその通信の結果を待ってから結果を返しますが、getArticleから実行が戻ってくるまでスレッドは他の処理に使うことはできませんから、通信の結果を待っている間は ユーザに応答することができずにブラウザがフリーズしたように見えてしまいます。getArticleを利用して実装されている getArticleWithCache、そしてbackgroundLoadが実行されている間も同様です。すべての記事データをダウンロードするには 相当な時間がかかるはずですから、ブラウザがフリーズしてしまうというのはbackgroundLoadにとっては深刻な問題です。そもそもブラウザがフリーズしてしまうようでは、ユーザが記事を読んでいる裏側で先読みを行うという目的が達成できません。

このように同期通信を使うとユーザ応答性に大きな問題となるため、JavaScriptでは原則的に非同期通信を用います。では、上で書いたプログラムを非同期通信を使って書き直してみましょう。JavaScriptでは非同期通信はイベント駆動型のプログラミングスタイルで記述されます。通常は通 信の結果が得られた後に呼び出されるコールバック関数を指定して、いったんプログラムを終了するような書き方をします。たとえば、上で定義した getArticleWithCacheを書き直すと次のようになります。

var cache = {};
function getArticleWithCache ( id, callback ) {
    if ( !cache[id] ) {
        callback(cache[id]);
    } else {
        getArticle(id, function( a ){
            cache[id] = a;
            callback(a);
        });
    }
}

今回も内部でgetArticle関数を呼び出していますが、非同期通信版のgetArticleが第二引数に関数を受け取るようになっていることに注意してください。このgetArticleは呼び出されるとサーバにリクエストを送信しますが、レスポンスが返ってくるのを待たずに処理を呼出し元へと返します。つまり、呼出し元に戻った時点では、通信の結果は返されません。getArticleの呼び出しから処理が戻ってきたあとには何もしないようにすることで、いったんプログラムを終了させます。こうすることで、通信の結果がサーバから得られてコールバック関数が呼び出されるまではスレッドが他の仕事に使えるようになるわけです。サーバからレスポンスが得られると、getArticleの第二引数に指定された関数が通信の結果を引数として呼び出さ れます。実際のところ、getArticleの実行結果はコールバック関数の呼び出しによって返されるようになっているわけです。 getArticleWithCacheも同様に、第二引数にコールバック関数を受け取るように変更されています。ここで受け取ったコールバック関数は getArticleへ渡した関数の中で呼び出すことで、通信処理が終わったあとに実行されるようになるわけです。

これだけでも随分と面倒なことになったと思われるかもしれません。しかし、次のbackgroundLoadの書き直しはもっと複雑です。backgroundLoadも同じようにコールバック関数を受け取るように変更して、次のように書き直すことができます。

function backgroundLoad ( ids, callback ) {
    var i = 0;
    function l ( ) {
        if ( i < ids.length ) {
            getArticleWithCache(ids[i++], function( a ){
                backgroundLoad(a.children, l);
            });
        } else {
            callback();
        }
    }
    l();
}

書き直す前と見比べてみると随分と違う関数になってしまった印象を受けるかもしれません。しかし実のところ、行っている処理の内容はまったく変わっていません。つまり、IDの配列を受け取って、その各要素にgetArticleWithCacheを呼び出し、得られた結果の子記事に対して再帰的にbackgroundLoadを適用しています。しかし、書き直し前にはfor文で表現されていた配列に対するループすら、上のコードからは簡単に読み取ることができません。どうして同じことをしているにも関わらず、こんなにも違うプログラムになってしまったのでしょうか?これはgetArticleWithCacheのように通信を必要とする関数を呼び出した後には一度プログラムを終了しなければならないことに起因しています。 プログラムを終了しなければ、通信の結果を受け取るべきコールバック関数を呼び出されないためです。for文のようなループ構文の途中でプログラムを中断して、あとになって中断した場所から再開するようなことはJavaScriptではできないため、ループ構文は使わず、代わりにコールバック関数を再帰的 に渡しあうことでループを表現しています(詳しい人へ:これは継続渡し形式(CPS)を手でコーディングしていることに相当します)。ループ構文がまったく使えないため、先に挙げたツリーを辿るような簡単なプログラムですら複雑な記述を要求されてしまうわけです。このようにイベント駆動型のプログラミングでは、ループのような制御フローの記述がわかりづらくなってしまうということが「制御フロー問題」と呼ばれて知られています(参考リンク・PDF)

さらにもうひとつの問題は、これまで定義してきた関数を非同期通信を使うように書き換えるには、関数の引数としてコールバック関数を受け取るように引数の形を変更しなければならなかったということです。内部の変更が外部から見えてしまうようでは、モジュール化において大きな問題であるということが分かるかと思います。

結局のところ、何がこれらの問題を引き起こしていたのでしょうか?そう、JavaScriptに1つしかスレッドがなかったことです。1つしかないスレッドの上で非同期通信をしようとすると、どうしてもイベント駆動型でプログラムしなければならなくなり、複雑な記述をしなければならないのでした。通信の結果が得られるまで待っている間にも他のスレッドがユーザへの応答を行ってくれれば、こんなに大変な思いはしなくてよいはずなのです。

マルチスレッド・プログラミングへの招待

上で述べたAjax開発の難しさを緩和してくれるものとして、JavaScriptでマルチスレッドを利用可能にするライブラリ Concurrent.Threadを紹介しましょう。これはすべてJavaScriptで実装されているフリーのライブラリで、Mozilla Public License / GNU General Public Licenseの下で利用できます。ソースコードはWebサイトからダウンロードすることができます。

さっそくダウンロードして使ってみましょう。以下ではダウンロードしたソースコードをConcurrent.Thread.jsというファイル名で保存したこととします。まずは深く考えずに次のプログラムを実行してみましょう。

<script type="text/javascript" src="Concurrent.Thread.js"></script>
<script type="text/javascript">
Concurrent.Thread.create(function(){
var i = 0;
while ( 1 ) {
document.body.innerHTML += i++ + "<br>";
}
});
</script>

このプログラムを実行すると、0から順番に数字が表示されるはずです。数字は下に続けてどんどん書き出されていきますが、その様子はページをスク ロールさせることで追いかけることができると思います。ソースコードをのぞいて見ると、中では「while ( 1 )」とあるように単純に無限ループで書かれています。通常このようなJavaScriptプログラムを書いてしまうと、プログラムが1つしかないスレッドをずっと使い続けてしまい、ブラウザがフリーズしたようになってしまいます。もちろん、画面をスクロールさせることもできません。どうして上のプログラムではスクロールができるのかというと、その上にある「Concurrent.Thread.create」に秘密があります。これはライブラリが提供しているメソッドで、新しくスレッドを作るためのものです。新しく作られたスレッドの上では引数に渡された関数が実行されます。上のプログラムを少し書き換えて、次のようにしてみましょう。

<script type="text/javascript" src="Concurrent.Thread.js"></script>
<script type="text/javascript">
function f ( i ){
while ( 1 ) {
document.body.innerHTML += i++ + "<br>";
}
}
Concurrent.Thread.create(f, 0);
Concurrent.Thread.create(f, 100000);
</script>

数字を繰り返し書き出す関数fを定義し、fを引数としてcreateメソッドを2回呼び出しています。2番目の引数はfにそのまま渡されます。このプログラムを実行すると、最初は0から始まる小さな数字がいくつか書き出されて、次に100000から始まる大きな数字がいくつか書き出され、また小さい 数字が前の続きから書き出される、というように小さな数字の列と大きな数字の列が交互に書き出されると思います。これは2つのスレッドが交代で処理を行っ ている様子を示しています。

Concurrent.Threadのもうひとつの使い方をご紹介しましょう。上の例ではライブラリの提供するcreateメソッドを呼び出してスレッドを作成していましたが、ライブラリのAPIをまったく呼び出さないでスレッドを作ることも可能です。たとえば、前者の例は次のようにも書くことができます。

<script type="text/javascript" src="Concurrent.Thread.js"></script>
<script type="text/x-script.multithreaded-js">
var i = 1;
while ( 1 ) {
document.body.innerHTML += i++ + "<br>";
}
</script>

scriptタグの内容は通常のJavaScriptで無限ループを書いているだけですが、タグのtype属性に注目してください。「text/x -script.multithreaded-js」という見慣れない値が書かれています。scriptタグにこの属性を指定しておくと、 Concurrent.Threadはそのタグの内容を新しいスレッドの上で実行してくれるのです。もちろんこの場合にも、 Concurrent.Threadのライブラリ本体をインクルードしておく必要がありますので、お忘れなく。

これで長い時間実行し続ける処理をしていたとしても、適当なタイミングで他のスレッドに交代して他の仕事を並行にすることができることがわかりました。このような動作をどのようにして実現しているのかを、少しだけお話しておきましょう。一言で言えば「コード変換」を行っています。非常に大雑把に言うと、createメソッドに渡された関数をいったん文字列にして、それを"細切れに"実行できるように書き換えます。そして書き換えた関数をスケジューラ の上で少しずつ実行するのです。スケジューラは複数のスレッド、つまり書き換えた関数を実行する際にそれぞれがなるべく均等に実行されるように調整する役 割を担います。システム全体を図で表すと右のようになります。つまり、実際にはConcurrent.Threadは新しくスレッドを作っているわけではなく、もともとある1つのスレッドの上で擬似的に複数のスレッドがあるかのように見せかけているわけです。

変換された関数が別々のスレッドの上で動作しているかのように見えるとはいえ、実際には1つのスレッドなので、変換された関数の中で同期通信を行ってしまうと結局はそこでブラウザがフリーズしたようになってしまいます。それでは問題はまったく解決していないじゃないか、と思われるかもしれませんが心配には及びません。Concurrent.Threadは専用の通信ライブラリを提供してくれています。これはJavaScriptの非同期通信の上に実装されていて、通信の結果を待っている間にも他のスレッドが動くことができるように作られています。通信ライブラリは Concurrent.Thread.Http名前空間の下にまとめられていて、例えば次のように使用します。

<script type="text/javascript" src="Concurrent.Thread.js"></script>
<script type="text/x-script.multithreaded-js">
var req = Concurrent.Thread.Http.get(url, ["Accept", "*"]);
if (req.status == 200) {
alert(req.responseText);
} else {
alert(req.statusText);
}
</script>

getメソッドはその名の通り、指定したURLの内容をHTTPのGETメソッドによって取得します。第1引数にはURLを、省略可能な第2引数にはHTTPヘッダフィールドを指定する配列を渡します。getメソッドはサーバと通信を行って、結果が得られると戻り値として XMLHttpRequestオブジェクトを返します。getメソッドから処理が返ってきたときにはすでにレスポンスが得られていますので、コールバック関数で結果を受け取る必要はありません。もちろん、サーバからのレスポンスを待っている間もブラウザがフリーズする心配もありません。また、サーバにデー タを送信するためにpostメソッドを使うこともできます。

<script type="text/javascript" src="Concurrent.Thread.js"></script>
<script type="text/x-script.multithreaded-js">
var req = Concurrent.Thread.Http.post(url, "key1=value1&key2=value2");
alert(req.statusText);
</script>

postメソッドは第一引数に送信先のURL、第二引数に送信する内容を取ります。またgetと同様に、省略可能な第三引数にヘッダフィールドを指定することもできます。

これらの通信ライブラリを用いて最初の例のgetArticleを実装すれば、getArticleWithCacheや backgroundLoadといったgetArticleを利用する関数も、最初に書いた簡潔な記述のまま利用することができるようになります。もちろん、こうして実装されたbackgroundLoadが記事のデータを読み込んでいる間にも、別のスレッドによってユーザへの応答ができるためブラウザが フリーズしてしまうこともありません。JavaScriptでマルチスレッドを使うことの便利さを感じてもらえたでしょうか。

より詳しく

JavaScriptでマルチスレッドを利用するためのライブラリ、Concurrent.Threadを紹介しましたが、ここで紹介したことはほんのさわりでしかありません。さらに詳しく知りたいという人はチュートリアルを一読するとよいでしょう。ここには今回紹介したことよりも詳しい使い方と上級者向けのドキュメントが紹介されていますので、スタート地点とするには最適です。また、新しい情報はConcurrent.ThreadのWebサイトに追加されますので、こちらもチェックしてみてください。

著者について

牧 大介(まき だいすけ): 国際基督教大学教養学部理学科を卒業(教養学士)したのち、電気通信大学大学院にて情報工学を専攻。Web開発、特にJavaScriptを用いたAjaxを専門とする。Concurrent.Threadの開発者であり、このプロジェクトは情報処理推進機構(IPA) 未踏ソフトウェア創造事業(2006年度)に採択された。
現在、電気通信大学大学院博士後期過程に在籍中。工学修士。
Web サイト: http://daisukemaki.dtdns.net

この記事に星をつける

おすすめ度
スタイル

BT