Vim Advent Calendar 2012 の193日目の記事です。今日は著者の誕生日です。
Vitalに入ったばかりの、開発されたほかほかのモジュールProcessManager。これはVim業界を震撼させると言われています。本記事はこのProcessManagerについて日本語では世界初の記事です。 (*1)
- https://github.com/vim-jp/vital.vim/blob/master/autoload/vital/__latest__/process_manager.vim
- doc (未完成) https://github.com/vim-jp/vital.vim/blob/master/doc/vital-process_manager.txt
ProcessManagerは、vimprocよりも高レイヤで、vimshellより低レイヤなライブラリです。ProcessManagerはプラギンではなく、ライブラリです。外部プロセスとのやりとりがあるような任意のプラギンに開発に用いることができます。
著者はScalaやClojureなどの言語を使っていることが多いです。これらの言語は、主にJVM上で動作するということも関係し、起動が非常に遅いことで有名です。最新のコンピュータを用いてもClojureの起動は1秒以上かかります。JVMのオプションをいじっても(*2)、0.5秒以上かかります。Scalaにおいては悲惨の一言で、3.5秒以上かかります(*3)。JVMのオプションをいじっても効果はないようです。ライブラリを読み込むと、事態は悪化します。
これはとくにsbt (Scalaのためのビルドツール。LeiningenやRake+GemやCabalをイメージしていただくとOKです) で顕著で、仕事に大きな支障をきたしていたため、vimshellのiexeと連携することで対処していました。これについて詳しくは著者による過去の記事 http://vim-users.jp/2013/02/vim-advent-calendar-2012-ujihisa-4/ と http://code.hootsuite.com/vimshell/ をご覧ください。これは明示的にsbtプロセスを事前に起動し、その後そのプロセスにメッセージを送るのをラクにするだけで、それ以上のファンシーな機能は持ち合わせていません。また、拡張するのも容易ではありません。
この問題を汎用的に解決するために著者が開発したのがVitalのProcessManagerです。
ProcessManagerの主目的は
- non-blockingなIOを自然に使う
- 各処理それぞれに適切な名前を与える
- プロセス管理をプラギン側ではなくライブラリ側に押しこむ (名前の由来)
です。個別のファンシーな機能の提供は、おまけです。
ProcessManagerはvimprocに依存しています。これはis_available()
関数で確認できます。
s:P
という変数にVitalのProcessManagerをimport済みであると仮定します。また、あなたが開発しているプラギンの名前がaaaで、かつそのプラギンは背後にirbというコマンドを一つだけ起動するものとします。また、irbのプロンプトは常に"> "という行だとします。
function! s:f(msg)
if !s:P.is_available()
return 'vimproc is required'
endif
call s:P.touch('aaa', 'irb')
call s:P.writeln('aaa', a:msg)
return s:P.read('aaa', ['> '])
endfunction
この関数s:f()
を用いると外部プロセスirbのstdinに引数の文字列を書き込み、stdoutから文字列を取得します。この関数呼び出しは、Vimをブロックしません(*4)。実際に使ってみると以下のようになります。
echo s:f('1') "=> ['1', '', 'matched']
echo s:f('warn 2') "=> ['', '2', 'matched']
echo s:f('sleep 5; 3') "=> ['', '', 'timedout']
HINT: rubyのwarnはstderrに引数を出力します。sleepはn秒間処理を停止します。
おっと、結果がtimeoutした場合はどうすればよいのでしょうか? それはプラギン側が面倒をみます。ProcessManagerはブロックしないIOを提供しますが、それでも継続的にデータが欲しいのであれば、それはプラギン側の責任となる、という設計です。
CursorHoldやCursorHoldIなどでs:P.read('aaa', ['> '])
を何度か実行し、'matched'が得られるまで続けましょう。
どうしても動機的に結果を得たいときには、whileループを使うのではなく、ProcessManagerのread_wait()
関数を用います。
echo s:P.read_wait('aaa', 2.0, ['> '})
第二引数にタイムアウトするまでの秒を指定します。この値は省略できません。この2.0という値は、「read_wait()
呼び出し時から2秒」ではなく、「read_wait()
呼び出し後、終端条件であるプロンプト文字列にマッチしていないのに、何も出力のない時間が2秒」で中断する、という意味です。
ここまで読んで、CursorHoldなどでうまくポーリングするのを実装するのは難しく面倒だなあと思った方も多いのではないでしょうか。そんな方に朗報があります。nonblocking IOに関してはProcessManagerに世話してもらい、そしてポーリングに関してはuniteに世話してもらえばよいのです。作ろうとしているプラギンをuniteのsourceにすれば、あとはunite用のasync_gather_candidates
を定義すればよいのです。
著者がいくつか既存のプラギンをProcessManagerを用いて高速化してみました。いずれもまだ本家にマージされていません(*5)。
- thinca/vim-ref (clojureのみ)
- thinca/vim-quickrun (clojureとscalaのみ)
- shougo/unite-build (途中!)
ref.vimは関数などのリファレンスを見るたけのフレームワークで、そのうちclojureのrefに関してのみ対応したパッチがこちらです。
diff --git autoload/ref/clojure.vim autoload/ref/clojure.vim
index 994060f..36edeac 100644
--- autoload/ref/clojure.vim
+++ autoload/ref/clojure.vim
@@ -75,8 +75,16 @@ endfunction
" functions. {{{1
+let s:P = vital#of('vital').import('ProcessManager')
function! s:clj(code)
- return ref#system(ref#to_list(g:ref_clojure_cmd, '-'), a:code)
+ "return ref#system(ref#to_list(g:ref_clojure_cmd, '-'), a:code)
+ let t = s:P.touch('ref-clojure', g:ref_clojure_cmd)
+ if t ==# 'new'
+ call s:P.read_wait('ref-clojure', 2.0, ['user=> ', 'vim-ref=> '])
+ endif
+ call s:P.writeln('ref-clojure', a:code)
+ let [out, err, _] = s:P.read('ref-clojure', ['vim-ref=> '])
+ return {'stdout': out, 'stderr': err}
endfunction
function! s:to_overview(body)
ref.vimにはまだvital.vimが組み込まれていないため、とりあえずいまは&rtp
にあるvitalをつかうようvital#of('vital')
しています。このパッチを適用すると、これまでリファレンスを開くのに毎回最低2秒ほどかかっていたのが、速すぎてリファレンスを開いたことに気付けないレベルの速度で開くことができるようになりました。この速度差は、量的な変化とはもはや言いがたく、質的な変化と言わざるをえないものでした。
quickrun.vimはモジュールシステムが整備されており、以下のファイルを追加することで新しいquickrun用runnerであるprocess_manager
が利用可能になります。 (cpoなどは省略して本文だけ表示しています)
autoload/quickrun/runner/process_manager.vim
let s:runner = {
\ 'config': {
\ 'load': 'load %s',
\ 'prompt': '>>> ',
\ 'ignorelines': 0,
\ }
\ }
augroup plugin-quickrun-process-manager
augroup END
function! s:runner.run(commands, input, session)
let [out, err, t] = s:execute(
\ a:session,
\ a:session.runner.config.prompt,
\ substitute(
\ a:session.runner.config.load,
\ '%s',
\ a:session.config.srcfile,
\ 'g'))
call a:session.output(out . (err ==# '' ? '' : printf('!!!%s!!!', err)))
if t ==# 'matched'
return 0
else " 'timedout'
let key = a:session.continue()
augroup plugin-quickrun-process-manager
execute 'autocmd! CursorHold,CursorHoldI * call'
\ 's:receive(' . string(key) . ')'
augroup END
let self._autocmd = 1
let self._updatetime = &updatetime
let &updatetime = 50
endif
endfunction
let s:P = vital#of('vital').import('ProcessManager')
function! s:execute(session, prompt, message)
let type = a:session.config.type
let cmd = printf("%s %s", a:session.config.command, a:session.config.cmdopt)
let cmd = g:quickrun#V.iconv(cmd, &encoding, &termencoding)
let t = s:P.touch(type, cmd)
if t ==# 'new'
call s:P.read_wait(type, 5.0, [a:prompt])
endif
if a:message !=# ''
call s:P.writeln(type, a:message)
endif
return s:P.read(type, [a:prompt])
endfunction
function! s:receive(key)
let session = quickrun#session(a:key)
let [out, err, t] = s:P.read(session.config.type, [session.runner.config.prompt])
call a:session.output(out . (err ==# '' ? '' : printf('!!!%s!!!', err)))
if t ==# 'matched'
call session.finish(1)
return 1
else " 'timedout'
call feedkeys(mode() ==# 'i' ? "\<C-g>\<ESC>" : "g\<ESC>", 'n')
return 0
endif
endfunction
function! s:runner.sweep()
if has_key(self, '_autocmd')
autocmd! plugin-quickrun-process-manager
endif
if has_key(self, '_updatetime')
let &updatetime = self._updatetime
endif
endfunction
function! quickrun#runner#process_manager#new()
return deepcopy(s:runner)
endfunction
これがある状態でclojureやscalaのための設定を行なっておきます。以下を追加しておきます。
+\ 'clojure/process_manager': {
+\ 'command': 'clojure-1.5',
+\ 'runner': 'process_manager',
+\ 'runner/process_manager/load': '(load-file "%s")',
+\ 'runner/process_manager/prompt': 'user=> ',
+\ },
+\ 'scala/process_manager': {
+\ 'command': 'scala',
+\ 'cmdopt': '-nc -i /tmp/a.scala',
+\ 'runner': 'process_manager',
+\ 'runner/process_manager/load': ':load %s',
+\ 'runner/process_manager/prompt': 'scala> ',
+\ },
これでclojureやscalaの実行が神速になります。
一番のキモであるread_wait()
関数は以下のように定義されています。なお、read()
は単にread_wait()
にtimeout = 0.05
を指定しただけのwrapperです。
let s:_processes = {}
function! s:read_wait(i, wait, endpatterns)
if !has_key(s:_processes, a:i)
throw printf("ProcessManager doesn't know about %s", a:i)
endif
let p = s:_processes[a:i]
let out_memo = ''
let err_memo = ''
let lastchanged = reltime()
while 1
let [x, y] = [p.stdout.read(), p.stderr.read()]
if x ==# '' && y ==# ''
if str2float(reltimestr(reltime(lastchanged))) > a:wait
return [out_memo, err_memo, 'timedout']
endif
else
let lastchanged = reltime()
let out_memo .= x
let err_memo .= y
for pattern in a:endpatterns
if out_memo =~ ("\n" . pattern)
return [substitute(out_memo, pattern, '', ''), err_memo, 'matched']
endif
endfor
endif
endwhile
endfunction
この実装が非常に短いことに驚かれたかもしれません。なお、str2float(reltimestr(reltime(lastchanged)))
は時刻の比較のためのidiomです。
Vitalに追加されたProcessManagerについて、動機・目的・使い方・利用例・実装について駆け足で解説しました。皆様の開発するVimプラギンにぜひともご利用ください。またその際に見つけた不具合、あるいは根本的な仕様に関して、改善できそうなところがあればぜひともカジュアルに開発にご参加ください。なお、今日は著者であるujihisaさんの誕生日です。
- (*1) 英語での世界初の記事はまだ生まれていません。これは読者への課題といたします。
- (*2) -XX:+TieredCompilation -XX:TieredStopAtLevel=1 -Xverify:none
- (*3) CompilerServerというのが背後でこっそり立ち上がって二回目以降高速動作するようになる環境があります。が、それでも0.5秒以上かかるようです。
- (*4) 正確には、継続的に出力がでている間はブロックします。また、プロンプトに辿りつけなかった場合、0.05秒はブロックして様子をみます。が、ほとんど体感できるレベルではありません。
- (*5) そもそも著者がまだpullreqを送っていないため。