Tę notkę mógłbym zacząć znanym stwierdzeniem: A miało być tak pięknie :) Przecież to w końcu na studiach człowiek zaczyna w pełni kierować własną edukacją i może zająć się poznawaniem tych zagadnień, które go naprawdę ciekawią. Koniec z wyjaśnianiem, dlaczego Słowacki wielkim poetą był i zapamiętywaniem szczegółów budowy mięśni poprzecznie-prążkowanych. Nareszcie przychodzi czas na bardziej przydatne i interesujące umiejętności – czyli programowanie, rzecz jasna.
A jak wygląda przełożenie tej teorii na praktykę? Otóż po dwóch latach mogę stwierdzić, że… dość kiepsko :) W istocie mamy tu do czynienia z wielkim dylematem – może nie natury filozoficznej, ale jednak całkiem ciężkiego kalibru.
Gdy bowiem zajmujemy się kodowaniem czysto hobbystycznie, automatycznie wydaje się nam ono o wiele rzędów wielkości atrakcyjniejsze niż to, nad czym musimy się skupiać przy okazji edukacji szkolnej. Działa tu pewnie prawo człowieka jako istoty przekornej, lubiącej podążać własnymi ścieżkami. Jednocześnie jednak tęskni się do wyidealizowanego czasu przyszłego, kiedy w końcu swoje zainteresowania będzie można realizować także w trakcie nauki i pracy.
Najwyraźniej wspominane prawo jest tak silne, że gdy ów czas w końcu przychodzi, zaczynamy lepiej wspominać wcześniejszy okres. Nie oznacza to oczywiście, że wolałbym dalej “przymusowo” uczyć się czegoś zupełnie niezwiązanego z szeroko pojętą informatyką; wręcz przeciwnie, nie zamieniłbym aktualnego kierunku studiów na żaden inny :) Problem w tym, że gdy ktoś zaczyna nas programowania uczyć i egzekwować wyniki, traci ono część swojej ‘magii’; podejrzewam, że dotyczy to chyba każdej dziedziny. Ponadto nie da się ukryć, że spełnianie stawianych wymagań oznacza konieczność napisania sporych ilości kodu i ostatecznie na własne kodowanie pozostaje mniej czasu i ochoty.
Wnioski są dwa. Pierwszy to – że użyję wychodzącego już na szczęście z mody sformułowania – oczywista oczywistość: każdy etap nauki czy pracy ma swoje dobre i złe strony, i do każdego trzeba się odpowiednio dostosować. Drugi zaś jest taki, że zawsze wierzymy, iż w przyszłości będzie lepiej. W tym przypadku faktycznie się to sprawdza, lecz chyba nigdy nie dzieje się to w sposób całkowicie zgodny z naszymi oczekiwaniami.
Wypada tylko się tym pogodzić i dalej robić swoje. Dla mnie też na pewno będzie to lepsze niż pisanie pseudofilozoficznych notek ;)
Niektóre funkcje dobrze jest pisać w asemblerze. Tak, wiem że dzisiaj – w epoce języków (zbyt) wysokiego poziomu – brzmi to dziwnie, ale to prawda. To najprostszy sposób na poprawienie wydajności często wykonywanych operacji, np. kalkulacji z użyciem wektorów i macierzy.
Rzecz w tym, że korzystając bezpośrednio z zaawansowanych możliwości oferowanych przez współczesne procesory, jednocześnie uzależniamy się od nich. Przykładowo, transformację wektora przez macierz można naturalnie po prostu przetłumaczyć z odpowiedniego wzoru na instrukcje jednostki zmiennoprzecinkowej i uzyskać kod działający na każdym procesorze. Jeżeli jednak użyjemy np. SSE2, możemy uzyskać kilkakrotny wzrost wydajności – lecz wówczas nasza funkcja będzie działała tylko na nowszych procesorach.
Najlepiej byłoby więc mieć kilka wersji takiej funkcji i wybierać odpowiednią dla procesora pracującego na danej maszynie. Jak jednak wykryć, co potrafi dana jednostka? Otóż z pomocą przychodzi nam system operacyjny. W Windows na przykład istnieje funkcja o wiele mówiącej nazwie IsProcessorFeaturePresent
, przy pomocy której możemy sprawdzić obecność rozszerzeń MMX, 3DNow!, SSE i SSE2.
Oczywiście, takiego sprawdzenia należy dokonać raz na początku działania programu. Jeśli jednak po prostu zapiszemy jego rezultat w formie globalnych flag boolowskich, to ich odczytywanie np. przy każdym dodawaniu wektorów będzie nie tylko kłopotliwe, ale i nieefektywne.
Lepszym rozwiązaniem jest stworzenie odpowiedniej liczby globalnych wskaźników na funkcje, inicjowanych w czasie uruchamiania programu; tak jak poniżej:
Dzięki temu zarówno w kodzie asemblerowym poszczególnych wersji (którego litościwie nie pokażę ;D), jak i wywołaniach, nie widać żadnego śladu po ‘magii’ wyboru funkcji dostosowanej do procesora. Narzut to rozwiązanie to naturalnie jedna dereferencja wskaźnika więcej; sprawdzanie flag (porównaniami i skokami) trwałoby znacznie dłużej.
Deweloperzy programujący wielowątkowo zapewne znają klasyczne typy wykorzystywanych przy okazji obiektów. Są to na przykład semafory, sekcje krytyczne (zwane też semaforami binarnymi) czy zdarzenia (events). Wszystkie one służą oczywiście do synchronizacji wątków tak, aby wykluczyć jednoczesny, wykluczający się dostęp do jednego zasobu.
Tego typu obiekty są wykorzystywane jednak głównie wtedy, kiedy mechanizm wątków jest zrealizowany w sposób specyficzny dla systemu operacyjnego – jak choćby poprzez API z Windows lub bibliotekę pthreads z Linuxa. Jeśli jednak mamy szczęście pracować z językiem, którego wielowątkowość jest częścią, wówczas korzysta się zwykle z nieco innych technik.
Taka sytuacja jest na przykład w Javie. Tam każdy obiekt (czyli instancja klasy dziedziczącej z java.lang.Object
) może być użyty jako obiekt synchronizujący. Z grubsza działa to w ten sposób, że gdy jeden z wątków zadeklaruje wykorzystanie danego obiektu – przy pomocy słowa kluczowego synchronized
– pozostałe nie mogą zrobić tego samego. Taka synchronizacja może odbywać się na wiele (składniowych) sposobów, jak choćby zadeklarowanie całej metody jak synchronizowanej:
W tym prościutkim przykładzie mamy zagwarantowane, że żaden postronny wątek nie wtrąci się w operację inkrementacji ze zwróceniem wartości (która nie jest atomowa) i stan licznika będzie zawsze poprawny.
Tak więc mamy semafory tudzież sekcje krytyczne. A co np. ze zdarzeniami (sygnałami)? Otóż każdy obiekt posiada metody wait
i notify
, umożliwiające czekanie na powiadomienie z innego wątku i oczywiście wysłanie takiego powiadomienia. Całkiem skuteczne i dosyć proste; naturalnie na tyle, na ile proste może być programowanie wielowątkowe :)
Ale czy oryginalne? Otóż dziwnym trafem na platformie .NET cała sprawa wygląda niemal dokładnie tak samo :) Odwzorowania przytoczonych elementów Javy w C# to odpowiednio: lock
(z dokładnością do kilku niuansów), Monitor.Wait
i Monitor.Pulse
. Sam sposób tworzenia wątków jest zresztą też bardzo bardzo podobny.
Wszelka zbieżność przypadkowa? Zdecydowanie nie. Lecz dobre rozwiązania warto jest przecież rozpowszechniać :]
Protokół HTTP i World Wide Web powstały już bardzo dawno temu, chociaż od tamtego czasu doczekały się całego mnóstwa usprawnień. Pewnie największym była możliwość generowania stron HTML dynamicznie przy użyciu skryptów CGI, PHP, i tak dalej. Dzięki temu strony umieją już całkiem sporo i nie są statycznymi dokumentami. Ich tworzenie zaczęło też bardziej przypominać pisanie normalnych aplikacji.
Pewne ograniczenia HTTP ciężko jest jednak przezwyciężyć. Największym jest chyba to, że serwery każde zgłoszenie (request), które do nich napływa, traktują zupełnie odrębnie i w oderwaniu od wszystkich innych. Działają więc na zasadzie pytanie-odpowiedź. Gdyby poszukać odpowiednika tego modelu w zwyczajnych programach, to byłyby nim narzędzia konsolowe obsługiwane w całości przełącznikami wiersza poleceń. W dobie interfejsów graficznych mieniących się wszystkimi kolorami palety RGB nie wyglądają one zbyt imponująco…
Dlatego też wszelkie platformy służące uruchamianiu aplikacji webowych starają się tę niedogodność jakoś obejść i umożliwić powiązanie poszczególnych żądań oraz przechowywanie danych pomiędzy nimi. Standardem jest mechanizm sesji, ale np. w PHP nigdy nie udało mi się do końca go zrozumieć :)
Ostatnio jednak z konieczności (nietrudno się domyślić, jakiej :)) zajmuję się ASP.NET i muszę przyznać, że tam cała sprawa została potraktowana nieporównywalnie lepiej. Przede wszystkim serwis jest tam rzeczywiście traktowany jako aplikacja, której instancja uruchamia się w momencie pierwszego requestu z danego hosta. Poszczególne strony przypominają też raczej okienka zwykłych programów, na których zamieszcza się kontrolki specyficzne dla ASP (przerabiane na wyjściu na HTML). I to ze wszystkimi konsekwencjami: one rzeczywiście istnieją na serwerze. To sprawia, że możemy np. pobrać dane formularza wypełnionego przed chwilą na poprzedniej stronie, po prostu czytając zawartość jej kontrolek.
Cała ta trwałość nie jest oczywiście aż tak daleko posunięta jak w tradycyjnych programach. Zdarza się (i to dość często), że musimy zwracać uwagę na to, kiedy strona może być przeładowywana. Ale i tak stanowi to spory postęp na przykład w stosunku do wspomnianego mechanizmu sesji (który naturalnie też jest dostępny).
Naturalnie wszystko to są jednak tylko obejścia do starego protokołu, który pierwotnie nie został zaprojektowany do tak złożonych zadań, do których się go obecnie wykorzystuje.
Teoretycznie wszystko zakodować można “na żywioł”, po prostu siadając do ulubionego środowiska programistycznego, pisząc i kompilując. Proste dzieła może i można w ten sposób stworzyć, ale do czegoś bardziej skomplikowanego zawsze pomaga chociaż pobieżny projekt.
W jakiej formie? To już nie jest sprawą taką prostą. Obecnie mamy sporo różnych narzędzi, umożliwiających formowanie naszych pomysłów w miejscu ich docelowego egzystencji – czyli programów komputerowych. I nie chodzi mi tu o aplikacje wysoce wyspecjalizowane, bo do zaprojektowania programu może się okazać przydatny edytor tekstu typu Notatnika. Każde z tych narzędzi będzie użyteczne dla określonego celu i każde ma też swoje ograniczenia.
Ale mamy również inny sposób. Możemy odejść (albo przynajmniej się odwrócić) od komputera, wziąć kartkę, długopis i… do dzieła. Być może wkrótce posługiwanie się tak “archaicznymi” sprzętami będzie nieco staroświeckie, ale pewnie jeszcze długo będzie miało niezaprzeczalne zalety:
Każdy jednak wie rzecz jasna, jakie są jego wady. Niektóre alternatywy – jak na przykład skrobanie po tabletowym komputerze – niwelują część z nich, zbijając przy okazji niektóre zalety. Największym mankamentem jest chyba jednak to, że rysunkowe bazgroły na papierze zawsze już pozostaną bazgrołami: nie da się ich przetworzyć na użyteczną formę schematów czy tabel zrozumiałą dla komputera, a i rozpoznawanie czystego tekstu też nie jest jeszcze doskonałe.
Wygląda więc na to, że poczciwa kartka papieru jeszcze długo będzie podstawowym środkiem tworzenia przynajmniej wstępnych – nomen omen – szkiców, zostawiając bardziej szczegółowe projektowanie innym narzędziom. Warto zatem wciąż kultywować starożytną umiejętność ręcznego pisania i rysowania :)
Kiedy już przekonamy się o korzyściach płynących z regularnego stosowania debuggera (a każdy początkujący programista powinien przekonać się o nich jak najszybciej), być może zaczniemy się zastanawiać, jakaż to “magia” sprawia, że cały ten proces w ogóle jest możliwy. W jaki sposób program śledzący potrafi dostać się do wnętrza naszej aplikacji i wylistować zawartość wszystkich zmiennych, nie mówiąc już o możliwości ustawiania punktów przerwań (breakpoints) czy pracy krokowej. Czy debuggery korzystają z jakichś nieudokumentowanych “wytrychów” do samego jądra systemu operacyjnego?…
Otóż niekoniecznie. Na przykład Windows API udostępnia zwyczajne funkcje systemowe, za pomocą których jeden proces może śledzić zachowanie innego. Obejmuje to przyłączenie się do innego procesu w charakterze debuggera, obsługę przeróżnych interesujących zdarzeń (jak np. załadowanie biblioteki DLL lub stworzenie nowego wątku przez proces, który śledzimy), a także odczytywanie zawartości pamięci procesu (jeśli mamy do tego uprawnienia) lub kontekstu jego wątku.
Naturalnie mało kto będzie pisał swój własny debugger, więc wspomniane funkcje są nieszczególnie przydatne dla większości programistów. Jednak istnieje też kilka takich, które można użytecznie stosować po drugiej stronie – w aplikacji, która jest debugowana:
OutputDebugString
to prawdopodobnie ta najbardziej znana. Służy ona do wysyłania komunikatów do debuggera, które zwykle są wyświetlane w specjalnym okienku naszego IDE. Zazwyczaj jest to bardziej poręczne rozwiązanie niż chociażby pokazywanie okienek komunikatów, które trzeba przecież potwierdzać kliknięciami w OK. Funkcję tę można aczkolwiek zastąpić wypisywaniem na standardowe wyjście diagnostyczne (stderr
, reprezentowane w iostream przez obiekty cerr
lub wcerr
).DebugBreak
działa z kolei jak punkt przerwania. Zasadniczo funkcja ta wywołuje odpowiedni wyjątek systemowy (nie mający za wiele wspólnego z wyjątkami języka C++), który każdy porządny debugger złapie. W środowisku programistycznym rezultatem będzie zawieszenie działania programu i ustawienie się z widokiem kodu źródłowego na miejsce wywołania funkcji, co oczywiście znakomicie ułatwia odnalezienie przyczyny błędu. Jeżeli jednak nikt nie śledzi naszej aplikacji, wówczas rzucony wyjątek spowoduje jej awaryjne zakończenie. Generalnie podobny efekt co DebugBreak
powinno dać wywołanie przerwania numer 3 (czyli asm { int 3 }
).IsDebuggerPresent
pozwala z kolei określić, czy aplikacja jest aktualnie debugowana przez jakiś inny proces. Dzięki temu możemy na przykład przekazywać do komunikatów diagnostycznych większą liczbę informacji, wiedząc, że ktoś “po drugiej stronie” faktycznie je odczytuje :)Wprawdzie samo korzystanie z tych funkcji nie zastąpi dobrego systemu kontroli błędów i ich raportowania (np. zapisywania w dzienniku), lecz z pewnością może pomóc. Bardzo dobrze prezentuje się na przykład okienko z obszernym komunikatem o błędzie i trzema możliwościami decyzji: kontynuacji programu, przejścia do trybu debugowania albo awaryjnego zakończenia aplikacji. Przy takim rozwiązaniu aż chce się popełniać błędy ;)
W większości języków możemy zdefiniować nową nazwę dla istniejącego typu danych; nazywa się ją zwykle aliasem. I tak na przykład w C/C++ jest to możliwe za pomocą słowa kluczowego typedef
. Analogicznie w Delphi mamy od tego słowo kluczowe type
:
[delphi]type TMyInt = Integer;[/delphi]
Tak powstały typ TMyInt
jest faktycznie tylko aliasem. Zmienne należące do tego typu są bowiem całkowicie kompatybilne ze zmiennymi zwykłego typu całkowitego Integer
. W razie potrzeby konwersja między nimi może bez problemu zachodzić w obie strony.
Jeżeli jednak użylibyśmy deklaracji w formie:
[delphi]type TMyInt = type Integer;[/delphi]
wówczas TMyInt
byłby już zupełnie innym typem niż Integer
. Mimo że oba mogłyby przechowywać wartości tego samego rodzaju (liczby całkowite) i z tego samego zakresu, konwersje między nimi wymagałyby rzutowania.
Można by sądzić, że tworzenie takich typów rozróżnialnych “na siłę” jest bezcelowe. Zauważmy jednak, że typy wyliczeniowe (deklarowane przez enum
) są przecież także w gruncie rzeczy liczbami z określonego zbioru. Najczęściej jednak nie chcemy, aby możliwa była niejawna konwersja między nimi a normalnymi typami liczbami. Wszystko dlatego, że w enumach liczby nie pełnią funkcji liczb, tylko identyfikatorów pewnych stanów.
Podobnie tutaj w przypadku TMyInt
nie chodzi nam zapewne o liczby w sensie ich wartości, tylko o coś w rodzaju uchwytów – identyfikatorów obiektow. Kopalnią typów przeznaczonych do takiego właśnie celu jest oczywiście Windows API, zawierające tak znane i lubiane typy jak HWND
, HINSTANCE
, HDC
, itd. Wszystkie one są w gruncie rzeczy liczbami 32-bitowymi, a mimo to nie są ze sobą kompatybilne. Gdyby API to było obiektowe, obiekty reprezentowane obecnie przez te uchwyty należałyby do różnych klas.
Efekt niezgodności uchwytów osiągnięto, deklarując ich typy nie jako aliasy na DWORD
:
lecz jako niezwiązane ze sobą typy wskaźnikowe:
Można to uznać za dość pokrętną sztuczkę, ale na pewno jest ona lepsza niż tworzenie typu wyliczeniowego zawierającego nieco ponad 4 miliardy (232) nazwanych stałych ;]