Является ли это ошибкой в ​​Files.lines(), или я что-то не понимаю о параллельных потоках?

Среда: 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 впервые указал).

Ответ 1

Поскольку текущее состояние проблемы совершенно противоположно предыдущим заявлениям, сделанным здесь, следует отметить, что теперь существует явное выражение Брайана Гетца о обратном распространении неупорядоченной характеристики, прошедшей после операции skip, считается ошибкой. Он также заявил, что в настоящее время считается, что он вообще не имеет никакого обратного распространения условности терминальной операции.

Существует также связанный отчет об ошибке JDK-8129120, статус которого "исправлен в Java 9" и его backported на Java 8, обновление 60

Я провел несколько тестов с jdk1.8.0_60, и кажется, что реализация теперь действительно демонстрирует более интуитивное поведение.

Ответ 2

НАСТОЯЩИЙ ОТВЕТ ОТВЕЧАЕТ - ПРОЧИТАЙТЕ ЭТО ОДИН ВМЕСТО!


Чтобы быстро ответить на вопрос: Предполагаемое поведение предназначено! Нет ошибок, и все происходит в соответствии с документацией. Но позвольте сказать, что это поведение должно быть документировано и передаваться лучше. Следует сделать более очевидным, как forEach игнорирует порядок.

Сначала я расскажу о концепциях, которые позволяют наблюдать поведение. Это дает фон для рассечения одного из примеров, заданного в вопросе. Я сделаю это на высоком уровне, а затем снова на очень низком уровне.

[TL; DR: прочитайте сам по себе, объяснение высокого уровня даст грубый ответ.]

Концепция

Вместо того, чтобы говорить о Stream s, который является типом, управляемым или возвращаемым потоковыми методами, расскажите о потоковых операциях и потоковых конвейерах. Метод вызывает lines, skip и parallel - это потоковые операции, которые строят конвейер потока [1] и, как отмечали другие, - что конвейер обрабатывается как целое, когда вызывается операция терминала forEach [2 ].

Конвейер можно рассматривать как последовательность операций, которые один за другим выполняются во всем потоке (например, фильтруют все элементы, сопоставляют оставшиеся элементы с числами, суммируют все числа). Но это вводит в заблуждение! Лучшая метафора заключается в том, что операция терминала тянет отдельные элементы через каждую операцию [3] (например, получает следующий нефильтрованный элемент, сопоставляет его, добавляет его в сумму, запрашивает следующий элемент). Некоторым промежуточным операциям может потребоваться пройти несколько элементов (например, skip) или, возможно, даже всех (например, sort), прежде чем они смогут вернуть запрошенный следующий элемент, и это один из источников состояния в операции.

Каждая операция сигнализирует свои характеристики с помощью этих StreamOpFlag s:

  • DISTINCT
  • SORTED
  • ORDERED
  • SIZED
  • SHORT_CIRCUIT

Они объединены через источник потока, промежуточные операции и операцию терминала и составляют характеристики конвейера (в целом), которые затем используются для оптимизации [4]. Точно так же, независимо от того, выполняется ли параллельный поток или нет, это свойство всего конвейера [5].

Поэтому всякий раз, когда вы делаете предположения относительно этих характеристик, вы должны внимательно изучить все операции по созданию конвейера, независимо от порядка их применения, и какие гарантии они делают. При этом следует иметь в виду, как операция терминала тянет каждый отдельный элемент по конвейеру.

Пример

Посмотрим на этот частный случай:

BufferedReader fooBarReader = new BufferedReader(new StringReader("Foo\nBar"));
fooBarReader.lines()
        .skip(1L)
        .parallel()
        .forEach(System.out::println);

Высокий уровень

Независимо от того, упорядочен ли ваш источник потока (он есть), вызывая forEach (вместо forEachOrdered), вы заявляете, что порядок не имеет значения для вас [6], который эффективно уменьшает skip от "пропустить первые n элементов", чтобы "пропустить любые n элементов" [7] (потому что без порядка первое становится бессмысленным).

Итак, вы даете конвейеру право игнорировать порядок, если это promises ускорение. Для параллельного выполнения это, по-видимому, так думает, поэтому вы получаете наблюдаемый результат. Следовательно, то, что вы наблюдаете, - это предполагаемое поведение и отсутствие ошибок.

Обратите внимание, что этот не конфликтует с skip с сохранением состояния! Как описано выше, наличие состояния не означает, что оно каким-то образом кэширует весь поток (минус пропущенные элементы), и все, что следует за ними, выполняется на этих элементах. Это просто означает, что операция имеет некоторое состояние, а именно количество пропущенных элементов (ну, это не фактически что легко, но с моим ограниченным пониманием что происходит, я бы сказал, что это справедливое упрощение).

Низкий уровень

Посмотрите на это более подробно:

  • BufferedReader.lines создает Stream, позволяет называть его _lines:
  • .skip создает новый Stream, назовите его _skip:
    • вызывает ReferencePipeline.skip
    • который строит операцию "среза" (обобщение пропусков и ограничений) с помощью SliceOps.makeRef
    • это создает анонимный экземпляр ReferencePipeline.StatefulOp, который ссылается на _lines как на источник
  • .parallel устанавливает флаг параллели для всего конвейера, как описано выше.
  • .forEach фактически запускает выполнение

Итак, посмотрим, как выполняется конвейер:

  • Вызов _skip.forEach создает ForEachOp (назовите его _forEach) и передайте его _skip.evaluate, что делает две вещи:
    • вызывает sourceSpliterator, чтобы создать разделитель вокруг источника для этого этапа конвейера:
    • вызывает _forEach.evaluateParallel, который создает ForEachTask (потому что он неупорядочен, назовем его _forEachTask) и вызывает его
  • В _forEachTask.compute задача разбивает первые 1024 строки, создает для нее новую задачу (позвоните по телефону _forEachTask2), понимает, что линий нет и заканчивается.
  • Внутри пула соединений fork _forEachTask2.compute вызывается, тщетно пытается снова разбить и наконец начинает копировать свои элементы в раковину (обертка, ориентированная на поток вокруг System.out.println), вызывая _skip.copyInto.
  • Это по сути делегирует задачу указанному разделителю. Это _sliceSpliterator, который был создан выше! Итак _sliceSpliterator.forEachRemaining несет ответственность за передачу не пропущенных элементов к println-раковине:
    • он получает кусок (в данном случае все) строк в буфер и считает их
    • он пытается запросить столько разрешений (предположим из-за распараллеливания) через acquirePermits
    • с двумя элементами в источнике и один, который нужно пропустить, есть только одно разрешение, которое он получает (вообще говоря, n)
    • он позволяет буферам помещать первые n элементов (так в этом случае только первый) в приемник

Так что UnorderedSliceSpliterator.OfRef.forEachRemaining - это то, где порядок наконец и действительно проигнорирован. Я не сравнивал это с упорядоченный вариант, но это мое предположение, почему это делается следующим образом:

  • при распараллеливании сглаживание элементов разделителя в буфер может чередоваться с другими задачами, выполняющими те же самые
  • это значительно упростит их порядок.
  • выполнение этого или предотвращение чередования ухудшает производительность и бессмысленно, если порядок не имеет значения.
  • если порядок утерян, еще немного сделать, но обработать первые n допустимых элементов

Есть вопросы?;) Извините за продолжение так долго. Может быть, я должен опустить детали и сделать запись в блоге....

Источники

[1] java.util.stream - Потоковые операции и конвейеры:

Операции потока делятся на промежуточные и терминальные операции и объединяются для создания потоков.

[2] java.util.stream - Потоковые операции и конвейеры:

Обход источника конвейера не начинается до тех пор, пока не будет выполнена операция терминала в конвейере.

[3] Эта метафора представляет мое понимание потоков. Основным источником, помимо кода, является эта цитата из java.util.stream - потоковые операции и конвейеры (выделение моего):

Процессные потоки лениво позволяют значительно повысить эффективность; в конвейере, таком как приведенный выше пример фильтра-карты, фильтрация, сопоставление и суммирование могут быть объединены в один проход данных с минимальным промежуточным состоянием. Лень также позволяет избежать изучения всех данных, когда это не является необходимым; для таких операций, как "найти первую строку длиной более 1000 символов", необходимо только просмотреть достаточно строк, чтобы найти тот, который имеет желаемые характеристики, не изучая все строки, доступные из источника.

[4] java.util.stream.StreamOpFlag:

На каждом этапе конвейера можно вычислить объединенные флаги потока и операции [... jadda, jadda, jadda о том, как флаги объединяются между исходными, промежуточными и терминальными операциями...] для создания выходных флагов из трубопровод. Затем эти флаги могут использоваться для применения оптимизаций.

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

[5] java.util.stream - Parallelism (к которому я не могу напрямую связать - прокрутите вниз немного):

Когда инициируется операция терминала, потоковый конвейер выполняется последовательно или параллельно в зависимости от ориентации потока, на который он вызывается.

В коде вы видите, что это находится в AbstractPipeline.sequential, parallel и isParallel, которые устанавливают/проверяют логический флаг в источнике потока, делая его несущественным, когда вызывающие устройства вызывают при построении потока.

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

Выполняет действие для каждого элемента этого потока. [...] Поведение этой операции явно недетерминировано.

Контрастируйте это с помощью java.util.stream.Stream.forEachOrdered:

Выполняет действие для каждого элемента этого потока в порядке выполнения потока, если поток имеет определенный порядок поиска.

[7] Это также четко не задокументировано, но моя интерпретация этого комментария на Stream.skip (сильно сокращена мной):

[...] skip() [...] может быть довольно дорогостоящим на упорядоченных параллельных конвейерах [...], так как skip (n) ограничено пропускать не только любые n элементов, но и первые n элементов в порядок встречи. [...] [R] eminging ordering constraint [...] может привести к значительным ускорениям skip() в параллельных конвейерах

Ответ 3

Проблема заключается в том, что вы используете параллельный поток вместе с forEach, и вы ожидаете, что действие skip зависит от правильного порядка элементов, что здесь не так. Выдержка из документа forEach:

Для параллельных поточных конвейеров эта операция не гарантирует соблюдайте порядок столкновений потока, так как это принесет жертву преимущество parallelism.

Я предполагаю, что в основном происходит то, что операция пропуска сначала выполняется во второй строке, а не первой. Если вы делаете поток последовательным или используете forEachOrdered, вы можете видеть, что тогда он производит ожидаемый результат. Другим подходом было бы использовать Collectors.

Ответ 4

Позвольте мне привести что-то актуальное: Javadoc skip:

В то время как skip() обычно является дешевой операцией на последовательных поточных конвейерах, он может быть довольно дорогостоящим на упорядоченных параллельных конвейерах, особенно при больших значениях n, поскольку skip (n) ограничено пропускать не только любые n элементов, но первые n элементов в порядке встречи.

Теперь совершенно очевидно, что Files.lines() имеет четко определенный порядок встреч и является потоком ORDERED (если бы он не был, не было бы гарантии даже в последовательной операции, что порядок встречи соответствует порядку файла), поэтому гарантируется, что результирующий поток будет детерминистически состоять только из второй строки в вашем примере.

Есть ли что-то еще, гарантия там определенно.

Ответ 5

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

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, но дело в том, что первый и второй конвейеры не разделяют состояния parallel и unordered. Поэтому, даже если вы используете forEach (или любую другую неупорядоченную операцию терминала), только второй поток становится неупорядоченным.

Внутри очень похожая вещь - это конкатенирование потока с пустым потоком:

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

Хотя у этого есть немного больше накладных расходов.