这是Files.lines()中的错误,还是我误解了并行流的某些内容?

时间:2015-02-01 04:56:02

标签: java parallel-processing java-8 java-stream

环境:Ubuntu x86_64(14.10),Oracle JDK 1.8u25

我尝试使用Files.lines()的并行流,但我想.skip()第一行(它是带有标题的CSV文件)。所以我试着这样做:

try (
    final Stream<String> stream = Files.lines(thePath, StandardCharsets.UTF_8)
        .skip(1L).parallel();
) {
    // etc
}

但是后来一列未能解析为int ...

所以我尝试了一些简单的代码。文件问题很简单:

$ cat info.csv 
startDate;treeDepth;nrMatchers;nrLines;nrChars;nrCodePoints;nrNodes
1422758875023;34;54;151;4375;4375;27486
$

代码同样简单:

public static void main(final String... args)
{
    final Path path = Paths.get("/home/fge/tmp/dd/info.csv");
    Files.lines(path, StandardCharsets.UTF_8).skip(1L).parallel()
        .forEach(System.out::println);
}

系统地获得以下结果(好的,我只运行了大约20次):

startDate;treeDepth;nrMatchers;nrLines;nrChars;nrCodePoints;nrNodes

我在这里缺少什么?


编辑似乎问题或误解比这更加根深蒂固(下面的两个例子是由FreeNode的## java编写的):

public static void main(final String... args)
{
    new BufferedReader(new StringReader("Hello\nWorld")).lines()
        .skip(1L).parallel()
        .forEach(System.out::println);

    final Iterator<String> iter
        = Arrays.asList("Hello", "World").iterator();
    final Spliterator<String> spliterator
        = Spliterators.spliteratorUnknownSize(iter, Spliterator.ORDERED);
    final Stream<String> s
        = StreamSupport.stream(spliterator, true);

    s.skip(1L).forEach(System.out::println);
}

打印:

Hello
Hello

呃。

@Holger建议对于使用此另一个示例的ORDERED而非SIZED的任何流都会发生这种情况:

Stream.of("Hello", "World")
    .filter(x -> true)
    .parallel()
    .skip(1L)
    .forEach(System.out::println);

此外,它源于已经发生的所有讨论,问题(如果是一个?)与.forEach()(作为@SotiriosDelimanolis first pointed out)。

5 个答案:

答案 0 :(得分:18)

这个答案已经过时 - 请阅读THIS ONE INSTEAD!


快速回答问题:观察到的行为是有意的!根据文档,没有任何错误,所有情况都在发生。但是,可以说,这种行为应该被记录下来并更好地传达。 forEach忽略排序应该更加明显。

我首先介绍允许观察到的行为的概念。这为解析问题中给出的示例之一提供了背景。我将在高级别上执行此操作,然后再在非常低级别上执行此操作。

[TL; DR:自行阅读,高级别解释将给出一个粗略的答案。]

概念

让我们谈谈流操作流管道,而不是谈论Stream,这是由流相关方法操作或返回的类型。方法调用linesskipparallel是流操作,它构建流管道[1],并且 - 正如其他人所指出的那样 - 管道在终端操作时作为一个整体进行处理{ {1}}被称为[1]。

管道可以被认为是一系列操作,它们一个接一个地在整个流上执行(例如,过滤所有元素,将剩余元素映射到数字,对所有数字求和)。 但这是误导性的!更好的比喻是终端操作通过每个操作拉出单个元素[3](例如,获取下一个未过滤的元素,映射它,将其添加到sum,请求下一个元素)。某些中间操作可能需要遍历多个(例如forEach)或甚至所有(例如skip)元素,然后才能返回所请求的下一个元素,这是操作中状态的来源之一。

每个操作都用这些StreamOpFlag s表示其特征:

  • sort
  • DISTINCT
  • SORTED
  • ORDERED
  • SIZED

它们在流源,中间操作和终端操作中组合在一起,构成管道的特征(作为一个整体),然后用于优化[4]。类似地,管道是否并行执行是整个管道的属性[5]。

因此,无论何时对这些特征做出假设,您都必须仔细查看构建管道的所有操作,无论它们的应用顺序如何,以及它们的保证。这样做时请记住终端操作如何通过管道拉动每个单独的元素。

实施例

让我们来看看这个特例:

SHORT_CIRCUIT

高级

无论您的流源是否已订购(通过调用BufferedReader fooBarReader = new BufferedReader(new StringReader("Foo\nBar")); fooBarReader.lines() .skip(1L) .parallel() .forEach(System.out::println); (而不是forEach),您都声明订单对您不重要< / strong> [6],有效地减少了forEachOrdered来自&#34;跳过第一个 n 元素&#34; to&#34;跳过任何 n 元素&#34; [7](因为没有顺序,前者变得毫无意义)。

所以如果承诺加速,你就给管道权利忽略秩序。对于并行执行,它显然是这么认为的,这就是为什么你得到观察到的输出。因此你观察到的是预期的行为而且没有错误。

请注意,此skip有状态不会发生冲突!如上所述,有状态并不意味着它以某种方式缓存整个流(减去跳过的元素),并且随后的所有内容都在这些元素上执行。它只是意味着操作有一些状态 - 即跳过的元素的数量(好吧,它实际上不是that easy,但是由于我对正在发生的事情的理解有限,我不知道说这是一个公平的简化。)

低级别

让我们更详细地看一下:

  1. skip创建BufferedReader.lines,我们称之为Stream
  2. StreamSupport.stream创建一个新的.skip,让我们称之为Stream
    • 致电_skip
    • 构建&#34;切片&#34;使用SliceOps.makeRef
    • 进行操作(跳过和限制的一般化)
    • 这将创建ReferencePipeline.skip的匿名实例,该实例引用ReferencePipeline.StatefulOp作为其来源
  3. _lines设置整个管道的并行标志,如上所述
  4. .parallel实际上开始执行
  5. 让我们看看管道是如何执行的:

    1. 致电_skip.forEach 会创建ForEachOp(让我们称之为.forEach)并将其交给_skip.evaluate,这会做两件事:
      1. 调用sourceSpliterator围绕此管道阶段的源创建一个分裂器:
      2. 调用_forEach.evaluateParallel创建ForEachTask(因为它是无序的;让我们称之为skip = 1)并调用它
    2. _forEachTask.compute任务中拆分前1024行,为它创建一个新任务(让我们称之为_forEachTask),意识到没有任何行留下并完成。
    3. 在fork join pool中,_forEachTask2.compute被调用,通过调用finally starts copying its elements into the sink徒劳地尝试再次拆分_skip.copyInto_forEachTask2周围的流感知包装器)。
    4. 这实际上将任务委托给指定的spliterator。 这是上面创建的System.out.println因此_sliceSpliterator.forEachRemaining负责将未跳过的元素处理到println-sink:
      1. 它将一行(在这种情况下全部)的行放入缓冲区并计算它们
      2. 它尝试通过acquirePermits
      3. 请求尽可能多的许可(我假设由于并行化)
      4. 在源代码中有两个元素,有一个要跳过的元素,它只获得一个许可证(通常让我们说 n
      5. 它让缓冲区将第一个 n 元素(所以在这种情况下只是第一个)放入接收器
    5. 所以UnorderedSliceSpliterator.OfRef.forEachRemaining是订单最终真正被忽略的地方。我没有将它与有序变体进行比较,但这是我为什么这样做的假设:

      • 在并行化下将分裂器的元素铲入缓冲区可能会与执行相同操作的其他任务交错
      • 这将使他们的订单非常难以跟踪
      • 这样做或防止交错会降低性能,如果订单无关紧要则毫无意义
      • 如果订单丢失,除了处理第一个 n 允许的元素外别无他法

      有任何问题吗? ;)抱歉这么久了。也许我应该省略细节并撰写博客文章......

      来源

      [1] java.util.stream - Stream operations and pipelines

        

      流操作分为中间终端操作,并组合成流管道

      [2] java.util.stream - Stream operations and pipelines

        

      在执行管道的终端操作之前,不会开始遍历管道源。

      [3]这个比喻代表了我对溪流的理解。除了代码之外,主要来源是java.util.stream - Stream operations and pipelines(突出我的):

        

      懒洋洋地处理流可以显着提高效率;在诸如上面的filter-map-sum示例的流水线中,过滤,映射和求和可以融合到数据的单个传递中,具有最小的中间状态。懒惰还允许在不必要时避免检查所有数据;对于诸如&#34;之类的操作,找到超过1000个字符的第一个字符串&#34;,只需要检查足够的字符串以找到具有所需特征的字符串,而不检查源中可用的所有字符串。

      [4] java.util.stream.StreamOpFlag

        

      在管道的每个阶段,可以计算组合的流和操作标志[... jadda,jadda,jadda,关于如何在源,中间和终端操作中组合标志 ... ]生成管道输出的标志。然后可以使用这些标志来应用优化。

      在代码中,您可以在AbstractPipeline.combinedFlags中看到这一点,它是在构造期间(以及在其他一些事件中)通过组合前一个操作和新操作的标志来设置的。

      [5] java.util.stream - Parallelism(我无法直接链接 - 向下滚动一下):

        

      启动终端操作时,流管道按顺序或并行执行,具体取决于调用它的流的方向。

      在代码中,您可以看到它位于AbstractPipeline.sequential, parallelisParallel,它在流源上设置/检查布尔标志,使得在构造流时调用setter时无关紧要。 / p>

      [6] java.util.stream.Stream.forEach

        

      对此流的每个元素执行操作。 [...]此操作的行为明确是不确定的。

      将此与java.util.stream.Stream.forEachOrdered对比:

        

      如果流具有已定义的遭遇顺序,则按流的遭遇顺序对此流的每个元素执行操作。

      [7]这也没有明确记载,但我对Stream.skip的评论的解释(由我严重缩短):

        

      [...] skip()[...]在有序并行流水线上可能非常昂贵[...]因为skip(n)被约束为不仅跳过任何n个元素,而是跳过前n个元素遇到订单。 [...] [R]提供排序约束[...]可能会导致并行管道中skip()的显着加速

答案 1 :(得分:17)

由于问题的当前状态与此前所做的陈述完全相反,应该注意的是,现在有一个explicit statement by Brian Goetz关于无序特征的反向传播超过{{1操作被认为是一个错误。 It’s also stated现在认为它根本没有对终端操作的有序性进行反向传播。

还有一个related bug report, JDK-8129120,其状态为“在Java 9中修复”,并且为backported to Java 8, update 60

我使用skip进行了一些测试,现在的实现似乎确实表现出更直观的行为。

答案 2 :(得分:6)

问题在于您将并行流与forEach一起使用,并且您希望跳过操作依赖于正确的元素顺序,而不是这里的情况。摘自forEach文档:

  

对于并行流管道,此操作不保证   尊重流的遭遇顺序,因为这样做会牺牲   并行的好处。

我猜基本上发生的事情是跳过操作首先在第二行执行,而不是在第一行执行。如果您使流顺序或使用forEachOrdered,您可以看到它然后产生预期的结果。另一种方法是使用Collectors

答案 3 :(得分:4)

让我引用一些相关内容 - skip的Javadoc:

  

虽然skip()通常是顺序流管道上的廉价操作,但在有序并行流水线上可能非常昂贵,特别是对于大的n值,因为skip(n)被限制为不仅跳过任何n个元素,而且遇到顺序中的前n个元素。

现在,可以确定Files.lines() 定义明确的遭遇顺序并且是ORDERED流(如果不是,则无法保证均匀在顺序操作中,遇到订单与文件顺序匹配),因此可以保证结果流确定性地仅包含示例中的第二行。

无论是否还有其他内容,保证肯定存在。

答案 4 :(得分:1)

我知道如何解决这个问题,这是我在之前的讨论中看不到的。您可以重新创建将管道拆分为两个管道的流,同时保持整个事物的延迟。

public static <T> Stream<T> recreate(Stream<T> stream) {
    return StreamSupport.stream(stream.spliterator(), stream.isParallel())
                        .onClose(stream::close);
}

public static void main(String[] args) {
    recreate(new BufferedReader(new StringReader("JUNK\n1\n2\n3\n4\n5")).lines()
        .skip(1).parallel()).forEach(System.out::println);
}

从初始流分割器重新创建流时,您可以有效地创建新管道。在大多数情况下,recreate将作为no-op使用,但问题是第一和第二个管道不共享parallelunordered个状态。因此,即使您使用forEach(或任何其他无序终端操作),也只有第二个流变为无序。

内部非常类似的事情是将您的流与空流连接:

Stream.concat(Stream.empty(), 
    new BufferedReader(new StringReader("JUNK\n1\n2\n3\n4\n5"))
          .lines().skip(1).parallel()).forEach(System.out::println);

虽然它有更多的开销。