Flusso parallelo Java - ordine di invocazione del metodo parallel () [chiuso]


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

Quando ho scritto questo ho assunto che i thread verranno generati solo dalla chiamata della mappa poiché il parallelo è posizionato dopo la mappa. Ma alcune righe nel file stavano ottenendo numeri di record diversi per ogni esecuzione.

Ho letto la documentazione ufficiale del flusso Java e alcuni siti Web per capire come funzionano i flussi sotto il cofano.

Alcune domande:

  • Il flusso parallelo Java funziona basato su SplitIterator , che viene implementato da ogni raccolta come ArrayList, LinkedList ecc. Quando costruiamo un flusso parallelo da quelle raccolte, verrà utilizzato l'iteratore di divisione corrispondente per dividere e iterare la raccolta. Questo spiega perché il parallelismo è avvenuto al livello dell'origine di input originale (righe del file) piuttosto che al risultato della mappa (ovvero Record pojo). La mia comprensione è corretta?

  • Nel mio caso, l'input è un flusso di file IO. Quale iteratore diviso verrà utilizzato?

  • Non importa dove ci posizioniamo parallel()nella pipeline. La sorgente di input originale verrà sempre suddivisa e verranno applicate le operazioni intermedie rimanenti.

    In questo caso, Java non dovrebbe consentire agli utenti di posizionare operazioni parallele in qualsiasi punto della pipeline, tranne che alla fonte originale. Perché, sta dando una comprensione sbagliata per coloro che non sanno come java stream funzioni internamente. So che l' parallel()operazione sarebbe stata definita per il tipo di oggetto Stream e quindi funziona in questo modo. Ma è meglio fornire una soluzione alternativa.

  • Nello snippet di codice sopra riportato, sto cercando di aggiungere un numero di riga a ogni record nel file di input e quindi dovrebbe essere ordinato. Tuttavia, voglio applicare doSomeOperation()in parallelo in quanto è una logica pesante. L'unico modo per ottenere è quello di scrivere il mio iteratore diviso personalizzato. C'è un altro modo?


2
Ha più a che fare con il modo in cui i creatori Java hanno deciso di progettare l'interfaccia. Metti le tue richieste in cantiere e tutto ciò che non è un'operazione finale verrà raccolto per primo. parallel()non è altro che una richiesta di modifica generale che viene applicata all'oggetto stream sottostante. Ricordare che esiste un solo flusso di origine se non si applicano le operazioni finali alla pipe, vale a dire finché non viene "eseguito" nulla. Detto questo, in pratica stai solo mettendo in discussione le scelte di progettazione di Java. Che è basato sull'opinione e non possiamo davvero aiutarlo.
Zabuzard,

1
Capisco perfettamente il tuo punto e la tua confusione, ma non credo che ci siano soluzioni molto migliori. Il metodo è offerto Streamdirettamente nell'interfaccia e, grazie alla buona sequenza, ogni operazione restituisce di Streamnuovo. Immagina che qualcuno voglia darti una, Streamma abbia già applicato un paio di operazioni come mapquesta. Come utente, vuoi comunque essere in grado di decidere se eseguirlo in parallelo o meno. Quindi deve essere possibile che tu chiami parallel()ancora, anche se il flusso esiste già.
Zabuzard,

1
Inoltre, vorrei piuttosto chiedermi perché vorresti eseguire una parte di un flusso in sequenza e poi, in seguito, passare al parallelo. Se lo stream è già abbastanza grande da qualificarsi per l'esecuzione parallela, probabilmente questo vale anche per tutto ciò che era in cantiere. Quindi perché non usare l'esecuzione parallela anche per quella parte? Capisco che ci sono casi limite come se aumenti drasticamente le dimensioni con flatMapo se esegui metodi non sicuri o simili.
Zabuzard,

1
@Zabuza Non metto in dubbio la scelta del design java ma sto solo sollevando la mia preoccupazione. Qualsiasi utente di base di java stream potrebbe avere la stessa confusione a meno che non capisca il funzionamento del flusso. Sono totalmente d'accordo con il tuo secondo commento però. Ho appena evidenziato una possibile soluzione che potrebbe avere il suo svantaggio, come hai già detto. Ma possiamo vedere se può essere risolto in qualsiasi altro modo. Per quanto riguarda il tuo terzo commento, ho già menzionato il mio caso d'uso nell'ultimo punto della mia descrizione
esploratore il

1
@Eugene quando si Pathtrova sul filesystem locale e stai usando un JDK recente, lo spliterator avrà una migliore capacità di elaborazione parallela rispetto ai multipli di batch di 1024. Ma la divisione bilanciata può anche essere controproducente in alcuni findFirstscenari ...
Holger

Risposte:


8

Questo spiega perché il parallelismo è avvenuto a livello dell'origine di input originale (righe del file) piuttosto che al risultato della mappa (ovvero Record pojo).

L'intero flusso è parallelo o sequenziale. Non selezioniamo un sottoinsieme di operazioni da eseguire in sequenza o in parallelo.

Quando viene avviata l'operazione terminale, la pipeline del flusso viene eseguita in sequenza o in parallelo a seconda dell'orientamento del flusso su cui viene invocato. [...] Quando viene avviata l'operazione terminale, la pipeline del flusso viene eseguita in sequenza o in parallelo a seconda della modalità del flusso su cui viene invocata. stessa fonte

Come accennato, i flussi paralleli utilizzano iteratori divisi. Chiaramente, si tratta di partizionare i dati prima che le operazioni inizino a essere eseguite.


Nel mio caso, l'input è un flusso di file IO. Quale iteratore diviso verrà utilizzato?

Guardando la fonte, vedo che usa java.nio.file.FileChannelLinesSpliterator


Non importa dove posizioniamo parallel () nella pipeline. La sorgente di input originale verrà sempre suddivisa e verranno applicate le operazioni intermedie rimanenti.

Giusto. Puoi persino chiamare parallel()e sequential()più volte. L'ultimo invocato vincerà. Quando chiamiamo parallel(), lo impostiamo per lo stream restituito; e come detto sopra, tutte le operazioni vengono eseguite in sequenza o in parallelo.


In questo caso, Java non dovrebbe consentire agli utenti di posizionare operazioni parallele in qualsiasi punto della pipeline tranne che nella sorgente originale ...

Questo diventa una questione di opinioni. Penso che Zabuza offra una buona ragione per supportare la scelta dei designer JDK.


L'unico modo per ottenere è quello di scrivere il mio iteratore diviso personalizzato. C'è un altro modo?

Questo dipende dalle tue operazioni

  • Se findFirst()è la tua vera operazione terminale, non dovrai nemmeno preoccuparti dell'esecuzione parallela, perché non ci saranno comunque molte chiamate doSomething()( findFirst()è un corto circuito). .parallel()infatti potrebbe causare l'elaborazione di più di un elemento, mentre findFirst()su un flusso sequenziale lo impedirebbe.
  • Se l'operazione del terminale non crea molti dati, forse puoi creare i tuoi Recordoggetti utilizzando un flusso sequenziale, quindi elaborare il risultato in parallelo:

    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());
  • Se la tua pipeline carica molti dati in memoria (che potrebbe essere il motivo che stai utilizzando Files.lines()), allora forse avrai bisogno di un iteratore di divisione personalizzato. Prima di andare lì, però, esaminerei altre opzioni (come salvare le linee con una colonna id per iniziare - questa è solo la mia opinione).
    Tenterei anche di elaborare i record in batch più piccoli, in questo modo:

    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);
    }

    Questo viene eseguito doSomeOperation()in parallelo senza caricare tutti i dati in memoria. Ma nota che batchSizedovrà essere pensato.


1
Grazie per il chiarimento. È bene conoscere la terza soluzione che hai evidenziato. Darò un'occhiata in quanto non ho usato takeWhile e Supplier.
esploratore il

2
SpliteratorUn'implementazione personalizzata non sarebbe più complicata di così, pur consentendo un'elaborazione parallela più efficiente ...
Holger,

1
Ognuna delle tue parallelStreamoperazioni interne ha un overhead fisso per iniziare l'operazione e attendere il risultato finale, pur essendo limitato a un parallelismo di batchSize. Innanzitutto, è necessario un multiplo del numero attualmente disponibile di core della CPU per evitare thread inattivi. Quindi, il numero dovrebbe essere abbastanza alto da compensare l'overhead fisso, ma maggiore è il numero, maggiore è la pausa imposta dall'operazione di lettura sequenziale prima che inizi anche l'elaborazione parallela.
Holger,

1
Ruotare il flusso esterno in parallelo causerebbe cattive interferenze con l'interno nell'implementazione corrente, oltre al punto che Stream.generateproduce un flusso non ordinato, che non funziona con casi d'uso previsti dal PO come findFirst(). Al contrario, un singolo flusso parallelo con uno spliterator che restituisce blocchi in trySplitopera direttamente e consente ai thread di lavoro di elaborare il blocco successivo senza attendere il completamento del precedente.
Holger,

2
Non vi è motivo di ritenere che findFirst()un'operazione elaborerà solo un numero limitato di elementi. La prima corrispondenza può ancora verificarsi dopo l'elaborazione del 90% di tutti gli elementi. Inoltre, quando si hanno dieci milioni di linee, anche trovare una corrispondenza dopo il 10% richiede ancora l'elaborazione di un milione di linee.
Holger,

7

Il design originale di Stream includeva l'idea di supportare le fasi successive della pipeline con diverse impostazioni di esecuzione parallele, ma questa idea è stata abbandonata. L'API può derivare da questo momento, ma d'altra parte, un progetto API che costringe il chiamante a prendere un'unica decisione inequivocabile per l'esecuzione parallela o sequenziale sarebbe molto più complicato.

L'attuale Spliteratorin uso Files.lines(…)dipende dall'implementazione. In Java 8 (Oracle o OpenJDK), ottieni sempre lo stesso di BufferedReader.lines(). Nei JDK più recenti, se Pathappartiene al filesystem predefinito e il set di caratteri è uno dei supportati per questa funzione, si ottiene uno Stream con Spliteratorun'implementazione dedicata , il java.nio.file.FileChannelLinesSpliterator. Se le condizioni preliminari non vengono soddisfatte, si ottiene lo stesso di BufferedReader.lines(), che si basa ancora su un Iteratorimplementato all'interno BufferedReadere racchiuso in Spliterators.spliteratorUnknownSize.

La tua attività specifica viene gestita al meglio con un'abitudine Spliteratorche può eseguire la numerazione delle righe direttamente alla fonte, prima dell'elaborazione parallela, per consentire la successiva elaborazione parallela senza restrizioni.

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

E la seguente è una semplice dimostrazione di quando viene applicata l'applicazione del parallelo. L'output di peek mostra chiaramente la differenza tra i due esempi. Nota: la mapchiamata viene appena lanciata per aggiungere un altro metodo prima di 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();
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.