镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

stream: Readable batched iteration #34207

Closed as not planned
Closed as not planned

Description

@ronag

This is a continuation of #34035 and the promises session we had on OpenJS about async iteration performance of streams. One alternative discussed was to batch reading.

I was thinking we could do something along the lines of:

async function* createBatchedAsyncIterator(stream, batchLen) {
  let callback = nop;

  function next(resolve) {
    if (this === stream) {
      callback();
      callback = nop;
    } else {
      callback = resolve;
    }
  }

  stream
    .on('readable', next)
    .on('error', next)
    .on('end', next)
    .on('close', next);

  try {
    const state = stream._readableState;
    while (true) {
      let buffer;
      while (true) {
        const chunk = stream.read();
        if (chunk === null) break;
        if (!buffer) buffer = [];
        buffer.push(chunk);
        if (batchLen && buffer.length >= batchLen) break;
      }
      if (buffer) {
        yield buffer;
      } else if (state.errored) {
        throw state.errored;
      } else if (state.ended) {
        break;
      } else if (state.closed) {
        // TODO(ronag): ERR_PREMATURE_CLOSE?
        break;
      } else {
        await new Promise(next);
      }
    }
  } catch (err) {
    destroyImpl.destroyer(stream, err);
    throw err;
  } finally {
    destroyImpl.destroyer(stream, null);
  }
}

Readable.batched = function (stream, batchLen) {
  return createBatchedAsyncIterator(stream, batchLen);
}

Which would make the following possible:

// Concurrency
for await (const requests of Readable.batched(stream, 128)) {
  // Process in parallel with concurrency limit of 128.
  await Promise.all(requests.map(dispatch))
}

// Speed
for await (const requests of Readable.batched(stream, 128)) {
  for (const request of requests) {
    // All in the same tick
  }
}

It's still not perfect since if one element takes very long it would reduce concurrency. However, it would still be a step forward. Also reducing the async iteration overhead.

Activity

  1. added
    streamIssues and PRs related to Node.js streams.
    on Jul 4, 2020
  2. ronag commented on Jul 4, 2020

    @ronag
    MemberAuthor

    @nodejs/streams @benjamingr

  3. ronag commented on Jul 4, 2020

    @ronag
    MemberAuthor

    Another possible implementation with maybe better performance:

          
          stream.read(0);
          const buffer = state.length > 0 ? state.buffer.splice(0, batchLen) : null;
          if (buffer) yield buffer;
          else ...

    Though then it becomes a question on whether it's an array or just an iterable.

  4. mcollina commented on Jul 5, 2020

    @mcollina
    SponsorMember

    I think this is great.

    what do you think @jasnell?

  5. ronag commented on Jul 5, 2020

    @ronag
    MemberAuthor

    Giving this further thought, If you want there always to be max concurrency we could also go for a pattern like this:

    for await (const result of Readable.parallel(stream, async (chunk) => {
      // Process chunk
      return String(chunk).toUpperCase()
    }, 128)) {
      // Result chunk in order
    }
    
    for await (const result of Readable.unorderedParallel(stream, async (chunk) => {
      // Process chunk
      return String(chunk).toUpperCase()
    }, 128)) {
      // Result chunk not in order
    }

    Or something...

    for await (const chunks of pipeline(
      Readable.from([1,2,3]),
      Readable.parallel(async function* (chunk) {
        yield chunk * 2
      }, 128),
      // [2,4,6]
      Readable.batched(async function* (chunks) {
        yield chunks
      }, 128),
      // [[2,4,6]]
      Readable.parallel(async function* (chunks) {
        yield* chunks
        yield* chunks
      }, 128),
      // [2,4,6,2,4,6]
      Readable.batched(128),
      // [[2,4,6,2,4,6]]
    )) {
       for (const chunk of chunks) {
         console.log(chunk)
       }
    }
  6. ronag commented on Jul 5, 2020

    @ronag
    MemberAuthor

    Anyway, going back to to original proposal I think it's a good idea to put the stream parameter as the last argument, i.e. Readable.batched(128, stream) to allow for extensions such as the previous post.

  7. mcollina commented on Jul 5, 2020

    @mcollina
    SponsorMember

    I would use an option-object instead.

  8. addaleax commented on Jul 5, 2020

    @addaleax
    Member

    How would this effectively differ from increasing the readable HWM? The Readable.batched() here would trade off memory usage for speed, which is also what the HWM does, but increasing the HWM doesn’t increase latency whereas this would, right?

  9. ronag commented on Jul 5, 2020

    @ronag
    MemberAuthor

    How would this effectively differ from increasing the readable HWM?

    Would differ quite a bit in objectMode.

    1. We would reduce the number of yield invocation, e.g. with batch=128 you would do yield once instead of 128 times. Most of the overhead of async iterators comes from this. Also remember that between each item there has to be a tick, so we would also reduce the number of ticks from 128, to 1.

    2. It would allow the users to work on several items in parallel. Currently async iterators serializes everything and forces the user to work on one item at a time.

    What I suggest here would basically bring the HWM buffer from the stream directly into the iteration.

  10. imcotton commented on Jul 8, 2020

    @imcotton
    Contributor

    Just want to pitch in my 2 cents, instead of fixed batch size, how about accepting size dynamically?

    The syntax is actually quite simple:

    const { asyncReadable } = require('async-readable');
    
    const stream = createReadStream('./sample.gif');
    const { read, off } = asyncReadable(stream);
    
    const [ G, I, F, EIGHT ] = await read(4);
    const [ SEVEN_OR_NINE, A ] = await read(2);
    
    const width = (await read(2)).readUInt16LE(0);
    const height = (await read(2)).readUInt16LE(0);
    
    off();
    
    console.info({ width, height });

    Based on that, Async Generator could also comes in play:

    const { toReadableStream } = require('async-readable');
    
    const chop = toReadableStream(async function* ({ read }) {
    
        while (true) {
            const size = rand(1, 9);
            const chunk = await read(size);
            yield { size, chunk };
        }
    
    });
    
    for await (const { size, chunk } of chop(stream)) {
        console.info({ size, chunk });
    }

    More realistic examples are like 1) bitcoin block parser, or 2) socket5 client negotiator.

    If you were interested, the underlying implementations are only around 60 lines of code, not sure it would be a good fit in core, would love to have any feedback, thanks.

    edit: code sample correction

  11. ronag commented on Jul 8, 2020

    @ronag
    MemberAuthor

    Just want to pitch in my 2 cents, instead of fixed batch size, how about accepting size dynamically?

    I'm not sure I see how that differs from my example?

    for await (const { size, chunk } of chop(stream)) {
        console.info({ size, chunk }); // size === length, chunk == items
    }

    vs

    for await (const chunks of Readable.batched(128, stream)) {
        console.info(chunks); // chunks.length === length, chunks == items
    }
  12. imcotton commented on Jul 8, 2020

    @imcotton
    Contributor

    Its read :: number -> Promise<Buffer> part I'm referring to, the read signature.

  13. ronag commented on Jul 8, 2020

    @ronag
    MemberAuthor

    part I'm referring to, the read signature.

    Ah, I think that's a different topic though?

  14. imcotton commented on Jul 8, 2020

    @imcotton
    Contributor

    Sorry I don't quite follow, the topic here been around batch reading methodology and its implementation, no?

  15. ronag commented on Jul 29, 2020

    @ronag
    MemberAuthor

    Here is another case where this would be useful:

    const file = await fs.open()
    try {
      for await (const chunks of Readable.batched(source)) {
        await file.writev(chunks)
      }
    } finally {
      await file.close()
    }
  16. jimmywarting commented on Dec 7, 2020

    @jimmywarting

    whatwg talked about having a extendable queuingStrategy

    ReadableStream.from(asyncIterable, queuingStrategy = {})

  17. mcollina commented on Dec 7, 2020

    @mcollina
    SponsorMember

    @jimmywarting that would not help in this case. A significant part of the cost is having a promise per chunk. Essentially we'll need to have an alternative async iteration implementation that returns an array.

    In

    you can see that we could have an alternate implementation that returns an array of chunks instead.

  18. benjamingr commented on Dec 7, 2020

    @benjamingr
    Member

    I'm honestly fine with just landing one of the APIs Robert mentioned above as experimental and going from there they seem adequate.

  19. mcollina commented on Dec 7, 2020

    @mcollina
    SponsorMember

    I'm honestly fine with just landing one of the APIs Robert mentioned above as experimental and going from there they seem adequate.

    I think we should just add this in.

  20. github-actions commented on Jun 27, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 210 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  21. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Jun 27, 2026
  22. github-actions commented on Jul 28, 2026

    @github-actions
    Contributor

    This issue has been automatically closed after 30 days of inactivity following its stale status (no activity for a total of 120 days).
    If this is still relevant, feel free to reopen it or leave a comment with additional details so we can continue the discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.streamIssues and PRs related to Node.js streams.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions