Praksa pronalaženja i optimizacije problema kada CPU na produkciji bude zauzet 100%
Ranije sam već govorio o praksi pronalaženja i optimizacije OOM-a; danas ću vam ispričati još jednu priču o pronalaženju i optimizaciji problema kada je CPU 100%.
Dobili smo alarm od kolege iz ekipe za održavanje sistema da je opterećenje pojedinih servera veoma visoko, tražeći od nas programera da lociramo problem. Kad sam preuzeo problem, otišao sam prvo na server i video da je od naših aplikacija u radu samo naša Java aplikacija. Zato sam komandom ps dobio PID aplikacije.
ps: komanda za pregled procesa; PID: identifikator procesa. Komandom
ps -ef | grep javamožete pregledati sve Java procese. I ovo smo već ranije spominjali.
Zatim komandom top -Hp pid prikažimo niti ovog procesa. Unošenjem velikog slova P niti se mogu sortirati po iskorišćenosti CPU-a, pa dobijamo sledeći rezultat.

Zaista, iskorišćenost CPU-a pojedinih niti je veoma visoka — 99,9% pa zar nije veoma visoka (😂).
Radi lakšeg lociranja problema, odmah sam komandom jstack pid > pid.log izbacio stek niti (dump) u log fajl. O komandi jstack smo malopre govorili.
Nasumično sam izabrao jednu od onih niti sa 99,9%, pid=194283, prebacio je u heksadecimalni oblik (2f6eb) i potražio u snimku niti:

Identifikatori niti u snimku niti su u heksadecimalnom obliku.
Utvrđujem da je to stek komponente Disruptor — pa to smo malopre imali, stari poznanik! Čak i moćni Disruptor zna da iskusim curenje memorije?
Ko bi rekao — opet iznova!
Da bih stanje niti pregledao preglednije, uploadovao sam podatke snimka na specijalizovanu platformu za analizu: http://fastthread.io/; verovatno su je neki čitaoci već koristili.

Jedna od stavki prikazuje sve niti koje troše CPU; pažljivo sam pregledao i uvideo da su skoro sve iste kao gore navedeni stek.
Drugim rečima, sve je u pitanju stek Disruptor reda, sve izvršavaju java.lang.Thread.yield.
Kao što je poznato, metod yield nagoveštava tekućoj niti da ustupi CPU resurs i da drugim nitima omogući da se takmiče (o yield-u smo govorili u poglavlju o višenitnosti, verujem da vam je još u sećanju).
Na osnovu malopređašnjeg snimka niti vidimo da je u stanju RUNNABLE i da izvršava yield otprilike tridesetak niti.
Preliminarno ocenjujem da veliki broj niti, nakon izvršenja yield, međusobno konkurisanjem povećava iskorišćenost CPU-a; analizom steka može se uvideti da je zaista u vezi sa Disruptor-om.
Ej, opet on.
S obzirom na to, hajde da ukratko pogledamo način upotrebe Disruptor-a. Videćemo koliko čitalaca ga je koristilo.
Prvi korak: u fajlu pom.xml unesite zavisnost ka Disruptor-u:
<dependency>
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
<version>3.4.2</version>
</dependency>Drugi korak: definišite događaj LongEvent:
public static class LongEvent {
private long value;
public void set(long value) {
this.value = value;
}
@Override
public String toString() {
return "LongEvent{value=" + value + '}';
}
}Treći korak: definišite fabriku događaja:
// definiše fabriku događaja
public static class LongEventFactory implements EventFactory<LongEvent> {
@Override
public LongEvent newInstance() {
return new LongEvent();
}
}Četvrti korak: definišite obrađivač događaja:
// definiše obrađivač događaja
public static class LongEventHandler implements EventHandler<LongEvent> {
@Override
public void onEvent(LongEvent event, long sequence, boolean endOfBatch) {
System.out.println("Event: " + event);
}
}Peti korak: definišite objavljioca događaja:
public static void main(String[] args) throws InterruptedException {
// odredi veličinu Ring Buffer-a
int bufferSize = 1024;
// izgradi Disruptor
Disruptor<LongEvent> disruptor = new Disruptor<>(
new LongEventFactory(),
bufferSize,
Executors.defaultThreadFactory());
// poveži obrađivača događaja
disruptor.handleEventsWith(new LongEventHandler());
// pokreni Disruptor
disruptor.start();
// dobavi Ring Buffer
RingBuffer<LongEvent> ringBuffer = disruptor.getRingBuffer();
// proizvedi događaje
ByteBuffer bb = ByteBuffer.allocate(8);
for (long l = 0; l < 100; l++) {
bb.putLong(0, l);
ringBuffer.publishEvent((event, sequence, buffer) -> event.set(buffer.getLong(0)), bb);
Thread.sleep(1000);
}
// isključi Disruptor
disruptor.shutdown();
}Ukratko objašnjenje:
- LongEvent: podatak ili događaj koji se kroz Disruptor prosleđuje.
- LongEventFactory: fabrička klasa za kreiranje objekata događaja.
- LongEventHandler: obrađivač događaja, definiše način obrade događaja.
- Izgradnja Disruptor-a: kreira se instanca Disruptor-a uz navođenje fabrike događaja, veličine bafera i fabrike niti.
- Objavljivanje događaja: primer prikazuje kako se događaj objavljuje u Ring Buffer.
Možete pokrenuti i pregledati izlaz.
Rešavanje problema
Pregledao sam kod i uvideo da svaki poslovni scenario interno koristi po 2 Disruptor reda radi decoupling-a.
Pretpostavimo sada da postoji 7 biznisa — to znači da je kreirano 2*7=14 Disruptor redova, pri čemu svaki red ima po jednog potrošača, odnosno ukupno 14 potrošača (na produkciji ih je i više).
Ujedno sam uvideo da je konfigurisana strategija čekanja potrošača YieldingWaitStrategy, a ta strategija čekanja izvršava yield radi ustupanja CPU-a. Kod je sledeći:

Preliminarno gledano, u velikoj je vezi sa strategijom čekanja.
Lokalna simulacija
Da bih to proverio, lokalno sam kreirao 15 Disruptor redova i, uz praćenje, posmatrao iskorišćenost CPU-a.
Obratite pažnju na kod YieldingWaitStrategy:

i na obrađivača događaja:

Kreirao sam 15 Disruptor redova, a istovremeno svaki red preko bazena niti šalje milion (1 000 000) podataka u Disruptor red. Program potrošača samo ispisuje podatke.

Nakon izvesnog vremena rada, iskorišćenost CPU-a zaista je vrlo visoka.

Istovremeno, dump niti pokazuje da se pojava poklapa sa onom na produkciji: niti potrošača nalaze se u stanju RUNNABLE i sve izvršavaju yield.
Pregledom zvanične dokumentacije Disruptor-a nalazimo:

YieldingWaitStrategy jeste strategija koja u potpunosti crpi CPU, koristeći spin + yield radi povećanja performansi. Preporučuje se kada je broj niti potrošača (Event Handler threads) manji od broja jezgara CPU-a.

Ujedno sam pronašao i ostale strategije čekanja, recimo BlockingWaitStrategy (koja je i podrazumevana strategija); ona koristi mehanizam brava i ne troši mnogo CPU-a.
Zato sam strategiju čekanja podesio na BlockingWaitStrategy.

Rezultat nakon pokretanja je sledeći:

U poređenju sa malopređašnjim rezultatom, uočava se značajan pad iskorišćenosti CPU-a; ujedno, nakon dump-a niti, primećujem da je većina niti u stanju waiting.

Optimizacija i rešenje
Čini se da prebacivanje strategije čekanja na BlockingWaitStrategy može smanjiti iskorišćenost CPU-a, ali primećujem da zvanični opis YieldingWaitStrategy glasi: preporučuje se kada je broj niti potrošača (Event Handler threads) manji od broja jezgara CPU-a.
A tekući scenario jeste takav da broj niti potrošača već znatno premašuje broj jezgara CPU-a, jer način korišćenja nalaže jednog potrošača po Disruptor redu, pa sam redove podesio na 1 i ponovo isprobao (strategija i dalje YieldingWaitStrategy).

Pregled efekte rada:

Nakon jednog minuta rada, uočavam da je iskorišćenost CPU-a sve vreme bilo prilično stabilno.
Kratak pregled
Ovim pronalaženjem problema možemo doći do zaključka: da bi se problem rešio u korenu, potrebno je razdvojiti postojeće biznise; trenutno jedna aplikacija istovremeno obrađuje N biznisa, pri čemu svaki biznis koristi više Disruptor redova.
Pošto sve radi na jednom serveru, to vodi ka tome da iskorišćenost CPU-a stalno ostaje visoko.
S obzirom na to da je u pitanju stariji sistem, naše podešavanje je sledeće:
- Najpre strategiju čekanja podesiti na
BlockingWaitStrategy, čime se efikasno smanjuje iskorišćenost CPU-a (i poslovno je to prihvatljivo). - Drugi korak jeste razdvajanje aplikacije, tako da jedna aplikacija obrađuje jednu vrstu biznisa; zatim ih rasporediti zasebno, čime se međusobno izoluju i ne utiču jedna na drugu.
Naravno, postoje i neke druge optimizacije — recimo, ovaj put dump-om sam otkrio da je aplikacija kreirala preko 800 niti. Način kreiranja bazena niti takođe je takav da su broj jezgarnih niti i maksimalan broj niti jednaki, što dovodi do toga da se pojedine niti u mirovanju ne mogu prikupiti. Treba prilagoditi način kreiranja bazena niti, smanjiti broj niti i težiti što boljem iskorišćenju.
Dakle, na produkciji se uglavnom susrećemo sa OOM-om i CPU problemima; nadam se da će vam ovo razmišljanje o pronalaženju problema pružiti određene smernice~
- Demo kod je postavljen na GitHub: https://github.com/crossoverJie/JCSprout
- Referentni link: crossoverJie, Pronalaženje problema kada je CPU 100%
