9 spôsobov ako pokaziť program použitím volatile

Kvalifikátor (qualifier, kľúčové slovo pre spresnenie definície typu premennej, pozn. prekl.) volatile v C/C++ je tak trocha podobný C preprocesoru: škaredý, tupý nastroj, ktorý je ľahké zneužiť, ktorý však - za prísne vymedzených okolností - vykoná svoju prácu. Tento článok najprv vysvetlí volatile a jeho históriu, a potom, za pomoci série príkladov jeho nesprávneho použitia vysvetlí, ako najefektívnejšie vytvoriť správny systémový software s použitím volatile. Hoci článok sa sústredí na C, takmer všetko v ňom platí aj pre C++.

Čo znamená program v C?

Štandard jazyka C definuje význam programu v C pomocou "abstraktného stroja", ktorý si môžeme predstaviť ako jednoduchý, neoptimalizujúci interpreter jazyka C. Správanie akejkoľvek implementácie jazyka C (t.j. kompilátor spolu s cieľovým počítačom) musí zahŕňať tie isté vedľajšie efekty ako abstraktný stroj, ale okrem toho štandard vyžaduje len málo spoločného medzi abstraktným strojom a výpočtami, ktoré sa skutočne vykonajú na cieľovej platforme. Inými slovami, program v C si môžeme predstaviť ako zoznam požadovaných efektov, a implementácia C rozhoduje, ako najlepšie dosiahnúť tieto efekty.

Ako jednoduchý príklad, uvážme túto funkciu:

int loop_add3 (int x) {
  int i;
  for (i=0; i<3; i++) x++;
  return x;
}

Správanie abstraktného stroja je jasné: vytvorí lokálnu premennú (viditeľnú len v rámci funkcie a so životnosťou obmedzenou na vykonanie funkcie) nazvanú i, ktorá sa v cykle mení od 0 do 2, pričom v každom prechode cyklom sa pridá 1 k premennej x. Dobrý kompilátor však vygeneruje nejaký takýto kód:

loop_add3:
  movl 4(%esp), %eax
  addl $3, %eax
  ret

V skutočnom stroji sa i nikdy neinkrementuje, ba dokonca sa ani nevytvorí, ale výsledný efekt je rovnaký, ako keby bol vytvorený a inkrementovaný. Vo všeobecnosti sa tento rozdiel medzi abstraktnou a skutočnou sématikou pokladá za prospešný, a výraz "ako keby" použitý štandardom dáva optimalizátoru voľnosť vytvoriť efektívny kód z programu zapísaného v jazyku vyššej úrovne.

Čo znamená volatile?

S rozdielom medzi abstraknou a konkrétnou sémantikou je ten problém, že C je jazyk nižšej úrovne, ktorý je určený na implementovanie operačných systémov. Písať operačný systém si častokrát vyžaduje, aby bol tento rozdiel zúžený alebo odstránený. Napriklad, ak OS potrebuje vytvoriť novú tabuľku stránok, v abstraktnej sémantike C nie je postihnutý dôležitý fakt, že programátor si žiada fyzicky vyhradiť tabuľku v RAM, ku ktorej môže potom hardware pristupovať. Ak implementácia C dospeje k záveru, že tabuľka je zbytočná a optimalizátor ju odstráni, vývojárov úmysel nebude splnený. Kanonický príklad tohoto problému je napríklad ak sa kompilátor rozhodne, že vývojárov kód pre vynulovanie citlivých údajov je neužitočný, a optimalizátor ho odstráni. Keďže abstraktný stroj jazyka C nebol navrhnutý tak, aby spoznal že tieto údaje môžu byť neskôr "odpočuté" či inak zneužité, optimalizácia je oprávnená (aj keď zjavne nežiadúca).

Štandard jazyka C nám dáva len niekoľko spojovacích bodov medzi abstraktným a skutočným strojom:

Väčšina implementácií jazyka C poskytuje aj ďalšie mechanizmy, napríklad inline assembler, alebo dodatočné knižničné funkcie, ktoré nie sú vyžadované štandardom.

Spôsob, akým volatile spája abstraktnú a skutočnú sémantiku je nasledujúci:

Vždy, keď abstraktný stroj číta z volatile premennej, skutočný stroj musí vykonať čítanie z adresy danej premennej. Každé čítanie môže vrátiť ľubovoľnú hodnotu (t.j. optimalizátor nesmie predpokladať, že dve nasledujúce čítania z tej istej adresy vracajú vždy tú istú hodnotu, pozn.prekl.) Vždy, keď abstraktný stroj zapisuje do volatile premennej, skutočný stroj musí vykonať zápis na príslušnú adresu. Za iných okolností sa k tejto adrese nesmie pristupovať (s určitými výnimkami), a poradie prístupu k volatile premenným nesmie byť zmenené (s určitými výnimkami).

Zhrnutie:

Odkiaľ pochádza volatile?

Historicky, spojenie medzi abstraktným a skutočným strojom bolo uskutočnené vlastne náhodne: prvé kompilátory nemali dostatočne dobrú optimalizáciu na to aby vznikol významný sémantický rozdiel. Ako sa optimalizátory vylepšovali, postupne sa stávalo jasnejším, že je potrebné systematické riešenie. Vo vynikajúcom príspevku v USENETe pred 20 rokmi Doug Gwyn vysvetlil, ako sa dospelo ku volatile:

Vezmime ako konkrétny príklad ovládače zariadení v UNIXe. Tieto sú takmer vždy celé napísané v C, a na PDP-11 a podobných architektúrach s pamäťovo mapovanými vstupno-výstupnými zariadeniami, niektoré zariadenia sa správajú rôzne pri čítaní byte, čítaní slova, zápise byte, zápise slova, cykle čítanie-modifikácia-zápis alebo iných variáciách pamäťového cyklu. Pokúšať sa vygenerovať správny strojový kód, pričom je zdrojový text zapísaný v C, je problematické, a výsledkom bolo množstvo ťažko vystopovateľných chýb. Ak sa použil iný kompilátor než Ritchie-ov (Dennis Ritche, tvorca jazyka C, pozn.prekl.), použitie optimalizácií často spôsobovalo premenlivé správanie. Prinajmenšom jedna verzia UNIX Portable C Compiler-a (PCC) zahŕňala špeciálny hack, aby rozoznala konštrukcie typu
((struct xxx *)0177450)->zzz
ako potenciálne odkazy na vstupno-výstupný priestor (t.j. registre zariadení), a neoptimalizovala takéto výrazy (ak konštanty spadali do adresného priestoru Unibus I/O). X3J11 (komisia ANSI pre štandardizáciu jazyka C, pozn.prekl.) sa rozhodla, že tomuto problému je potrebné sa postaviť čelom, a zaviedla volatile, aby podobné hacky v budúcnosti neboli potrebné . Aj keď bolo navrhnuté, aby sa od prekladačov vyžadovala implementácia minimálne možnej šírky pristupovaných dát pre volatile dáta, a vyžadovať od implementácií aby ju jasne definovali, nebolo praktické trvať na tomto pre všetky implementácie; takže implementátorom bola umožnená určitá voľnosť v tomto smere.

Je jasné, že je zlý nápad, aby prekladač používal nejaký zvláštny heuristický prístup aby sa na základe štruktúry programu snažil uhádnuť vývojárov zámer.

Deväť spôsobov ako pokaziť systémový program použitím volatile

1. Príliš málo volatile

Najzrejmejšou chybou použitia volatile je nepoužiť ho, keď je potrebný. Vezmime tento konkrétny príklad. Predpokladajme, že vyvíjame software na 8-bitový mikrokontrolér AVR, ktorý (niektoré jeho modely) nemá hardwarovú násobičku. Keďže násobenie sa vykonáva v software, chceme vedieť, ako dlho trvá, a teda nakoľko sa mu máme snažiť vyhnúť. Takže si napíšeme nasledovný malý testovací program:

#define TCNT1 (*(uint16_t *)(0x4C))
signed char a, b, c;
uint16_t time_mul (void) {
  uint16_t first = TCNT1;
  c = a * b;
  uint16_t second = TCNT1;
  return second - first;
}

TCNT1 je tu ukazovateľom na hardwarový register, ktorý je umiestnený na adrese 0×4C. Tento register poskytuje prístup k počítadlu Timer/Counter 1, ktoré je voľne bežiacim 16-bitovým počítadlom. Predpokladáme, že sme ho už predtým nastavili aby bežalo rýchlosťou zodpovedajúcou nášmu experimentu. Prečítame register pred a po násobení a tieto dve hodnoty navzájom odčítame, aby sme získali celkové trvanie násobenia. Poznámka: aj keď na prvý pohľad tento program vyzerá, že zlyhá pre prípad, keď TCNT1 pretečie z 65535 na 0 počas behu testu, v skutočnosti pracuje správne pre všetky časy medzi 0 a 65535 tikov počítadla.

Nanešťastie, ak pustíme tento program, vždy bude ukazovať, že násobenie trvalo nula tikov počítadla. Aby sme videli, čo sa pokazilo, pozrime sa na asemblerovú verziu preloženého programu:

$ avr-gcc -Os -S -o - reg1.c
time_mul:
  lds r22,a
  lds r24,b
  rcall __mulqi3
  sts c,r24
  ldi r24,lo8(0)
  ldi r25,hi8(0)
  ret

Problém je teraz jasný: obe čítania z registra TCNT1 boli odstránené, a funkcia jednoducho vracia konštantne nulu (avr-gcc vracia 16-bitovú hodnotu v registrovom páre r24:r25).

Ako to, že kompilátor nikdy nečíta z TCNT1? Najprv si spomeňme, že význam programu v C je definovaný abstraktným strojom definovaným v štandarde jazyka C. Keďže pravidlá pre abstraktný stroj neobsahujú nič o hardwarových registroch (alebo o súčasných dejoch), implementácia C je oprávnená predpokladať, že dve nasledujúce čítania z objektu, bez zápisu medzi nimi, vrátia rovnakú hodnotu. Samozrejme, ak sa odčítajú dve rovnaké hodnoty, výsledok je vždy nula. Takže preklad, ktorý vyrobil avr-gcc, je správny; chybný je náš program.

Aby sme problém napravili, musíme program zmeniť tak, aby TCNT1 ukazovalo na volatile pozíciu.

#define TCNT1 (*(volatile uint16_t *)(0x4C))

Neraz už implementácia jazyka C nemôže eliminovať obe čítania a ani nesmie predpokladať, že sa číta tá istá hodnota v oboch prípadoch. Tentokrát kompilátor vyprodukuje lepší kód:

$ avr-gcc -Os -S -o - reg2.c
time_mul:
  in r18,0x2c
  in r19,0x2d
  lds r22,a
  lds r24,b
  rcall __mulqi3
  sts c,r24
  in r24,0x2c
  in r25,0x2d
  sub r24,r18
  sbc r25,r19
  ret

Hoci tento asemblerovský kód je správny, náš program v C ešte stále obsahuje skrytú chybu. Budeme ju skúmať neskôr.

Obvykle nájdete definície pre registre v systémových hlavičkových súboroch. Ak je to tak, nepotrebujete použiť volatile, ale oplatí sa definície si skontrolovať, nie vždy sú správne.

Vezmime iný príklad. V systéme, ktorý implementujete, nejaký výpočet musí počkať, až kým nenastane určité prerušenie. Program môže vyzerať napríklad takto:

int done;
__attribute((signal)) void __vector_4 (void) {
  done = 1;
}
void wait_for_done (void) {
  while (!done) ;
}

Funkcia wait_for_done() je určená na volanie z kontextu hlavného programu (nie z prerušenia), kým __vector_4() je volaný prerušovacím systémom ako odpoveď na nejakú vonkajšiu udalosť. Po preklade tohoto programu do assembleru:

$ avr-gcc -Os wait.c -S -o -
__vector_4:
  push r0
  in r0,__SREG__
  push r0
  push r24
  ldi r24,lo8(1)
  sts done,r24
  pop r24
  pop r0
  out __SREG__,r0
  pop r0
  reti
wait_for_done:
  lds r24,done
.L3:
  tst r24
  breq .L3
  ret

Kód pre prerušovaciu rutinu vyzerá dobre - uloží hodnotu do premennej done tak, ako bolo zamýšľané. Zvyšok prerušovacej rutiny je štandardným kódom obslúženia prerušenia v AVR. Avšak kód pre wait_for_done() obsahuje závažnú chybu: dokola číta register r24 namiesto toho aby čítal pamäťové miesto kde je uložená premenná done. Toto sa deje kvôli tomu, lebo abstraktný C stroj nevie o potrebe komunikácie medzi súčasne prebiehajúcimi procesmi (či sú to vlákna, prerušenia alebo niečo iné). Znova, preklad je dokonalý, avšak sa nezhoduje s vývojárovým zámerom.

Ak označíme premennú done ako volatile, kód pre prerušovaciu rutinu sa nezmení, ale wait_for_done() teraz vyzerá takto:

wait_for_done:
.L3:
  lds r24,done
  tst r24
  breq .L3
  ret

Tento kód už bude fungovať. Kľúčovým bodom je tu viditeľnosť. Ak uložíme hodnotu do premennej v C, ktoré výpočty bežiace na tomto stroji túto zmenu zaručene uvidia? Ak čítame z globálnej premennej, o ktorých výpočtoch sa predpokladá, že majú vplyv na jej hodnotu? V oboch prípadoch, odpoveďou je, že "výpočet, ktorý vykonáva čítanie alebo zápis, je jediným, ktorý má na danú premennú vplyv". To znamená, že pre normálne premenné C nerobí žiadne záruky na jej viditeľnosť (t.j. všetky zmeny pre túto premennú môže vykonávať v registroch, alebo ich nemusí vykonávať vôbec, ak usúdi, že nie sú potrebné - pozn.prekl.). Označenie volatile vynucuje zápis zmien do pamäte a čítania z pamäte, čo nám zaručuje viditeľnosť vo viacerých výpočtoch (vláknach, prerušeniach, korutinách, čomkoľvek).

Náš program v C znova obsahuje skrytú chybu, ktorú budeme skúmať neskôr.

Niekoľko ďalších opodstatnených prípadov pre použitie volatile, vrátane zviditeľnenia premenných UNIXovských programov pre handlery signálov, sú rozobrané Hansom Boehmom.

Zhrnutie: Abstraktný C stroj je prepojený so skutočným strojom len v niekoľkých bodoch. Správanie sa skutočného stroja vo vzťahu k pamäti môže byť veľmi odlišné od operácií daných zdrojovým textom. Ak je vyžadovaná tesnejšia väzba medzi oboma úrovňami abstrakcie, napríklad pre prístup k hardwarovým registrom, je potrebné použiť označenie volatile.

2. Príliš veľa volatile

V dobre navrhnutom programe sa volatile používa presne na tých miestach, kde je potrebné. Slúži ako dokumentácia, ktorá hovorí: "táto premenná nefunguje podľa pravidiel C a vyžaduje silné prepojenie na pamäť." V systéme, v ktorom je priveľa volatile, sú premenné neuvážene označované ako volatile bez akéhokoľvek technického opodstatnenia.Sú tri dôvody, prečo je toto zlé. Po prvé, dokumentačnosť zlyhá, a mätie to pri následnej údržbe programu. Po druhé, volatile niekedy má za následok skrytie chýb typu "race condition".Ak správna činnosť programu vyťžaduje volatile a nerozumiete prečo, tak je pravdepodobné, že sa jedná o tento prípad. Je ďaleko lepšie odstrániť skutočný problém ako sa spoľahnúť na hack, ktorému nerozumiete, na odstránenie problému, ktorého príčinu nepoznáte. Napokon, volatile spôsobuje zhoršenie efektivity, pretože bráni správnej činnosti optimalizátora. Zdržanie, ktoré spôsobuje, je ťažké lokalizovať, pretože je rozmiestnené v celom systéme - použitie profileru tu vôbec nemusí pomôcť.

Použitie volatile je tak trocha ako rozhodovanie o tom, akú poistku si zriadiť. Príliš malá poistka, a ľahko sa dostanete do problémov. Príliš veľká poistka vás ochráni, ale v dlhodobom dôsledku za ňu zaplatíte priveľa.

Zhrnutie: Použite volatile len ak máte na to presné technické odôvodnenie. Volatile nie je náhradou za myslenie (toto povedal Nigel Jones).

3. Zle umiestnený kvalifikátor

Čo sa týka syntaxe C, volatile je kvalifikátor typu. Môže sa aplikovať na akýkoľvek typ, s použitím pravidiel, ktoré sú podobné, avšak nie zhodné, s pravidlami pre kvalifikátor const. Situácia sa stáva neprehľadnou ak sa typy s kvalifikátormi použijú na konštrukciu zložitejších typov. Napríklad, na jednoduchý ukazovateľ (pointer) sa dá kvalifikátor volatile použiť štyrmi spôsobmi:

int *p;                              // ukazovateľ na int
volatile int *p_to_vol;              // ukazovateľ na volatile int
int *volatile vol_p;                 // volatile ukazovateľ na int
volatile int *volatile vol_p_to_vol; // volatile ukazovateľ na volatile int

V jednotlivých prípadoch teda môže byť aj ukazovateľ volatile, aj cieľ na ktorý ukazuje ukazovateľ môže byť volatile. Rozdiel je zásadný: ak použijete "volatile ukazovateľ na obyčajný int" aby ste pristúpili k hardwarovému registru, kompilátor môže vyoptimalizovať prístupy k registru. Toto tiež má za následok pomalý kód, keďže kompilátor nemôže optimalizovať prístupy k ukazovateľu. Tento problém sa často objavuje v mailových konferenciách na tému embedded zariadení; je to častá chyba. Je tiež ľahké ju priehliadnuť pri kontrole programu, keďže oči hľadajú len "nejaký" výskyt volatile.

Napríklad, nasledujúci program je nesprávny:

int *volatile REGISTER = 0xfeed;
*REGISTER = new_val;

Pre jednoduchšie a ľahšie udržiavateľné programy je vhodné si pripraviť zložitejšie typy pomocou typedef (to je samozrejme všeobecne dobrá zásada). Napríklad by sme mohli začať definíciou nového typu "vint", čo je vlastne volatile int:

typedef volatile int vint;

Teraz vytvoríme ukazovateľ na vint:

vint *REGISTER = 0xfeed;

Jednotlívé prvky struct alebo union môžu byť volatile, ako aj celé struct/union môžu byť volatile. Ak je združený typ volatile, výsledok je ten istý, ako keby boli všetky jeho prvky volatile.

Mohli by sme sa opýtať: má význam deklarovať nejaký objekt aj const aj volatile?

const volatile int *p;

Hoci to spočiatku vyzerá ako protiklad, nie je tomu tak. Sémantika (význam) const v C je: "nepokúsim sa uložiť do tejto premennej", a nie "premenná sa nemení". Takže toto označenie je dokonale zmysluplné a je aj užitočné, napríklad na deklaráciu registra časovača, ktorý sa automaticky mení, avšak do ktorého sa nemá nič ukladať (tento príklad je priamo spomenutý v štandarde C).

Záver: Keďže syntax deklarácie typov v C je pomerne neintuitívna a je niekedy ťažko čitateľná, umiestnenie volatile sa musí vždy dôkladne premyslieť. Typedef je užitočná pomôcka na definovanie zložitejších typov.

4. Nekonzistentné kvalifikátory

Posledná verzia Linux 2.2 bola 2.2.26. V tej verzii, v súbore arch/i386/kernel/smp.c na riadku 125, sa nachádza táto definícia:

volatile unsigned long ipi_count;

To zatiaľ nie je žiadny problém: deklaruje sa tam premenná typu long na uloženie počtu medzi-procesorových prerušení, a je označená volatile. Avšak v hlavičkovom súbore include/asm i386/smp.h na riadku 178 nachádzame túto deklaráciu:

extern unsigned long ipi_count;

C súbory, ktoré #include-ujú tento hlavičkový súbor, nebudú narábať s ipi_count ako s volatile, a to môže ľahko viesť k problémom. Aj kernely verzií 2.3 obsahujú túto chybu.

Novšie verzie gcc pokladajú tento druh nekonzistencie za chybu pri kompilácii, čím tieto problémy vymizli. Je však ľahko možné, že niektoré embedded kompilátory (samozrejme vrátane tých, ktoré sú založené na starších verziách gcc) dovolia túto chybu urobiť.

Iný spôsob ako dospieť k nekonzistentnému kvalifikátoru je prostredníctvom pretypovania (typecast). Pretypovanie môže byť implicitné, napríklad ak sa odovzdá ukazovateľ na volatile objekt funkcii, ktorá očakáva obyčajný ukazovateľ. Kompilátor na toto upozorní, takéto upozornenia (warning-y) sa nikdy nesmú ignorovať či potlačiť. Explicitným pretypovaniam, ktoré odstraňujú kvalifikátor, sa treba vyhnúť, pretože vo všeobecnosti na ne nie sú vydané upozornenia. Štandard jazyka C výslovne uvádza, že ak sa pristupuje ku volatile objektu prostredníctvom obyčajného ukazovateľa, správanie programu je nedefinované.

Zhrnutie: Nikdy nepoužívajte nekonzistentné kvalifikátory. Ak je premenná deklarovaná ako volatile, tak všetky prístupy k nej, priame či nepriame, musia byť cez volatile premenné alebo ukazovatele na volatile.

5. Očakávanie, že volatile zabráni preusporiadanie ne volatile prístupov

Prichádzame k problému, kde robia chyby aj niektoré experti na vývoj embedded software, a o ktorom vedú spory aj experti na sémantiku jazyka C.

Otázka znie: čo je zlé na príkladoch v 1. kapitole, kde sme pridali volatile pre prístup k registru TCNT1 a k flagu done? Odpoveď, v závislosti od toho, komu chcete veriť, je buď "nič", alebo "kompilátor môže operácie preusporiadať tak, že výsledok bude nesprávny".

Jeden myšlienkový smer tvrdí, že kompilátory nesmú presunúť prístupy ku globálnym premenným cez volatile prístupy. Určitá interpretácia štandardu tento prístup podporuje. Problém však je ten, že niektoré dôležité kompilátory sú založené na inej interpretácii, ktorá hovorí, že prístupy k ne volatile objektom môžu byť voľne presúvané pred a za volatile prístupy.

Vezmime tento jednoduchý príklad (ktorý pochádza od Archa Robisona):

volatile int ready;
int message[100];
void foo (int i) {
  message[i/10] = 42;
  ready = 1;
}

Účelom funkcie foo() je uložiť hodnotu do poľa message, a potom nastaviť flag ready, aby iné prerušenie alebo vlákno mohli vidieť túto hodnotu. Tento zdrojový text preložia GCC, Intel CC, Sun CC, a Open64 veľmi podobným spôsobom:

$ gcc -O2 barrier1.c -S -o -
foo:
  movl 4(%esp), %ecx
  movl $1717986919, %edx
  movl $1, ready
  movl %ecx, %eax
  imull %edx
  sarl $31, %ecx
  sarl $2, %edx
  subl %ecx, %edx
  movl $42, message(,%edx,4)
  ret

Očividne je tu programátorov úmysel nerešpektovaný, keďže flag je uložený predtým ako je hodnota zapísaná do poľa. V čase, keď tento článok bol písaný, LLVM nerobí toto preusporiadanie, ale pokiaľ viem, toto je skôr náhoda ako zámer. Niekoľko embedded prekladačov zámerne nerobia takéto preusporiadanie následkom vedomého rozhodnutia uprednostniť bezpečnosť pred výkonom. Počul som, ale nekontroloval som, že nedávne Microsoft C/C++ kompilátory zaujímajú tiež veľmi konzervatívne stanovisko k volatile prístupom.To je pravdepodobne správny prístup, avšak to nepomôže ľuďom, ktorí musia písať prenositeľné programy.

Jedným z riešení tohoto problému je deklarovať pole message ako volatile pole. Štandard C hovorí jednoznačne, že vedľajšie efekty volatile nesmú prechádzať cez "sekvenčné body" ("sequence points", štandardom definované body programu, kde sa musí zhodovať stav abstraktného a skutočného stroja, pozn.prekl.), takže tento prístup bude fungovať. Na druhej strane, pridávanie množstva volatile môže potlačiť dôležité optimalizácie na iných miestach programu.Nebolo by pekné, ak by sme vedeli vnútiť dáta do pamäte len vo vybraných bodoch programu, bez toho aby sme mali volatile všade?

Konštrukcia, ktorú potrebujeme, je "kompilačná bariéra" ("compiler barrier"). Toto nie je zahrnuté v štandarde C, ale mnohé prekladače ho poskytujú. Napríklad, GCC a dostatočne kompatibilné kompilátory (vrátane LLVM a Intel CC) poskytujú "pamäťovú bariéru" v tejto podobe:

asm volatile ("" : : : "memory");

Toto zhruba znamená, že "tento inline assemblerovský kód, hoci neobsahuje žiadne inštrukcie, môže čítať alebo zapisovať kamkoľvek v RAM". Výsledkom je, že kompilátor uloží všetky premenné z registrov do RAM pred bariérou, a načíta ich po bariére. Naviac, kusy programu sa nemôžu presúvať ani v jednom smere cez bariéru. (Bohužiaľ, zdá sa, že táto bariéra prinajmenšom v aktuálnej verzii avr-gcc nefunguje celkom podľa očakávaní, viď napríklad túto diskusiu - pozn.prekl.). V podstate je kompiličná bariéra to isté ako pamäťová bariéra pre procesor vykonávajúci kód mimo poradia (out-of-order processor).

Použijeme bariéru v našom príklade:

volatile int ready;
int message[100];
void foo (int i) {
  message[i/10] = 42;
  asm volatile ("" : : : "memory");
  ready = 1;
}

Teraz je výsledok taký, aký očakávame:

$ gcc -O2 barrier2.c -S -o -
foo:
  movl 4(%esp), %ecx
  movl $1717986919, %edx
  movl %ecx, %eax
  imull %edx
  sarl $31, %ecx
  sarl $2, %edx
  subl %ecx, %edx
  movl $42, message(,%edx,4)
  movl $1, ready
  ret

A čo s kompilátormi ktoré neposkytujú pamäťové bariéry? Jedno zo zlých riešení je dúfať, že takýto kompilátor nie je príliš agresívny v optimalizáciách a nepresúva prístup k pamäti nevhodným spôsobom. Iné zlé riešenie je vložiť volanie externej funkcie, kde by mala byť vložená bariéra. Keďže kompilátor nevie, do akej pamäte bude externá funkcia pristupovať, toto môže mať podobný efekt ako bariéra. Lepšie riešenie je požiadať dodávateľa kompilátora o nápravu a o doporučené dočasné riešenie, kým sa neurobí náprava.

Zhrnutie: Väčšina kompilátorov môže presúvať prístup k ne-volatile objektom voči prístupom k volatile objektom, a aj tak činia, takže sa netreba spoliehať na to, že bude dodržané poradie dané zápisom v programe.

6. Použitie volatile na dosiahnutie atomicity

V predchádzajúcom texte sme videli prípad, kde volatile bolo použité na zviditeľnenie novej hodnoty premennej pre súčasne prebiehajúci proces. Toto je - v určitých prípadoch - správne implementačné rozhodnutie. Na druhej strane nie je správne použiť volatile na dosiahnutie atomicity.

Tak trocha prekvapujúco pre jazyk určený pre operačné systémy, C nedáva záruky na atomicitu pamäťových operácií, bez ohľadu na volatilitu pristupovaných objektov. Jednotlivé prekladače však dávajú všeobecné záruky typu "prístupy ku zarovnaných premenným veľkosti word sú atomické".

Vo väčšine prípadov, na zaručenie atomicity treba použiť zámky (locks). V šťastných prípadoch sú k dispozícii dobre navrhnuté zámky, ktoré v sebe obsahujú aj kompilačné bariéry. Ak programujete priamo hardware na embedded procesore, zrejme nebudete mať také šťastie. Ak si implementujete vlastné zámky, je rozumné pridať aj kompilačné bariéry. Napríklad staršie verzie TinyOS pre AVR používali takéto funkcie na získanie a uvoľnenie zámku pre globálne prerušenie:

char __nesc_atomic_start (void) {
  char result = SREG;
  __nesc_disable_interrupt();
  return result;
}
void __nesc_atomic_end (char save) {
  SREG = save;
}

Keďže tieto funkcie môžu byť (a obvykle aj sú) inlinované, je tu vždy možnosť, že kompilátor presunie časť programu von z kritického úseku. Zmenili sme preto zámky takto:

char__nesc_atomic_start(void) {
  char result = SREG;
  __nesc_disable_interrupt();
  asm volatile("" : : : "memory");
  return result;
}
void __nesc_atomic_end(char save) {
  asm volatile("" : : : "memory");
  SREG = save;
}

Zaujímavým spôsobom toto nemalo žiadny vplyv na efektivitu TinyOS efficiency, dokonca sa v niektorých prípadoch výsledný kód zmenšil.

Záver: Volatile nemá nič spoločné s atomicitou. Použite zámky.

7. Použitie volatile na modernom stroji

Volatile má veľmi obmedzené použitie na stroji, ktorý vykonáva inštrukcie mimo poradia (out-of-order), je multiprocesorový, prípadne oboje. Problém spočíva v tom, že kým volatile núti kompilátor pristupovať k pamäti na aktuálnom stroji, tieto prístupy obvykle neovplyvnia správanie celého hardware. Je treba uprednostniť použitie dodaných kvalitných zamykacích rutín pred vlastnou tvorbou. Vezmime túto spin-unlock funkciu z portu Linuxu pre ARM:

static inline void arch_spin_unlock(arch_spinlock_t *lock) {
  smp_mb();
  __asm__ __volatile__("str %1, [%0]\n" : : "r" (&lock->lock), "r" (0) : "cc");
}

Pred odomknutím sa vykoná smp_mb(), čo je vlastne niečo takéto:

__asm__ __volatile__ ("dmb" : : : "memory");

čo je súčasne kompilačná aj pamäťová bariéra.

Záver: Bez pomoci správne napísaných synchronizačných knižníc je písanie súčasne vykonávaného programu na out-of-order stroji alebo multiprocesore mimoriadne ťažké, a volatile tu sotva pomôže.

8. Použitie volatile v programe s viacerými vláknami

Tento problém je podobný predchádzajúcemu, ale je dostatočne dôležitý aby si zaslúžil opakovani. Arch Robison povedal, že volatile je takmer úplne zbytočné pre programovanie s viacerými vláknami (multi-threaded programming). A má pravdu. Ak máte vlákna, mali by ste mať aj zámky, a máte ich používať. Je dokázané, že správne synchronizovaný program - kde sú zdieľané premenné vždy pristupované z kritických úsekov - sa vykonáva sekvenčne konzistentne (za predpokladu správnej implementácie zámkov, ale o to by ste sa nemali starať). To znamená, že ak použijete zámky, nemusíte sa starať o kompilačné bariéry, pamäťové bariéry, ani o volatile. Nič z toho nie je potrebné.

Záver: Na písanie správneho viacvláknového programu potrebujete funkcie ktoré poskytujú (prinajmenšom) atomicitu a viditeľnosť. Na modernom hardware volatile nezabezpečí ani jedno z toho.

9. Predpoklad, že volatile prístupy sú správne preložené

Kompilátory nie sú dokonale spoľahlivé čo sa týka prekladu prístupu k objektom označeným ako volatile. Túto tému som už dôkladne rozobral na inom mieste, ale tu je jeden rýchly príklad:

volatile int x;
void foo (void) {
  x = x;
}

Správne správanie tohoto programu na skutočnom stroji je jednoznačné: musí sa vykonať čítanie z x, a potom zápis do x. Napriek tomu, verzia GCC pre procesor MSP430 sa správa inak:

$ msp430-gcc -O vol.c -S -o -
foo:
  ret

Výsledkom je nevykonanie žiadnej operácie. To je nesprávne. Vo všeobecnosti sú kompilátory založené na gcc 4.x zväčša správne, čo sa týka volatile, a tak je to aj s poslednými verziami LLVM a Intel CC. Verzie gcc pred 4.0 majú problémy, a podobne je to aj s mnohými inými kompilátormi.

Záver: Ak váš program používa volatile správnym spôsobom, avšak nefunguje podľa očakávaní, preskúmajte výsledok kompilácie, či kompilátor vygeneroval správne prístupy do pamäte.

A čo s vývojármi Linuxu?

Na Linuxových mailových konferenciách a webových stránkach môžete nájsť množstvo námietok voči volatile. Tieto sú zväčša opodstatnené, avšak si uvedomte, že:

  1. Linux častokrát beží na out-of-order procesoroch a viacjadrových procesoroch, kde volatile samo osebe je neúčinné.

  2. Linux kernel poskytuje bohatú kolekciu funkcií pre synchronizáciu a prístup k hardwaru, ktoré pri ich správnom použití pokryjú takmer všetky potenciálne miesta použitia volatile v bežnom kernelovom kóde.

Ak píšete program pre embedded procesor, ktorý vykonáva inštrukcie v poradí, a máte k dispozícii malú alebo žiadnu infraštruktúru okrem samotného C kompilátora, musíte sa spoľahnúť na volatile viac.

Záver

Je ťažké správne argumentovať pri použití optimalizujúcich prekladačov, podobne ako pri out-of-order procesoroch. Aj štandard jazyka C má svoje tienisté zákutia. Kvalifikátor volatile priam privoláva všetky problémy dohromady, aby naviac navzájom interagovali. Obvykle poskytuje slabšie záruky, než sa bežne predpokladá. Je potrebné byť opatrný pri tvorbe správneho programu s použitím volatile.

Našťastie sa väčšina programátorov, ktorí píšu užívateľské programy v C a C++, nikdy s volatile nestretnú. Ani väčšina programátorov pre kernel volatile nepotrebuje. Potrebný je predovšetkým pre vývojárov, ktorí pracujú priamo s hardwarom, napríklad s embedded mikrokontrolérom, alebo ktorí portujú nejaký operačný systém na novú platformu.

O autorovi

John Regehr (Associate Professor of Computer Science, University of Utah, USA ) sa venuje programovaniu už 26 rokov, z toho programuje embedded systems 17 rokov. Posledných 8 rokov prednáša na tému operačné systémy, embedded systémy, kompilátory a podobne študentom základného aj postgraduálneho štúdia. Snaží sa ich učiť význam volatile a má vcelku prehľad o dôvodoch, kde sa s ním robia chyby.

Vo februári 2010 prednášal na RWTH Aachen, kde sa okrem iného asi hodinu venoval nesprávnemu použitiu volatile. Tento článok je rozšírením materiálu z tejto prednášky.

(z originálu http://blog.regehr.org/archives/28 preložil wek, Apríl 2010)