Почему асинхронный цикл File.stream().getReader().read() может блокировать основной поток?

<input type = "file" id = "el">
<code id = "out"></code>
const el = document.getElementById('el');
const out = document.getElementById('out');
el.addEventListener('change', async () => {
  const file = el.files?.[0];
  if (file) {
    const reader = file.stream().getReader();
    out.innerText = JSON.stringify({ fileSize: file.size });
    let received = 0;
    while (true) {
      const chunk = await reader.read();
      if (chunk.done) break;

      // chunk.value.forEach((it) => it + 1);

      received += chunk.value.byteLength;
      out.innerText = JSON.stringify({
        fileSize: file.size,
        process: `${received} / ${file.size} (${((received / file.size) * 100).toFixed(2)}%)`,
      });
    }
  }
});

Приведенный выше код работает хорошо, <code> будет показывать прогресс в режиме реального времени. Но если я добавлю строку chunk.value.forEach((it) => it + 1);, то основной поток как бы блокируется, страница перестает отвечать до завершения обработки файла. (Тест в Edge 125)

Я могу использовать requestAnimationFrame, чтобы это исправить. Но почему это происходит, есть ли способ лучше, чем requestAnimationFrame?

----редактировать
chunk.value.forEach((it) => it + 1); — это упрощение реального кода. Я хочу вычислить md5 файла.

Браузер, похоже, ограничивает размер каждого фрагмента, чтобы каждый цикл длился около 16 мс. Дополнительный код практически не влияет на время и размер фрагмента.

while(true) {
  const start = Date.now();
  // ...
  console.info('chunk', Date.now() - start, chunk.value.byteLength);
}

// chunk 7 524288
// chunk 17 1572864
// chunk 25 2097152
// chunk 16 2097152
// chunk 18 2097152
// chunk 15 2097152
// chunk 16 2097152
// ...

🤔 А знаете ли вы, что...
JavaScript позволяет создавать мобильные приложения для iOS и Android с использованием фреймворков, таких как React Native и NativeScript.


57
1

Ответ:

Решено

Боюсь, такое поведение на самом деле соответствует спецификациям, даже если оно приводит к ужасным впечатлениям...

Шаги Pull контроллера ReadableStream могут синхронно возвращать уже поставленные в очередь фрагменты. Таким образом, если ваш обратный вызов в реакции read() выполняется дольше, чем файловая система занимает постановку в очередь новых фрагментов, браузер никогда не вернет управление обратно в цикл событий, и вы попадете в цикл микрозадач, что в значительной степени блокирует пользовательский интерфейс.

Интересно, что Firefox, похоже, где-то ставит задачу в очередь, поскольку они не блокируют пользовательский интерфейс, в отличие от Chrome. Возможно, на уровне спецификаций можно было бы обосновать это стандартом. (Однако Chrome демонстрирует поведение блокировки в течение довольно долгого времени, по крайней мере, M84, поэтому его нельзя рассматривать как такую ​​​​большую проблему...).

На данный момент, чтобы избежать этого, вы можете поставить задачу в очередь из обратного вызова. Для этого быстрее всего было бы использовать все еще экспериментальный метод Scheduler.postTask() с приоритетом «блокировка пользователей»,

await scheduler.postTask(() => {}, { priority: "user-blocking" });

а для браузеров, которые до сих пор не поддерживают этот метод, вы можете пропатчить его с помощью MessageChannel()

{
  const { port1, port2 } = new MessageChannel();
  globalThis.scheduler ??= {
    postTask(cb, options) {
      return new Promise((resolve, reject) => {
        port1.addEventListener("message", () => {
          try { resolve(cb()); } catch(err) { reject(err); }
        }, { once: true });
        port2.postMessage("");
        port1.start();
      });
    }
  };
}

// monkey-patch scheduler.postTask(cb, { priority: "user-blocking" })
{
  const { port1, port2 } = new MessageChannel();
  globalThis.scheduler ??= {
    postTask(cb, options) {
      return new Promise((resolve, reject) => {
        port1.addEventListener("message", () => {
          try { resolve(cb()); } catch(err) { reject(err); }
        }, { once: true });
        port2.postMessage("");
        port1.start();
      });
    }
  };
}
scheduler.postTask(() => {}).then(() => console.info("yep"))
const el = document.getElementById('el');
const out = document.getElementById('out');
el.addEventListener('change', async () => {
  const file = el.files?.[0];
  if (file) {
    const reader = file.stream().getReader();
    out.innerText = JSON.stringify({ fileSize: file.size });
    let received = 0;
    while (true) {
      const chunk = await reader.read();
      if (chunk.done) break;
      // Lock for 100ms
            t1 = performance.now();
      while(performance.now() - t1 < 100) {}
      await scheduler.postTask(() => {}, { priority: "user-blocking" });
      received += chunk.value.byteLength;
      out.innerText = JSON.stringify({
        fileSize: file.size,
        process: `${received} / ${file.size} (${((received / file.size) * 100).toFixed(2)}%)`,
      });
    }
  }
});
<input type = "file" id = "el">
<code id = "out"></code>

Но в вашем случае лучше всего использовать Web Worker,

и отправить туда ваш файл, потому что даже если, поставив задачу в очередь в цикле, мы дадим циклу событий немного передохнуть, он все равно будет с трудом справляться со всеми обновлениями пользовательского интерфейса при таком небольшом количестве доступного времени. Обратите внимание, что отправка объекта File (или Blob) в рабочий контекст не копирует данные, поэтому вам не нужно беспокоиться об использовании памяти для этого.


Ps: Я открыл BUG 355256389, посмотрим, как пойдет.