Edytor tekstu zamiast wypasionego IDE?

Filozoficzne pytanie

Mam silny background .NET-owy, zdarzyło mi się dłużej programować w Javie. Właściwie od kiedy zacząłem programować bardziej „na serio”, towarzyszyło mi Visual Studio, Eclipse, czy NetBeans. Od zawszę więc myślałem, że dobre środowisko pracy, to podstawa. Bynajmniej, nie twierdzę teraz inaczej – ale czy dobre środowisko oznacza ciężkie, w pełni wyposażone, dopasowane do danego języka IDE? A może „zwykły” edytor tekstu wystarczy?

Rys historyczny

Pierwszą styczność z edytorem tekstu, konkretniej z vim-em zawdzięczam mojemu Wspaniełemu Koledze. Wtedy, szczerze powiedziawszy, nie spodziewałem się, że ktokolwiek tego używa – nie działa backspace, trzeba wcisnąć „i”, żeby pisać i nie da się z niego wyjść? Żartów z niego było co niemiara ;> ale Michał poruszał się w nim całkiem sprawnie. Niedługo i ja musiałem się nieco do niego przekonać, gdyż jest on podstawowym (jedynym) wyposażeniem w części pracowni na mojej zacnej uczelni. Hmm.. przekonać to za dużo powiedziane – wypadałoby to raczej nazwać – powstrzymywaniem nienawiści :)

Sytuacja uległa nieco zmianie, gdy przeczytałem (bardzo dobry moim zdaniem) artykuł Roba Conery’ego właśnie o edytorach tekstu, głównie o vim-ie. Pomyślałem wtedy, że coś w tym musi być! Poszperałem więc w internecie, obejrzałem screencast podlinkowany przez Roba.. i dałem vim-owi drugą szansę. Realizowałem akurat całkiem duży (jak na realia szkolne) uczelniany projekt, więc szansa była. Tym razem miałem w sobie więcej entuzjazmu i po niełatwej przeprawie udało się – dostrzegłem urodę edytorów tekstu!

Bolesna przesiadka

Nie ma co ukrywać, że zmiana IDE na edytor jest bolesna, bardzo bolesna. Zaryzykowałbym stwierdzenie, że większość osób, która nawet próbuje, odpada bardzo szybko. Tak było i w moim przypadku, ale za którymś podejściem się przełamałem. Edytory takie jak vim lub Emacs wprost „z pudełka”, z przydatnych rzeczy, oferują podświetlanie składni dla kilku języków.. i to by było na tyle. Dodajmy do tego, że nawet podstawowa obsługa i nawigacją są skomplikowane i katastrofa gotowa ;)

Obrazek ten jest humorystyczną ilustracją tego o czym mówię :) Co zatem przyciąga licznych wyznawców tekstowych edytorów?

Nieskończone dobro

Oba wspomniane edytory posiadają nieograniczone możliwości konfiguracyjne – skonfigurować można dosłownie wszystko za sprawą specjalnych, charakterystycznych dla danego edytora języków. Jasnym chyba jest, że dzięki temu zaprogramować można w nich wszystko, nawet przejmowanie kontroli nad światem :>

Użytkownicy nie muszą jednak programować wszystkiego sami – istnieją setki, tysiące (miliony?) pluginów zarówno do Emacsa jak i vima. Pomyśl nad najbardziej absurdalną rzeczą, jaka przychodzi Ci do głowy i jaką może mieć edytor (i nie, przeglądarka WWW, gry, organizator czasu to nie są absurdalne rzeczy) – tak Emacs ma ją już napisaną :)

Właśnie za sprawą owych dodatków (własnych, ściągniętych, czy też dostosowanych) dostajemy do edytorów wsparcie do rozmaitych języków programowania (podświetlanie składni, snippety, uzupełnianie, połączenie z debuggerami, integracja z narzędziami do testowania itd. itp.). W ten sposób ujawnia się jedna z największych zalet edytorów – uczymy się narzędzia RAZ i używamy go do wszystkich języków programowania, robiąc tylko odpowiednie dostosowania. W ten sposób zwraca nam się zainwestowany na początku czas – z nawiązką.

Last but not least pamiętajmy, że edytory nastawione są na.. edycję. W związku z tym mają niezwykle bogate możliwości edycji tekstu (a tym przecież jest kod!) i to w sposób łatwy i szybki. Pomyślcie teraz – czy więcej czasu podczas kodzenia spędzacie na pisaniu, czy edycji tego co już powstało? No właśnie :)

Co edytor zrobił dla mnie?

O tym postaram się napisać w przyszłości, prezentując przy okazji używane przeze mnie dodatki. Niestety, nie będą to tutoriale step-by-step, gdyż sam jeszcze jestem lamką :)

Poniżej jeszcze kilka linków do poczytania:

Wpis na blogu Roba Conery’ego podlinkowany wyżej

O programowaniu C# w Emacsie

Bardzo dobry wpis o edycji tesktu w vimie, przedstawiający „filozofię” tego edytora

Tworzenie systemu operacyjnego – część 0×01: Włączam komputer i…, bootloader

Power on!

W życiu każdego młodego informatyka nastaje moment, gdy zaczyna się zastanawiać, co tak właściwie się dzieje, gdy włącza komputer. Na ten temat można by mówić długo i na różnym poziomie szczegółowości. Ja nie będę zajmował się jednak skaczącymi elektronami i zmieniającymi się poziomami energetycznymi w krzemie, a tym co interesuje programistę systemów operacyjnych – poziomem oprogramowania.

Włączenie komputera rozpoczyna się od naciśnięcia przycisku (genialne spostrzeżenie, prawda?), który włącza zasilanie komputera, a konkretniej – zasila płytę główną. Ta z kolei, w pierwszej chwili uruchamia swój własny firmware. Ciężko określić jak dokładnie proces ten przebiega, gdyż dla każdej płyty głównej może być to trochę inne. Tak naprawdę chodzi o to, że płyta główna potrzebuje programu, który uruchomi procesor. Jeśli w tym momencie coś pójdzie nie tak, będziemy oglądać ciemny ekran, słyszeć kręcące się wiatraczki i piski z głośniczka na płycie głównej. Załóżmy jednak, że udało się uruchomić procesor. Co ciekawsi zastanawiają się pewnie – „a co jeśli komputer posiada dwa lub więcej procesorów?”. Otóż w systemach wieloprocesorowych jeden z procesorów jest tzw. bootstrap processor (BSP), który odpowiedzialny jest za uruchomienie BIOS-u oraz zainicjowanie jądra. Drugi z procesorów pozostaje w stanie zatrzymania (halt), aż do momentu kiedy explicite zostanie obudzony przez jądro systemu operacyjnego. Wracając jednak do naszej sekwencji – nasz świeżo uruchomiony procesor znajduje się w dobrze zdefiniowanym stanie – tj. znane są zawartości części jego rejestrów, w szczególności rejestru wskaźnika instrukcji CS:IP. Rejestr ten przechowuje magiczny adres nazwany reset vector. W tym momencie płyta główna musi się upewnić, że instrukcja znajdująca się pod tym adresem, to skok do miejsca w pamięci, w którym znajduje się zmapowany BIOS – jest to obszar adresów nieco poniżej magicznej granicy 1MB (np. 0x9FC00 – 0xFFFFF). Warto zwrócić uwagę na słowo zmapowany – tzn. dokładnie w tym miejscu w pamięci RAM znajdują się śmiecie, a płyta główna tworzy iluzję, jakoby znajdował się tam BIOS (wie jak to zrobić dzięki przechowywanej przez nią mapy pamięci). Od momentu gdy zacznie wykonywać się BIOS, ciężko powiedzieć co dzieje się dalej, gdyż to zależy od konkretnego BIOS-u – a więc od producenta płyty głównej. Standardowo jest to wykonanie „Power-on self-test”, czyli tzw. POST-u, który testuje – integralność samego BIOS-u, niektóre podzespoły, pamięć itp. Kolejnym krokiem po POST jest przejście do sekwencji bootowania. Pierwszym krokiem bootowania jest przejrzenie (predefiniowanej przez użytkownika w ustawieniach BIOS-u) listy urządzeń do wystartowania i odszukaniu urządzenia (np. dysku), którego pierwszy (a w zasadzie zerowy) sektor, czyli 512 bajtów, kończy się bajtami 0x55 oraz 0xAA. Bajty te oznaczają, iż urządzenie jest bootowalne – zawiera bootloader. Następnie, wspomniane 512 bajtów jest kopiowane pod adres 0x7C00, a procesor zaczyna wykonywać instrukcje spod tegoż adresu. Warto wspomnieć, że istnieje pewien standard, jak owe 512 bajtów powinno wyglądać – tzn. powinien być tam MBR. Nie jest to jednak istotne, gdyż w tym momencie pełną władzę przejmujemy my – programiści! :)

Przed dalszą lekturą upewnij się, że zapoznałeś się informacjami zawartymi w:

QEMU – podstawy

Tryby operacyjne – tryb rzeczywisty

Bootloader

Pierwsza wersja naszego bootloadera będzie robiła.. nic :) a dokładniej nasz bootloader „zatrzyma” procesor. W kolejnej części nieco go rozbudujemy. W tym miejscu trzeba zaznaczyć, że komputer pracuje w trybie rzeczywistym. Spójrzmy na kod naszego bootloadera:

[ORG 0x7C00]			; here is where our code will be loaded by BIOS
[BITS 16]

bootloader:
	cli			; turn off maskable interrupts, we don't need them now
	hlt			; halt CPU
	jmp	bootloader	; this should not happen, but.. ;)

times 510-($-$$) db 0		; fill rest with zeros
dw 0xAA55			; bootloader indicator, used by BIOS

Teraz będą objaśnienia. Pierwsza linia to wskazówka dla naszego kompilatora, żeby przyjął odpowiednie przesunięcie w adresach. Gdyby tej linii nie było (co możecie sprawdzić) kompilator „myślałby”,  że kod wskazywany przez etykietę bootloader znajduje się pod adresem 0x0000, a co za tym idzie skok z linii 7 by się nie powiódł.

Druga linia, to kolejna wskazówka, mówiąca tym razem, że chcemy kod wynikowy 16 bitowy – pamiętacie o trybie rzeczywistym?

Linie 5, 6 i 7 należy rozpatrywać razem. Instrukcja cli wyłącza maskowalne przerwania. O tym co to dokładniej znaczy będzie innym razem, ważne żeby zapamiętać iż przerwania wybudzają procesor, ze stanu halt, aktywowanego poleceniem hlt. W stanie halt procesor zatrzymuje się i nie wykonuje następującej instrukcji, aż do wznowienia. Tak więc brak instrukcji cli spowodowałby chwilowe wstrzymanie procesora, a następnie (gdyż przerwań w tle dzieje się dużo!) wykonanie instrukcji z linii 7, która jest swoistą „ostatnią deską ratunku” :) Polecam poeksperymentować usuwając linie 5 i 7 i zobaczyć dokąd zabrnie nasz IP i czy nie skończy się to restartem.

Linia 9 to makro NASM-a mówiące, aby wypełnić następujące bajty, aż do 510, zerami.

Linia 10 to wspomniany indykator świadczący o tym, iż jest to kod bootloadera.

W ten sposób otrzymujemy 512 bajtów bootloadera.

Nasz kod kompilujemy poleceniem:

nasm boot.asm -f bin -o ./bin/boot.bin

Przełącznik -f bin mówi, iż chcemy, aby wynikowy kod był tzw. płaską binarką (ang. flat binary), czyli ma nie zawierać żadnych nagłówków, ani informacji pomocniczych, a całość kodu źródłowego jest bezpośrednio zamieniana na kod. Możemy to sprawdzić używając narzędzia hexdump – zobaczymy, iż pierwszym bajtem wynikowego pliku boot.bin jest 0xFA, które jest kodem operacji cli :)

Pozostaje nam teraz uruchomienie naszego bootloadera za pomocą polecenia:

qemu -hda ./bin/boot.bin

I voila! Nasz bootloader działa i nic nie robi :) Będąc w QEMU polecam pobawić się trybem monitora, w szczególności wyświetlić fragmenty pamięci.

Końcowe słowa

To tyle w dzisiejszym odcinku. W następnym postaramy się rozbudować nasz bootloader w sposób, który pozwoli załadować nam nasz przyszły kernel. W repozytorium znajduje się pełen kod wraz z plikiem Makefile (niezbyt pięknym, swoją drogą), który pozwala na automatyzację kompilacji i uruchamiania kodu.

Aby ściągnąć kod części 0x01 wykonaj:

git clone git://github.com/luksow/OS.git --branch 0x01

Jeśli chcesz go tylko przejrzeć, wejdź tutaj.

Tryby operacyjne – tryb rzeczywisty

Tryby operacyjne

Tryb operacyjny (ang. operating mode) procesora to po prostu pewien dobrze zdefiniowany tryb, w którym procesor zachowuje się w ściśle określony, charakterystyczny dla danego trybu sposób. Ciężko o formalną definicję tego pojęcia, ale myślę że zamieszczone niżej opisy trybów operacyjnych IA-32 (architektura potocznie zwana x86) i Intel 64 (architektura x86-64) rozwieją wszystkie wątpliwości.

Intel Architecture 32 bit (x86)

Architektura ta posiada 3 tryby operacyjne plus jeden quasi-tryb:

  • Real mode (tryb rzeczywisty) – inicjalny tryb pracy procesora, posiada interfejs programistyczny procesora Intel 8086 wraz z kilkoma rozszerzeniami – między innymi z możliwością przejścia do trybu chronionego. Dużo szczegółów poniżej.
  • Protected mode (tryb chroniony) – natywny tryb procesora, jeśli czytasz tę notkę z komputera PC, to bardzo prawdopodobne, że Twój procesor właśnie używa tego trybu. Tryb ten pozwala na użycie pełnego potencjału nowoczesnego procesora i to on będzie nam towarzyszył przez znacznie większą część programowania systemu operacyjnego. Dużo szczegółów wkrótce.
  • System management mode (tryb zarządzania) – bardzo nietypowy tryb, w którym mamy wysoko uprzywilejowane środowisko, pozwalające na zarządzanie energią, obsługiwanie krytycznych błędów itp. Na razie tryb ten nie będzie nas interesował.
  • Virtual-8086 mode – wspomniany quasi-tryb, który pozwala na uruchamianie programów przeznaczonych na procesor 8086 w trybie chronionym.

Intel 64 (x86-64)

Architektura ta posiada wszystkie tryby, które posiada IA-32 plus:

  • IA-32e mode, który posiada dwa podtryby:
    • Compatibility mode (tryb zgodności) – podtryb pozwalający uruchamiać aplikacje 16 i 32-bitowe bez potrzeby rekompilacji ich dla procesora 64-bitowego. Co ciekawe, aplikacje działające w trybie Virtual 8086 nie będą działały w tym trybie.
    • 64-bit mode – natywny dla 64-bitowego procesora tryb, który pozwala na korzystanie z dobrodziejstw architektury x86-64. Jeśli Twój system jest 64-bitowy, to najprawdopodobniej Twój procesor pracuje właśnie w tym trybie.

Warto wspomnieć, iż niektórzy wyróżniają tryb nazywany potocznie Unreal mode (tryb nierzeczywisty), jednak nie będę go opisywał.

Tryb rzeczywisty

Wady i zalety

Tryb rzeczywisty to inicjalny tryb procesora, który istnieje do dziś ze względu na inżynierów procesorów, którzy chcą zachować wsteczną zgodność z procesorami sprzed ery 80386. Jakie są tego konsekwencje? Niestety bardzo dotkliwe.

  • Nieco ponad 1 MiB adresowalnej pamięci, a faktycznie nieco poniżej 1 MiB pamięci do użycia
  • Brak zdecydowanej większości ficzerów architektury IA-32, w tym m. in.:
    • Brak jakiejkolwiek ochrony pamięci, co za tym idzie – brak pamięci wirtualnej
    • Brak wielowątkowośći/wieloprocesowości (nikłe możliwości emulacji)
    • Każdy „proces” może wszystko – np. mazać dowolny fragment pamięci, wykonywać instrukcje systemowe itp.
    • Niemożność użycia wszystkich rejestrów, które w trybie chronionym są ogólnodostępne
    • Ograniczone i skomplikowane możliwości adresowania pamięci

Jedyne zalety trybu rzeczywistego, to dość bogate funkcje oferowane przez BIOS, z których będziemy mieli okazję skorzystać.

Adresowanie

Adresowanie w trybie rzeczywistym nie odbywa się w typowy, liniowy sposób. Pamięć adresowana jest w następujący sposób:

Segment : Przesunięcie

Tak zaadresowana pamięć mapowana jest na fizyczny adres o postaci:

16 * Segment + Przesunięcie

Dziwne, prawda? Jeszcze dziwniejsze jest to, że pojedynczy adres fizyczny ma wiele reprezentacji w formacie „rzeczywistotrybowym”, gdyż segmenty zachodzą na siebie. I tak np. 0x12B1 : 0x0069 odpowiada adresowi 0x12B79 (0x10 * 0x12B1 + 0x0069 = 0x12B79), natomiast 0x1000 : 0x2B79 odpowiada adresowi… 0x12B79! (0x10 * 0x1000 + 0x2B79 = 0x12B79).

Do dyspozycji mamy 6 16-bitowych rejestrów segmentowych: CS, DS, ES, FS, GS oraz SS. Stos obsługuje się używając pary rejestrów SS : SP.

Dostępne tryby adresowania to:

  • [BX + offset]
  • [SI + offset]
  • [DI + offset]
  • [BP + offset]
  • [BX + SI + offset]
  • [BX + DI + offset]
  • [BP + SI + offset]
  • [BP + DI + offset]
  • [offset]

Gdzie offset zawiera się pomiędzy -32768 oraz 32767.

Z adresowaniem związane jest jeszcze jedno ciekawe zjawisko, mianowicie, gdy segment ustawimy na 0xFFFF, natomiast przesunięcie na wartość pomiędzy 0x10, a 0xFFFF, to zaadresujemy 64 KiB powyżej granicy 1 MiB, co najprawdopodobniej zaowocuje „zawinięciem” pamięci. Dokładniej zjawisko to potraktujemy przy okazji omawiania linii A20.

Funkcje oferowane przez BIOS

BIOS oferuje do użycia w trybie rzeczywistym szereg funkcji dostępnych poprzez przerwania (o przerwaniach będzie jeszcze rozlegle w kolejnych odcinkach). Opis niektórych funkcji można znaleźć tu. Przykład użycia funkcji BIOS będzie można prześledzić w następnych częściach cyklu.

Podsumowanie

Jak widać, tryb rzeczywisty ma bardzo niewiele do zaoferowania programiście systemów operacyjnych. Tryb rzeczywisty powinien nam zatem posłużyć do szybkiego przejścia do trybu chronionego i ewentualnego wykonania niezbędnych czynności poprzedzających. Aha i tak, system operacyjny DOS działał w całości w trybie rzeczywistym :)

QEMU – podstawy

Emulator, maszyna wirtualna?

QEMU to zarówno maszyna wirtualna jak i emulator, ale co to tak właściwie znaczy?

QEMU jest emulatorem, gdyż potrafi uruchamiać pojedyncze programy jak i całe systemy operacyjne napisane dla procesora innego, od tego na którym aktualnie pracuje. Jest to możliwe dzięki szybkiej translacji rozkazów procesora docelowego na rozkazy procesora na którym działa QEMU.

QEMU w trybie wirtualizacji (potrzebny Xen lub moduł KVM) działa natomiast bezpośrednio na procesorze gospodarza.

Obsługiwane przez QEMU procesory to m. in.: x86, x86-64, PowerPC, MIPS, ARM, SPARC. Pełna lista tutaj.

Podstawowa obsługa – uruchamianie

QEMU to narzędzie konsolowe, podstawowy schemat uruchamiania to:

qemu [options] [image]

Wśród opcji możemy podać m. in.:

-M machine – wybór konkretnej architektury do emulacji (-M ? wyświetla listę dostępnych)

-cpu model – wybiera konkretny procesor (-cpu ? wyświetla listę dostępnych)

-fda file, -fdb file, -hda file, -hdb file, -hdc file, -hdd file, -cdrom file – włącza do naszego emulowanego systemu urządzenia: dystkietkę (przedrostki f), dyski (przedrostki h), cdrom z zwaratością taką jak zawiera file. Uwaga: nie można używać jednocześnie hdc i cdrom.

-boot x – ustala kolejność bootowania: a, b – pierszeństwo dyskietek, c – dysk twardy, d – cdrom.

-m megs – ustala ilość wirtualnego ramu na megs MiB, domyślnie jest to 128 MiB

-kernel file – bezpośrednio uruchamia podany plik zawierający jądro naszego systemu operacyjnego zgodnie ze specyfikacją Multiboot (będzie o tym, w którymś z najbliższych wpisów)

Istnieje jeszcze wiele przełączników pozwalających ustawiać sieć, bluetooth oraz różne inne urządzenia, ale tyle co tu opisałem, w zupełności wystarczy początkującemu osdevcowi :)

Spójrzmy na parę przykładów:

qemu linux.img

Uruchamia system z podanego obrazu linux.img, wszystkie opcje pozostają domyślne.

qemu -hda hda.img -m 128

Uruchamia emulator z podłączonym dyskiem o zawartości hda.img ze 128 MiB ramu. To co się dalej stanie, zależy od zawartości hda.img, gdyż wirtualny BIOS będzie próbował bootować system właśnie z owego dysku. Różnica między tym a poprzedni przykładem jest taka, iż poprzednia komenda mówiła explicite: „bootuj linux.img”, w tym przypadku dajemy QEMU informację o stanie: „Jest dysk, rób co chcesz” ;)

qemu -kernel kernel.bin

Uruchomi nasz kernel według specyfikacji Multiboot, inaczej mówiąc – nie potrzebujemy żadnego bootloadera, a nasz kernel powinien być w jakimś dobrze zdefiniowanym formacie wykonywalnym, np. ELF (o tym też innym razem).

Ficzery QEMU

W QEMU lubię szybkość działania i łatwość obsługi, ale to nie jedyne jego, oprócz wspomnianej już wieloarchitekturowości i bogatych możliwości konfiguracji,  zalety. Istotną cechą QEMU jest to, iż wspiera VBE 2.0 oraz posiada natywne wsparcie dla GDB. Moim ulubionym „ficzerem” jest natomiast QEMU monitor.

QEMU monitor

QEMU monitor, to diagnostyczny tryb emulatora pozwalający podglądać interesujące rzeczy. Po uruchomieniu systemu w QEMU wciskamy CTRL+ALT+2 i już jesteśmy w trybie monitora (CTRL+ALT+1 wraca z powrotem na ekran systemu, natomiast CTRL+ALT pozwala „odzyskać” z powrotem myszkę). W trybie monitora mamy dostępny szereg komend:

info cpus – podaje aktualny stan procesorów

info registers – wyświetla zawartość wszystkich rejestrów dostępnych dla danej architektury. Polecenie to jest szalenie przydatne, szczególnie na początkowych etapach osdevu – np. gdy chcemy sprawdzić czy stronicowanie jest już aktywne, gdzie właśnie utknął EIP naszego procesora i w wielu, wielu innych przypadkach.

Komenda info potrafi jeszcze co nieco, ale na razie tyle nam wystarczy. Kolejne przydatne polecenia to:

x /fmt addr oraz xp /fmt addr – pierwsza z nich wyświetla zawartość wirtualnej pamięci pod adresem addr w formacie fmt, natomiast druga robi to samo, tyle że dla pamięci fizycznej. Format jest natomiast następujący: count – ilość elementów do wyświetlenia (liczba dziesiętna), format – format wyświetlenia zawartości pamięci, tj. x – hex, d – dziesiętny, u – dziesiętny bez znaku, o – oktalny, c – char lub (uwaga!) i – instrukcja!, size – rozmiar, tj. b – 8 bitów, h – 16 bitów, w – 32 bity, g – 64 bity. Przykłady:

xp /40db 0x7C00

Wyświetli dziesiętnie 40 elementów bajtowych spod adres 0x7C00.

xp /40xh 0x7C00

Wyświetli heksadecymalnie 40 elementów dwubajtowcyh spod adresu 0x7C00.

xp /10i $eip

Wyświetli 10 kolejnych instrukcji po aktualnej pozycji EIP. Widzimy, że nie musimy tu podawać rozmiaru, gdyż jak wiadomo w x86 instrukcje mają zmienny rozmiar. Takie wyświetlenie jest szczególnie przydatne, gdy zastanawiamy się gdzie właśnie utknął nasz kernel :)

QEMU monitor posiada jeszcze wiele przydatnych opcji jak np. zrobienie screena, czy nagranie dźwięku jednak nie widzę sensu opisywania ich na obecnym etapie. Kompletny spis można znaleźć tu.

Na zakończenie

QEMU będzie nam bardzo pomocne w całej serii artykułów Tworzenie systemu operacyjnego. Będzie więc wiele okazji aby przećwiczyć zdobytą tutaj wiedzę na temat obsługi tego niezwykle zgrabnego narzędzia. Tymczasem zapraszam do ściągnięcia QEMU stąd, zapoznania się z dokumentacją tu oraz do spojrzenia na inne emulatory, np. bochs. Aha, jeśli ktoś naprawdę nie znosi konsolowych aplikacji, to istnieje parę graficznych nakładek na QEMU, jak np. qtemu czy qemulator, jednak żadnej z nich nie testowałem.

Tworzenie systemu operacyjnego – część 0x00: Wstęp i formalizmy

Skąd pomysł?

Po ponad 6 miesiącach od powstania idei serii postów, pierwsza część ujrzała właśnie światło dzienne. Pomysł na cykl notek zrodził mi się zaraz po ukończeniu kursu „Systemy operacyjne” na mojej ulubionej uczelni. Według (genialnego!) Prowadzącego, około 15% studentów po tym kursie przystępuje do napisania własnego systemu operacyjnego i wygląda na to, że ja jestem w grupie tych ~19 osób. Zapowiada się świetna zabawa :)

Co to będzie?

Seria będzie dokumentować i opowiadać o moich zmaganiach z próbą napisania (od zera) systemu operacyjnego o pewnej funkcjonalności. W postach postaram się zawrzeć wiedzę i kod, który krok po kroku będzie układał się w pełnowartościowy system operacyjny.

Po co to?

Cykl postów o tematyce programowania systemu operacyjnego będzie dokumentacją moich poczynań. Posty będą zdecydowanie miały charakter poznawczy, gdyż w żadnym przypadku nie jestem autorytetem w tej dziedzinie, a (na razie!) tylko początkującym amatorem. Proponuję więc nie polegać na zawartej tu wiedzy podczas kolokwiów z architektury komputerów ;) Proszę też o regularne komentarze z poprawkami do prezentowanych przeze mnie zagadnień – z pewnością będę je wtedy poprawiał. Przy okazji serii mam nadzieję zachęcić czytelników do zagłębienia się w fascynujący świat programowania systemowego, odkryć go nieco, a może nawet skłonić do napisania własnego systemu operacyjnego? Ponadto, nie znalazłem w internecie publikacji w języku polskim, które prowadziłyby krok po kroku (no dobrze, kilka by się znalazło, jednak kroki kończyły się zwykle na 3 odcinkach) po niezwykle zawiłej tematyce tworzenia systemu operacyjnego – będę więc pionierem ;> Przypominam również, że powstający system operacyjny nie ma za zadanie przewyższyć udziału rynkowego takich świetnych systemów jak Microsoft Windows, GNU/Linux czy Mac OS X, gdyż byłoby to niedorzeczne. To po prostu zwykły projekt eksperymentalny.

Co będzie potrzebne?

Najważniejszą rzeczą jest wytrwałość i chęć zdobywania wiedzy :) Posiadając te dwie cechy, nawet całkowity nowicjusz sporo może wynieść z tego cyklu. W swoich wypocinach postaram się zgrabnie balansować między nadmierną gęstością tekstu, a przesadną zwięzłością. Trudniejsze rzeczy postaram się dokładnie objaśniać, łatwiejsze będę traktował po macoszemu, dodając odnośniki, z których będzie można doczytać więcej. Zachęcam do komentowania rzeczy niejasnych i skomplikowanych tak, abym z czasem mógł dostosować poziom.

System operacyjny, przynajmniej z początku, będzie napisany w języku C z małym dodatkiem assemblera x86 (będę używał „dialektu” NASM-a), zatem znajomość C, jak również nieco assemblera zdecydowanie się przyda.

Z całą pewnością potrzebny będzie jakiś działający system operacyjny z dostępnymi narzędziami programistycznymi. W swoich postach będę opisywał tworzenie systemu operacyjnego w środowisku GNU/Linux w połączeniu z typowymi dla tej platformy narzędziami takimi jak gcc czy binutils. Przedstawione przykłady, da się jednak bez problemu skompilować pod Windowsem, czy Mac OS X, wymagałoby to jednak nieco więcej pracy (jeśli będzie taka potrzeba, mogę opisać techniki w oddzielnym poście). Zwolenników systemów innych niż Linux zachęcam do zainstalowania jakiejś łatwej i przyjemnej dystrybucji, choćby na maszynie wirtualnej i programowania w takim środowisku. Gwarantuję, że inicjalny trud się opłaci.

Kolejna rzeczą, która nie jest konieczna, ale znacząco ułatwi nam programowanie i testowanie naszego systemu będzie emulator platformy x86 (gdyż na tą platformę będzie powstawał nasz system). Mój wybór padł na qemu. Emulator przyda się, aby uruchamiać świeżo skompilowany system – choć oczywiście dla wytrwałych pozostaje opcja odpalania systemu na prawdziwej maszynie.

Formalizmy

Koniec gadania, przystępujmy do rzeczy. Chcemy napisać system operacyjny, no dobrze… tylko co to tak właściwie jest? W literaturze dałoby się znaleźć setki mniej lub bardziej zmyślnych/złożonych/obszernych definicji, wymyślić nowych można by było kilka kolejnych, a więc… która jest dobra? Odpowiedzi na to pytanie nie ma, gdyż brakuje obiektywnych kryteriów oceny, czy dana definicja jest poprawna, zła, ładna, zgrabna itd. Mi najbardziej do gustu przypadła następująca:

System operacyjny jest to zbiór programów i procedur spełniających dwie podstawowe funkcje:

– zarządzanie zasobami systemu komputerowego,

– tworzenie maszyny wirtualnej.

Natomiast najtrafniejsza definicja zasobu systemu to wg mnie:

Zasobem systemu jest każdy jego element sprzętowy lub programowy, który może być przydzielony danemu procesowi.

Definicją procesu zajmiemy się przy innej okazji.

Teraz kilka słów wyjaśnień. Jak widać z definicji systemu operacyjnego – nie jest on sam w sobie programem, a zbiorem programów i procedur. System, który będziemy implementować za jakiś czas będzie wyglądał z punktu widzenia czysto technicznego jakby był jednym programem, jednak to tylko decyzja implementacyjna – w ogólności tak być nie musi. Pierwsza funkcja, którą pełni system operacyjny wydaje się być jasna – zarządza on zasobami, które oferuje. Zarządza, czyli udziela dostępu użytkownikowi, czy też raczej programowi użytkowemu. Zasoby sprzętowe to na przykład dysk twardy, pamięć, czy też komputery podłączone w sieci, do stacji na której działa system operacyjny. Zasoby programowe to natomiast wszelkiego rodzaju tablice, czy też semafory. Druga funkcja, czyli tworzenie maszyny wirtualnej może brzmieć nieco tajemniczo. Sama maszyna wirtualna jest raczej kojarzona z procesem wirtualizacji, stawianiem odseparowanych serwerów itp., tym razem chodzi jednak po prostu o tworzenie warstwy abstrakcji łatwej do oprogramowania, użytkowania i tę właśnie warstwę nazywamy maszyną wirtualną. Nieformalnie reasumując: system operacyjny ma za zadanie oferować to, co posiada, w przystępnej formie ;)

Rozważmy teraz takie pytanie: Czy program działający na mikrokontrolerze np. mikrofalówki, lodówki, czy pralki jest systemem operacyjnym? Odpowiedź na to pytanie (według podanej wyżej definicji) to: nie. Program ten jest zadany odgórnie, nie ma możliwości oprogramowywania go – nie posiada żadnej warstwy abstrakcji i o zasobach tutaj też trudno mówić. Z kolei, zastanówmy się nad takim telewizorem, który działa w oparciu o jądro Linux. Tu sprawa nie jest już taka prosta. W końcu wiadomo, że Linux to system operacyjny (puryści mogliby się doczepić do tego stwierdzenia.. ;)), tylko że ten system dla użytkownika końcowego jest całkowicie przezroczysty – nie ma on bezpośredniego dostępu do zasobów, szczególnej maszyny wirtualnej też tu nie widać. Zatem, czy patrząc na telewizor w kontekście pudełka,  w którego środku coś się dzieje i otrzymujemy obraz, należy mówić o systemie operacyjnym? Tak jak już mówiłem, z definicjami nie jest łatwo.

Na koniec

To tyle na pierwszy odcinek serii. W kolejnym odcinku zajmiemy się już konkretami, czyli własnościami architektury x86. Poniżej załączam listę stron z materiałami, które zdecydowanie będą się przydawać.

OSDev.org – bardzo aktywny serwis typu wiki, traktujący o tematyce programowania systemów operacyjnych. Polecam od niego zaczynać szukanie odpowiedzi na pojawiające się wątpliwości.

Bona Fide OS Developer – strona zawiera pokaźną ilość przydatnych tutoriali, niestety nie jest zbyt często uaktualniana

Operating System Resource Center – skarbnica wiedzy o wszelakich zasobach systemów operacyjnych. Panuje tam trochę bałaganu, nie mniej jednak, można tam znaleźć masę przydatnych informacji.

Intel® 64 and IA-32 Architectures Software Developer’s Manuals – darmowe (!) podręczniki Intela do architektury x86 (i x86-64), zawierające 2 grube tomy na temat programowania systemowego. Przystępnie napisane, zwięźle objaśniają subtelności architektoniczne. Da się tam chyba znaleźć odpowiedź na każde pytanie, choć tego nikt nie jest pewien, bo nikt nie dał rady ich przeczytać ;)

Into the Void – skrócony (jakby ten intelowski okazał się za długi, ciężko dostępny) opis instrukcji architektury x86

W porządku, to tyle. Do usłyszenia w następnej części!

Mój udział w konkursie „Daj się poznać”

Konkurs daj się poznać oficjalnie rozpoczęty! Na stronie uczestników aktualnie widnieją 24 osoby (w tym ja, a jakże!), co należy uznać za niemały sukces. W tej notce chciałbym po krótce wyjaśnić, co będzie można u mnie przeczytać/zobaczyć.

Co?

Moim projektem jest… system operacyjny. Pomysł na serię postów dotyczących osdevu jest dosyć leciwy, jednak brak czasu i motywacji spowodowały odwlekanie go w czasie. „Daj się poznać” natomiast ma być dla mnie motywacją, aby owa seria powstała. Chciałbym jednak podkreślić, że konkurs sam w sobie nie jest powodem powstania projektu i niezależnie od przebiegu „zawodów” seria będzie miała swoją kontynuację.

Po co?

Odpowiedź na pytanie jest bardzo prosta – aby się czegoś nauczyć. Zaczynając ten projekt nie mam zamiaru przewyższyć udziału rynkowego takich świetnych systemów jak Microsoft Windows, GNU/Linux czy Mac OS X. Inicjatywa ma być zabawą połączoną z całkiem poważnym „riserczem”.

Dla kogo?

Seria ta przeznaczona jest dla wszystkich, którzy zainteresowani są tym, jak działa komputer (a konkretniej model systemowy architektury x86) – w detalach! Część czytelników zapewne zechce skompilować i pobawić się kodem, inni pewnie przejrzą tylko omawiane aspekty – wszyscy czytelnicy jednak, mam nadzieję, czegoś ciekawego się dowiedzą.

Kiedy?

Seria postów na ten temat ruszy jak tylko wrócę z urlopu – czyli najprawdopodobniej na przełomie sierpnia i września :) Wszystkich, którzy są zainteresowani, już dzisiaj zachęcam do subskrypcji kanału RSS, gwarantuję, że nie pożałujecie :) Posty dotyczące projektu oznaczone będą tagiem osdev, dodatkowo te, biorące udział w konkursie tagiem daj się poznać.

Problemy?

Widzę jeden poważny problem – częstotliwość postów. Tak jak wspomniałem, osdev to poważny temat, który wymaga na bieżąco intensywnego doszkalania się – nie wiem, czy będę w stanie produkować pełnej jakości (a innych, niż dopracowane – nie chcę) posty dwa razy w tygodniu (szczególnie, że niektóre zagadnienia wymagają przyswojenia dwóch setek stron z manuali Intela ;)).  Opcją jest również od czasu do czasu post o używanych przeze mnie narzędziach – które również mogą się przydać innym. Jeśli jednak nie podołam „z normą” – trudno, obejdzie się bez nagród ;). Mam nadzieję, że konkurs przysporzy mi wiernych czytelników serii – i to jest dla mnie priorytetem.

Do usłyszenia na przełomie sierpnia i września i nie zapomnijcie o zasubskrybowaniu kanału!

Byłbym zapomniał – kodów źródłowych projektu szukajcie tutaj, na razie niestety, nic tam nie ma.

Konkurs „Daj się poznać”

Wydaje mi się, że środowisko polskich programistów jest naprawdę aktywne, jeśli chodzi o rozmaite wydarzenia, konferencje, inicjatywy czy konkursy. Widocznym problemem jest jednak brak lub mała widoczność projektów open source, które znacząco mogłyby podnieść jakość „sceny”. Naprzeciw temu (i nie tylko temu) zagadnieniu wyszedł Maciek Aniserowicz (ogromne wyrazy uznania!) ze swoim konkursem „Daj się poznać”. Zasady są bardzo proste – blogowanie przez minimum 10 tygodni wokoło projektu open source, którego samemu się tworzy. Autorzy najlepszych wpisów na swoich blogach zostaną nagrodzeni rewelacyjnymi i licznymi nagrodami po zakończeniu konkursu – 15 listopada. Start już 1 sierpnia, a więc nie zwlekajcie z rejestracją (ostateczny termin – 15 sierpnia)! :)

Od siebie dodam, że zamierzam wziąć udział w konkursie (ciekawe jak to będzie z regularnością moich wpisów, bo jak wiadomo, mam z tym problem, ale przy takiej motywacji.. ;>) z chyba dość nietypowym pomysłem, który zarazem będzie realizacją moich półrocznych planów. Nie będę wychodził przed szereg i więcej szczegółów na temat mojego projektu dopiero po oficjalnym rozpoczęciu :)

Blog Maćka

Strona konkursu

Zapytanie LINQ vs metody LINQ

Wszyscy chyba zdają sobie sprawę z tego, jakim dobrodziejstwem jest LINQ, które pojawiło się dosyć dawno, wraz z .NET 3.0 3.5. Jak wiadomo LINQ oferuje trochę nowych słów kluczowych oraz trochę metod – i tu pojawia się pytanie  – czym różni się zapis za pomocą słów kluczowych od zapisu „metodowego”? Szczególnie interesujące zdaje się być to, czy któryś z zapisów powoduje jakiś narzut wydajnościowy.

Nie będę budował napięcia i od razu odpowiem – query syntax w zasadzie nie różni się niczym od zapisu za pomocą extension methods. W praktyce, zapytanie zapisane za pomocą słów kluczowych jest tłumaczone podczas procesu kompilacji na coś, co CLR świetnie rozumie – metody. Microsoft, tak jak wielu programistów, preferuje zapis „zapytaniowy” nad „metodowym” ze względu na czytelność. Moim zdaniem jest to sprawa mocno dyskusyjna, dla mnie często dużo bardziej czytelniejsze jest używanie extension methods. Ponadto, niestety nie wszystko zapisywalne za pomocą extension methods da się zapisać za pomocą słów kluczowych, ale o tym za moment w przykładach.

Przykład 1

int[] numbers = { 5, 10, 8, 3, 6, 12 };

IEnumerable numQuery1 =
    from num in numbers
    where num % 2 == 0
    orderby num
    select num;

Taki kod równoważny jest (i jednocześnie tłumaczony w procesie kompilacji na następujący):

IEnumerable numQuery2 = numbers.Where(num => num % 2 == 0).OrderBy(n => n);

Czy ten drugi nie wydaje się Wam czytelniejszy? ;) Nietrudno jednak stworzyć przykład w którym query syntax będzie bardziej wymowne.

Przykład 2

var personQuery1 = from person in people
                   let fullname = person.Name + " " + person.Surname
                   select new { Name = fullname, Age = person.Age };

var personQuery2 = people
                   .Select(person => new { Name = person.Name + " " + person.Surname, Age = person.Age });

W pierwszej linijce widzimy jak przydatne (dla czytelności) jest słowo kluczowe let pozwalające na tymczasową definicję nowej zmiennej (więcej o słówku let tu i tu na blogu Maćka Aniserowicza). Zapis za pomocą extension methods nie jest już taki ładny (choć to też zapewne sprawa gustu). Ostatni przykład zaprezentuje nam, że nie wszystko da się zrobić za pomocą query syntax.

Przykład 3

int[] numbers = { 5, 10, 8, 3, 6, 12 };
var nums = numbers.Skip(3).Take(2).ToList();

Efektu wywołania takich metod nie otrzyma się za pomocą zapytania.

Podsumowanie

Wszystko co da się napisać za pomocą query syntax da się zapisać za pomocą extension methods. Odwrotnie już tak niestety nie jest. Często zapis za pomocą słów kluczowych jest dużo czytelniejszy (szczególnie przy używaniu let, group, join), natomiast metody dają nam szersze możliwości. Wybór więc powinien zależeć od osobistego stylu i zdania na temat tego, co jest bardziej wymownie. Nieważne jednak czy korzystasz z metod, czy z zapytań, powinieneś robić to świadomie, aby nie tracić na wydajności – polecam przeczytać drugi z linków do blogu Maćka powyżej.

Prosty sposób na uaktualnienie GUI za pomocą BackgroundWorkera

Podczas pisania aplikacji okienkowych często zdarza się, że nasz program pobiera duże ilości danych, a następnie wyświetla je użytkownikowi. Niezwykle denerwującą sytuacją jest, gdy całość danych wyświetla się „na raz”. Jeśli nie „zamraża” to interfejsu użytkownika i jeszcze do tego pisze coś w stylu „Praca w toku” to jeszcze pół biedy, gorzej jak użytkownik pozostaje z okienkiem z uroczym dopiskiem „Nie odpowiada”. Wielkie szanse, że zirytowany taką sytuacją wyłączy naszą aplikację i zgłosi nam błąd – że nie działa :) Warto jednak zrobić coś jeszcze lepszego, niż zwykłe ostrzeżenie użytkownika, że troszkę poczeka.

Moim zdaniem najlepiej jest wprowadzić do aplikacji pasek postępu (choć nie zawsze jest to możliwe, bo czasem nie wiadomo ile danych przybędzie) i przede wszystkim – podzielić dane na paczki i prezentować dane progresywnie.

Jak to zrobić?

Najprostszym i przyjemnym sposobem jest użycie klasy BackgroundWorker, ale zacznijmy od początku. W celach demonstracyjnych stworzymy proste okienko, które będzie zawierało ListBox – do prezentacji wyników, przycisk „Start” oraz ProgressBar. Przejdźmy teraz do kodu:

private BackgroundWorker backgroundWorker = new BackgroundWorker();
private const int Results = 40;
private const int MaxPartCount = 5;

public PartialUpdateWindow()
{
    InitializeComponent();

    //initializing backgroundWorker
    backgroundWorker.DoWork += backgroundWorker_DoWork;
    backgroundWorker.ProgressChanged += backgroundWorker_ProgressChanged;
    backgroundWorker.RunWorkerCompleted += backgroundWorker_RunWorkerCompleted;
    backgroundWorker.WorkerReportsProgress = true;
}

Kod chyba jest bardzo wymowny. Tworzymy nową instancję BackgroundWorkera, dla prostoty przykładu ustalamy ile będzie wyników i ile ma liczyć maksymalna paczka danych do wyświetlenia. Następnie przypisujemy odpowiednie zdarzenia – DoWork – czyli to co ma zostać wykonane, ProgressChanged – zdarzenie odpalane w wątku GUI, które wyświetla wyniki, RunWorkerCompleted – odpalane po zakończeniu DoWork. Ostatnia linijka jest istotna, bo domyślnie ustawiona jest na false i wywołanie ProgressChanged skończy się wyjątkiem. Przejdźmy teraz do implementacji poszczególnych metod.

private void startButton_Click(object sender, RoutedEventArgs e)
{
    resultsListBox.Items.Clear();
    startButton.IsEnabled = false;
    backgroundWorker.RunWorkerAsync(Results);
}

Na początek funkcja podpięta pod przycisk która odpali naszego BackgroundWorkera. Jak widzimy do metody RunWorkerAsync, która uruchamia wątek możemy przekazać opcjonalnie argument. Może być on dowolnego typu i może być tylko jeden. W razie potrzeby, można zawsze przesłać tablicę lub inny kontener.

private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e)
{
    int percentageComplete;
    List tempResults = new List();
    int partsCount;

    var random = new Random();

    for (var i = 1; i <= Math.Ceiling((int)e.Argument / (double)MaxPartCount); i++)
    {
        Thread.Sleep(random.Next(500, 1000)); //simulating data fetching

        if (i * MaxPartCount <= (int)e.Argument)
            partsCount = MaxPartCount; //send maximum
        else
            partsCount = (int)e.Argument - i * MaxPartCount; //send remaining

        for (var j = 0; j < partsCount; j++)
            tempResults.Add(random.Next());

        percentageComplete = (int)(((double)(i * MaxPartCount) / (int)e.Argument) * 100);

        backgroundWorker.ReportProgress(percentageComplete, tempResults);
    }
}

Teraz główna część naszego przykładu, czyli metoda podpięta pod DoWork. W środku tej metody tworzymy pętlę, która przebiega odpowiednią ilość razy (sufit ze wszystkich wyników podzielonych na maksymalna ilość części). Jak widzimy możemy się odwołać do przesłanego argumentu poprzez e.Argument. Musimy jednak stosować rzutowanie, gdyż e.Argument jest typu object (to dosyć oczywiste). W dalszych linijkach generujemy w losowym odstępie czasu po partsCount (równa MaxPartCount albo to co nie zostało jeszcze wysłane) liczb losowych, a także obliczamy procent wykonanej roboty. Kluczową częścią metody jest jej ostatnia linijka, która wywołuje w wątku GUI metodę podpiętą pod ProgressChanged. Do metody tej przekazujemy procent wykonanej pracy oraz znowu mamy opcjonalnie do przekazania jeden argument (ponownie typu object). To umożliwia nam sprytne użycie pozyskanych częściowych wyników - wysłanie ich do GUI. Dalsza część to już oczywistość:

private void backgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
    completeProgressBar.Value = e.ProgressPercentage;
    foreach (var item in (List)e.UserState)
        resultsListBox.Items.Add(item);
}

private void backgroundWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    startButton.IsEnabled = true;
}

W backgroundWorker_ProgressChanged uaktualniamy stan ProgressBara oraz dodajemy pozyskane elementu do ListBoksa. Jest to możliwe gdyż, jak już wspomniałem, metoda ta wywoływana jest w tym samym wątku co GUI. Warto zwrócić uwagę na przekazany argument dostępny pod e.UserState - znów konieczne jest rzutowanie. Po skończonej pracy odblokowywujemy przycisk start i to tyle.

Podsumowanie

Widzimy, że rozwiązanie problemu częściowego uaktualniania interfejsu za pomocą BackgroundWorkera jest bardzo łatwe. To jest niewątpliwy plus. Ten sposób ma też (jak chyba wszystko ;)) jednak swoje wady. Po pierwsze, widać w kodzie, że w wielu miejscach musimy stosować rzutowanie z typu object na inny typ. Nie jest to operacja bezpieczna i przy jakichkolwiek zmianach w kodzie należy pamiętać aby pozmieniać też rzutowania w innych miejscach. Innym minusem jest utrudnione oddzielnie widoku od logiki, gdyż klasa BackgroundWorker pozostaje mocno powiązana z GUI.

Konicząc mój wywód - jeśli tworzysz małą aplikację lub pogodzenie się z minusami BackgroundWorkera nie jest szkodliwe dla Ciebie, ani dla tworzonego kodu - jak najbardziej polecam to rozwiązanie, bo po co pisać kod, który stworzył już ktoś wcześniej?

Producent i konsument – przykład użycia słowa kluczowego lock

Niejednokrotnie podczas pisania aplikacji napotyka się na sytuację gdy jedna metoda produkuje pewne dane, inna natomiast w pewien sposób je konsumuje. Czasem dobrym pomysłem jest, w przypadku gdy produkowane dane są w pewien sposób podzielne na części, wykonywać produkcję i konsumpcję w równoległych wątkach. Tutaj pojawia się istotny problem z zagadnienia wielowątkowości – synchronizacja. Oba (wszystkie) wątki współdzielące dany zasób muszą z niego korzystać w pewien ustalony sposób, tak aby w danej chwili korzystał z niego tylko jeden z wątków. W uproszczeniu, zapewnienie tego stanu nazywamy synchronizacją. Problem ten jest na tyle niebanalny i powszechny, że platforma .NET wspomaga synchronizację za pomocą wbudowanych mechanizmów. Jednym z nich jest słowo kluczowe lock. Użycie słowa kluczowego lock wygląda tak:

 lock(somethingSharedToLock)
{
     // operations on the locked object
}

Jak widać składnia jest bardzo prosta, ale co tak właściwie daje nam lock? Użycie słowa kluczowego lock gwarantuje nam, iż żaden inny wątek nie będzie ingerował w zablokowany obiekt podczas wykonywania instrukcji z nawiasu klamrowego. Natomiast jeśli obiekt jest zablokowany przez inny wątek, to kolejny lock będzie cierpliwie czekał na zwolnienie obiektu i dopiero wtedy go zablokuje. Pięknie, prawda? :) Oczywiście lock, jak wszystko – ma swoje wady. Częste i nierozważne lockowanie może doprowadzić do tzw. zakleszczenia (ang. deadlock). Starczy tej teorii, przejdźmy do przykładu (przykład z całą pewnością nie pokazuje best practices tworzenia kodu, ale chodziło mi o maksymalne uproszczenie przykładu).

Stwórzmy sobie prostą klasę producenta:

class Producer
{
public bool IsFinished
{
    get;
    set;
}
public List List
{
    get;
    set;
}
public Producer()
{
    List = new List();
    IsFinished = false;
}
public void Produce()
{
    Random random = new Random();

    for (int i = 0; i < 20; i++)
    {
        List.Add(random.Next(0, 20));
        Console.WriteLine("Producer[{0}]={1}", i, List[i]);
        Thread.Sleep(500);
    }
    IsFinished = true;
    Console.WriteLine("Producer has finished his work");
}
}

Klasa ta, jak widać, w konstruktorze przyjmuję listę, a następnie po wywołaniu Produce() wpisuje do tej listy losowe liczby całkowite z przedziału <0,20) wypisując je przy okazji. Klasa posiada pole które poinformuje nas, że praca została zakończona. Usypianie w metodzie produkującej umożliwi nam zaobserwowanie pewnej ciekawej rzeczy, ale o tym dalej.

Klasa konsumenta:

class Consument
{
    private Producer producer;
    public Consument(Producer producer)
    {
        this.producer = producer;
    }
    public void Consume()
    {
        int currentElement = 0;
        int elementCount = 0;

        while (true)
        {
              elementCount = producer.List.Count;

              if (producer.List.Count > currentElement)
              {
                   producer.List[currentElement] += 2;
                   Console.WriteLine("Consument[{0}]={1}", currentElement, producer.List[currentElement]);
                   currentElement++;
              }
              if (producer.List.Count == currentElement && producer.IsFinished)
                   break;
         }
         Console.WriteLine("Consument has finished his work");
    }
}

Konsument, w konstruktorze przyjmuje producenta, a po wywołaniu Consume(), o ile pojawiło się coś nowego zwiększa wartość każdego elementu o 2, a następnie go wypisuje. Metoda kończy swoje działanie w momencie, gdy Producent da o tym znać i wszystkie elementy zostaną zmienione.

Pozostaje nam odpalić produkcje i konsumpcje w oddzielnych wątkach:

private static void Main(string[] args)
{
     Producer producer = new Producer();
     Consument consument = new Consument(producer);

     Thread producerThread = new Thread(new ThreadStart(producer.Produce));
     Thread consumentThread = new Thread(new ThreadStart(consument.Consume));

     producerThread.Start();
     consumentThread.Start();
     producerThread.Join();
     consumentThread.Join();
}

Wynik działania programu (przykładowy!!):

Producer[0]=0
Consument[0]=2
Producer[1]=4
Consument[1]=4
Producer[2]=17
Consument[2]=17
Producer[3]=8
Consument[3]=8
Producer[4]=0
Consument[4]=2
Producer[5]=16
Consument[5]=16
Producer[6]=6
Consument[6]=6
Producer[7]=13
Consument[7]=13
Producer[8]=17
Consument[8]=19
Producer[9]=9
Consument[9]=11
Producer[10]=15
Consument[10]=17
Producer[11]=14
Consument[11]=14
Producer[12]=11
Consument[12]=13
Producer[13]=3
Consument[13]=5
Producer[14]=15
Consument[14]=15
Producer[15]=13
Consument[15]=13
Producer[16]=16
Consument[16]=18
Producer[17]=5
Consument[17]=7
Producer[18]=2
Consument[18]=2
Producer[19]=14
Consument[19]=16
Producer has finished his work
Consument has finished his work

Ups! Widzimy, że w wielu przypadkach (1, 2, 3, 5, 6, 7, 11, 14, 15, 18 - czyli w połowie wszystkich) wartość producenta nie została zmieniona, tak jakbyśmy chcieli. Czemu się tak stało? Otóż produkcja kolejnych elementów trwa dłużej niż konsumpcja (poprzez wspomniane wcześniej Thread.Sleep(500)) co powoduje iż w pewnych momentach dana jest "jeszcze" tworzona, a próbuje już być skonsumowana, co powoduje iż proces konsumpcji (dodanie do wartości 2) zostaje pominięte. Uniknięcie tej katastrofy jest możliwe dzięki wspomnianemu lockowi. Wystarczy napisać:

lock (((ICollection)List).SyncRoot)
{
    List.Add(random.Next(0, 20));
    Console.WriteLine("Producer[{0}]={1}", i, List[i]);
    Thread.Sleep(500);
}

oraz:

lock (((ICollection)producer.List).SyncRoot)
{
    elementCount = producer.List.Count;

    if (producer.List.Count > currentElement)
    {
        producer.List[currentElement] += 2;
        Console.WriteLine("Consument[{0}]={1}", currentElement, producer.List[currentElement]);
        currentElement++;
    }
    if (producer.List.Count == currentElement && producer.IsFinished)
        break;
}

To zapewni nam prawidłowe działanie kodu (jak nie wierzycie - sprawdźcie sami :)). A co tak właściwie się stało? Kolekcja w momencie produkowania jest na chwilę blokowana, a więc konsumpcja jest wtedy wstrzymana, gdy produkcja się skończy, konsumpcja zabiera kolekcję dla siebie. Dzięki temu działanie jest zgrabnie zsynchronizowane. Dla kolekcji, w celu synchronizacji należy używać właściwości SyncRoot która jest wymagana przez interfejs ICollection. Wszystkie inne obiekty możemy lockować w sposób bezpośredni (lock(someObject)) chyba, że coś (dokumentacja) podpowiada nam inaczej ;)

To tyle w tej kwestii, dla zainteresowanych polecam poczytać Thread Synchronization oraz How to: Synchronize a Producer and a Consumer Thread.

Pełny listing:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;

namespace ProducentConsumerExample
{
    class Example
    {
        private static void Main(string[] args)
        {
            Producer producer = new Producer();
            Consument consument = new Consument(producer);

            Thread producerThread = new Thread(new ThreadStart(producer.Produce));
            Thread consumentThread = new Thread(new ThreadStart(consument.Consume));

            producerThread.Start();
            consumentThread.Start();

            producerThread.Join();
            consumentThread.Join();
        }
    }

    class Producer
    {
        public bool IsFinished
        {
            get;
            set;
        }

        public List List
        {
            get;
            set;
        }

        public Producer()
        {
            List = new List();
            IsFinished = false;
        }

        public void Produce()
        {
            Random random = new Random();

            for (int i = 0; i < 20; i++)
            {
                lock (((ICollection)List).SyncRoot)
                {
                    List.Add(random.Next(0, 20));
                    Console.WriteLine("Producer[{0}]={1}", i, List[i]);
                    Thread.Sleep(500);
                }
            }

            IsFinished = true;

            Console.WriteLine("Producer has finished his work");
        }
    }

    class Consument
    {
        private Producer producer;

        public Consument(Producer producer)
        {
            this.producer = producer;
        }

        public void Consume()
        {
            int currentElement = 0;
            int elementCount = 0;

            while (true)
            {
                lock (((ICollection)producer.List).SyncRoot)
                {
                    elementCount = producer.List.Count;

                    if (producer.List.Count > currentElement)
                    {
                        producer.List[currentElement] += 2;
                        Console.WriteLine("Consument[{0}]={1}", currentElement, producer.List[currentElement]);
                        currentElement++;
                    }
                    if (producer.List.Count == currentElement && producer.IsFinished)
                        break;
                }
            }

            Console.WriteLine("Consument has finished his work");
        }
    }
}