この記事はtraPアドベントカレンダー2019の 3日目(11/2) の記事です。
19のSysAd班のsappi_redです。
今回は部内サービスに行った改善について話したいと思います。
遅い、遅い、遅すぎる
traQという部内SNS[1]ではメッセージ中でmarkdown記法を利用できます。
また、このサークルの性質上プログラムのソースコードが投稿されることが多くあります。GitHubのwebhookによって投稿されるメッセージもかなり多くあります。これらにはソースコードが含まれていることが多いです。
markdownのパースが重いチャンネルを開くとタブがフリーズして操作を受け付けなくなるということが発生していました。特にシンタックスハイライトがめちゃくそ重かったので、長いコードが投稿されているチャンネルが遅かったです。
off-the-main-thread
そこで、この問題を解決するためにメインスレッドではないところでmarkdownのパースをするようにしました(off-the-main-thread)。ブラウザでメインスレッドではないスレッドでJavaScriptを実行する方法としては、Worklet[2]などもありますが、今回はWeb Workerを利用しています。
また、タブがフリーズするのを改善する手段としては別スレッドで実行する以外にも方法があります。フリーズが発生する原因は実行するスクリプトの処理が分割されていないためです[3]。したがって、requestIdleCallback
やrequestAnimationFrame
やsetTimeout
などで処理を細切れに分割することによりフリーズすることを回避できます。しかし、シンタックスハイライトはライブラリに依存していてそのようにするのは厳しかったため、今回は採用しませんでした。また、処理が長いならばスレッドを分けてしまったほうがよいということと、処理の強制終了がしやすいということもあります。
Web Worker化
Web Workerの関数はメインスレッドから直接呼び出すことはできず、Workerとメインスレッドの間の通信はpostMessage()
に限られています。これでは、非常に使いにくいので今回はworkerize-loader
を利用することにしました。このWebpack loaderは、Workerの関数をメインスレッドから通常の非同期関数のように呼び出せるようにするものです。
WebWorker側にmarkdownをパースする関数があって、メインスレッドでそれを利用したい場合は以下のように利用できます。
import Md from 'workerize-loader!@/worker/markdown'
const md = new Md() // ここでWorkerが生成される
export const parseSplit = async text => {
const res = await md.parse(text)
return res.split('\n')
}
Vuexの同期
さて、markdownのパースをWorkerで行うことにしました。しかし、処理をWorkerで動かすだけではうまく行きません。traQのmarkdownでは、メンション機能だったりスタンプ機能だったりのために、メンバーやチャンネルやスタンプの情報がパースの際に必要になります。この情報はVuexで管理されています。しかし、WorkerはメインスレッドのJavaScriptからスコープが分離されているので直接Vuexのデータを参照できません。このため、Workerで利用するデータはWorkerで保持しておかなければなりません。
これを実現するために、Workerの生成時にデータのリクエストを行って、その後はVuexのstore.watch()
で変更時に同期されるようにしました。
チャンネル切り替え
これで、タブがフリーズすることから解放されました。しかし、まだ問題があります。一度重いチャンネルを開いてしまうと、別のチャンネルに移動してもそのチャンネルのパースが終了しない限り、今見ているチャンネルのメッセージがパースされません。
これを解決するためにまず試してみたのは、パースのタスクの登録を一斉に行うのではなく、タスクの登録を一つに限ることで直列化した上で、AbortController
を利用して終了したいタイミングを検知することです。しかし、これだと一つのタスクが長いときにそのタスクを待つ必要が生じました。これは、off-the-main-threadの章で話したようにタスクをさらに細かく分割することで改善することができますが保守性の面で行いませんでした[4]。
さて、タスクを無理やり終了させたい。Worker自体を終了させればいいのでは?ここでWorker.terminate()
の出番です。チャンネル切り替え時に使っていたWorkerをメインスレッドからWorker.terminate()
を利用して処理中だとしても問答無用で終了させ、別のWorkerを利用するようにしました。これによって、チャンネル切り替え時もスムーズなパースができるようになりました。
結果
上が改善前、下が改善後です。
このようにだいぶもたつきが改善されました。
参照
これが実際のプルリクエストです。
https://github.com/traPtitech/traQ-UI/pull/965
https://github.com/traPtitech/traQ-UI/pull/1102
/post/309/#1traq ↩︎
Workletの一種のPaint WorkletについてtraP SysAd techbookに記事を書いています。よろしければぜひ。 ↩︎
参考: JavaScript のスレッド並列実行環境 1. レンダリングエンジンと JavaScript の実行モデル - nhiroki's weblog ↩︎
自動で細切れにするConcurrent.Threadという大昔の黒魔術ライブラリがあったりする(今でも動くかわからない) ↩︎