Luồng song song Java - thứ tự gọi phương thứcallel () [đã đóng]


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

Khi tôi viết điều này, tôi giả sử rằng các luồng sẽ chỉ xuất hiện cuộc gọi bản đồ vì song song được đặt sau bản đồ. Nhưng một số dòng trong tệp đã nhận được số lượng bản ghi khác nhau cho mỗi lần thực hiện.

Tôi đọc tài liệu luồng Java chính thức và một vài trang web để hiểu cách các luồng hoạt động dưới mui xe.

Một số câu hỏi:

  • Luồng song song Java hoạt động dựa trên SplitIterator , được triển khai bởi mọi bộ sưu tập như ArrayList, LinkedList, v.v. Điều này giải thích tại sao sự song song xảy ra ở cấp nguồn đầu vào ban đầu (Dòng tệp) thay vì kết quả của bản đồ (tức là Record pojo). Tôi hiểu có đúng không?

  • Trong trường hợp của tôi, đầu vào là một luồng IO tệp. Lặp lại phân chia sẽ được sử dụng?

  • Nó không quan trọng nơi chúng tôi đặt parallel()trong đường ống. Nguồn đầu vào ban đầu sẽ luôn được phân chia và các hoạt động trung gian còn lại sẽ được áp dụng.

    Trong trường hợp này, Java không nên cho phép người dùng đặt hoạt động song song ở bất kỳ đâu trong đường ống ngoại trừ tại nguồn ban đầu. Bởi vì, nó mang lại hiểu sai cho những người không biết cách java hoạt động nội bộ. Tôi biết parallel()hoạt động sẽ được xác định cho loại đối tượng Stream và vì vậy, nó đang hoạt động theo cách này. Nhưng, tốt hơn là cung cấp một số giải pháp thay thế.

  • Trong đoạn mã trên, tôi đang cố gắng thêm một số dòng vào mỗi bản ghi trong tệp đầu vào và do đó nó phải được đặt hàng. Tuy nhiên, tôi muốn áp dụng doSomeOperation()song song vì nó là logic nặng. Một cách để đạt được là viết trình lặp phân tách tùy chỉnh của riêng tôi. Còn cách nào khác không?


2
Nó liên quan nhiều hơn đến cách các nhà sáng tạo Java quyết định thiết kế giao diện. Bạn đặt yêu cầu của bạn đến đường ống và mọi thứ không phải là thao tác cuối cùng sẽ được thu thập trước. parallel()không gì khác hơn là một yêu cầu sửa đổi chung được áp dụng cho đối tượng luồng bên dưới. Hãy nhớ rằng chỉ có một luồng nguồn nếu bạn không áp dụng các thao tác cuối cùng cho đường ống, tức là miễn là không có gì được "thực thi". Phải nói rằng, về cơ bản, bạn chỉ đang đặt câu hỏi về các lựa chọn thiết kế Java. Đó là ý kiến ​​dựa trên và chúng tôi thực sự không thể giúp với điều đó.
Zabuzard

1
Tôi hoàn toàn hiểu ý của bạn và nhầm lẫn nhưng tôi không nghĩ rằng có nhiều giải pháp tốt hơn. Phương thức này được cung cấp Streamtrực tiếp trong giao diện và do xếp tầng đẹp nên mọi thao tác sẽ quay trở Streamlại. Hãy tưởng tượng ai đó muốn cung cấp cho bạn Streamnhưng đã áp dụng một vài thao tác như thế map. Bạn, với tư cách là người dùng, vẫn muốn có thể quyết định có thực hiện song song hay không. Vì vậy, bạn phải có thể gọi parallel()tĩnh, mặc dù luồng đã tồn tại.
Zabuzard

1
Ngoài ra, tôi muốn hỏi tại sao bạn muốn thực hiện một phần của luồng một cách tuần tự và sau đó, sau đó chuyển sang song song. Nếu luồng đã đủ lớn để đủ điều kiện thực hiện song song, thì điều này có lẽ cũng áp dụng cho mọi thứ trước khi trong đường ống. Vậy tại sao không sử dụng thực thi song song cho phần đó là tốt? Tôi hiểu rằng có những trường hợp cạnh như nếu bạn tăng đáng kể kích thước bằng flatMaphoặc nếu bạn thực thi các phương thức không an toàn của luồng hoặc tương tự.
Zabuzard

1
@Zabuza Tôi không đặt câu hỏi về sự lựa chọn thiết kế java nhưng tôi chỉ nêu lên mối quan tâm của mình. Bất kỳ người dùng luồng java cơ bản nào cũng có thể có cùng một sự nhầm lẫn trừ khi họ hiểu hoạt động của luồng. Tôi hoàn toàn đồng ý với nhận xét thứ 2 của bạn mặc dù. Tôi vừa nhấn mạnh một giải pháp khả thi có thể có nhược điểm riêng như bạn đã đề cập. Nhưng, chúng ta có thể thấy nếu nó có thể được giải quyết theo bất kỳ cách nào khác. Về bình luận thứ 3 của bạn, tôi đã đề cập đến trường hợp sử dụng của tôi trong điểm cuối cùng của mô tả của tôi
nhà thám hiểm

1
@Eugene khi Pathcó trên hệ thống tệp cục bộ và bạn đang sử dụng JDK gần đây, trình phân tách sẽ có khả năng xử lý song song tốt hơn so với các bội số của 1024. Nhưng việc chia tách cân bằng thậm chí có thể phản tác dụng trong một số findFirsttrường hợp
Holger

Câu trả lời:


8

Điều này giải thích tại sao sự song song xảy ra ở cấp nguồn đầu vào ban đầu (Dòng tệp) thay vì kết quả của bản đồ (tức là Record pojo).

Toàn bộ luồng là song song hoặc tuần tự. Chúng tôi không chọn một tập hợp con các hoạt động để chạy tuần tự hoặc song song.

Khi hoạt động đầu cuối được bắt đầu, đường ống luồng được thực hiện tuần tự hoặc song song tùy thuộc vào hướng của luồng mà nó được gọi. [...] Khi hoạt động đầu cuối được bắt đầu, đường ống luồng được thực hiện tuần tự hoặc song song tùy thuộc vào chế độ của luồng mà nó được gọi. cùng một nguồn

Như bạn đã đề cập, các luồng song song sử dụng các trình vòng lặp phân tách. Rõ ràng, đây là để phân vùng dữ liệu trước khi hoạt động bắt đầu chạy.


Trong trường hợp của tôi, đầu vào là một luồng IO tệp. Lặp lại phân chia sẽ được sử dụng?

Nhìn vào nguồn, tôi thấy nó sử dụng java.nio.file.FileChannelLinesSpliterator


Không quan trọng là chúng ta đặt song song () trong đường ống. Nguồn đầu vào ban đầu sẽ luôn được phân chia và các hoạt động trung gian còn lại sẽ được áp dụng.

Đúng. Bạn thậm chí có thể gọi parallel()sequential()nhiều lần. Người được gọi cuối cùng sẽ giành chiến thắng. Khi chúng tôi gọi parallel(), chúng tôi đặt điều đó cho luồng được trả về; và như đã nêu ở trên, tất cả các hoạt động chạy tuần tự hoặc song song.


Trong trường hợp này, Java không nên cho phép người dùng đặt hoạt động song song ở bất kỳ đâu trong đường ống ngoại trừ tại nguồn ban đầu ...

Điều này trở thành một vấn đề của ý kiến. Tôi nghĩ Zabuza đưa ra một lý do chính đáng để hỗ trợ sự lựa chọn của các nhà thiết kế JDK.


Một cách để đạt được là viết trình lặp phân tách tùy chỉnh của riêng tôi. Còn cách nào khác không?

Điều này phụ thuộc vào hoạt động của bạn

  • Nếu findFirst()là hoạt động của thiết bị đầu cuối thực sự của bạn, thì bạn thậm chí không cần phải lo lắng về việc thực thi song song, vì dù sao cũng sẽ không có nhiều cuộc gọi đến doSomething()( findFirst()bị đoản mạch). .parallel()trong thực tế có thể khiến nhiều phần tử được xử lý, trong khi findFirst()trên luồng tuần tự sẽ ngăn chặn điều đó.
  • Nếu hoạt động đầu cuối của bạn không tạo ra nhiều dữ liệu, thì có lẽ bạn có thể tạo các Recordđối tượng của mình bằng luồng tuần tự, sau đó xử lý kết quả song song:

    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());
  • Nếu đường ống của bạn sẽ tải rất nhiều dữ liệu trong bộ nhớ (có thể là lý do bạn đang sử dụng Files.lines()), thì có lẽ bạn sẽ cần một trình lặp phân tách tùy chỉnh. Tuy nhiên, trước khi tôi đến đó, tôi sẽ xem xét các tùy chọn khác (ví dụ như các dòng lưu với cột id - đó chỉ là ý kiến ​​của tôi).
    Tôi cũng sẽ cố gắng xử lý các bản ghi theo lô nhỏ hơn, như thế này:

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

    Điều này thực thi doSomeOperation()song song mà không tải tất cả dữ liệu vào bộ nhớ. Nhưng lưu ý rằng batchSizesẽ cần phải được suy nghĩ.


1
Cảm ơn bạn đã làm rõ. Thật tốt khi biết về giải pháp thứ 3 mà bạn đã nhấn mạnh. Tôi sẽ xem như tôi chưa sử dụng TakeWhile và Nhà cung cấp.
thám hiểm

2
Việc Spliteratortriển khai tùy chỉnh sẽ không phức tạp hơn thế này, đồng thời cho phép xử lý song song hiệu quả hơn
Holger

1
Mỗi parallelStreamhoạt động bên trong của bạn có một chi phí cố định để bắt đầu hoạt động và chờ kết quả cuối cùng, trong khi bị giới hạn ở chế độ song song batchSize. Trước tiên, bạn cần nhiều lõi CPU hiện có để tránh các luồng không hoạt động. Sau đó, số phải đủ cao để bù cho chi phí cố định, nhưng số càng cao, tạm dừng áp đặt bởi hoạt động đọc tuần tự xảy ra trước khi quá trình xử lý song song bắt đầu.
Holger

1
Xoay song song luồng ngoài sẽ gây ra nhiễu sóng xấu với bên trong trong triển khai hiện tại, bên cạnh điểm Stream.generatetạo ra luồng không có thứ tự, không hoạt động với các trường hợp sử dụng dự định của OP như findFirst(). Ngược lại, một luồng song song duy nhất với bộ chia sẽ trả về các khối trong trySplitcông việc thẳng và cho phép các luồng công nhân xử lý đoạn tiếp theo mà không cần chờ hoàn thành trước đó.
Holger

2
Không có lý do để cho rằng một findFirst()hoạt động sẽ chỉ xử lý một số lượng nhỏ các yếu tố. Trận đấu đầu tiên vẫn có thể xảy ra sau khi xử lý 90% tất cả các yếu tố. Hơn nữa, khi có mười triệu dòng, thậm chí tìm thấy một trận đấu sau 10% vẫn yêu cầu xử lý một triệu dòng.
Holger

7

Thiết kế Stream ban đầu bao gồm ý tưởng hỗ trợ các giai đoạn đường ống tiếp theo với các cài đặt thực thi song song khác nhau, nhưng ý tưởng này đã bị từ bỏ. API có thể xuất phát từ thời điểm này, nhưng mặt khác, một thiết kế API buộc người gọi phải đưa ra một quyết định rõ ràng duy nhất cho việc thực hiện song song hoặc tuần tự sẽ phức tạp hơn nhiều.

Thực tế Spliteratorsử dụng Files.lines(…)là phụ thuộc vào việc thực hiện. Trong Java 8 (Oracle hoặc OpenJDK), bạn luôn nhận được giống như với BufferedReader.lines(). Trong các JDK gần đây, nếu Paththuộc về hệ thống tệp mặc định và bộ ký tự là một trong những hỗ trợ cho tính năng này, bạn sẽ nhận được một Luồng có Spliteratortriển khai chuyên dụng java.nio.file.FileChannelLinesSpliterator. Nếu các điều kiện tiên quyết không được đáp ứng, bạn sẽ nhận được tương tự như với BufferedReader.lines(), điều này vẫn dựa trên Iteratorviệc thực hiện bên trong BufferedReadervà được bao bọc thông qua Spliterators.spliteratorUnknownSize.

Nhiệm vụ cụ thể của bạn được xử lý tốt nhất với một tùy chỉnh Spliteratorcó thể thực hiện đánh số dòng ngay tại nguồn, trước khi xử lý song song, để cho phép xử lý song song tiếp theo mà không bị hạn chế.

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

Và sau đây là một minh chứng đơn giản khi áp dụng song song. Đầu ra từ peek cho thấy rõ sự khác biệt giữa hai ví dụ. Lưu ý: Cuộc mapgọi chỉ được đưa vào để thêm phương thức khác trước 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();
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.