feature image

2019年11月2日 | ブログ記事

traQのmarkdownのパースをWeb Workerでやるようにした話【AdC2019 3日目】

この記事はtraPアドベントカレンダー2019の 3日目(11/2) の記事です。
19のSysAd班のsappi_redです。
今回は部内サービスに行った改善について話したいと思います。

遅い、遅い、遅すぎる

traQという部内SNS[1]ではメッセージ中でmarkdown記法を利用できます。

また、このサークルの性質上プログラムのソースコードが投稿されることが多くあります。GitHubのwebhookによって投稿されるメッセージもかなり多くあります。これらにはソースコードが含まれていることが多いです。

code

markdownのパースが重いチャンネルを開くとタブがフリーズして操作を受け付けなくなるということが発生していました。特にシンタックスハイライトがめちゃくそ重かったので、長いコードが投稿されているチャンネルが遅かったです。

off-the-main-thread

そこで、この問題を解決するためにメインスレッドではないところでmarkdownのパースをするようにしました(off-the-main-thread)。ブラウザでメインスレッドではないスレッドでJavaScriptを実行する方法としては、Worklet[2]などもありますが、今回はWeb Workerを利用しています。

また、タブがフリーズするのを改善する手段としては別スレッドで実行する以外にも方法があります。フリーズが発生する原因は実行するスクリプトの処理が分割されていないためです[3]。したがって、requestIdleCallbackrequestAnimationFramesetTimeoutなどで処理を細切れに分割することによりフリーズすることを回避できます。しかし、シンタックスハイライトはライブラリに依存していてそのようにするのは厳しかったため、今回は採用しませんでした。また、処理が長いならばスレッドを分けてしまったほうがよいということと、処理の強制終了がしやすいということもあります。

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を利用するようにしました。これによって、チャンネル切り替え時もスムーズなパースができるようになりました。

結果

WebWorker化前
WebWorker化後

上が改善前、下が改善後です。
このようにだいぶもたつきが改善されました。

参照

これが実際のプルリクエストです。
https://github.com/traPtitech/traQ-UI/pull/965
https://github.com/traPtitech/traQ-UI/pull/1102


  1. /post/309/#1traq ↩︎

  2. Workletの一種のPaint WorkletについてtraP SysAd techbookに記事を書いています。よろしければぜひ。 ↩︎

  3. 参考: JavaScript のスレッド並列実行環境 1. レンダリングエンジンと JavaScript の実行モデル - nhiroki's weblog ↩︎

  4. 自動で細切れにするConcurrent.Threadという大昔の黒魔術ライブラリがあったりする(今でも動くかわからない) ↩︎

sappi_red icon
この記事を書いた人
sappi_red

19B。SysAd班。 JavaScript書いたりTypeScript書いたりGo書いたりRust書いたり…

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2022年4月5日
アーキテクチャとディレクトリ構造
mazrean icon mazrean
2021年5月16日
CPCTFを支えたインフラ
mazrean icon mazrean
2022年8月13日
traQにOBからバグ報告が来た
logica icon logica
2022年3月27日
ReactでToDoリストを作る(後編)
mehm8128 icon mehm8128
2022年3月19日
ReactでToDoリストを作る(中編)
mehm8128 icon mehm8128
2022年3月18日
ReactでToDoリストを作る(前編)
mehm8128 icon mehm8128
記事一覧 タグ一覧 Google アナリティクスについて