🗂

recoilでループをするときは積極的にwaitForAllを使おう

2022/05/13に公開約3,600字

概要

recoilは非常に便利ですが、書き方によってはパフォーマンス上の問題が生じる場合があります。
その1つがselector内の配列やループの書き方にあります。

recoilの公式ドキュメントにも記述がありますが、ここでより詳しく取り上げます。

https://recoiljs.org/docs/guides/asynchronous-data-queries/#concurrent-requests

具体的な書き方

100人の生徒の成績があり、生徒の平均点を計算するselectorを考えます。

点数データや生徒idは次のようなAtomFamilyがあるとしましょう。

// 各生徒・各教科の点数
const scoreAtomFamily = atomFamily<number, [studentId:number, subject:string]>({
  key: 'scoreAtomFamily',
  default: ([studentId, subject]) => Math.floor(Math.random() * 100),
}); 

// 生徒の一覧
const studentsAtom = atom({
  key: 'studentsAtom',
  default: [...Array(100).keys()],
});

const subjects = ['英語', '数学', '国語', '理科', '社会'];

あまりよくない書き方

const rankingSelector = selector({
    key: 'rankingSelector',
    get: ({get}) => {
        const studentIds = get(studentsAtom);
        for (const studentId of studentIds) {
            let sum = 0;
            for (const subject of subjects) {
                sum += get(scoreAtomFamily([studentId, subject]))
            }
        }

        // ソート
        // return ...
    }
})

今回の例ではこのselectorは一瞬で完了します。常にダメというわけでもなく、一瞬で完了するとわかりきっている場合は問題のないコードです。
ただし実際のコードではscoreAtomFamilyから点数が取得できるのではなくAPIアクセスなどを伴うなど時間がかかるケースも多いです。仮にそうだと仮定したときの問題点を考えてみましょう。

1つ値が変わるだけでrankingSelector全体が再計算される

1人の生徒の点数が変わっただけでも全員分の合計点を再計算することになります。

getが400回逐次的に呼ばれる

for文の中でawaitするとパフォーマンス上の問題が生じやすいことと同質の問題です。一切並列化されず逐次的に点数を取得することになります。
得点の取得が0.1秒かかる場合、全部で40秒もかかります。

ループ内を別selectorにしよう


const sumScoreSelectorFamily = atomFamily<number, string>({
  key: 'sumScoreSelectorFamily',
  get: (studentId) => ({get}) => {
        let sum = 0;
        for (const subject of subjects) {
            sum += get(scoreAtomFamily([studentId, subject]))
        }
        return sum;
  },
}); 

const rankingSelector = selector({
    key: 'rankingSelector',
    get: ({get}) => {
        const studentIds = get(studentsAtom);
        for (const studentId of studentIds) {
            const sum = get(sumScoreSelectorFamily(studentId))
        }

        // ソート
        // return ...
    }
})

1人の生徒の点数が変わったとき、全員分の合計点を再計算するのではなく、その生徒の合計点のみが再計算されるようになりました。ほかの生徒の合計点はキャッシュが使われます。
(もちろんソートは全員を対象として行われます)

getが逐次的な問題は引き続き残っています。

ループをwaitForAllに変える

const sumScoreSelectorFamily = atomFamily<number, string>({
  key: 'sumScoreSelectorFamily',
  get: (studentId) => ({get}) => {
        const sum = get(waitForAll(subjects.map(sbj => sumScoreSelectorFamily([studentId, sbj])))).reduce((a, b)=>{ return a+b; }, 0);
        return sum;
  },
}); 

const rankingSelector = selector({
    key: 'rankingSelector',
    get: ({get}) => {
        const studentIds = get(studentsAtom);
        const sums = get(waitForAll(studentIds.map(s => sumScoreSelectorFamily(s))));

        // ソート
        // return ...
    }
})

getが並列化されました。得点の取得に0.1秒かかる場合、全体もおおよそ0.1秒で完了します。
ところでこの書き方だと400個のAPIリクエストが投げられるかもしれません。これを防ぐにはdataloaderというライブラリを用い、リクエストをバッチ化するとよいです。

https://recoiljs.org/docs/api-reference/utils/waitForAll/

https://github.com/graphql/dataloader

waitForNone

waitForAllはすべてのselectorの完了を待ちます。waitForNoneを使うと並列化しつつ、完了したselectorの値から順に処理をすることができます。ただし、部分的に完了、部分的に未完了なLoadableなリストの配列を扱うことになるのでコードはだいぶ複雑になります。必要がない場合はwaitForAllが無難です。

まとめ

  • selectorでループが登場するとき、ループ内を別selectorにしよう
  • waitForAllを使って並列化しよう

Discussion

ログインするとコメントできます