Worklet: функция, уехавшая в другой поток

движение≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Строка подхода из прошлой статьи едет за пальцем, только пока свободен JS-поток: там выполняется весь PanResponder. Нативный драйвер тут не выручил — в другой поток он увозит описание из короткого словаря, а не ваш код. Следующий шаг напрашивается: отправить во второй поток сам код жеста. Так делает Reanimated, и функция, которая туда переезжает, называется worklet. Сам Reanimated в предпросмотр не попадёт (почему — следующая статья), поэтому второй поток здесь будет браузерный и настоящий — Web Worker. Отправим туда решение свайпа и посмотрим, что доедет. Этаж — сторонние библиотеки react-native-reanimated 4.5 и react-native-worklets 0.10: своих worklet-ов нет ни у React Native, ни у Expo.

Разделы темы:·

Второй поток лежит в подложке примеров. Это Worker: браузер выполняет его код в отдельном потоке, со своей кучей и своим глобальным объектом. Общих переменных со страницей у него нет, дорога одна — сообщение: postMessage на одной стороне, onmessage на другой. Пример говорит с потоком через sendToWorker из подложки: она отправляет значение, а второй поток отвечает текстом того, что получил. Сам Worker пример не называет, как не называет document: на телефоне его нет, и в типах React Native — тоже. Назовите — и компилятор ответит «Не удается найти имя "Worker"».

Отправим то, что нужно решению свайпа: состояние жеста { dx, vx } — и сразу после отправки поменяем в нём dx. Потом отправим само решение. Прогноз: что получит второй поток и в каком порядке появятся строки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Функцию не пропустили — и не молча, как JSON.stringify в модели границы из статьи про нативный драйвер, а исключением DataCloneError прямо в вызове postMessage: строка с ошибкой стоит раньше «отправка кончилась». Текст ошибки здесь от Chrome, в других браузерах слова другие, а имя то же. Состояние жеста дошло таким, каким было в момент отправки: dx равен −120, хотя строкой ниже его обнулили. И дошло последним: ответ второго потока — тоже сообщение, и этот поток прочёл его, только закончив свой такт.

Сообщение копирует структурное клонирование — алгоритм, общий для postMessage и structuredClone (сам structuredClone разобран в статье части I «Копирование объектов: поверхностное и глубокое»). Он обходит значение и строит такое же в куче получателя, и связи с оригиналом у копии нет. Функцию он не копирует: функция — не только текст, но и окружение, в котором её создали, а окружение живёт в куче отправителя и в чужую не переносится.

Что отправляютЧто получит второй поток
число, строку, true, nullто же значение
массив, простой объект, Date, Map, Setкопию всей глубины, снятую в момент отправки
функцию или объект, где среди полей есть функцияничего: postMessage бросает DataCloneError, сообщение не уходит

Раз функцию не скопировать, отправим её текст: toString() у функции отдаёт исходник. На этом стоит runOnWorker(функция, ...аргументы) из подложки: текст и аргументы уходят сообщением, второй поток собирает из текста функцию, вызывает её и присылает результат, а runOnWorker отдаёт его промисом. Отправим одно и то же решение в двух видах: decideWith получает ширину аргументом, decide берёт её снаружи — как строка подхода в прошлой статье. Прогноз?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

decideWith доехала и решила так же, как здесь. decide, которая в этом потоке работает, во втором упала: WIDTH там не объявлена. Текст функции несёт имя WIDTH, но не значение. Значение лежит в окружении, где функцию создали, — в её замыкании (как функция помнит окружение, разобрано в статье части I «Замыкания: как функция помнит своё окружение»). Окружение осталось в этой куче, а во второй текст собрался заново — среди глобалей Worker, где никакой WIDTH нет.

Почему бы не отправить окружение вместе с текстом? Потому что из JavaScript его не видно. Посмотрим, что можно узнать о замыкании, имея только функцию. Прогноз: будут ли в тексте типы и найдётся ли среди собственных ключей функции хоть что-то про WIDTH?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Типов в тексте нет: toString отдаёт то, что выполняется, а выполняется код после компилятора. Замыкания нет ни в тексте, ни среди ключей: у стрелки их два, length и name. Спросить у функции «какие внешние переменные ты держишь и чему они равны» в языке нечем — замыкание видит только движок. Значит, библиотека, которая хочет увезти функцию в другой поток, во время выполнения не узнает, что прихватить с собой.

Узнать это можно по исходнику, пока код ещё не собран: найти в теле функции имена, объявленные снаружи, и приложить к функции их значения. Это работа для сборки, и это та самая причина, по которой понадобились worklet-ы. Worklet — функция, у которой текст и список внешних имён собраны заранее, при сборке, и поэтому она переносится в другой поток целиком.

Сделаем за сборку её работу руками. worklet(функция, замыкание) из подложки ничего не вычисляет: она кладёт в свойство __closure объект с внешними переменными, которые перечислили вы. runOnWorker, увидев такое свойство, отправляет вместе с текстом копию этого объекта. Второй поток превращает ключи копии в параметры обёртки и вызывает её со значениями — внутри обёртки текст функции снова стоит в окружении, где эти имена есть:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Остаётся вопрос, когда снимается копия замыкания: при каждой отправке, как состояние жеста в первом разделе, или однажды. Проверим на пороге свайпа: в «Подходе» он лежит в настройках, и его меняют, не уходя с экрана. Прогноз: что ответят оба потока после правки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первый ответ второго потока — «удаляем»: порог доехал, и 150 точек больше сотни. Но и после правки второй поток ответил «удаляем», а этот — «назад». Копия замыкания снята при первой отправке, и правка до неё не дошла. У одной функции в двух потоках теперь разные пороги, и заметно это только по свайпу, который удалил лишнее.

Снимать копию однажды — не прихоть миниатюры. scheduleOnUI из react-native-worklets переводит worklet вместе с замыканием в переносимый вид и кеширует перевод: в комментарии к ней сказано, что для одного worklet-а перевод делается один раз. А чтобы правка вроде нашей не проходила молча, в отладочной сборке объекты замыкания после перевода замораживаются:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Присваивание такому ключу значения не меняет, а печатает «[Worklets] Tried to modify key `swipe` of an object which has been already passed to a worklet…». В выпускной сборке заморозки нет, и расклад там наш: этот поток видит новый порог, второй — старый. Значение, которое меняют и читают обе стороны, в Reanimated держат не в замыкании, а в shared value — о нём следующая статья. А worklet, объявленный в компоненте, создаётся заново в каждой отрисовке и увозит пропсы и состояние этой отрисовки — тот же снимок, что в статье части IV «Состояние — снимок, а не переменная», только его копия живёт в чужой куче.

Перечислять замыкание руками неудобно и опасно: забытое имя — тот же ReferenceError, только на телефоне и в чужом потоке (первая задача). Поэтому в Reanimated замыкание перечисляет сборка. Функцию помечают строкой "worklet" в начале тела, а Babel-плагин react-native-worklets находит в ней внешние имена и переписывает определение. Вот что он сделал с решением свайпа — настоящий вывод плагина:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · вывод Babel-плагина react-native-worklets 0.10.1, сборка production
const WIDTH = 300;
const _worklet_9180618560978_init_data = {
code: "function swipeJs1(dx){const{WIDTH}=this.__closure;return dx<-WIDTH/3;}"
};
const isGone = function swipeJs1Factory({
_worklet_9180618560978_init_data,
WIDTH
}) {
const swipeJs1 = function (dx) {
return dx < -WIDTH / 3;
};
swipeJs1.__closure = {
WIDTH
};
swipeJs1.__workletHash = 9180618560978;
swipeJs1.__initData = _worklet_9180618560978_init_data;
return swipeJs1;
}({
_worklet_9180618560978_init_data,
WIDTH
});

Узнаётся всё. __closure — второй аргумент нашей worklet, только собранный плагином. В __initData.code — текст функции, но не тот, что вернул бы toString: плагин кладёт его в сборку заранее, сжатым, и переписывает так, что внешние имена читаются из this.__closure. Остальное делает второй рантайм, и делает как наш Worker: в valueUnpacker.native.ts он собирает функцию из этого текста через eval (в отладочной сборке — через его варианты evalWithSourceMap и evalWithSourceUrl, чтобы отладчик знал, откуда код) и привязывает её к объекту, где лежит копия замыкания, — отсюда и this. Зачем плагин обязателен и что ещё он делает — следующая статья.

Зачем всё это. В статье про нативный драйвер опыт с занятым JS-потоком дал в предпросмотре одинаковый ответ для обоих прогонов: второго потока не было, и простояло всё. Теперь он есть. Повторим опыт: самую длинную паузу между тиками таймера меряют оба потока, а этот, как только второй откликнется, на 400 мс уходит в тяжёлую работу. longestPause уезжает во второй поток одним текстом — внешних имён у неё нет. Рядом идёт короткий замер на 300 мс: во втором потоке он кончится раньше, чем этот освободится. Прогноз: какие будут паузы и когда этот поток узнает о коротком замере?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Разошлись: этот поток простоял больше 300 мс, второй не заметил ничего. Короткий замер показал обратную сторону. Второй поток досчитал его к 300-й миллисекунде, но ответ прочитан, только когда этот поток освободился: сообщение ждёт в очереди получателя, как события onAnimatedValueUpdate ждут слушателей нативной анимации. Всё, что второй поток делает сам, делается вовремя; всё, что он отдаёт этому, ждёт.

Отсюда граница миниатюры. Worker рисовать не умеет: у него нет ни document, ни доступа к экрану, и результат его работы всё равно приходит в поток страницы и ждёт очереди. Строка подхода, которую двигал бы Worker, ехала бы за пальцем не раньше, чем освободится занятый поток. Второй рантайм Reanimated живёт в другом месте — в главном потоке, том самом, который рисует интерфейс и принимает касания. Worklet жеста выполняется там же, где приходят касания, и новый стиль попадает в вид без очереди к JS-потоку; поэтому жест на Reanimated и не проседает. Как именно — следующая статья.

Устройство переезда на телефоне то же: текст функции, копия замыкания, второй рантайм со своей кучей. Отличается всё вокруг.

Миниатюра: Web WorkerТелефон: react-native-worklets 0.10
Где второй потокWorker: отдельный поток со своей кучей, без экранаUI-рантайм: второй экземпляр JS-движка в главном потоке, рядом с экраном
Откуда текст функцииtoString() в момент отправки__initData.code: плагин положил его в сборку
Кто перечисляет замыканиевы, вторым аргументом workletBabel-плагин, по директиве "worklet"
Копия замыканияstructuredClone при первой отправкеперевод в переносимый вид при первой отправке, с кешем; в отладочной сборке объекты замораживаются
ВызовrunOnWorker(fn, ...args) — ответ промисомscheduleOnUI(fn, ...args) — без ответа; runOnUISync ждёт ответа синхронно
Обычная функция вместо worklet-ауезжает текстом и падает на первом внешнем именив отладочной сборке scheduleOnUI бросает «[Worklets] `scheduleOnUI` can only be used with worklets.»
Обратно в JSответ сообщениемscheduleOnRN(fn, ...args): вызов встаёт в очередь JS-потока

Последняя оговорка — про веб-версию приложения на Expo. Даже будь Reanimated в предпросмотре, второго потока там не было бы: у react-native-worklets для веба свой файл threads.ts, и его scheduleOnUI выполняет worklet в том же потоке, в ближайшем кадре:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В вебе worklet — обычная функция, которую вызывают в ближайшем кадре, и копия её замыкания никуда не едет. То, что в этой статье ломалось во втором потоке, веб-версия не покажет, а телефон покажет. Поэтому второй поток здесь браузерный, а worklet-ы проверяют на телефоне.

На телефоне иначе: worklet выполняется во втором JS-рантайме в главном потоке, и попадают туда только текст функции, положенный в сборку плагином, и копия её замыкания. В веб-версии второго рантайма нет: scheduleOnUI вызывает worklet в том же потоке, в ближайшем кадре.

Второй поток, sendToWorker, runOnWorker и worklet — в том виде, в каком они лежат в подложке и едут в следующую статью. В подложке первой задачи рядом с ними — палец и строка подхода из прошлой статьи, которая спрашивает решение отпускания у второго потока.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

  • Потоки не делят память: между ними ходят только сообщения, и структурное клонирование копирует их в момент отправки. Функцию оно не копирует и бросает DataCloneError.
  • Текст функции (toString) переносится, но несёт имена, а не значения: во втором потоке внешняя переменная даёт ReferenceError. Замыкание осталось в куче того, кто функцию создал.
  • Замыкание не видно из JavaScript: ни текст, ни свойства функции не называют её внешних переменных. Их находят по исходнику, при сборке, — поэтому worklet-ы делает Babel-плагин: __closure со значениями и __initData.code с текстом.
  • Копия замыкания снимается однажды: правка после первой отправки до второго потока не доходит. react-native-worklets кеширует перевод и в отладочной сборке замораживает объекты замыкания. Меняющееся передают аргументом, общее — держат в shared value.
  • Второй поток не ждёт занятого первого, но всё, что он отдаёт обратно, ждёт очереди. Worker рисовать не умеет, а UI-рантайм Reanimated живёт в главном потоке, рядом с экраном. В веб-версии второго рантайма нет: scheduleOnUI вызывает worklet в том же потоке.

1/4Второму потоку отправляют { dx: -120, decide }, где decide — функция. Что будет?

2/4Функцию отправили во второй поток текстом через toString(). Внутри она читает внешнюю константу WIDTH. Что будет при вызове?

3/4Почему worklet-ам нужен плагин сборки, а не одна библиотека?

4/4Worklet прихватил объект limits и уже был отправлен во второй поток. Потом в JS поменяли limits.swipe. Что увидит второй поток при следующем вызове того же worklet-а?

Бросок не удаляет подход

Строка подхода из прошлой статьи теперь спрашивает решение у второго потока: в подложке лежит SwipeRow, чей onPanResponderRelease отдаёт decide, сдвиг dx и скорость vx в runOnWorker и ждёт ответа. Удалять подход нужно не только за длинный свайп, но и за бросок — короткий и быстрый. Сейчас бросок строку возвращает. Почините decide, не трогая Sets.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Второй поток сказал прямо: FLING в замыкании нет. Перечислить нужно все внешние имена, которые встречаются в теле:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь бросок удаляет подход: второй строкой приходит подходы: 82,5 кг × 6 | 85 кг × 5. Заметьте, что длинный свайп удалял и со сломанным замыканием: на нём dx < -WIDTH / 3 истинно, и || до FLING не доходит. Забытое имя прячется в ветке, до которой не дошло, и падает в первый раз, когда до неё дойдут, — на телефоне это будет чужой поток и чей-то палец. Плагин react-native-worklets так не ошибается: он собирает внешние имена из всего тела, в том числе из веток, которые ещё ни разу не выполнялись. И ещё одна разница: наша строка ждёт ответа сообщением, а в Reanimated весь жест выполняется во втором рантайме и решает сам, без поездки туда и обратно.

Порог из настроек

Порог свайпа лежит в настройках. Его подняли до 200 точек, но второй поток по-прежнему удаляет подход на 150. Сделайте так, чтобы второй поток решал по текущему порогу. settings и scenario не трогайте.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Замыкание копируется один раз, при первой отправке, а аргументы — при каждом вызове. Всё, что меняется между вызовами, передают аргументом:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй раз приходит порог 200, свайп -150: назад. Работает и другой путь — после правки создать новый worklet: это новый объект, и копия его замыкания снимется заново. В компоненте так и выходит само собой: worklet, объявленный в теле, создаётся в каждой отрисовке. Но если значение читают во втором потоке каждый кадр, а меняют в JS когда угодно, ни аргумент, ни пересоздание не годятся — для этого в Reanimated есть shared value, о нём следующая статья.

Счёт удалённых не растёт

Worklet отпускания считает удалённые подходы в объекте stats из замыкания. Три свайпа, два из них удаляют, а счёт скачет между единицей и нулём, и в этом потоке остаётся ноль. Сделайте так, чтобы счёт рос: 1, 1, 2 — и 2 в этом потоке. scenario можно менять.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Каждый вызов получает во втором потоке свою копию stats: прибавленное к ней живёт, пока идёт вызов, и обратно не возвращается. У счёта должен быть один хозяин, и хозяин — тот поток, где счёт читают. Второй поток пусть только решает:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: 1, 1, 2 и 2. Это то же правило, что у нативного драйвера, — у значения один хозяин кадров. В Reanimated изменение, сделанное worklet-ом, в JS само тоже не вернётся: результат отправляют явно через scheduleOnRN(функция, ...аргументы), и вызов встаёт в очередь JS-потока. А значение, которое по-настоящему нужно обеим сторонам, держат в shared value.