Ett inte alltför ovanligt problem i multitråd-program är deadlocks. Men först en liten sammanfattning av några begrepp som används i påföljande diskussion. I Microsoft Windows-miljö (vilken är min primära plattform för systemprogramutveckling) används följande termer: Process: En process är den samling av resurser ett program använder. Dessa resurser består av ett virtuellt adressutrymme inom vilket applikationen arbetar, den exekverbara koden, handles till använda systemobjekt, en säkerhetskontext, en unik programidentifierare, en prioritetsklass, min- och max-gränsvärden för arbetsminnet för direktbearbetning (working set), samt minst en exekveringstråd. Varje process startas med en exekveringstråd, vilken kallas den primära tråden, men kan skapa ytterligare trådar från vilken tråd som helst. Tråd: En tråd är en begreppsenhet inom en process som kan schemaläggas för exekvering av kod. Alla trådar i en process delar på samma virtuella adressutrymme och systemresurser. Dessutom hanterar varje tråd felhantering, prioriteten för trådens schemaläggning, lokal lagring av psuedogemensamma trådvariabler, och andra strukturer som operativsystemet använder för att spara trådens kontext tills dess att den schemaläggs för exekvering. En trådkontext inkluderar trådens CPU-register, kernelstacken, miljövariabler för tråden, och en programstack i processens adressutrymme. Trådar kan också ha sin egen säkerhetskontext för att kunna uppträda som andra klienter. Trådar (och skilda processer) exekverar parallellt med varandra (det är lite mer komplicerat än så, men för vår diskussion nedan är detta tillfyllest). Det betyder att ifall flera trådar delar på någon gemensam resurs kommer vi snabbt att få ett problem: resursen kommer att bli fördärvad. 1 / 5
Ett mycket enkelt exempel: ta två trådar, T1 och T2. De delar på en datavariabel som vi kan kalla V. I T1 sätts nu V till ett visst värde. Så tar T2 över och sätter ett annat värde på variabeln. T1 exekverar obekymrat på och läser värdet på V för att ta ett beslut. Det blir dock ett helt galet beslut, eftersom det värde som T1 satte lite tidigare har gått förlorat. Detta blir orsaken till i bästa fall slumpmässiga konstigheter i programmet, och i värsta fall till en total programkrasch. Det är alltså oerhört viktigt att kunna förhindra att operativsystemet på ett oväntat sätt låter multipla trådar manipulera delade resurser på detta sätt. För att kunna ta kontroll över detta finns det synkroniseringsobjekt, populärt kallat för lås. Alla multitråd-operativsystem innehåller åtminstone någon form av enkla lås. En tråd tar ägarskap över ett lås, och om låset inte är tillgängligt suspenderar operativsystemet trådens exekvering fram tills dess låset släpps fritt, och tråden äntligen kan ta ägarskapet över det. När tråden så sedan har använt den gemensamma resursen och inte behöver det längre, öppnas låset och vilken annan tråd som helst kan ta över ägarskapet, och därmed också manipulera resursen enligt eget tycke. Det finns dock ett subtilt problem som lurar ifall man har fler delade resurser som skyddas av åtskilda lås. Man kan råka ut för ett klassiskt deadlock. I ett enkelt sådant scenario kan vi åter tänka oss två trådar, T1 och T2, som delar på två datavariabler, VA och VB. Dessa skyddas av sina respektive lås, LA och LB. Nu tänker vi oss att följande händer: 1. T1 låser LA för att exklusivt kunna manipulera VA. 2. T2 låser LB för att exklusivt kunna manipulera VB. 3. T1 försöker låsa LB för att kunna manipulera VB, men den är redan låst av T2. 4. T2 försöker låsa LA för att kunna manipulera VA, men den är redan låst av T1. T1 T2 LA 1 0 LB 0 1 2 / 5
[ 1 = deadlock ] Om nu låsen väntar utan tidsbegränsning, kommer de båda trådarna för evigt att vänta på att den andra tråden ska släppa fri den andra resursen som den vill ha, och vi har fått något som är en klassisk deadlock-situation. Ett sätt att försöka lösa upp denna situation är att se till att låsen alltid låses i samma ordning, t.ex. genom att låsa dem med stigande minnesadress. I ett enkelt program går detta alldeles utmärkt, men med stigande komplexitet (och med allt fler programmerare inblandade) blir det snabbt mycket svårt att kunna implementera det på detta sätt. Olyckan kommer förr eller senare att inträffa, och då behöver vi en metod att exakt kunna hitta var i programmet en deadlock uppstår (speciellt när det sker ute hos kund). I det system som jag f.n. arbetar med är det dessutom mer komplicerat än så. I det finns det två typer av lås, "readlock" och "writelock", som i många fler olika kombinationer kan orsaka deadlock: T1(LA->LB) r->r r->w w->r w->w T2(LB->LA) r->r 0 0 0 r->w 0 1 0 w->r 0 0 1 w->w 1 1 1 [ r = readlock, w = writelock, 1 = deadlock ] 3 / 5
Nå, behovet av ett system för att kunna detektera deadlocks är alltså oerhört viktigt. Hur angripa detta problem? I Dr. Dobbs läste jag en artikel som tog upp deadlockproblematiken, och det blev inspirationen till följande lösning. All kod som jag härefter skriver är i C++ och WIN32 API, eftersom det är vad som den applikation som jag jobbar med idag använder. Vad vi behöver är en helt separat tråd i programmet, vars enda uppgift är att detektera ifall vi uppnått en deadlock-situation enligt ovanstående sanningstabell. För att kunna uppnå detta behövs först att varje gång en tråd vill ta över ett lås, skickas först ett meddelande till detektortråden om att låset strax kommer att begäras, att låset har övertagits, alternativt att en tidsgräns har överskridits och tråden misslyckats ta över låset, samt att tråden släpper låset fritt igen. I ett Windowsprogram är den mest uppebara kandidaten för att skicka sådana meddelanden till detektortråden en Windows-meddelandepump. Alltså ska själva grunden i detektortråden vara en enkel meddelandepump (ErrorHandler och MessageHandler är egna funktioner som tar hand om detaljer som inte är viktiga för denna diskussion): MSG msg = { 0 }; for ( ; ; ) { BOOL retval = ::GetMessage(&msg, NULL, 0, 0); switch (retval) { case -1: // Error return ErrorHandler(::GetLastError()); case FALSE: // Received WM_QUIT return 0; case TRUE: (VOID) ::DispatchMessage(&msg); // Necessary so that any Timer Callback Function is called. MessageHandler(msg); break; default: _ASSERT(false); // Should be impossible! return 42; } if (!m_isrunning &&!::PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE)) { (VOID) ::PostThreadMessage(::GetCurrentThreadId(), WM_QUIT, WPARAM(0), LPARAM(0)); // Gracefully exit this thread. } } Nästa steg är att formge de meddelanden som vi vill använda i deadlock-detektortråden. Den information vi behöver är vad det är för typ av lås (readlock/writelock), vilket läge låset befinner sig i (strax före låsförsöket/när låset har blivit taget/när låset släppt fritt igen/om låsförsöket misslyckades), trådens identitet, låsets identitet, var i koden händelsen skett, samt i vilken ordning (tidpunkten) händelsen på låset skett. Vi lagrar denna information i en datastruktur och skickar den till detektortråden: 4 / 5
enum LOCKATTEMPT_TYPE { typeunknown = 0, readlock = 1, // is a read lock writelock, // is a write lock }; enum LOCK_STATE { stateunknown = 0, stateattempting = 1, // state: is attempting a lock statelocked, // state: is successfully locked stateunlocked, // state: is successfully unlocked statefailed, // state: attempt failed }; typedef struct { } Object;typedef const Object* TRWMutexPtr; typedef DWORD THREADID;typedef DWORD TIMEOUT; typedef UINT64 SESS_COUNTER; typedef INT64 SESS_COUNTER_DIFF; struct LOCKSESSION { SESS_COUNTER m_counter; // Locksession counter THREADID m_threadid; // thread ID TRWMutexPtr m_lockid; // the involved mutex LOCKATTEMPT_TYPE m_locktype; // what type of lock LOCK_STATE m_lockstate; // state of the session std::string m_sourcefilename; // Path to the source file where the lock operation was registered. UINT m_sourcelinenumber; // Line number of the source file where the lock operation was registered. }; Nu behöver vi bara registrera några Windows-meddelanden, och se till att den kod som låser skickar rätt meddelanden vid rätt tillfälle: const UINT AM_TRYGETLOCK = ::RegisterWindowMessage("AM_TRYGETLOCK"); const UINT AM_OWNLOCK = ::RegisterWindowMessage("AM_OWNLOCK"); const UINT AM_RELEASELOCK = ::RegisterWindowMessage("AM_RELEASELOCK"); const UINT AM_FAILEDGETLOCK_TIMEOUT = ::RegisterWindowMessage("AM_FAILEDGETLOCK_TIMEOUT"); const UINT AM_FAILEDGETLOCK = ::RegisterWindowMessage("AM_FAILEDGETLOCK"); LOCKSESSION psess = new LOCKSESSION; _ASSERT(pSess!= NULL); if (psess!= NULL) { psess->m_lockid = TRWMutexPtr(pMutex); psess->m_locktype = locktype; psess->m_lockstate = stateattempting; psess->m_threadid = threadid; psess->m_sourcefilename = sourcefilename; psess->m_sourcelinenumber = sourcelinenumber; if (!::PostThreadMessage(m_threadID, AM_TRYGETLOCK, WPARAM(pSess), LPARAM(0))) { delete psess; } } Resten är, som man säger, detaljer. Den mottagande koden (MessageHandler ovan) läser den information som vi registrerat i psess ovan. Första gången händelsekedjan på låset registreras i en tråd sparas informationen i en lista (en std::map närmare bestämt) och uppdateras därefter fram tills dess meddelandet AM_RELEASELOCK, AM_FAILEDGETLOCK_TIMEOUT, eller AM_FAILEDGETLOCK tas emot, då den i stället tas bort från listan. Med hjälp av t.ex. en timer kontrolleras med jämna mellanrum innehållet i listan, och ifall en deadlock enligt schemat upptäcks, rapporteras det på lämpligt vis. 5 / 5