Test af kode med flere tråde i Java

1. Introduktion

I denne vejledning dækker vi nogle af de grundlæggende i at teste et samtidigt program. Vi fokuserer primært på trådbaseret samtidighed og de problemer, den præsenterer i test.

Vi forstår også, hvordan vi kan løse nogle af disse problemer og teste multi-threaded kode effektivt i Java.

2. Samtidig programmering

Samtidig programmering refererer til programmering, hvor vi nedbryde et stort stykke beregning i mindre, relativt uafhængige beregninger.

Hensigten med denne øvelse er at køre disse mindre beregninger samtidigt, muligvis endda parallelt. Mens der er flere måder at opnå dette på, er målet altid at køre programmet hurtigere.

2.1. Tråde og samtidig programmering

Da processorer pakker flere kerner end nogensinde, er samtidig programmering i spidsen for at udnytte dem effektivt. Faktum er dog stadig det samtidige programmer er meget sværere at designe, skrive, teste og vedligeholde. Så hvis vi trods alt kan skrive effektive og automatiserede testsager til samtidige programmer, kan vi løse en stor del af disse problemer.

Så hvad gør skrivetest til samtidige kode så vanskelig? For at forstå det skal vi forstå, hvordan vi opnår samtidighed i vores programmer. En af de mest populære samtidige programmeringsteknikker involverer brug af tråde.

Nu kan tråde være native, i hvilket tilfælde de er planlagt af de underliggende operativsystemer. Vi kan også bruge såkaldte grønne tråde, som er planlagt direkte af en runtime.

2.2. Vanskeligheder med at teste samtidige programmer

Uanset hvilken type tråd vi bruger, er det trådkommunikation, der gør dem vanskelige at bruge. Hvis det virkelig lykkes os at skrive et program, der involverer tråde, men ingen trådkommunikation, er der ikke noget bedre! Mere realistisk skal tråde normalt kommunikere. Der er to måder at opnå dette på - delt hukommelse og videregivelse af meddelelser.

Hovedparten af problem forbundet med samtidig programmering opstår ved at bruge indfødte tråde med delt hukommelse. Det er vanskeligt at teste sådanne programmer af samme grunde. Flere tråde med adgang til delt hukommelse kræver generelt gensidig udelukkelse. Vi opnår dette typisk gennem en beskyttelsesmekanisme ved hjælp af låse.

Men dette kan stadig føre til en række problemer som race betingelser, live låse, deadlocks og tråd sult, for at nævne nogle få. Desuden er disse problemer intermitterende, da trådplanlægning i tilfælde af native tråde er helt ikke-deterministisk.

Derfor er det virkelig en udfordring at skrive effektive tests til samtidige programmer, der kan opdage disse problemer på en deterministisk måde!

2.3. Anatomi af trådinterleaving

Vi ved, at native threads kan planlægges af operativsystemer uforudsigeligt. I tilfælde af disse tråde får adgang til og ændrer delte data, det giver anledning til interessant trådindfladning. Mens nogle af disse sammenflettninger kan være fuldstændig acceptable, kan andre efterlade de endelige data i en uønsket tilstand.

Lad os tage et eksempel. Antag, at vi har en global tæller, der øges af hver tråd. Ved afslutningen af ​​behandlingen vil vi gerne have, at denne tællers tilstand er nøjagtig den samme som antallet af tråde, der er udført:

privat int-tæller; offentlig tomrumsforøgelse () {tæller ++; }

Nu til forøgelse af et primitivt heltal i Java er ikke en atomoperation. Den består i at læse værdien, øge den og endelig gemme den. Mens flere tråde udfører den samme operation, kan det give anledning til mange mulige sammenflettninger:

Mens denne særlige sammenfletning giver helt acceptable resultater, hvad med denne:

Dette var ikke, hvad vi forventede. Forestil dig nu hundreder af tråde, der kører kode, der er meget mere kompleks end dette. Dette vil give anledning til utænkelige måder, som trådene sammenfletter.

Der er flere måder at skrive kode, der undgår dette problem, men det er ikke emnet for denne vejledning. Synkronisering ved hjælp af en lås er en af ​​de almindelige, men det har sine problemer relateret til løbetilstand.

3. Test af kode med flere tråde

Nu hvor vi forstår de grundlæggende udfordringer ved test af kode med flere tråde, vil vi se, hvordan vi kan overvinde dem. Vi bygger en simpel brugssag og forsøger at simulere så mange problemer relateret til samtidighed som muligt.

Lad os begynde med at definere en simpel klasse, der holder optællingen af ​​muligvis alt:

offentlig klasse MyCounter {private int count; offentlig tomrumsforøgelse () {int temp = count; tælle = temp + 1; } // Getter for optælling}

Dette er et tilsyneladende harmløst stykke kode, men det er ikke svært at forstå, at det ikke er trådsikkert. Hvis vi tilfældigvis skriver et samtidigt program med denne klasse, er det sikkert defekt. Formålet med testning her er at identificere sådanne mangler.

3.1. Test af ikke-samtidige dele

Som en tommelfingerregel, Det tilrådes altid at teste kode ved at isolere den fra enhver samtidig adfærd. Dette er med rimelighed at fastslå, at der ikke er nogen anden fejl i koden, der ikke er relateret til samtidighed. Lad os se hvordan vi kan gøre det:

@Test offentlig ugyldig testCounter () {MyCounter-tæller = ny MyCounter (); for (int i = 0; i <500; i ++) {counter.increment (); } assertEquals (500, counter.getCount ()); }

Selvom der ikke er meget der foregår her, giver denne test os tillid til, at den fungerer i det mindste i mangel af samtidighed.

3.2. Første forsøg på at teste med samtidighed

Lad os gå videre til at teste den samme kode igen, denne gang i en samtidig opsætning. Vi prøver at få adgang til den samme forekomst af denne klasse med flere tråde og se, hvordan den opfører sig:

@Test offentlig ugyldig testCounterWithConcurrency () kaster InterruptedException {int numberOfThreads = 10; ExecutorService service = Executors.newFixedThreadPool (10); CountDownLatch latch = ny CountDownLatch (numberOfThreads); MyCounter-tæller = ny MyCounter (); for (int i = 0; i {counter.increment (); latch.countDown ();}); } latch.await (); assertEquals (numberOfThreads, counter.getCount ()); }

Denne test er rimelig, da vi forsøger at betjene delte data med flere tråde. Da vi holder antallet af tråde lavt, som 10, vil vi bemærke, at det passerer næsten hele tiden. Interessant nok hvis vi begynder at øge antallet af tråde, siger til 100, vil vi se, at testen begynder at fejle det meste af tiden.

3.3. Et bedre forsøg på at teste med samtidighed

Mens den forrige test afslørede, at vores kode ikke er trådsikker, er der et problem med denne patte. Denne test er ikke deterministisk, fordi de underliggende tråde sammenfletter på en ikke-deterministisk måde. Vi kan virkelig ikke stole på denne test til vores program.

Hvad vi har brug for er en måde at kontrollere sammenfletning af tråde, så vi kan afsløre samtidige problemer på en deterministisk måde med meget færre tråde. Vi begynder med at tilpasse koden, vi tester lidt:

offentlig synkroniseret tomrumsforøgelse () kaster InterruptedException {int temp = count; vent (100); tælle = temp + 1; }

Her har vi lavet metoden synkroniseret og introducerede et ventetid mellem de to trin inden for metoden. Det synkroniseret nøgleordet sikrer, at kun en tråd kan ændre tælle variabel ad gangen, og ventetiden introducerer en forsinkelse mellem hver trådudførelse.

Bemærk, at vi ikke nødvendigvis behøver at ændre den kode, vi har til hensigt at teste. Men da der ikke er mange måder, hvorpå vi kan påvirke trådplanlægning, griber vi til dette.

I et senere afsnit vil vi se, hvordan vi kan gøre dette uden at ændre koden.

Lad os nu også teste denne kode som vi gjorde tidligere:

@Test offentlig ugyldig testSummationWithConcurrency () kaster InterruptedException {int numberOfThreads = 2; ExecutorService service = Executors.newFixedThreadPool (10); CountDownLatch latch = ny CountDownLatch (numberOfThreads); MyCounter-tæller = ny MyCounter (); for (int i = 0; jeg {prøv {counter.increment ();} fang (InterruptedException e) {// Håndter undtagelse} latch.countDown ();}); } latch.await (); assertEquals (numberOfThreads, counter.getCount ()); }

Her kører vi dette kun med kun to tråde, og chancerne er, at vi er i stand til at få den mangel, vi har savnet. Hvad vi har gjort her er at forsøge at opnå en bestemt trådindfladning, som vi ved kan påvirke os. Selvom det er godt for demonstrationen, vi finder det måske ikke nyttigt til praktiske formål.

4. Testværktøjer til rådighed

Efterhånden som antallet af tråde vokser, vokser det mulige antal måder, de kan sammenflettes eksponentielt på. Det er det bare ikke muligt at finde ud af alle sådanne sammenflettninger og teste for dem. Vi er afhængige af værktøjer til at udføre den samme eller lignende indsats for os. Heldigvis er der et par af dem tilgængelige for at gøre vores liv lettere.

Der er to brede kategorier af værktøjer til rådighed for os til test af samtidig kode. Den første gør det muligt for os at producere rimelig høj belastning på den samtidige kode med mange tråde. Stress øger sandsynligheden for sjælden sammenfletning og øger således vores chancer for at finde defekter.

Det andet giver os mulighed for at simulere specifik trådindfladning og derved hjælpe os med at finde defekter med mere sikkerhed.

4.1. tempus-fugit

Tempus-fugit Java-biblioteket hjælper os med let at skrive og teste samtidig kode. Vi fokuserer bare på testdelen af ​​dette bibliotek her. Vi så tidligere, at det at producere stress på kode med flere tråde øger chancerne for at finde mangler relateret til samtidighed.

Mens vi selv kan skrive hjælpeprogrammer til at producere stress, giver tempus-fugit praktiske måder at opnå det samme på.

Lad os besøge den samme kode, som vi prøvede at producere stress for tidligere, og forstå hvordan vi kan opnå det samme ved hjælp af tempus-fugit:

offentlig klasse MyCounterTests {@Rule public ConcurrentRule concurrently = new ConcurrentRule (); @Regel offentlig RepeatingRule regel = ny RepeatingRule (); privat statisk MyCounter-tæller = ny MyCounter (); @Test @Concurrent (count = 10) @Repeating (repetition = 10) public ugyldige runsMultipleTimes () {counter.increment (); } @AfterClass offentlig statisk ugyldighed annotatedTestRunsMultipleTimes () kaster InterruptedException {assertEquals (counter.getCount (), 100); }}

Her bruger vi to af Herskeer tilgængelige for os fra tempus-fugit. Disse regler opfanger testene og hjælper os med at anvende den ønskede adfærd, som gentagelse og samtidighed. Så effektivt gentager vi operationen under test ti gange hver fra ti forskellige tråde.

Når vi øger gentagelsen og samtidigheden, vil vores chancer for at opdage mangler relateret til samtidighed øges.

4.2. Trådvæver

Trådvæver er i det væsentlige en Java-ramme til testning af kode med flere tråde. Vi har tidligere set, at trådinterfoliering er ret uforudsigelig, og derfor finder vi måske aldrig visse defekter gennem regelmæssige tests. Det, vi effektivt har brug for, er en måde at kontrollere interleaves og teste alle mulige interleaving. Dette har vist sig at være en ganske kompleks opgave i vores tidligere forsøg.

Lad os se, hvordan trådvæver kan hjælpe os her. Thread Weaver giver os mulighed for at blande udførelsen af ​​to separate tråde på et stort antal måder uden at skulle bekymre os om hvordan. Det giver os også muligheden for at have en finkornet kontrol over, hvordan vi vil have trådene sammenflettet.

Lad os se, hvordan vi kan forbedre vores tidligere, naive forsøg:

offentlig klasse MyCounterTests {privat MyCounter-tæller; @ThreadedBefore ugyldigt offentligt før () {counter = new MyCounter (); } @ThreadedMain offentligt ugyldigt mainThread () {counter.increment (); } @ThreadedSecondary ugyldigt secondThread () {counter.increment (); } @ThreadedAfter offentlig ugyldighed efter () {assertEquals (2, counter.getCount ()); } @Test offentlig ugyldighed testCounter () {new AnnotatedTestRunner (). RunTests (this.getClass (), MyCounter.class); }}

Her har vi defineret to tråde, der prøver at øge vores tæller. Thread Weaver vil forsøge at køre denne test med disse tråde i alle mulige sammenfletningsscenarier. Muligvis i en af ​​interleavesne får vi defekten, hvilket er ret tydeligt i vores kode.

4.3. MultithreadedTC

MultithreadedTC er endnu en ramme til test af samtidige applikationer. Den har en metronom, der bruges til at give fin kontrol over sekvensen af ​​aktiviteter i flere tråde. Det understøtter testtilfælde, der udøver en bestemt sammenfletning af tråde. Derfor bør vi ideelt set være i stand til at teste hver signifikant sammenfletning i en separat tråd deterministisk.

Nu er en komplet introduktion til dette funktionsrige bibliotek uden for omfanget af denne vejledning. Men vi kan bestemt se, hvordan man hurtigt opretter tests, der giver os mulige sammenflettninger mellem udførelse af tråde.

Lad os se, hvordan kan vi teste vores kode mere deterministisk med MultithreadedTC:

offentlig klasse MyTests udvider MultithreadedTestCase {privat MyCounter-tæller; @ Overstyr offentlig tomrum initialiser () {counter = new MyCounter (); } offentlig ugyldig tråd1 () kaster InterruptedException {counter.increment (); } offentlig ugyldig tråd2 () kaster InterruptedException {counter.increment (); } @ Override offentlig ugyldig finish () {assertEquals (2, counter.getCount ()); } @ Test offentlig ugyldig testCounter () kaster Throwable {TestFramework.runManyTimes (nye MyTests (), 1000); }}

Her opretter vi to tråde til at fungere på den delte tæller og øge den. Vi har konfigureret MultithreadedTC til at udføre denne test med disse tråde i op til tusind forskellige sammenflettninger, indtil den registrerer en, der fejler.

4.4. Java jcstress

OpenJDK vedligeholder Code Tool Project for at give udviklerværktøjer til at arbejde på OpenJDK-projekterne. Der er flere nyttige værktøjer under dette projekt, herunder Java Concurrency Stress Tests (jcstress). Dette udvikles som en eksperimentel seletøj og række tests til at undersøge rigtigheden af ​​samtidighedsstøtte i Java.

Selvom dette er et eksperimentelt værktøj, kan vi stadig udnytte dette til at analysere samtidig kode og skrive test for at finansiere mangler relateret til den. Lad os se, hvordan vi kan teste den kode, vi hidtil har brugt i denne vejledning. Konceptet er ret ens fra et brugsperspektiv:

@JCStressTest @Outcome (id = "1", expect = ACCEPTABLE_INTERESTING, desc = "One update lost.") @Outcome (id = "2", expect = ACCEPTABLE, desc = "Begge opdateringer.") @Stat offentlig klasse MyCounterTests {privat MyCounter-tæller; @Actor offentlig ugyldig skuespiller1 () {counter.increment (); } @Actor offentlig ugyldig skuespiller2 () {counter.increment (); } @Arbiter public void arbiter (I_Result r) {r.r1 = counter.getCount (); }}

Her har vi markeret klassen med en kommentar Stat, hvilket indikerer, at det indeholder data, der er muteret af flere tråde. Vi bruger også en kommentar Skuespiller, som markerer de metoder, der indeholder handlinger udført af forskellige tråde.

Endelig har vi en metode markeret med en kommentar Arbiter, der i det væsentlige kun besøger staten en gang Skuespillers har besøgt det. Vi har også brugt annotering Resultat at definere vores forventninger.

Samlet set er opsætningen ret enkel og intuitiv at følge. Vi kan køre dette ved hjælp af en testsele, givet af rammen, der finder alle klasser kommenteret med JCStressTest og udfører dem i flere iterationer for at opnå alle mulige sammenflettninger.

5. Andre måder at opdage samtidige problemer på

Skrivning af test til samtidig kode er vanskelig, men mulig. Vi har set udfordringerne og nogle af de populære måder at overvinde dem på. Imidlertid, vi er muligvis ikke i stand til at identificere alle mulige samtidige problemer gennem test alene - især når de ekstraomkostninger ved at skrive flere tests begynder at opveje deres fordele.

Derfor kan vi sammen med et rimeligt antal automatiserede tests anvende andre teknikker til at identificere samtidige problemer. Dette vil øge vores chancer for at finde samtidige problemer uden at komme for meget dybere ind i kompleksiteten af ​​automatiserede tests. Vi vil dække nogle af disse i dette afsnit.

5.1. Statisk analyse

Statisk analyse henviser til analysen af ​​et program uden faktisk at udføre det. Hvad godt kan en sådan analyse gøre? Vi kommer til det, men lad os først forstå, hvordan det står i kontrast til dynamisk analyse. Enhedstestene, vi hidtil har skrevet, skal køres med faktisk udførelse af det program, de tester. Dette er grunden til, at de er en del af det, vi stort set kalder en dynamisk analyse.

Bemærk, at statisk analyse på ingen måde er erstatning for dynamisk analyse. Det giver dog et uvurderligt værktøj til at undersøge kodestrukturen og identificere mulige fejl længe før vi overhovedet udfører koden. Det statisk analyse bruger en række skabeloner, der er kurateret med erfaring og forståelse.

Selvom det er meget muligt bare at se igennem koden og sammenligne med de bedste fremgangsmåder og regler, vi har kurateret, må vi indrømme, at det ikke er sandsynligt for større programmer. Der er dog flere værktøjer til rådighed til at udføre denne analyse for os. De er temmelig modne med et stort regelsæt for de fleste af de populære programmeringssprog.

Et udbredt statisk analyseværktøj til Java er FindBugs. FindBugs ser efter forekomster af "bug mønstre". Et fejlmønster er et kodeudtryk, der ofte er en fejl. Dette kan opstå på grund af flere grunde som vanskelige sprogfunktioner, misforståede metoder og misforståede invarianter.

FindBugs inspicerer Java-bytecode for forekomster af bugmønstre uden faktisk at udføre bytekoden. Dette er ret praktisk at bruge og hurtigt at køre. FindBugs rapporterer bugs, der tilhører mange kategorier som betingelser, design og duplikeret kode.

Det inkluderer også mangler relateret til samtidighed. Det skal dog bemærkes, at FindBugs kan rapportere falske positive. Disse er færre i praksis, men skal korreleres med manuel analyse.

5.2. Modelkontrol

Modelkontrol er en metode til at kontrollere, om en endelig tilstandsmodel af et system opfylder en given specifikation. Denne definition lyder måske for akademisk, men hold den i et stykke tid!

Vi kan typisk repræsentere et beregningsproblem som en finite-state maskine. Selvom dette er et stort område i sig selv, giver det os en model med et endeligt sæt stater og overgangsregler mellem dem med klart definerede start- og sluttilstande.

Nu, den specifikation definerer, hvordan en model skal opføre sig for at blive betragtet som korrekt. I det væsentlige indeholder denne specifikation alle de krav til systemet, som modellen repræsenterer. En af måderne til at opfange specifikationer er at bruge den tidsmæssige logiske formel, udviklet af Amir Pnueli.

Mens det er logisk muligt at udføre modelkontrol manuelt, er det ret upraktisk. Heldigvis er der mange værktøjer til rådighed, der kan hjælpe os her.Et sådant værktøj, der er tilgængeligt for Java, er Java PathFinder (JPF). JPF blev udviklet med mange års erfaring og forskning hos NASA.

Specifikt JPF er en modelkontrol til Java bytecode. Det kører et program på alle mulige måder og kontrollerer derved for ejendomsovertrædelser som deadlock og ubehandlede undtagelser langs alle mulige eksekveringsstier. Det kan derfor vise sig at være ret nyttigt til at finde mangler relateret til samtidighed i ethvert program.

6. Eftertanke

Nu skal det ikke være en overraskelse for os det er bedst at undgå kompleksiteter relateret til kode med flere tråde så meget som muligt. At udvikle programmer med enklere design, som er lettere at teste og vedligeholde, bør være vores primære mål. Vi er enige om, at samtidig programmering ofte er nødvendig for moderne applikationer.

Imidlertid, vi kan vedtage flere bedste praksis og principper, mens vi udvikler samtidige programmer det kan gøre vores liv lettere. I dette afsnit gennemgår vi nogle af disse bedste fremgangsmåder, men vi skal huske på, at denne liste langt fra er komplet!

6.1. Reducer kompleksitet

Kompleksitet er en faktor, der kan vanskeliggøre test af et program, selv uden nogen samtidige elementer. Dette forbindes bare i lyset af samtidighed. Det er ikke svært at forstå hvorfor enklere og mindre programmer er lettere at ræsonnere om og dermed at teste effektivt. Der er flere bedste mønstre, der kan hjælpe os her, som SRP (Single Responsibility Pattern) og KISS (Keep It Dupid Simple), for blot at nævne nogle få.

Nu, mens disse ikke adresserer spørgsmålet om at skrive tests til samtidige kode direkte, gør de jobbet lettere at forsøge.

6.2. Overvej Atomic Operations

Atomiske operationer er operationer, der kører helt uafhængigt af hinanden. Derfor kan vanskelighederne med at forudsige og teste sammenflettning simpelthen undgås. Compare-and-swap er en sådan udbredt atominstruktion. Kort sagt, det sammenligner indholdet på en hukommelsesplacering med en given værdi og ændrer kun indholdet af den hukommelsesplacering, hvis det er det samme.

De fleste moderne mikroprocessorer tilbyder en variant af denne instruktion. Java tilbyder en række atomklasser som f.eks AtomicInteger og AtomicBoolean, der tilbyder fordelene ved sammenlignings-og-swap-instruktioner nedenunder.

6.3. Omfavne uforanderlighed

I flertrådet programmering giver delte data, der kan ændres, altid plads til fejl. Uforanderlighed henviser til den tilstand, hvor en datastruktur ikke kan ændres efter instantiering. Dette er en match lavet i himlen for samtidige programmer. Hvis et objekts tilstand ikke kan ændres efter dets oprettelse, behøver konkurrerende tråde ikke at ansøge om gensidig udelukkelse af dem. Dette forenkler i høj grad skrivning og test af samtidige programmer.

Vær dog opmærksom på, at vi måske ikke altid har friheden til at vælge uforanderlighed, men vi skal vælge det, når det er muligt.

6.4. Undgå delt hukommelse

De fleste af problemerne i forbindelse med programmering med flere tråde kan tilskrives det faktum, at vi har delt hukommelse mellem konkurrerende tråde. Hvad hvis vi bare kunne slippe af med dem! Nå, vi har stadig brug for en mekanisme, som tråde kan kommunikere med.

Der er alternative designmønstre til samtidige applikationer, der giver os denne mulighed. En af de populære er Actor Model, som foreskriver skuespilleren som den grundlæggende enhed af samtidighed. I denne model interagerer skuespillere med hinanden ved at sende beskeder.

Akka er en ramme skrevet i Scala, der udnytter Actor Model til at tilbyde bedre samtidige primitiver.

7. Konklusion

I denne vejledning dækkede vi nogle af de grundlæggende relaterede til samtidig programmering. Vi diskuterede multi-threaded samtidighed i Java i detaljer. Vi gennemgik de udfordringer, det giver os, mens vi testede en sådan kode, især med delte data. Desuden gennemgik vi nogle af de tilgængelige værktøjer og teknikker til at teste samtidig kode.

Vi diskuterede også andre måder at undgå samtidige problemer på, herunder værktøjer og teknikker udover automatiserede tests. Endelig gennemgik vi nogle af de bedste praksis for programmering relateret til samtidig programmering.

Kildekoden til denne artikel kan findes på GitHub.