Skip to content

Instantly share code, notes, and snippets.

@ujihisa ujihisa/vac193.md
Last active Dec 18, 2015

Embed
What would you like to do?

Vim Advent Calendar 2012 ujihisa 11 (193日目)

Vim Advent Calendar 2012 の193日目の記事です。今日は著者の誕生日です。

Vital.ProcessManager

Vitalに入ったばかりの、開発されたほかほかのモジュールProcessManager。これはVim業界を震撼させると言われています。本記事はこのProcessManagerについて日本語では世界初の記事です。 (*1)

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を実際に使ったプラギン (のプロトタイプ)

著者がいくつか既存のプラギンをProcessManagerを用いて高速化してみました。いずれもまだ本家にマージされていません(*5)。

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の実行が神速になります。

ProcessManagerの実装

一番のキモである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を送っていないため。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.