Паралельний потік Java - порядок виклику методу паралельного () [закритого]


11
AtomicInteger recordNumber = new AtomicInteger();
Files.lines(inputFile.toPath(), StandardCharsets.UTF_8)
     .map(record -> new Record(recordNumber.incrementAndGet(), record)) 
     .parallel()           
     .filter(record -> doSomeOperation())
     .findFirst()

Коли я писав це, я припускав, що потоки будуть породжуватись лише викликом карти, оскільки параметр розміщується після карти. Але деякі рядки у файлі отримували різну кількість записів для кожного виконання.

Я прочитав офіційну документацію потоку Java та кілька веб-сайтів, щоб зрозуміти, як потоки працюють під капотом.

Кілька питань:

  • Паралельний потік Java працює на основі SplitIterator , який реалізується кожною колекцією, як ArrayList, LinkedList тощо. Коли ми будуємо паралельний потік із цих колекцій, відповідний ітератор розділення буде використаний для розділення та ітерації колекції. Це пояснює, чому паралелізм траплявся на рівні вихідного джерела входу (рядки файлів), а не в результаті відображення на карті (тобто записувати pojo). Чи правильно моє розуміння?

  • У моєму випадку вхід - це потік IO файлів. Який ітератор розділення буде використовуватися?

  • Не має значення, де ми розміщуємось parallel()у трубопроводі. Початкове вхідне джерело завжди буде розділене, а інші проміжні операції будуть застосовані.

    У цьому випадку Java не повинна дозволяти користувачам проводити паралельні операції в будь-якому місці трубопроводу, за винятком початкового джерела. Тому що це дає неправильне розуміння тим, хто не знає, як java stream працює всередині. Я знаю, що parallel()операція була б визначена для типу об'єкта Stream, і так, вона працює таким чином. Але, краще надати якесь альтернативне рішення.

  • У наведеному вище фрагменті коду я намагаюся додати номер рядка до кожної записи у вхідному файлі, і тому його слід замовити. Однак я хочу застосувати doSomeOperation()паралельно, оскільки це логіка великої ваги. Один із способів досягти - написати власний індивідуальний ітератор розділення. Чи є інший спосіб?


2
Це більше стосується того, як творці Java вирішили створити інтерфейс. Ви подаєте запити на трубопровід, і все, що не є остаточним операцією, буде зібране першим. parallel()є не що інше, як загальний запит на модифікатор, який застосовується до базового об'єкта потоку. Пам'ятайте, що є лише один джерело-потік, якщо ви не застосовуєте остаточні операції до труби, тобто до тих пір, поки нічого не буде "виконано". Сказавши це, ви в основному ставите під сумнів вибір дизайну Java. Що ґрунтується на думці, і ми не можемо в цьому допомогти.
Забузар

1
Я цілком розумію вашу думку і плутанину, але не думаю, що є набагато кращі рішення. Метод пропонується в Streamінтерфейсі безпосередньо, і через приємне каскадування кожна операція повертається Streamзнову. Уявіть, що хтось хоче вам надати, Streamале вже застосував пару операцій на кшталт mapцього. Ви, як користувач, все ще хочете мати можливість вирішити, паралельно це виконувати чи ні. Отже, ви повинні мати можливість parallel()ще дзвонити , хоча потік уже існує.
Забузар

1
Крім того, я б скоріше сумнівався, чому ви хочете виконати частину потоку послідовно, а потім пізніше перейти до паралельної. Якщо потік вже достатньо великий, щоб отримати право на паралельне виконання, то, ймовірно, це стосується і всього, що раніше в конвеєрі. То чому б не використовувати паралельне виконання і для цієї частини? Я розумію, що існують випадки, наприклад, якщо ви різко збільшуєте розмір за допомогою flatMapабо виконайте небезпечні методи для потоків чи подібні.
Забузар

1
@Zabuza Я не сумніваюся у виборі дизайну Java, але я просто викликаю свою стурбованість. Будь-який базовий користувач Java-потоку може отримати ту саму плутанину, якщо не зрозуміє роботу потоку. Я повністю згоден з вашим другим коментарем. Я щойно підкреслив одне можливе рішення, яке могло б мати свій мінус, як ви вже згадували. Але ми можемо побачити, чи можна це вирішити будь-яким іншим способом. Щодо твого 3-го коментаря, я вже згадував про моє використання в останньому пункті мого опису
провідник

1
@Eugene, коли в Pathлокальній файловій системі ви використовуєте недавній JDK, сплітератор матиме кращу можливість паралельної обробки, ніж пакетне множення 1024. Але збалансоване розщеплення може бути навіть контрпродуктивним у деяких findFirstсценаріях…
Holger

Відповіді:


8

Це пояснює, чому паралелізм траплявся на рівні вихідного джерела входу (рядки файлів), а не в результаті відображення на карті (тобто записувати pojo).

Весь потік або паралельний, або послідовний. Ми не вибираємо підмножину операцій, які слід виконувати послідовно або паралельно.

Коли ініціюється робота терміналу, трубопровід потоку виконується послідовно або паралельно залежно від орієнтації потоку, на який він викликається. [...] Коли ініціюється термінальна операція, трубопровід потоку виконується послідовно або паралельно залежно від режиму потоку, на який він викликається. те саме джерело

Як ви вже згадували, паралельні потоки використовують ітератори розбиття. Зрозуміло, що це розділити дані до початку операцій.


У моєму випадку вхід - це потік IO файлів. Який ітератор розділення буде використовуватися?

Дивлячись на джерело, я бачу, що воно використовує java.nio.file.FileChannelLinesSpliterator


Не має значення, де ми розміщуємо паралельно () у трубопроводі. Початкове вхідне джерело завжди буде розділене, а інші проміжні операції будуть застосовані.

Правильно. Можна навіть дзвонити parallel()і sequential()кілька разів. Той, хто закликав останнього, виграє. Коли ми телефонуємо parallel(), ми встановлюємо це для потоку, який повертається; і як зазначено вище, всі операції виконуються послідовно або паралельно.


У цьому випадку Java не повинна дозволяти користувачам проводити паралельні операції в будь-якому місці конвеєра, за винятком початкового джерела ...

Це стає питанням думок. Я думаю, що Zabuza дає вагомі підстави підтримати вибір дизайнерів JDK.


Один із способів досягти - написати власний індивідуальний ітератор розділення. Чи є інший спосіб?

Це залежить від ваших операцій

  • Якщо findFirst()це ваша реальна термінальна робота, то вам навіть не потрібно турбуватися про паралельне виконання, оскільки doSomething()все одно не буде багато дзвінків ( findFirst()це коротке замикання). .parallel()насправді може спричинити обробку декількох елементів, тоді як findFirst()у послідовному потоці це буде перешкоджати.
  • Якщо ваша операція терміналу не створює багато даних, можливо, ви можете створити свої Recordоб'єкти за допомогою послідовного потоку, а потім обробити результат паралельно:

    List<Record> smallData = Files.lines(inputFile.toPath(), 
                                         StandardCharsets.UTF_8)
      .map(record -> new Record(recordNumber.incrementAndGet(), record)) 
      .collect(Collectors.toList())
      .parallelStream()     
      .filter(record -> doSomeOperation())
      .collect(Collectors.toList());
  • Якщо ваш конвеєр буде завантажувати в пам'ять багато даних (це може бути причиною використання Files.lines()), можливо, вам знадобиться спеціальний ітератор розділення. Перш ніж поїхати туди, я би роздивився інші варіанти (такі рядові рядки з стовпцем ідентифікатора для початку - це лише моя думка).
    Я б також спробував обробити записи меншими партіями:

    AtomicInteger recordNumber = new AtomicInteger();
    final int batchSize = 10;
    
    try(BufferedReader reader = Files.newBufferedReader(inputFile.toPath(), 
            StandardCharsets.UTF_8);) {
        Supplier<List<Record>> batchSupplier = () -> {
            List<Record> batch = new ArrayList<>();
            for (int i = 0; i < batchSize; i++) {
                String nextLine;
                try {
                    nextLine = reader.readLine();
                } catch (IOException e) {
                    //hanlde exception
                    throw new RuntimeException(e);
                }
    
                if(null == nextLine) 
                    return batch;
                batch.add(new Record(recordNumber.getAndIncrement(), nextLine));
            }
            System.out.println("next batch");
    
            return batch;
        };
    
        Stream.generate(batchSupplier)
            .takeWhile(list -> list.size() >= batchSize)
            .map(list -> list.parallelStream()
                             .filter(record -> doSomeOperation())
                             .collect(Collectors.toList()))
            .flatMap(List::stream)
            .forEach(System.out::println);
    }

    Це виконується doSomeOperation()паралельно без завантаження всіх даних у пам'ять. Але зауважте, що batchSizeпотрібно буде подумати.


1
Дякуємо за роз’яснення. Добре знати 3-е рішення, яке ви виділили. Я погляну, як я не користувався takeWhile і постачальником.
дослідник

2
Спеціальна Spliteratorреалізація не була б більш складною, ніж ця, дозволяючи більш ефективну паралельну обробку…
Holger

1
Кожна з ваших внутрішніх parallelStreamоперацій має фіксований накладні витрати для початку операції та очікування кінцевого результату, обмежуючись при цьому паралелізмом batchSize. По-перше, вам потрібно кратне на даний момент кількість ядер CPU, щоб уникнути запущених потоків. Тоді число повинно бути достатньо високим, щоб компенсувати фіксований накладний обсяг, але чим вище число, тим вище пауза, накладена послідовною операцією зчитування, що відбувається перед початком паралельної обробки.
Холгер

1
Якщо повернути зовнішній потік паралельно, це призвело б до поганих втручань у внутрішні у поточну реалізацію, окрім точки, що Stream.generateстворює невпорядкований потік, який не працює з подібними випадками використання ОП findFirst(). На відміну від цього, один паралельний потік зі сплітератором, який повертає шматки, trySplitпрацює прямо і дозволяє робочим потокам обробляти наступний фрагмент, не чекаючи завершення попереднього.
Холгер

2
Немає підстав припускати, що findFirst()операція обробляє лише невелику кількість елементів. Перша відповідність може все-таки відбутися після обробки 90% усіх елементів. Крім того, якщо ви маєте десять мільйонів рядків, навіть пошук відповідності після 10% все ж вимагає обробки одного мільйона рядків.
Холгер

7

Оригінальний дизайн потоку включав ідею підтримувати наступні стадії конвеєра з різними налаштуваннями паралельного виконання, але від цієї ідеї було відмовлено. API може випливати з цього часу, але з іншого боку, дизайн API, який змушує абонента приймати єдине однозначне рішення для паралельного чи послідовного виконання, був би набагато складнішим.

Фактичне Spliteratorвикористання, яке використовується, Files.lines(…)залежить від реалізації. У Java 8 (Oracle або OpenJDK) ви завжди отримуєте те саме, що і в BufferedReader.lines(). У більш пізніх JDK, якщо Pathналежить до файлової системи за замовчуванням і шаблона є однією з підтримуваних для цієї функції, ви отримуєте Потік із спеціальною Spliteratorреалізацією, the java.nio.file.FileChannelLinesSpliterator. Якщо передумови не дотримані, ви отримуєте те саме, що і в BufferedReader.lines(), яке все ще засноване на Iteratorреалізованому всередині BufferedReaderі завершеному через Spliterators.spliteratorUnknownSize.

Ваша конкретна задача найкраще впорядковується із користувацьким Spliteratorспособом, який може виконувати нумерацію рядків прямо у джерела перед паралельною обробкою, щоб дозволити подальшу паралельну обробку без обмежень.

public static Stream<Record> records(Path p) throws IOException {
    LineNoSpliterator sp = new LineNoSpliterator(p);
    return StreamSupport.stream(sp, false).onClose(sp);
}

private static class LineNoSpliterator implements Spliterator<Record>, Runnable {
    int chunkSize = 100;
    SeekableByteChannel channel;
    LineNumberReader reader;

    LineNoSpliterator(Path path) throws IOException {
        channel = Files.newByteChannel(path, StandardOpenOption.READ);
        reader=new LineNumberReader(Channels.newReader(channel,StandardCharsets.UTF_8));
    }

    @Override
    public void run() {
        try(Closeable c1 = reader; Closeable c2 = channel) {}
        catch(IOException ex) { throw new UncheckedIOException(ex); }
        finally { reader = null; channel = null; }
    }

    @Override
    public boolean tryAdvance(Consumer<? super Record> action) {
        try {
            String line = reader.readLine();
            if(line == null) return false;
            action.accept(new Record(reader.getLineNumber(), line));
            return true;
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    @Override
    public Spliterator<Record> trySplit() {
        Record[] chunks = new Record[chunkSize];
        int read;
        for(read = 0; read < chunks.length; read++) {
            int pos = read;
            if(!tryAdvance(r -> chunks[pos] = r)) break;
        }
        return Spliterators.spliterator(chunks, 0, read, characteristics());
    }

    @Override
    public long estimateSize() {
        try {
            return (channel.size() - channel.position()) / 60;
        } catch (IOException ex) {
            return 0;
        }
    }

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

0

А далі - проста демонстрація того, коли застосовується паралельне застосування. Вихід від peek чітко показує різницю між двома прикладами. Примітка. mapВиклик просто додається, щоб додати ще один метод parallel.

IntStream.rangeClosed (1,20).peek(a->System.out.print(a+" "))
        .map(a->a + 200).sum();
System.out.println();
IntStream.rangeClosed(1,20).peek(a->System.out.print(a+" "))
        .map(a->a + 200).parallel().sum();
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.