Asynchronous & Non-blocking

6 min

Oba termina su u upotrebi kada pričamo o sistemima sa paralelnim izvršavanjem operacija. U mnogim slučajevima predstavljaju različiti naziv za istu stvar; ali postoji kontekst u kojem se razlikuju. Ne pomaže to što terminologija u softverskoj industriji nije ujednačena i što je ustanovljena praksa prenaglašavanja naziva zarad popularnosti. U vezi ovih pojmova ima stvari koje mi nisu sasvim jasne, pa je ovo pokušaj da bar koliko-toliko sumiram misli na jednom mestu.

Non-blocking

Da krenemo od suprotnog pojma: blocking operacija je ona na čiji rezultat mora da se sačeka. Na primer:

String content = FileUtil.readFile("fajl.txt");

To najčešće znači da trenutni thread čeka (spava) dok blokirajuća metoda ne završi sa radom. Obično se čeka da drugi deo sistema (na pr.: I/O) vrati podatke ili da neki zajednički resurs (baza, lock) postane dostupan. To ujedno oslikava problem blokirajućih operacija: pošto se thread ne koristi za rad i pušta se da čeka, iskorišćenost procesora nije potpuna.

Nije nužno da blokirajuća operacija uključuje druge delove sistema. Na primer, kompleksno izračunavanje isto može da bude blokirajuće prirode:

BigDouble double = MathUtil.calculateBigPrime();

Računanje se izvršava u istom threadu, pa je iskorišćenost threada sada potpuna, ali ostatak programa negde već čeka da se račun završi - blokiranje, dakle, nastaje na drugom mestu. Posle koliko vremena kažemo da je metoda koja se izvršava u istom treadu blokirajuća? Ne postoji jedan odgovor. Ako je u pitanju UI, davno je još ustanovljeno da se odziv od 0.1 sekundi smatra trenutnim, a do jedne sekunde se toleriše. VertX na primer, dopušta najviše dve sekunde zauzeća tkzv. event-loop threada koji obrađuje HTTP zahteve. Ako imate projektni zahtev da server obrađuje 1000 HTTP zahteva u sekundi, očigledno je da se svaki zahtev mora obraditi za najviše 1 milisekundu.

Non-blocking metoda je ona koja ne blokira thread, vraća odmah šta god može i dozvoljava da program nastavi sa radom. Operacija koja je pozvana najčešće nije izvršena do kraja u trenutku kada se neblokirajuća method završi, te je ostavljeno programu da sam kompletira dobavljanje rezultata, jednom kada oni budu dostupni.

Termin non-blocking se često odnosi na pooling: periodičnu proveru da li je neko stanje spremno za upotrebu:

while (!isDataReady()) {
    socketchannel.read(inputBuffer);
    // uradi nešto, pa ponovo pročitaj
}

Da rezimiramo: blokiranje je pojam koje se tiče trajanja izvršavanja operacije, iskorišćenja threadova, čekanja na resurse i zaustavljanja drugih delova programa ili sistema.

Asynchronous

Asinhrono svojstvo izvršavanja operacija se bavi redosledom toka operacija. Asihnrona operacija se izvršava nezavisno od glavnog toka programa, tj. od threada iz kojeg je bila inicirana. Rezultat asinhrone operacije se može očekivati bilo kada, potpuno nezavisno od dela programa koji ju je inicirao. Asinhrona operacija ne implicira da je i neblokirajuća.

Asinhrono opisuje relaciju između dva modula, dva mesta u programu. Kao što kretanje tela ne može da postoji bez referentne tačke, tako i asinhrono izvršavanje mora da ima referentni tok u odnosu na koju nije sinhrono. Kada god pratim priču o asinhronim operacijama, gledam da pronađem tu referentnu tačku (“…asinhrono u odnosu na šta?”).

Asinhron način izvršavanja omogućuje i da neku blokirajuću operaciju učinimo da bude neblokirajuća - bar na tom mestu gde je pre postojala blokada. Postoji više softverskih konstrukta za to; o tome drugom prilikom.

Primer: e-mail je asinhrona komunikacija. Kada pošalješ mejl, ne očekuješ odgovor baš istog trenutka. Ali je ova komunikacija blokirajuća, jer ne možeš da nastaviš konverzaciju dok god ne dobiješ odgovor nazad. Onog trenutka kada prestaneš da čekaš na mejl i počneš da radiš nešto drugo, komunikacija postaje asinhrona.

Zašto non-blocking?

Suština neblokirajuće aplikacije je da ne blokira sistem: uključujući UI, threadove, file descriptore itd. Postoji više nivoa u aplikaciji koji se mogu blokirati, tako da treba obratiti pažnju na to šta se zapravo (ne) blokira. Najbolji primer je uporediti tradicionalne i non-blocking web servere.

Tradicionalni web server radi tako što se za svaki HTTP zahtev odvaja po jedan thread u kome handler obrađuje request. Odgovor se sinhrono šalje nazad. Ovo, jasno, zahteva korišćenje velikog broja threadova, što ima svojih loših strana: sporije izvršavanje, opterećenje procesora ne programom već sistemskim stvarima, kao što je context-switch i sl. Šta se tu blokira? Da ne komplikujem previše: koriste se blokirajući file deskriptori (tj. soketi); dakle rad sa sistemskim I/O je blokirajuće prirode.

Neblokirajući web server koristi neblokirajući file deskriptore zajedno sa I/O multipleksingom (kao što je epoll) - kako bilo, ideja je da se efikasno koristi kernel i tako omogući da se jedan thread koristi za ‘hvatanje’ više sistemskih signala (eventa). To znači da je na aplikativnom nivou moguće imati samo jedan thread koji će primati HTTP zahteve. U praksi se dozvoljava postojanje više ovakvih threadova (koji se često zovu event loop), i to onoliko koliko procesor ima jezgara, radi potpunijeg iskorišćenja procesora.

Neblokirajući serveri su, po pravilu, značajno boljih performansi: broj threadova je mali, pa je opterećenje procesora manje, manje puta se dešava context-switch, troši se manje memorije, propusnost sistema je veća, skaliraju se bolje itd.

Da razbijemo jedan mit: ne znači da je aplikacija neblokirajuća ukoliko se koristi takav server. Kao što je rečeno, blokiranje može da nastane na više nivoa, pa ako su handleri blokirajuće prirode, nismo ništa uradili.

Da li non-blocking?

Činjenica je da asinhroni kod nije baš trivijalno pisati i razumeti. Nije lako pratiti šta se dešava u message-driven sistemima. U jednom projektu smo probali da sve handlere pišemo na asinhron način: da što pre oslobodimo event-loop thread da ga ne bi blokirali kako bi on nastavio da opslužuje sledeće HTTP zahteve, dok mi još uvek aisnhrono procesiramo prethodni HTTP zahtev. To se svelo na to da fork-join pool preuzme ulogu working thread poola. Što opet znači da umesto da pišemo kod kroz CompletableFuture, možemo jednostavnije da procesiramo HTTP zahteve u zasebnom, uobičajenom, dinamičkom thread poolu. Drugim rečima: iako dobavljanje HTTP zahteva radi na non-blocking način, procesiranje zahteva se izvršava na tradicionalni način, svaki u jednom threadu.

Digresija: ovo nije jedini način kako se asinhroni kod može pisati. Reactive princip, recimo, izdvaja message-driven implementaciju u svom (čudnom) manifestu kao neku vrstu preporuke.

Da li to usuđujem da dovodim u pitanje non-blocking web servere?

Izgleda da nisam jedini. Da, svuda ćete naći ono što sam napisao: da je neblokirajući pristup brži, da se bolje skalira i sve već gore napisano. Ali da li je to tačno? Mislim da niko ne zna. Ali evo nešto malo šta znamo. Ovaj benchmark prikazuje da su čisti servleti brži nego, recimo, Undertow (i Skala, kada je bila u listi). Ovaj blog tvrdi da je Tomcatov NIO konektor sporiji. Meni je najpotpunija ova prezentacija, koja vrlo zdravorazumski pristupa problemu. Ovaj rad praktično kaže da su eventi loša ideja. Opet, u pitanju su subjektivno stavovi koje ne možemo tek tako da usvojimo; ne znamo da li se tiču samo Jave ili je u pitanju Node, da li su rezultati ponovljivi itd.

Ako posle svega ovoga ne znate šta da mislite; pa… dobrodošli u klub 🙂 Nije to bila namera, no svakako je ovo tema kojoj vredi posvetiti pažnju. Voleo bih da u softverskom svetu postoje konkretnija istraživanja koja bi bacila svetlo u mrak ovih nedoumica.

🧧
Nisam definisan svojim stavovima. Stavove usvajamo, menjamo, nadograđujemo, ali oni ne čine nas same. Manje je važno da li se slažemo, koliko da se razumemo.