重たい処理をしている最中に再描画する方法
前回同様Ruby製プログラムをCoffeeScript(JavaScript)に移植する際の話ですが、PC上で動くプログラムでもそうですが、重たい処理をしている最中に画面の更新をしたい時があると思います。三目並べの全局面データを作成する処理がRuby製プログラムで4、5秒かかるのですが、この処理をブラウザ上で動かすCoffeeScript(JavaScript)製のプログラムにした時の顛末を書きます。
スレッドが使えない
今回の三目並べプログラムはBFS(Breadth First Search)アルゴリズムや機械学習プログラムの動作確認用のサンプルとして作ったものですから、レスポンスが遅くても問題なかったのですが、以前RubyとShoesでGUIの将棋プログラムを作った時は、先読みさせる時に別スレッドを起動して画面を更新しました。人間とソフトの対戦中のループ処理内にThread.new
で囲んだブロックを以下のように入れておくだけで、ソフトが思考中でもユーザーの操作を妨げることなく画面の再描画やメニュー操作をすることが出来ました。
@thinking = Thread.new do
#思考ルーチンを呼び出す処理
@game_end = true unless @game.command(@board, player)
#画面再描画処理
draw_board
end
非GUIのソフトならsleep関数を呼び出すだけで済む場合もあると思いますし、Windowsのソフト(GUIソフト)でも簡単な仕組みが用意してあって、何らかのループ処理中にPM_REMOVE
フラグを付けてPeekMessage
というWindowsAPIを呼び出すだけで画面を再描画させることが出来ます。VisualBasicならDoEvents
コマンドを呼び出すだけ、DelphiならApplication.ProcessMessages
を呼び出すだけで画面を再描画(Windowsシステムの待ち行列に溜まっているWindowsメッセージを処理)してくれます。非常に重たい処理を行う関数があったとして、その中で画面の再描画をしたい場合を疑似コードで書いてみると以下のようになります。
#重たい処理を行う関数
function heavy()
#重たいループ処理(while文)
while 何らかの条件
#重複しない局面を保存する処理
Pickup()
#画面描画関数呼び出し
Draw()
#OSに制御を返す(=再描画される)
PeekMessage || DoEvents || Application.ProcessMessages || etc...
end
end
- やりたい処理を行う
- 画面描画処理を行う
- OSに制御を返す
1.2.に加えて3.の処理を追加するだけでループ中に画面の再描画をさせることが出来ます。
でもJavaScriptはブラウザのプロセス内で動くプログラムなのでシングルスレッドでしか動かせないので、ループ処理の中に1行追加するだけでいいというわけにはいかないようです。タイマーを使った割り込み処理と言っていいと思うのですが、重たい処理をぶつ切りに区切って一定時間毎に実行するイメージに書き換える必要があります。上記のように一行書き加えるだけで済むようなものではなく、処理の流れを考慮してコードを書き換えないといけないので結構面倒です。大昔MS−DOS
の割り込み処理を書いた時を思い出しました。その時とはコードスタイルが違いますが…。
配列を使ったサンプル
実際にはBFSで三目並べの全局面(986,410)を検索して、重複のない局面6046個のオブジェクトを多分木データオブジェクトに追加していく処理(2分ほどかかる)だったのですが、分かりやすいように単純な配列を使ったサンプルコードに書き換えてみました。
- 処理中に再描画しないサンプル
$ ->
new Sample
class Sample
constructor: ->
@status = null
@trees = null
@progressBar = document.getElementById("progressbar")
@startbtn = document.getElementById("btnStart")
@spanCount = document.getElementById("spanCount")
@statusarea = document.getElementById("spanStatus")
@labelprogress = document.getElementById("trees")
$('#btnStart').on 'click', (e) =>
target = $(e.currentTarget)
@btnstart(target)
btnstart: (target) ->
@trees = new Array()
@heavy()
@statusarea.innerHTML = @trees.length.toString()
heavy: ->
seq = 0
while seq < 6046
seq += 1
@trees.push(seq)
bar = (seq / 6046) * 100
console.log(@trees.length.toString())
@spanCount.innerHTML = Math.floor(bar) if bar?
- 進捗状況をプログレスバーに表示しながら処理するサンプル
$ ->
new Sample
class Sample
constructor: ->
@status = null
@trees = null
@progressBar = document.getElementById("progressbar")
@startbtn = document.getElementById("btnStart")
@spanCount = document.getElementById("spanCount")
@statusarea = document.getElementById("spanStatus")
@labelprogress = document.getElementById("trees")
$('#btnStart').on 'click', (e) =>
target = $(e.currentTarget)
@btnstart(target)
btnstart: (target) ->
@labelprogress.style.display = "block"
@heavy(0)
heavy: (seq) ->
if seq == 0
@trees = new Array()
if seq < 6046
seq += 1
@trees.push(seq)
bar = (seq / 6046) * 100
setTimeout (=>
@heavy(seq)
), 0
else
bar = 100
@labelprogress.style.display = "none"
console.log(@trees.length.toString())
@statusarea.innerHTML = @trees.length.toString()
@progressBar.value = Math.floor(bar) if bar?
@spanCount.innerHTML = Math.floor(bar) if bar?
- 共通で使用するHTML
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>SetTimeout サンプル</title>
<script src="js/jquery-1.11.2.min.js"> </script>
<script src="sample.js" defer> </script>
</head>
<body>
<p id="title">SetTimeout サンプル</p>
<label id="trees" style="display: none;"><progress id="progressbar" value="0" max="100"></progress><span id="spanCount"></span>%</label>
<p>
<input id="btnStart" type="button" value="Start!">
<span id="spanStatus" style="color: red;"></span>
</p>
</body>
</html>
coffee -c
でCoffeeScriptファイルをコンパイル後(sample.js)ブラウザで実行
setTimeoutを使った割り込み処理に書き換えるコツ
配列を使ったサンプルだとそれほどでもないですが、処理内容によってはタイマーを使った(setTimeoutを使った)割り込み処理に書き換える際に、どこから手をつけていいか悩むことがあるかもしれません。コツとしては重たいループ処理をまずはカウンターを使った処理に書き換えることです。今回BFSで局面を辿っていく際に新規局面を生成してキュー(queue)に追加し、「キューが空になるまで繰り返す」というループだったのですが、「カウンターの数値が何回になるまで繰り返す」というようにシーケンシャルな数値の条件に書き換えて、そのトータルの回数(今回は6046)を終了条件とした再帰関数を作るってことです。
あと気をつけないといけないのは、setTimeoutで呼び出した関数は非同期で実行されるということです。スタートボタンを押した時のイベントに、以下のように重たい関数の実行が終わったら配列の総数を表示するコードを書いていますが、
btnstart: (target) ->
@trees = new Array()
@heavy()
@statusarea.innerHTML = @trees.length.toString()
setTimeoutを使ったサンプルをこれと同じように書いてもheavy関数の実行終了を待たずに実行されるので意味がありません。setTimeoutを使ったサンプルで、初期化処理(@trees = new Array()
)と終了時の処理(@statusarea.innerHTML = @trees.length.toString()
)をheavy関数内に移動しているのはそのためです。
それから、Promiseを使えば敢えて同期をとって実行させることも出来るらしいと聞いたので、何か得るものがあるかと思い少し弄ってみたのですが、setTimeoutを使わずに同様のことが出来るようになるわけでもなさそうだったので使うのをやめました、用途が違うようです。どんなツールを使ってもブラウザ上で動作するシングルスレッド環境には変わりないので、前述のWindowsソフトのような書き方は出来なくて、タイマーによる割り込み処理を避けることはたぶん無理なのでしょう。