Clean Code - Hoofdstuk 13 en 14

Ingediend door Dirk Hornstra op 05-aug-2019 21:42

Eind november 2018 hebben we bij het backend-overleg hoofdstuk 1 en 2 doorgenomen (link), in januari 2019 hoofdstuk 3 en 4 (link), in maart 2019 hoofdstuk 5 en 6 (link), in april hoofdstuk 7 en 8 (link) en in mei hoofdstuk 9 en 10 (link), in juni hoofdstuk 11 en 12 (link).

We zitten in de vakantieperiode (ik weet niet of het eerstvolgende backend-overleg wel door gaat), maar goed, dan is de samenvatting alvast klaar. Zijn mijn collega's vast blij mee :)

Hoofdstuk 13.
Dit hoofdstuk gaat over concurrency. Dankzij de await- en async-statements in C# draait al veel code concurrent. Toch zien we zo nu en dan in onze logging "deadlocks" optreden. Misschien dat dit hoofdstuk daar wat licht op werpt.

We beginnen met de quote van James O. Coplien: "objecten zijn een abstracte weergave van verwerking, threads zijn een abstracte weergave van planning".

Waarom concurrency?

Met concurrency haal je het "wat" en "wanneer" uit elkaar. Het zorgt voor een verbetering van de "separation of concern". Als je een single-threaded proces hebt wat 20 websites moet doorzoeken is dat een stuk trager dan dat er 20 asynchrone processen die sites benaderen.

Mythes en misvattingen

Een lineaire flow is voor iedereen te begrijpen. Asynchrone processen zijn een ander verhaal. Concurrency kan zwaar zijn. Een aantal stellingen:

  • Concurrency zorgt altijd voor een verbetering van performance

Het kan performance verbeteren, maar dat gaat alleen op als er in de applicatie lange wachttijden zijn die gedeeld kunnen worden tussen meerdere threads of processors.

  • Het ontwerp verandert niet als je concurrent programma's maakt

Niet waar, juist het loskoppelen van wat en wanneer zorgt er juist voor dat je code er anders uit gaat zien.

  • Als je met containers werkt (java: Web, EJB), dan hoef je zelf niets van concurrency te weten

Zoals bij het intro al gemeld, ook wij hebben wel eens deadlock-meldingen. Handig dat je er dan toch zelf wat van begrijpt.

De stellingen die meer waarheidsgetrouw zijn:

  • Bij concurrency heb je wat meer overhead, zowel bij de performance als bij het schrijven van (extra) code
  • Correcte concurrency is complex, ook bij simpele problemen.
  • Concurrency-problemen zijn vaak niet reproduceerbaar, waardoor ze (ten onrechte) vaak genegeerd worden als "een 1-malig probleem"
  • Concurrency vraagt een fundamentele aanpassing van je ontwerp strategie

 

Uitdagingen

In het boek staat een voorbeeld van een class die een private int-variabele heeft en een functie getNextId() die een ++variabele teruggeeft. Daarna een aantal voorbeelden waarbij 2 threads deze functie aanroepen. Dat gebeurt net voor/na elkaar, dus thread 1 krijgt 43, thread 2 krijgt 44, andersom, maar ook één waarbij ze beide 43 terug krijgen. Op dat moment is de waarde van de variabele 43, terwijl 44 verwacht was.

In de appendix staat uitgelegd dat er (omdat dit een int is) er 12.870 verschillende execution paths zijn, waarbij dus ook paden met foutieve antwoorden mogelijk zijn. In de appendix staat ook dat als je er een long van maakt (2 x 32 bit waardes) in de actie dat de eerste 32 bit waarde aangepast wordt een andere thread tussendoor sneakt en dus invloed heeft voordat de volgende 32 bit waarde aangepast wordt.

Concurrency Beveiliging Principes

Single Responsibility Principle

In de vorige hoofdstukken is al ter sprake gekomen dat een methode/class/component een Single Responsibility Principle heeft, het moet eigenlijk maar één ding doen.

  • Concurrency gerelateerde code heeft haar eigen life-cycle met betrekking tot ontwikkeling, aanpassingen en tunen
  • Concurrency gerelateerde code heeft haar eigen uitdagingen die afwijken en vaak een stuk ingewikkelder zijn dan de "normale"  code
  • De grote aantallen mogelijke fouten die je kunt maken met concurrency gerelateerde code zorgt dat het al uitdagend genoeg is zonder dat er ook nog eens "normale" code omheen zit

 

Advies: houd je concurrency-gerelateerde code apart van je "normale" code.

Beperk de scope van de data

Die twee threads die hun aanpassingen (bijna) gelijktijdig deden, in het boek wordt als een oplossing genoemd dat je het "synchronized" key-word gebruikt. Want op hoe meer plaatsen je data op verschillende manieren kunt aanpassen:

  • hoe groter de kans dat je zult vergeten om op plaatsen de toegang daartoe te beperken
  • het afdwingen van het beschermen van de toegang zal zorgen voor veel duplicaten van dezelfde code (en dat mag niet van het DRY-principe: don't repeat yourself!)
  • het maakt het ongelovelijk moeilijk om te bepalen waar die fout vandaan kwam, dat is ook als je de boel wel goed afgescherm hebt sowieso al moeilijk

 

Advies: let altijd op het afschermen van data, beperk de toegang tot de toegang van data die gedeeld kan/mag worden zoveel mogelijk.

Gebruik kopieën van data

Om gedeelde data tegen te gaan kun je elke thread een eigen read-only variant van het object geven. Voeg die later weer samen in één thread.

Hoewel het de vraag is of dit het loont (het maken van die extra objecten kost ook weer tijd/geheugen), maar dat is een kwestie van onderzoek. Als je hiermee het locken kunt voorkomen kan dit best een goede oplossing zijn.

Threads zouden zelfstandig moeten zijn

Threads zouden gemaakt moeten worden op de manier dat er geen data met andere threads gedeeld wordt. Het voorbeeld van de HttpServlet, de doGet en doPost acties ontvangen via de parameters de benodigde data.

Ken je bibliotheek

In het voorbeeld wordt de Java bibliotheek genoemd waarbij bepaalde classes thread-safe gemaakt zijn. Zo is het ook in C#, heb hebt bepaalde collecties die thread-safe zijn.

Een aantal Java-classes wordt genoemd, in C# heb je ook een Semaphore-class om acties singulier te maken.

Ken je uitvoer-modellen

Bound Resources: bronnen die een vaste grootte hebben (of waarde). Voorbeelden zijn databaseconnecties en fixed-grootte read/write buffers.

Mutual Exclusion: slechts één thread heeft toegang tot een gedeelde bron of gedeelde data voor een bepaalde tijd.

Starvation: één of meerdere threads worden door het systeem tegengehouden om uitgevoerd te worden.

Deadlock: daar was ie al. Twee of meer threads wachten op elkaar om te stoppen. Beide threads hebben echter een bron in gebruik die de andere thread ook nodig heeft, waardoor er een soort "bevroren situatie" optreedt. Na een x-tijd zal een time-out optreden waarbij één thread als "deadlock-victim" wordt uitgekozen en afgeschoten.

Livelock: elke keer als een thread wat wil doen, is er een andere thread die "in de weg zit". Na een pauze probeert de thread het weer, maar weer staat die andere thread "in de weg". En zo gaat het maar door.

Producer - Consumer

Één of meerdere producer-threads plaatsen werk in een bufffer of queue. Één of meerdere consumer-threads pakken het werk op uit de queue en verwerken het. De queue is een vaste resource, een producer moet wachten met het plaatsen van taken als er ruimte in de queue is. Als er werk in geplaatst wordt moet de producer tevens de consumer(s) triggeren dat er taken staan te wachten.

Readers-Writers

Threads die data lezen moeten die niet lezen op het moment dat er geschreven wordt. Als een writer moet wachten tot er geen lezers meer zijn, kan dat starvation veroorzaken. Dit principe moet dat afvangen (je zou een schaduwkopie in het geheugen kunnen hebben die aan de readers geleverd wordt op het moment dat er geschreven wordt).

Dinerende filosofen

Volgens mij is dit ook ooit wel op de opleiding Hogere Informatica besproken. Een groep filosofen zit om een ronde tafel. Links van het bord ligt een vork. In het midden van de tafel staat een grote pan spaghetti. De filosofen denken na (dat is wat ze doen) tot ze honger krijgen. Als ze honger hebben pakken ze de vorken links (eigen) en rechts (van de buurman). Als een filosoof klaar is met eten legt hij beide vorken weer neer. Als je buurman een vork gepakt heeft moet je dus wachten tot hij klaar is met eten. Sommige applicaties zijn op deze manier gebouwd en daar kun je dus last krijgen van deadlock, livelock, doorvoerproblemen en afname van de efficiëntie.

Pas op voor afhankelijkheden tussen synchronized methoden

We krijgen het voorbeeld van Java, waarbij als je binnen 1 class 2 synchronized methoden hebt de uitkomst wel eens foutief kan zijn. Mocht je toch meerdere gesynchroniseerde methoden nodig hebben, dan moet je gaan werken met locks. Cliënt-based locks, server-based locks (of de cliënt plaatst het lock of de server) of een adapted server (deze gaat tussen de aanroepende instantie en de class/methode zitten en zorgt voor de locking).

Houd synchronized secties klein

Als je synchronized in je code zet, maak je een lock aan. Omdat locks duur zijn (ze voegen overhead toe, zorgen voor vertraging) moet je daar spaarzaam mee werken.

Het schrijven van afsluitende code is moeilijk

Je wilt je programma afsluiten. Maar ook netjes. Stel dat er een parent-thread is met child-threads. De parent meldt alle child-threads: afsluiten en wacht op de terugkoppeling. Één child-thread zit echter in een deadlock en sluit niet af: het programma kan/wil niet afsluiten. Of de theads werken met het producer-consumer-principe, producer sluit af, maar de consumers verwachten dat er nog taken zijn en blijven wachten. Als je gaat bouwen, dus meteen bij de start ook meteen goed naar de "afsluitende code" kijken.

Het testen van code die met threads werkt

Testen is complex. Een paar handvaten:

  • behandel elke fout als een mogelijk thread-gerelateerd probleem.
  • zorgt dat je code die niet concurrent is eerst werkt. Ga dan pas met je concurrent code aan de slag.
  • Zorgt dat je threaded-code pluggable is.
  • Zorg dat je threaded-code te tunen is.
  • Voer met meer threads uit dan er processors beschikbaar zijn.
  • Voer het op verschillende platformen uit.
  • Pas je code aan zodat fouten afgedwongen kunnen worden.

 

Toelichting hierop:

Pluggable: zorgt dat je kunt testen met 1 thread, meerdere threads. De code kan acties uitvoeren met echte objecten, maar ook met een "test" object. Zorg dat die test-objecten zowel snel als traag uitgevoerd kunnen worden. En configureer je testen zodat ze meerdere keren uitgevoerd kunnen worden.

Tunable: door het systeem extra te belasten (echte load te simuleren) krijg je misschien hele andere uitkomsten dan dat je met een aantal requests werkt. Zorg dat je jouw code kunt tunen daarop.

Verschillende platformen: de auteurs hadden tests geschreven voor OSX. Vervolgens werden de tests uitgevoerd in een Virtual Machine op Windows XP. Ineens traden de fouten minder op!

Fouten kunnen afdwingen: omdat er zoveel verschillende paden zijn qua aanroep van  de code komen bugs die verborgen zitten in de concurrent code heel sporadisch voor. Om dit in je testen beter te kunnen detecteren kun je code toevoegen om deze situaties juist op te roepen: Object.wait(), Object.sleep().

Handmatig afdwingen

Je kunt in je code de Object.sleep(), Thread.yield() handmatig toevoegen op plekken in je code. Als je code kapot gaat komt het niet door dit statement, maar door een fout in je huidige code. Zitten nogal wat haken en ogen aan deze aanpak:

  • Je moet handmatig goede plekken vinden om deze code toe te voegen.
  • Hoe moet je dat weten? En überhaupt welke call je er dan in moet zetten?
  • Toevoegen van deze statements in productie code zorgt voor een onnodige vertraging. Do not want...
  • Het is geen garantie dat fouten gevonden worden. Het kan, maar het kan ook niet.

Je wilt dit dus alleen in test doen en je wilt ook verschillende mogelijkheden testen.

Automatisch afdwingen

En dus komen we bij automatisch afdwingen. We krijgen voorbeelden van Java-tools, zoals Aspect-Oriented Framework, CGLIB, ASM, ConTest van IBM (link van heb boek werkt niet meer).

Hoofdstuk 14.
Een voorbeeld-case van een console-applicatie de wordt gerefactord en opgeschoond.

We doen web-applicaties. Dus een console-applicatie is niet echt een praktijk-gerelateerd voorbeeld. Maar goed, ook zaken die niet rechtstreeks betrekking hebben op iets waar je mee bezig bent kunnen je verder helpen.

Omdat dit hoofstuk ruim 90% codevoorbeelden en uitleg daarbij (dit gaat over naar een eigen class, dit wordt als parameter meegegeven) raad ik je aan om het hoofdstuk zelf door te lezen. Hier wordt nog even test-driven-development genoemd. Je hebt een set van tests, jouw aanpassingen mogen er niet voor zorgen dat deze tests een fout-resultaat geven.