Design av synkronisering
Hanteringen av lås kan göra program onödigt långsamma,
dels därför att trådar kan förlora tid genom att vänta
på lås och dels därför att själva hanteringen av
låsen är långsamma operationer. Exempelvis kan problem uppstå
genom att onödigt stora kodblock synkroniseras fast delar av dem skulle
kunna utföras parallellt eller genom att ett och samma lås används
för att synkronisera flera olika kodblock som egentligen är oberoende
av varandra. I avsikt att motverka dessa problem kan resultatet bli att onödigt
många lås införs varvid programmet måste ägna
onödigt mycket tid åt att låsa och låsa upp. Det här
avsnittet handlar om några listiga metoder att förhindra kapplöpning
utan att programmet blir onödigt långsamt.
Litteratur
Lea: Concurrent Programming in Java, avsnitt 2.4
Refactoring
Uttrycket refactoring används flitigt i boken. Det betyder att
ändra i programmet utan att ändra dess funktionalitet. Syftet kan
vara att koden ska bli mer lättförståelig eller att programmet
ska bli snabbare.
En enkel regel
Utan att behöva kasta sig in i alltför listiga strategier räcker
följande enkla direktiv väldigt långt:
- Lås (dvs synkronisera) alltid när ett objekts fält
uppdateras.
- Lås alltid när ett objekts fält läses och de
kan ha uppdaterats av en annan tråd.
- Lås alltid upp innan en metod i ett annat objekt anropas.
Nu kastar vi oss in i de listiga strategierna.
Krympa synchronized-block (2.4.1)
Accessorer (get()-metoder)
Metoder som bara returnerar värdet på ett fält behöver
inte nödvändigtvis vara synkroniserade även om andra trådar
kan tänkas skriva till fältet. Det är två saker man måste
ha i tankarna om accessorer inte är synkroniserade:
- Fälten får aldrig anta några otillåtna värden.
Det måste gå att när som helst avbryta en tråd som
utför beräkningar med ett fält och låta en annan tråd
returnera fältets värde. Detta innebär att fält av typen
double och long måste vara volatile.
Om fältet är en objektreferens räcker det inte med att själva
referensen har ett tillåtet värde, även det refererade objektets
fält måste ha det.
- Alla gjorda uppdateringar av fälten kanske inte syns. Förutom
att garantera exklusiv access garanterar synchronized och volatile
också att uppdateringar gjorda av andra trådar är synliga.
Om accessorerna inte är synkroniserade kan det hända att ett värde
som en annan tråd har skrivit ligger kvar i den trådens minnesarea
och inte är synligt för den tråd som returnerar fältets
värde. Om fältet är av en primitiv typ kan detta lösas
genom att fältet görs volatile.
Double-check idiom
Betrakta metoden instance() i klassen LazySingletonCounter
i avsnittet om fullständigt synkroniserade
objekt:
public static synchronized LazySingletonCounter instance() {
if (s == null) {
s = new LazySingletonCounter();
}
return s;
}
Metoden är synkroniserad därför att inte flera trådar
ska komma in i den samtidigt och alla upptäcka att s är
null och därmed skapa varsin instans av LazySingletonCounter.
Men om s faktiskt inte är null gör metoden inget
annat än att returnera s och då behöver den inte
vara synkroniserad (se föregående stycke). Går det att lösa
problemet att kollen if (s == null) ska utföras i ett synchronized-block
om s är null men utanför blocket om s
inte är null? Ja, med nedanstående kod, vilken är
en välkänd variant av föregående exempel. Detta sätt
att utföra kollen två gånger brukar kallas double-check
idiom.
public static LazySingletonCounter instance() {
if (s == null) {
synchronized (this) {
if
(s == null) {
s = new LazySingletonCounter();
}
}
}
return s;
}
Open calls
public class ClassWithState {
private int state = 7;
public synchronized void updateState() {
state = ...; //Some calculation
that must be executed in a synchronized block.
anotherObject.longMethodThatDoesNotUseState();
}
}
Metoden updateState() ovan är synkroniserad därför att beräkningen
av state inte får bli avbruten. Men om anotherObject.longMethodThatDoesNotUseState()
inte behöver synkroniseras kommer andra trådar att få vänta
i onödan på att den returnerar. Detta problem löses enkelt
genom att det senare anropet utförs osynkroniserat enligt nedan.
public class ClassWithState {
private int state = 7;
public void updateState() {
synchronized (this) {
state
= ...; //Some calculation that must be executed in a synchronized block.
}
anotherObject.longMethodThatDoesNotUseState();
}
}
Dela upp synchronized-block (2.4.2)
Dela upp klasser
Ponera att vi har en klass Shape som hanterar
x-koordinat, y-koordinat, bredd och höjd för någon krumelur
på skärmen. Alla metoder i klassen är synkroniserade och
det enda lås som används är låset till this.
Antag nu att positionen på skärmen (x- och y-koordinat) är
helt oberoende av dimensionen (bredd och höjd). Det är då
slöseri med tid att en operation på dimensionen ska behöva
vänta på en operation på positionen och vice versa. Detta
kan vi lösa genom att införa två nya klasser, Location
och Dimension, vilka representerar position och dimension. Klassen
Shape får nu delegera till dessa nya klasser, DelegatingShape.java. All synkronisering
sker nu i delegaterna vilket innebär att operationer kan ske samtidigt
på position och dimension.
Detta sätt att dela upp klasser kan förbättra prestandan
för trådade program men det är i all objektorienterad programmering
lämpligt att utföra sådan uppdelning för att göra
programmet mer lättförstått. Faktum är att denna typ
av uppdelning är en av de främsta anledningarna till att över
huvud taget använda objektorientering.
Dela upp lås
Om det av någon anledning inte skulle gå att dela Shape
enligt ovan kan vi åtminstone erhålla prestandavinsten genom dela
upp låset så att vi har separata lås för position och
dimension. Eftersom det i Java finns ett lås per objekt skapar vi helt
enkelt ett nytt objekt för varje lås, sedan synkroniserar vi på
de nya "lås"-objekten. Här kommer den varianten, LockSplitShape.java. När operationer
ska göras på positionen synkroniserar vi på locationLock
och när operationer ska göras på dimensionen synkroniserar
vi på dimensionLock. Eftersom vi inte ska använda låsobjekten
till något annat än just att utnyttja deras lås räcker
det att de är Object. Observera att låsen är final,
det finns ingen anledning att någonsin byta ut dem mot andra låsobjekt.
Copy-on-Write (2.4.4)
Om en klass innehåller flera fält som inte är oberoende av
varandra utan måste uppdateras samtidigt kan det bli komplicerat att
designa synkroniseringen så att inte någon tråd operar på
värden som inte hör ihop. Antag till exempel att x- och y-koordinaterna
i de olika varianterna av Shape ovan inte fick uppdateras oberoende
av varandra. Hur ska vi garantera att inte följande scenario inträffar:
tråd 1 hämtar x (genom att anropa getX()), blir avbruten,
tråd 2 uppdaterar koordinaterna, tråd 1 exekverar igen och hämtar
y (som inte hör ihop med det nyss hämtade värdet på
x)? Det hjälper inte att synkronisera getX() och getY()
eftersom en tråd kan bli avbruten när bara en av dem är anropad.
Lösningen är att alla metoder i Shape måste hantera
både x och y. Ett enkelt sätt att garantera att så sker
är att inte representera x oxh y med tvåint utan
med en klass som innehåller båda två och inte tillåter
att de ändrar värde. En sådan klass kan se ut så här,
ImmutableLocation.java. Gör
vi om Shape så att denna används är det ingen risk
att x och y kommer i otakt. I ShapeWithImmutablePoint.java
måste x och y alltid hanteras tillsammans.