1. Wprowadzenie do asynchronicznego I/O
Zacznijmy od terminów. W klasycznym (synchronicznym) I/O, gdy wywołujesz metodę odczytu lub zapisu, twój wątek wykonania (np. główny wątek programu) zatrzymuje się i czeka na zakończenie operacji. To podobne do sytuacji, gdy dzwonisz do znajomego i dopóki nie odbierze, stoisz i czekasz, wpatrując się w telefon.
Asynchroniczne I/O (AIO) — to sytuacja, gdy zlecasz operację odczytu/zapisu systemowi, a sam kontynuujesz pracę. Gdy operacja się zakończy — „oddzwonią” do ciebie (np. zostanie wywołana twoja metoda callback lub wynik pojawi się przez Future).
Gdzie to jest potrzebne?
- Aplikacje serwerowe: aby nie marnować wątków, gdy dysk „myśli”.
- Masowe przetwarzanie dużych plików: żeby nie blokować głównego wątku.
- Aplikacje z interfejsem (UI): by interfejs nie „zawieszał się” podczas odczytu/zapisu.
Wyobraź sobie, że zamówiłeś pizzę. W świecie synchronicznym stałbyś przy drzwiach i czekał na dostawcę. W asynchronicznym — robisz swoje, a gdy pizza przyjedzie, zadzwonią i powiedzą: „Pizza jest!”.
2. Przegląd AsynchronousFileChannel
W Javie asynchroniczne wejście/wyjście jest zaimplementowane w pakiecie java.nio.channels od 7 wersji. Główny bohater — klasa AsynchronousFileChannel.
Co potrafi?
- Asynchronicznie czytać i zapisywać dane do pliku.
- Pracować z buforami (ByteBuffer).
- Używać różnych podejść do odbioru wyniku: przez Future lub CompletionHandler.
- Pozwala jawnie wskazać pulę wątków (ExecutorService) do obsługi zdarzeń.
Najważniejsze metody
- read(ByteBuffer dst, long position): zwraca Future<Integer>.
- read(ByteBuffer dst, long position, A attachment, CompletionHandler<Integer, ? super A> handler).
- write(ByteBuffer src, long position): zwraca Future<Integer>.
- write(ByteBuffer src, long position, A attachment, CompletionHandler<Integer, ? super A> handler).
- static open(Path file, Set<OpenOption> options, ExecutorService executor, FileAttribute<?>... attrs) — otwiera kanał.
Sposoby użycia:
- Przez Future: uruchamiasz operację i możesz później poczekać na jej zakończenie.
- Przez CompletionHandler: przekazujesz „obsługę”, która zostanie wywołana, gdy operacja się zakończy (lub zakończy błędem).
Przykład otwarcia pliku do asynchronicznego odczytu/zapisu
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.EnumSet;
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("data.txt"),
EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE)
);
Można także jawnie wskazać pulę wątków do obsługi zdarzeń:
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
ExecutorService executor = Executors.newFixedThreadPool(4);
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("data.txt"),
EnumSet.of(StandardOpenOption.READ, StandardOpenOption.WRITE),
executor
);
Ciekawostka:
Jeśli nie wskażesz ExecutorService, Java utworzy własną wewnętrzną pulę wątków, która będzie obsługiwać zdarzenia I/O. Do prostych zadań to wystarczy, ale dla aplikacji serwerowych lepiej zarządzać pulą samodzielnie.
3. ExecutorService i jego rola
Gdy pracujesz z asynchronicznym kanałem, gdzieś za kulisami Java musi wykonać twoje callbacki albo zakończyć Future. Robi to nie magią, lecz przy pomocy specjalnych wątków roboczych — executor service.
Jeśli nie przekazujesz własnej puli wątków, Java po prostu tworzy wewnętrzną — zwykle jeden wątek na każdy procesor. Wygodne, ale nie zawsze bezpieczne. Jeśli jednak chcesz sam decydować, ile wątków działa, które zadania są ważniejsze i jak rozkłada się obciążenie, lepiej utworzyć własny ExecutorService i przekazać go do open.
W aplikacjach serwerowych to szczególnie ważne. Bez własnej puli wątków łatwo o nieoczekiwane skoki obciążenia — zamiast płynnej pracy serwer zacznie się dusić.
Przykład:
ExecutorService pool = Executors.newFixedThreadPool(8);
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("huge.log"),
EnumSet.of(StandardOpenOption.READ),
pool
);
Wpływ wyboru puli:
- Dużo wątków — większy poziom równoległości, ale też większe obciążenie systemu.
- Mało wątków — mniej jednoczesnych operacji, ale mniejszy narzut.
- Jeśli uruchamiasz tysiące operacji asynchronicznych, zadbaj o równowagę!
4. Praktyka: asynchroniczny odczyt pliku
Synchroniczny odczyt (dla porównania)
import java.nio.file.Files;
import java.nio.file.Path;
byte[] data = Files.readAllBytes(Path.of("input.txt"));
System.out.println("Przeczytano bajtów: " + data.length);
Problem w tym, że wątek po prostu czeka, aż cały plik zostanie odczytany. Jeśli plik jest duży albo dysk wolny, program też zaczyna „zamulać” — wszystko inne w tym czasie stoi.
Asynchroniczny odczyt z AsynchronousFileChannel i Future
import java.nio.channels.AsynchronousFileChannel;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
public class AsyncReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("input.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // czytamy po 1 KB
Future<Integer> result = channel.read(buffer, 0);
// Można robić coś równolegle!
System.out.println("Rozpoczęto odczyt...");
// ... a potem czekamy na wynik
int bytesRead = result.get(); // blokuje wątek do zakończenia operacji
System.out.println("Przeczytano bajtów: " + bytesRead);
buffer.flip();
// Konwersja bajtów na tekst (jeśli to tekst)
byte[] data = new byte[bytesRead];
buffer.get(data, 0, bytesRead);
String text = new String(data);
System.out.println("Zawartość: " + text);
}
}
}
- channel.read(buffer, 0) — uruchamia asynchroniczny odczyt z pozycji 0.
- Zwraca Future<Integer>, którego można użyć do oczekiwania na wynik.
- Gdy operacja trwa, można wykonywać inne działania.
- result.get() blokuje wątek, ale tylko jeśli wynik nie jest jeszcze gotowy.
Asynchroniczny odczyt z CompletionHandler
(Szczegóły omówimy w następnej lekcji, a na rozgrzewkę...)
import java.nio.channels.AsynchronousFileChannel;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.channels.CompletionHandler;
public class AsyncReadWithHandler {
public static void main(String[] args) throws Exception {
Path path = Path.of("input.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer bytesRead, ByteBuffer buf) {
buf.flip();
byte[] data = new byte[bytesRead];
buf.get(data, 0, bytesRead);
String text = new String(data);
System.out.println("Asynchronicznie przeczytano: " + text);
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
System.err.println("Błąd odczytu: " + exc.getMessage());
}
});
// Nie pozwalamy programowi zakończyć się od razu (inaczej callback nie zdąży)
Thread.sleep(100); // W prawdziwych aplikacjach — lepiej synchronizacja przez latch, future itp.
}
}
}
5. Przydatne szczegóły
Porównanie: asynchroniczny vs synchroniczny odczyt
| Cecha | Synchroniczne I/O ( Files.readAllBytes ) | Asynchroniczne I/O ( AsynchronousFileChannel ) |
|---|---|---|
| Blokuje wątek | Tak | Nie (jeśli nie wywołasz get()) |
| Skalowalność | Niska | Wysoka |
| Nadaje się do UI/serwerów | Nie | Tak |
| Złożoność kodu | Prosta | Nieco większa |
| Zarządzanie zasobami | Proste | Ważne: nie zapomnij zamknąć kanału! |
Schemat działania asynchronicznego I/O
sequenceDiagram
participant Main as Twój wątek
participant OS as System operacyjny
participant Disk as Dysk
Main->>OS: Uruchamia asynchroniczny odczyt (read)
OS->>Disk: Czyta dane
Main->>Main: Wykonuje inne zadania
OS-->>Main: Informuje o zakończeniu (Future/CompletionHandler)
Main->>Main: Przetwarza wynik
6. Typowe błędy przy pracy z AsynchronousFileChannel
Błąd nr 1: zapomniano zamknąć kanał.
AsynchronousFileChannel — to zasób, który należy zamykać. Jeśli zapomnisz zamknąć kanał (channel.close() lub try-with-resources), możesz doprowadzić do wycieków deskryptorów i problemów z dostępem do plików. Używaj try-with-resources zawsze, gdy to możliwe.
Błąd nr 2: blokujące get() w głównym wątku.
Jeśli używasz Future i wywołujesz get() w wątku głównym (np. w aplikacji z UI), tracisz sens asynchronicznego I/O — wątek i tak będzie czekał. Użyj CompletionHandler albo osobnego wątku do oczekiwania na wynik.
Błąd nr 3: niepoprawna praca z ByteBuffer.
Po zapisaniu do bufora pamiętaj o flip(), by przygotować go do odczytu. Po odczycie — clear() lub compact(), jeśli zamierzasz użyć go ponownie.
Błąd nr 4: brak obsługi błędów.
Operacje asynchroniczne mogą zakończyć się błędem (np. plik nie znaleziony, brak dostępu). Jeśli nie obsłużysz wyjątków w CompletionHandler lub nie sprawdzisz Future pod kątem błędu, program „po cichu” nie wykona operacji.
Błąd nr 5: nieuwzględniona współbieżność.
Jeśli uruchamiasz kilka operacji na jednym kanale jednocześnie, upewnij się, że twój kod jest bezpieczny dla wątków i nie dochodzi do wyścigu o bufory lub pozycje w pliku.
GO TO FULL VERSION