如何创建一个通用的分页Spliterator?

25
我希望能够处理从必须按页面访问的源读取的Java流。首先,我实现了一个分页迭代器,当当前页面用完项目时,简单地请求页面,然后使用StreamSupport.stream(iterator, false)获取迭代器上的流句柄。

由于我的页面获取成本很高,因此我想通过并行流方式访问页面。在这一点上,我发现由Java直接提供的spliterator实现导致了我天真方法提供的并行性不存在。由于我实际上对我想要遍历的元素非常了解(我知道在请求第一页后的总结果计数,而且该源支持偏移量和限制),所以我认为应该可以实现自己的spliterator,以实现真正的并发性(无论是在页面元素上的工作还是在查询页面上)。

我已经很容易地实现了“元素上的工作”并发性,但在我的初始实现中,页面的查询仅由最顶层的spliterator执行,因此无法从fork-join实现提供的工作分割中受益。

如何编写一个既实现这两个目标的spliterator?

供参考,我将提供到目前为止我所做的事情(我知道它没有适当地分配查询)。

   public final class PagingSourceSpliterator<T> implements Spliterator<T> {

    public static final long DEFAULT_PAGE_SIZE = 100;

    private Page<T> result;
    private Iterator<T> results;
    private boolean needsReset = false;
    private final PageProducer<T> generator;
    private long offset = 0L;
    private long limit = DEFAULT_PAGE_SIZE;


    public PagingSourceSpliterator(PageProducer<T> generator) {
        this.generator = generator;
    }

    public PagingSourceSpliterator(long pageSize, PageProducer<T> generator) {
        this.generator = generator;
        this.limit = pageSize;
    }


    @Override
    public boolean tryAdvance(Consumer<? super T> action) {

        if (hasAnotherElement()) {
            if (!results.hasNext()) {
                loadPageAndPrepareNextPaging();
            }
            if (results.hasNext()) {
                action.accept(results.next());
                return true;
            }
        }

        return false;
    }

    @Override
    public Spliterator<T> trySplit() {
        // if we know there's another page, go ahead and hand off whatever
        // remains of this spliterator as a new spliterator for other
        // threads to work on, and then mark that next time something is
        // requested from this spliterator it needs to be reset to the head
        // of the next page
        if (hasAnotherPage()) {
            Spliterator<T> other = result.getPage().spliterator();
            needsReset = true;
            return other;
        } else {
            return null;
        }

    }

    @Override
    public long estimateSize() {
        if(limit == 0) {
            return 0;
        }

        ensureStateIsUpToDateEnoughToAnswerInquiries();
        return result.getTotalResults();
    }

    @Override
    public int characteristics() {
        return IMMUTABLE | ORDERED | DISTINCT | NONNULL | SIZED | SUBSIZED;
    }

    private boolean hasAnotherElement() {
        ensureStateIsUpToDateEnoughToAnswerInquiries();
        return isBound() && (results.hasNext() || hasAnotherPage());
    }

    private boolean hasAnotherPage() {
        ensureStateIsUpToDateEnoughToAnswerInquiries();
        return isBound() && (result.getTotalResults() > offset);
    }

    private boolean isBound() {
        return Objects.nonNull(results) && Objects.nonNull(result);
    }

    private void ensureStateIsUpToDateEnoughToAnswerInquiries() {
        ensureBound();
        ensureResetIfNecessary();
    }

    private void ensureBound() {
        if (!isBound()) {
            loadPageAndPrepareNextPaging();
        }
    }

    private void ensureResetIfNecessary() {
        if(needsReset) {
            loadPageAndPrepareNextPaging();
            needsReset = false;
        }
    }

    private void loadPageAndPrepareNextPaging() {
        // keep track of the overall result so that we can reference the original list and total size
        this.result = generator.apply(offset, limit);

        // make sure that the iterator we use to traverse a single page removes
        // results from the underlying list as we go so that we can simply pass
        // off the list spliterator for the trySplit rather than constructing a
        // new kind of spliterator for what remains.
        this.results = new DelegatingIterator<T>(result.getPage().listIterator()) {
            @Override
            public T next() {
                T next = super.next();
                this.remove();
                return next;
            }
        };

        // update the paging for the next request and inquiries prior to the next request
        // we use the page of the actual result set instead of the limit in case the limit
        // was not respected exactly.
        this.offset += result.getPage().size();
    }

    public static class DelegatingIterator<T> implements Iterator<T> {

        private final Iterator<T> iterator;

        public DelegatingIterator(Iterator<T> iterator) {
            this.iterator = iterator;
        }


        @Override
        public boolean hasNext() {
            return iterator.hasNext();
        }

        @Override
        public T next() {
            return iterator.next();
        }

        @Override
        public void remove() {
            iterator.remove();
        }

        @Override
        public void forEachRemaining(Consumer<? super T> action) {
            iterator.forEachRemaining(action);
        }
    }
}

我的网页的源代码:

public interface PageProducer<T> extends BiFunction<Long, Long, Page<T>> {

}

并且一个页面:

public final class Page<T> {

    private long totalResults;
    private final List<T> page = new ArrayList<>();

    public long getTotalResults() {
        return totalResults;
    }

    public List<T> getPage() {
        return page;
    }

    public Page setTotalResults(long totalResults) {
        this.totalResults = totalResults;
        return this;
    }

    public Page setPage(List<T> results) {
        this.page.clear();
        this.page.addAll(results);
        return this;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Page)) {
            return false;
        }
        Page<?> page1 = (Page<?>) o;
        return totalResults == page1.totalResults && Objects.equals(page, page1.page);
    }

    @Override
    public int hashCode() {
        return Objects.hash(totalResults, page);
    }

}

获取“缓慢”分页流的示例,用于测试。
private <T> Stream<T> asSlowPagedSource(long pageSize, List<T> things) {

    PageProducer<T> producer = (offset, limit) -> {

        try {
            Thread.sleep(5000L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        int beginIndex = offset.intValue();
        int endIndex = Math.min(offset.intValue() + limit.intValue(), things.size());
        return new Page<T>().setTotalResults(things.size())
                .setPage(things.subList(beginIndex, endIndex));
    };

    return StreamSupport.stream(new PagingSourceSpliterator<>(pageSize, producer), true);
}

您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - kag0
我认为在有序流的“trySplit”方法周围存在一些限制,即返回的分割器必须严格是此分割器管理的元素的前缀。因此,树要拆分的最后一个分割器需要处理第一批元素,因此协调单个队列变得具有挑战性。据我所知,我需要让第一个分割器执行查询,然后将其结果集传递给最后一个被分叉的分割器,然后每个其他分割器都可以惰性地查询页面。 - RutledgePaulV
啊,好的。characteristics()中的其他内容也是您的要求吗?也许最好的方法是在每个拆分上调整并传递索引(因为您知道总大小)。因此,在拆分时,创建的spliterator将负责剩余(未查询)元素的一半,而原始spliterator将负责前一半以及它已经检索到的任何页面。 - kag0
很抱歉这个问题难以分析,我觉得如果没有提供任何代码就问问题会让人感到不好,所以我把我尝试过的一些东西都倒进去了。我最感兴趣的是创建一个可以有效地并行化对分页源进行操作的Spliterator的问题。 - RutledgePaulV
2个回答

10
你的spliterator没有帮助你更接近目标的主要原因是它尝试拆分页面而不是源元素空间。如果你知道元素的总数并且有一个允许通过偏移量和限制获取页面的源,则最自然的spliterator形式是封装这些元素中的范围,例如通过偏移量和限制或结束来进行。然后,拆分意味着只需拆分该“范围”,将你的spliterator的偏移量调整为拆分位置,并创建一个表示前缀的新spliterator,从“旧偏移量”到拆分位置。
Before splitting:
      this spliterator: offset=x, end=y
After splitting:
      this spliterator: offset=z, end=y
  returned spliterator: offset=x, end=z

x <= z <= y

在最好的情况下,z正好位于xy的中间,以产生平衡的分割,但在我们的情况下,我们将稍微调整它以产生工作集是页面大小的倍数。

这个逻辑可以在不需要获取页面的情况下工作,因此如果你推迟到框架想要开始遍历之后(即在分裂之后),获取操作可以并行运行。最大的障碍是您需要获取第一页才能了解元素的总数。以下解决方案将这个第一个获取与其余部分分开,简化了实现。当然,它必须传递下第一页获取的结果,在第一次遍历(在顺序情况下)中消耗或作为第一个分离的前缀返回,接受一个不平衡的分裂在这一点上,但之后不必再处理它。

public class PagingSpliterator<T> implements Spliterator<T> {
    public interface PageFetcher<T> {
        List<T> fetchPage(long offset, long limit, LongConsumer totalSizeSink);
    }
    public static final long DEFAULT_PAGE_SIZE = 100;

    public static <T> Stream<T> paged(PageFetcher<T> pageAccessor) {
        return paged(pageAccessor, DEFAULT_PAGE_SIZE, false);
    }
    public static <T> Stream<T> paged(PageFetcher<T> pageAccessor,
                                      long pageSize, boolean parallel) {
        if(pageSize<=0) throw new IllegalArgumentException();
        return StreamSupport.stream(() -> {
            PagingSpliterator<T> pgSp
                = new PagingSpliterator<>(pageAccessor, 0, 0, pageSize);
            pgSp.danglingFirstPage
                =spliterator(pageAccessor.fetchPage(0, pageSize, l -> pgSp.end=l));
            return pgSp;
        }, CHARACTERISTICS, parallel);
    }
    private static final int CHARACTERISTICS = IMMUTABLE|ORDERED|SIZED|SUBSIZED;

    private final PageFetcher<T> supplier;
    long start, end, pageSize;
    Spliterator<T> currentPage, danglingFirstPage;

    PagingSpliterator(PageFetcher<T> supplier,
            long start, long end, long pageSize) {
        this.supplier = supplier;
        this.start    = start;
        this.end      = end;
        this.pageSize = pageSize;
    }

    public boolean tryAdvance(Consumer<? super T> action) {
        for(;;) {
            if(ensurePage().tryAdvance(action)) return true;
            if(start>=end) return false;
            currentPage=null;
        }
    }
    public void forEachRemaining(Consumer<? super T> action) {
        do {
            ensurePage().forEachRemaining(action);
            currentPage=null;
        } while(start<end);
    }
    public Spliterator<T> trySplit() {
        if(danglingFirstPage!=null) {
            Spliterator<T> fp=danglingFirstPage;
            danglingFirstPage=null;
            start=fp.getExactSizeIfKnown();
            return fp;
        }
        if(currentPage!=null)
            return currentPage.trySplit();
        if(end-start>pageSize) {
            long mid=(start+end)>>>1;
            mid=mid/pageSize*pageSize;
            if(mid==start) mid+=pageSize;
            return new PagingSpliterator<>(supplier, start, start=mid, pageSize);
        }
        return ensurePage().trySplit();
    }
    /**
     * Fetch data immediately before traversing or sub-page splitting.
     */
    private Spliterator<T> ensurePage() {
        if(danglingFirstPage!=null) {
            Spliterator<T> fp=danglingFirstPage;
            danglingFirstPage=null;
            currentPage=fp;
            start=fp.getExactSizeIfKnown();
            return fp;
        }
        Spliterator<T> sp = currentPage;
        if(sp==null) {
            if(start>=end) return Spliterators.emptySpliterator();
            sp = spliterator(supplier.fetchPage(
                                 start, Math.min(end-start, pageSize), l->{}));
            start += sp.getExactSizeIfKnown();
            currentPage=sp;
        }
        return sp;
    }
    /**
     * Ensure that the sub-spliterator provided by the List is compatible with
     * ours, i.e. is {@code SIZED | SUBSIZED}. For standard List implementations,
     * the spliterators are, so the costs of dumping into an intermediate array
     * in the other case is irrelevant.
     */
    private static <E> Spliterator<E> spliterator(List<E> list) {
        Spliterator<E> sp = list.spliterator();
        if((sp.characteristics()&(SIZED|SUBSIZED))!=(SIZED|SUBSIZED))
            sp=Spliterators.spliterator(
                StreamSupport.stream(sp, false).toArray(), IMMUTABLE | ORDERED);
        return sp;
    }
    public long estimateSize() {
        if(currentPage!=null) return currentPage.estimateSize();
        return end-start;
    }
    public int characteristics() {
        return CHARACTERISTICS;
    }
}

它使用一个专门的PageFetcher功能接口,通过调用回调的accept方法以获得结果的总大小并返回项目列表来实现。分页并行迭代器将简单地委托给列表的迭代器进行遍历,并且如果并发性显著高于结果页面数,则甚至可以从这些页面分隔器中分割,这意味着随机访问列表(例如ArrayList)是此处首选的列表类型。

将您的示例代码进行适配。

private static <T> Stream<T> asSlowPagedSource(long pageSize, List<T> things) {
    return PagingSpliterator.paged( (offset, limit, totalSizeSink) -> {
        totalSizeSink.accept(things.size());
        if(offset>things.size()) return Collections.emptyList();
        int beginIndex = (int)offset;
        assert beginIndex==offset;
        int endIndex = Math.min(beginIndex+(int)limit, things.size());
        System.out.printf("Page %6d-%6d:\t%s%n",
                          beginIndex, endIndex, Thread.currentThread());
        // artificial slowdown
        LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5));
        return things.subList(beginIndex, endIndex);
    }, pageSize, true);
}

你可以像这样进行测试

List<Integer> samples=IntStream.range(0, 555_000).boxed().collect(Collectors.toList());
List<Integer> result =asSlowPagedSource(10_000, samples) .collect(Collectors.toList());
if(!samples.equals(result))
    throw new AssertionError();

如果有足够的空闲CPU核心,它将展示如何同时获取页面,因此是无序的,而结果将正确地按照遇到的顺序排列。您还可以测试子页面并发性,这适用于页面较少的情况:

Set<Thread> threads=ConcurrentHashMap.newKeySet();
List<Integer> samples=IntStream.range(0, 1_000_000).boxed().collect(Collectors.toList());
List<Integer> result=asSlowPagedSource(500_000, samples)
    .peek(x -> threads.add(Thread.currentThread()))
    .collect(Collectors.toList());
if(!samples.equals(result))
    throw new AssertionError();
System.out.println("Concurrency: "+threads.size());

2
这太棒了 :) - user1135300
2
同意,这很棒@Holger。我正在将其封装成一个独立的库:https://github.com/RutledgePaulV/paging-spliterator。我会添加测试等。 - RutledgePaulV
@holger 这确实非常好。如果您不知道项目的总数或页面数量,该如何更改? - Victor
1
@Victor:你需要一些猜测,这需要一种测试特定页面索引是否存在的方法,即仍在限制范围内。当你测试特定索引并且它存在时,你的起始偏移量和该索引之间的范围可以像我的答案一样处理,而处理超过该索引的索引的分裂器必须再次进行猜测下一个分裂(建议使用增长增量)。一旦你遇到不存在的索引,你可以在该索引和你的偏移量之间进行二进制搜索。如果你无法以这种方式探测,你将需要像AbstractSpliterator一样的缓冲。 - Holger
@marko-topolnik 写了一篇非常好的文章,讲述如何为未知长度的流编写 Spliterator。https://www.airpair.com/java/posts/parallel-processing-of-io-based-data-with-java-streams - Victor

0

https://docs.oracle.com/javase/8/docs/api/java/util/Spliterator.html

据我理解,spliterating 的速度来自于不可变性。源越不可变,处理速度就越快,因为不可变性更适合并行处理或者说分割。
这个想法似乎是尽可能地在将其作为一个整体(最好)或部分(通常情况,因此您和许多其他人的挑战)绑定到 spliterator 之前,尽可能地解决源的任何更改。
在您的情况下,这可能意味着首先确保页面大小得到尊重,而不是:
//.. 如果限制没有完全得到尊重。 this.offset += result.getPage().size();
这也可能意味着流源需要被准备好,而不是直接使用。
文档末尾有一个示例,展示了“如何并行计算框架(例如 java.util.stream 包)在并行计算中使用 Spliterator”。请注意,这是流如何使用 spliterator,而不是 spliterator 如何使用流作为源。
示例的末尾有一个有趣的“compute”方法。
顺便说一句,如果您能够创建一个通用高效的 PageSpliterator 类,请务必让我们中的一些人知道。
谢谢。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接