Jedan članak za potpuno razumevanje Java IO modela (blokirajući IO, neblokirajući IO, I/O multipleksiranje)
Java IO deli se u dve velike kategorije: s jedne strane tradicionalni IO (Blocking IO), a s druge strane NIO (New IO).
Tradicionalni IO se zasniva na bajt i znakovnim tokovima i pretežno na blokirajućim I/O operacijama. Često korišćene klase su FileInputStream, FileOutputStream, InputStreamReader, OutputStreamWriter itd. Pri čitanju i pisanju podataka, ove klaze dovode do blokiranja niti koji ih izvršava, sve dok se operacija ne završi.
Java NIO uveden je u verziji Jave 1.4; zasniva se na kanalima (Channel) i baferima (Buffer) i usvaja neblokirajuće I/O operacije, čime se dozvoljava niti da, dok čeka na IO, obavlja druge zadatke. Uobičajene NIO klase su ByteBuffer, FileChannel, SocketChannel, ServerSocketChannel itd.
Blokirajući IO i neblokirajući IO
Pa šta je, zapravo, blokirajući IO, a šta neblokirajući IO?
Blokirajući I/O (Blocking I/O): u ovom modelu I/O operacije su blokirajuće — to znači da se prilikom izvršavanja I/O operacije nit blokira sve dok se operacija ne završi. U blokirajućem I/O modelu, svaka veza zahteva po jedan nit za obradu. Shodno tome, za scenarije sa velikim brojem konkurentnih veza, ovaj model ima loše performanse.

Neblokirajući I/O (Non-blocking I/O): u ovom modelu I/O operacije ne blokiraju nit. Kada podaci još uvek nisu spremni, I/O poziv odmah vraća rezultat. Nit može nastaviti sa izvršavanjem drugih zadataka, a potom, u pogodnom trenutku, ponovo pokušati I/O operaciju. Neblokirajući I/O model dozvoljava jednom nitu da istovremeno obrađuje više veza, ali to može zahtevati prilično složeno raspoređivanje i upravljanje na nivou aplikacije.

Kernel prostor i korisnički prostor
Na prethodne dve slike odnose se dva pojma: kernel prostor i korisnički prostor. Već smo ranije, prilikom obrade ne-direktnog bafera, videli sledeću sliku.

Na njoj se ne-direktni bafer (JVM) nalazi u korisničkom prostoru, dok se bafer kernela (OS) nalazi u kernel prostoru.
Kernel prostor jeste namenska memorijska oblast operativnog sistema (kernela), namenjena čuvanju koda kernela, struktura podataka i za izvršavanje sistemskih poziva na nivou kernela. Kernel prostor ima viši nivo privilegija i može neposredno pristupati hardverskim resursima i uslugama nižeg nivoa. U principu, kernel prostor je strogo zaštićen — programi na korisničkom nivou ne mogu mu pristupiti neposredno, kako bi se očuvala stabilnost i bezbednost operativnog sistema.
Korisnički prostor jeste memorijska oblast dodeljena aplikacijama i uslugama na korisničkom nivou. Sadrži kod aplikacije, podatke i izvršni stek poziva. Korisnički prostor je relativno izolovan od kernel prostora, ima niži nivo privilegija i ne može neposredno pristupati kernel prostoru niti hardverskim resursima. Aplikacija sa kernel prostorom komunicira putem sistemskih poziva, kojima od operativnog sistema traži potrebne usluge.
Podela na kernel prostor i korisnički prostor pomaže operativnom sistemu da sprovede zaštitu memorije i kontrolu pristupa, čime se osigurava stabilan i siguran rad sistema. Kada neki korisnički program želi da pristupi sistemskim resursima ili da izvrši privilegovane operacije, on to mora uraditi putem sistemskog poziva, uz prebacivanje u kernel prostor, gde kernel, u njegovo ime, obavlja odgovarajuću operaciju. Ovakav dizajn sprečava zlonamerne ili pogrešno napisane korisničke programe da neposredno pristupaju kernel prostoru i na taj način naruše stabilnost i bezbednost sistema. Istovremeno, ova podela povećava i proširivost operativnog sistema, jer se kernel prostor i korisnički prostor mogu nezavisno proširivati i optimizovati.

Multipleksiranje, signalno vođen IO i asinhroni IO
Pored gore pomenutih blokirajućeg i neblokirajućeg IO modela, postoje još tri IO modela: multipleksiranje, signalno vođen IO i asinhroni IO.
Multipleksiranje
I/O multipleksiranje (I/O Multiplexing) model koristi mogućnosti multipleksiranja koje pruža operativni sistem (kao što su select, poll, epoll itd.), čime jedan nit istovremeno može obrađivati više I/O događaja. Kada podaci na nekoj vezi postanu spremni, operativni sistem obaveštava aplikaciju. Na taj način aplikacija u jednom nitu može obrađivati više konkurentnih veza, bez potrebe da za svaku vezu kreira poseban nit.
select je najranija tehnika I/O multipleksiranja na Unix sistemima. Omogućava jednom nitu da istovremeno nadgleda više deskriptora fajlova (poput soketa) i čeka I/O događaj (čitanje, pisanje ili izuzetak) na nekom od njih. Glavni problem select-a jeste ograničenje u performansama, posebno pri obradi velikog broja deskriptora fajlova. Razlog je taj što on koristi bit-masku za predstavljanje skupa deskriptora fajlova — pri svakom pozivu tu masku treba proslediti i kopirati je između kernela i korisničkog prostora.
poll je unapređenje select-a. Umesto bit-maske, za predstavljanje skupa deskriptora fajlova koristi niz deskriptora fajlova. Time se izbegavaju performansni problemi koje ima select. Međutim, poll i dalje mora da prođe kroz čitav niz deskriptora fajlova kako bi proverio status svakog od njih. Shodno tome, pri obradi velikog broja deskriptora fajlova performanse su i dalje ograničene.
epoll je tehnika I/O multipleksiranja visokih performansi u okviru Linuxa. Održavanjem tabele događaja u kernelu izbegava performansni problem obilaska niza deskriptora fajlova. Kada se I/O događaj desi na nekom deskriptoru fajla, kernel dodaje taj događaj u tabelu događaja. Aplikacija zatim pomoću funkcije epoll_wait može dohvatiti I/O događaje koji su spremni, bez potrebe da obilazi čitav skup deskriptora fajlova. Ovaj pristup znatno poboljšava performanse pri velikom broju konkurentnih veza.

U Java NIO-u, I/O multipleksiranje se pretežno ostvaruje preko klase Selector. Selector može nadgledati I/O događaje na više Channel-a (kanala), kao što su povezivanje, čitanje i pisanje. To jednom nitu dozvoljava obradu više konkurentnih veza, čime se poboljšavaju performanse i skalabilnost programa.
Evo kako se I/O multipleksiranje primenjuje u Java NIO-u:
①. Najpre je potrebno kreirati objekat Selector.
Selector selector = Selector.open();②. Zatim je potrebno registrovati Channel kod Selector-a. Svaki Channel mora biti konfigurisan u neblokirajućem režimu kako bi se mogao koristiti sa Selector-om. Pri registrovanju Channel-a potrebno je navesti i I/O događaje od interesa, kao što su SelectionKey.OP_ACCEPT (prihvatanje veze), SelectionKey.OP_READ (čitanje podataka) itd.
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(8080));
// Registrovanje događaja od interesa
serverChannel.register(selector, SelectionKey.OP_ACCEPT);③. Nakon toga, pomoću metode select() iz klase Selector čeka se na I/O događaj. Metoda select() blokira sve dok se na barem jednom Channel-u ne desi događaj. Kada se događaj desi, pozivom metode selectedKeys() može se dobiti skup SelectionKey-eva za Channel-e koji su spremni za I/O operaciju.
while (true) {
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// Obrada događaja prihvatanja veze
} else if (key.isReadable()) {
// Obrada događaja čitanja podataka
} else if (key.isWritable()) {
// Obrada događaja pisanja podataka
}
keyIterator.remove();
}
}④. Konačno, na osnovu stanja SelectionKey-a izvršava se odgovarajuća I/O operacija. Na primer, ako SelectionKey ukazuje na to da je Channel spreman da prihvati novu vezu, može se pozvati metoda accept() iz ServerSocketChannel-a. Ako SelectionKey ukazuje na to da je Channel spreman za čitanje podataka, podaci se mogu pročitati iz SocketChannel-a.
if (key.isAcceptable()) {
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("Klijent se povezao: " + socketChannel.getRemoteAddress());
}Kompletan primer koda možete pogledati u prethodnom poglavlju: Praksa mrežnog programiranja u Java NIO-u
Signalno vođen IO
U modelu signalno vođenog I/O-a (Signal-driven I/O), aplikacija može kod operativnog sistema registrovati funkciju za obradu signala; kada se desi neki I/O događaj, operativni sistem šalje signal kojim obaveštava aplikaciju. Po prijemu signala, aplikacija obrađuje odgovarajući I/O događaj. Ovaj model je sličan neblokirajućem I/O-u i takođe zahteva upravljanje događajima i raspoređivanje na nivou aplikacije.

Razlika između multipleksiranja i signalno vođenog I/O-a pretežno je u mehanizmu obaveštavanja o događajima i u scenarijima primene.
Model multipleksiranja dozvoljava jednom nitu da istovremeno upravlja više I/O veza. To se ostvaruje pomoću posebnih sistemskih poziva (kao što su select, poll i epoll), koji mogu nadgledati I/O događaje na više deskriptora fajlova. Kada se neki I/O događaj desi, ti sistemski pozivi vraćaju rezultat i obaveštavaju aplikaciju da izvrši odgovarajuću I/O operaciju. I/O model multipleksiranja je pogodan za scenarije visoke konkurentnosti, niskih kašnjenja i velikog protoka, jer može efikasno da smanji broj niti i troškove promene konteksta.
Signalno vođeni model oslanja se na signale (poput SIGIO) kojima se aplikacija obaveštava o nastanku I/O događaja. U ovom modelu aplikacija najpre postavlja deskriptor fajla u signalno vođeni režim i za odgovarajući signal registruje funkciju za obradu. Kada se I/O događaj desi, kernel šalje signal aplikaciji, čime se pokreće izvršavanje funkcije za obradu signala. Aplikacija zatim unutar te funkcije može izvršiti odgovarajuću I/O operaciju. Signalno vođeni I/O model je pogodan za scenarije niže konkurentnosti, niskih kašnjenaja i manjeg protoka, jer za svaki I/O događaj mora da kreira signal i funkciju za njegovu obradu.
Kernel Linux-a sve spoljne uređaje tretira kao fajlove; čitanje i pisanje nad fajlom poziva sistemsku komandu (API) koju pruža kernel, a koja vraća file descriptor (fd, deskriptor fajla). I čitanje i pisanje nad Socket-om takođe ima odgovarajući deskriptor, koji se naziva socket fd (deskriptor fajla soketa). Deskriptor je zapravo broj koji ukazuje na strukturu u kernelu (putanja fajla, oblast sa podacima i još neke atribute).
Na Linuxu se rad sa fajlovima ostvaruje pomoću deskriptora fajla (file descriptor).
Asinhroni IO
Glavna razlika između modela asinhronog I/O-a (Asynchronous I/O) i modela sinhronog I/O-a jeste u tome što se asinhrone I/O operacije izvršavaju u pozadini, a kada se završe, operativni sistem obaveštava aplikaciju. Aplikacija ne mora da čeka završetak I/O operacije — može nastaviti sa izvršavanjem drugih zadataka. Ovaj model je pogodan za obradu velikog broja konkurentnih veza i može pojednostaviti dizajn i razvoj aplikacije.
- Sinhrono: pri izvršavanju I/O operacije, aplikacija mora da sačeka njen završetak. Sinhrona operacija dovodi do blokiranja nita sve dok se operacija ne završi. Sinhroni I/O obuhvata blokirajući I/O, neblokirajući I/O i I/O multipleksiranje.
- Asinhrono: pri izvršavanju I/O operacije, aplikacija ne mora da čeka njen završetak. Asinhrone operacije dozvoljavaju aplikaciji da, dok I/O operacija traje, nastavi sa drugim zadacima. Asinhroni I/O model obuhvata signalno vođeni I/O i asinhroni I/O.

Zamisli da si sada glavni kuvar (koji sprema pileću čorbu od starije koke, a uz to seče krompir, đumbir i mladi luk na tanke rezance):
- Sinhrono/blokirajuće: stojiš kraj šerpe i čekaš dok čorba ne bude gotova; u međuvremenu ne možeš raditi ništa drugo, već tek kada čorba bude gotova prelaziš na ostale zadatke.
- Sinhrono/neblokirajuće: neprestano zaviruješ u šerpu da proveriš da li je čorba gotova. Između tih provera možeš obavljati druge zadatke, kao što je sečenje povrća. Ali moraš stalno prebacivati pažnju s jednog zadatka na drugi, kako bi na vreme reagovao čim čorba bude gotova.
- Asinhrono/signalno vođeno: na šerpu ugrađuješ senzor koji će, čim čorba bude gotova, poslati signal i podsetiti te. U međuvremenu možeš raditi druge zadatke, bez brige da ćeš propustiti trenutak kada čorba bude gotova.
- Asinhroni I/O: angažuješ pomoćnika i prepustiš mu da sprema čorbu; kada čorba bude gotova, pomoćnik će te obavestiti. Ti možeš mirno da se posvetiš drugim zadacima, bez brige o samom procesu spremanja čorbe.
Kratak rezime
Ukratko, postoje uglavnom pet IO modela: blokirajući I/O, neblokirajući I/O, multipleksiranje, signalno vođeni I/O i asinhroni I/O.
- Blokirajući I/O: kada aplikacija izvrši I/O operaciju, ona sve vreme čeka da se prenos podataka završi; za to vreme ne može obavljati druge zadatke.
- Neblokirajući I/O: kada aplikacija izvrši I/O operaciju, a podaci još nisu spremni, odmah dobija status greške i ne čeka završetak prenosa podataka — može raditi druge zadatke.
- Multipleksiranje: dozvoljava jednom nitu da istovremeno upravlja više I/O veza; pogodno za scenarije visoke konkurentnosti, niskih kašnjenja i velikog protoka; smanjuje broj niti i troškove promene konteksta.
- Signalno vođeni I/O: oslanja se na signale kojima aplikacija biva obaveštena o I/O događajima; pogodan za scenarije niže konkurentnosti, niskih kašnjenja i manjeg protoka; za svaki I/O događaj mora da kreira signal i funkciju za obradu signala.
- Asinhroni I/O: nakon što aplikacija pokrene I/O operaciju, prenos podataka obavlja kernel i po završetku obaveštava aplikaciju. Aplikacija ne mora da čeka prenos podataka i može obavljati druge zadatke.
