2016-07-26 20:17:27
Sleep considered harmful

Funkcji Sleep() powinni zabronić. Powinna być zamieniona na coś dużo bardziej skomplikowanego tak, by programiści nauczyli się wreszcie programowania asynchronicznego/zdarzeniowego i nie korzystali ze sleepów bez sensu. Sama funkcja nic nie szkodzi - wstrzymuje pracę wątka na ileś czasu rzeczywistego, w niektórych implementacjach pozwala oddać procesor innemu wątkowi. Przydatne. Ale niektórym się wydaje, że to idealna metoda synchronizacji międzykomponentowej...

Widziałem to już tyle razy, i ZAWSZE sprowadzało się do jednego i tego samego - komponent A (aplikacja) wywołuje komponent B (kolejna aplikacja, czasami zewnętrznego dostawcy), ten zaczyna działać swoim życiem. Komponent A jednak musi zebrać wyniki działania komponentu B, a nie może tego zrobić, jeśli ten ciągle pracuje. Pod Windows wystarczy wywołanie ShellExecuteEx oraz wywołanie WaitForSingleObject. Pod Unixowatymi mamy np. popen. Ale to takie banalne... Można przecież prościej! Z kreatywniejszych przykładów, które poszły na produkcję w różnych miejscach:

  • Uruchamiać komponent B tak, by wyrzucał dane do nowego pliku i jeśli tam będzie błąd w rodzaju "process running", czynność powtórzyć. Opcjonalnie czekać ileś (m)s.
  • Uruchamiać komponent B tak, by wyrzucał dane do konkretnego pliku i jeśli próba otwarcia tego pliku w trybie do zmiany się nie powiedzie, czynność powtórzyć. Opcjonalnie czekać ileś (m)s.
  • Zaimplementować funkcję/usługę w rodzaju IsBusy która sprawdza czy komponent B ciągle działa (tylko jeśli komponent B jest naszego autorstwa). A to można zrealizować np. poprzez zostawianie pliku-znacznika w katalogu /tmp albo %TEMP% na czas działania procesu. Opcjonalnie czekać ileś (m)s.
  • Zmierzyć ile czasu komponent B potrzebuje (nominalnie) na wykonanie zadania, potem zapisanie w kodzie by tyle + x sekund czekał na komponent, a po tym czasie wychodził z założenia że komponent się zawiesił.
  • Sprawdzanie, czy komponent B widnieje na liście procesów. Opcjonalnie czekać ileś (m)s.
  • Uruchamiać powłokę ze sztucznymi limitami tak, by komponent B wypełnił te limity i uruchomienie kolejnego nie powiodło się, dopóki pierwsza instancja komponentu B działa. Opcjonalnie czekać ileś (m)s.

Jak widać, kreatywność ludzka nie zna granic. Mimo, że lista jest spora jak na postawiony problem, to na pewno jeszcze kiedyś przyjdzie mi się spotkać z nowym, ciekawszym rozwiązaniem. Nie mogę się doczekać. ;-)

Oczywiście racją jest, że czasami po prostu nie ma innego wyjścia niż "aktywne czekanie", i takie przypadki są "rozgrzeszone", ale w sytuacji gdy są lepsze rozwiązania? Samo aktywne czekanie jest z definicji złe, ponieważ wymusza marnowanie czasu procesora. W zależności od metody, stracone zasoby mogą być większe bądź mniejsze. "Sleep" w aktywnym czekaniu pełni istotną funkcję, mianowicie "przesuwa czas" o konkretną ilość do przodu przy zerowym wykorzystaniu CPU. Innymi słowy, zapewnia bezczynne, bezwarunkowe oczekiwanie. I jeszcze pół biedy, gdy ktoś używa sleepa do zredukowania ilości iteracji z miliona na sekundę do 50, przy zachowaniu podobnego czasu reakcji, ale co jeśli ktoś estymuje czas działania komponentu? Zaczynają się popsute nocne buildy, rozjechane maszyny stanowe i ogólny galimatias. A już w ogóle przestępstwem jest bezwarunkowe likwidowanie komponentu B po jakimś czasie. "Działa" to w ten sposób, że czekamy np. 200 sekund, po tym jeśli proces się nie zakończył, to traktujemy go za pomocą kill -9 i zgłaszamy błąd -- nie wychodzimy z założenia, że źle wyestymowaliśmy czas działania (no gdzieżby znowu!) i mamy totalnie w dupie to, że komponent B żyje i nawet coś nadaje (skojarzenia z Don't Tase Me Bro jak najbardziej prawidłowe).

No dobra, to co zamiast sleepa? Wbudowane mechanizmy synchronizacyjne, np. te wymienione na początku. Samo WaitForSingleObject jest o tyle fajne, że można wskazać, by czekało nieprzerwanie, albo jedynie określoną ilość czasu. W ten łatwy sposób można zaimplementować zarówno czekanie dokładnie tyle, ile potrzebuje komponent B, jak i formę wskaźnika, że proces trwa (podajemy timeout np. 250ms i po zwróceniu przez funkcję błędu timeoutu wyrzucamy na ekran kropkę i/lub obsługujemy Ctrl+C czy inny bodziec przerywający).

Inny kolejny przykład który pięknie się rozwiązuje poprzez asynchronikę to powiadamianie o zmianach stanu. Ajaxowcy znają to aż za dobrze, ale nie wszyscy mieli z tym styczność, więc pokrótce opowiem o co chodzi.

Załóżmy, że mamy TRZY komponenty. Jeden to fizyczny sprzęt. Drugi pełni funkcję interpretera i tłumacza zarazem (po enterprise'owemu pisząc, interfejs do urządzenia), a trzeci to konsument interfejsu. Konsument wywołuje jakąś funkcję i chce, by wykonała się asynchronicznie, bo chce dać użytkownikowi kontrolę nad interfejsem, zamiast straszyć oknem z tekstem "Brak odpowiedzi". Konsument tworzy nowy wątek który czeka (sleep) ileś ms, wywołuje funkcję a'la "zwróć stan" interfejsu i w zależności od zwróconego wyniku informuje użytkownika o sukcesie, niepowodzeniu lub konieczności czekania. No ale interfejs też musi się jakoś komunikować - z urządzeniem. Więc wysyła odpowiednio sformatowane polecenie, i... w wątku (co by nie blokować konsumenta!) co ileś ms sprawdza, czy urządzenie zakończyło przetwarzanie. Wygląda to mniej-więcej tak:


Taki tam pseudo-uml

Na powyższym obrazku każdy prostokąt to jakiś zużyty przez procesor kwant czasu. Oprócz tego, że procesory są budzone bardzo często, to jeszcze wprowadzone jest (tak naprawdę sztuczne) opóźnienie - około 2,5-tej sekundy konsument pyta o stan, interfejs odpowiada "zajęty", po czym równoległy wątek interfejsu sprawdza czy urządzenie coś nadało (ciągle nie), i czekanie trwa dalej. Chwilę później urządzenie przesyła odpowiedź, ta zostaje zbuforowana przez system operacyjny i... Konsument pyta o stan, interfejs odpowiada "zajęty", po czym interfejs sprawdza port, odbiera odpowiedź i ustawia nowy stan. Konsument znowu pyta o stan, a interfejs dopiero wtedy odpowiada zgodnie z tym, co odpowiedziało urządzenie. Da się zdecydowanie lepiej.

Implementując mechanizm "callbacku" (dosłownie "oddzwonienie") można oszczędzić sporo czasu procesora. Sam mechanizm polega na tym, że zamiast ręcznie sprawdzać stan, czekamy aż "coś" nas powiadomi o zmianie stanu, poprzez wykonanie wskazanej przez nas funkcji. Bardzo prostym sposobem implementacji tego pod Windows jest użycie funkcji PostMessage i napisanie własnego handlera komunikatu. W naszym przypadku, w sytuacji gdy interfejs zauważy zmianę stanu, wysyła komunikat via PostMessage, a konsument obsłuży sobie go w najbliższym dogodnym momencie, przeważnie niezwłocznie. A co z samym interfejsem? Można użyć asynchronicznego I/O (polecam przeczytać o Overlapped I/O (Windows) oraz AIO pod Linuksem) i standardowych funkcji. Tu się rodzi mały problem - te funkcje przeważnie zwracają często na raz po znaku, gdy w grę wchodzi wolne łącze szeregowe (standardowy COM), należy więc przygotować się odpowiednio na taką ewentualność tak implementując parser protokołu, by działał ze złożonością możliwie bliską stałej, zamiast liniową, albo nie daj gwiazdor wykładniczą.

Jak mogą wyglądać linie czasu dla tak zoptymalizowanego rozwiązania? Na przykład tak:

Czas pracy urządzenia się nie zmienił, ale czas rzeczywisty spadł poniżej trzech sekund. Jak to się stało? Interfejs czekał (pasywnie) na porcie, w sytuacji gdy nadszedł bodziec od urządzenia, interfejs natychmiast zaczął przetwarzać odpowiedź i niezwłocznie wysłał powiadomienie do konsumenta. Oprócz zredukowanego czasu rzeczywistego, zredukował się też zużyty czas procesora - poprzednio procesor był wzbudzony 23 razy, w nowym podejściu tylko 3, a to i tak nie jest wersja optymalna (interfejs mógł od razu wysłać polecenie do urządzenia zamiast przenosić je do wątku równoległego, które je przesłało kwant czasu później, co dałoby tylko dwa wzbudzenia).

No i najważniejsze - w żadnym miejscu nie czekaliśmy ani aktywnie, ani nie korzystaliśmy z funkcji Sleep! W optymistycznym przypadku (którego NIE polecam) możemy sobie odpuścić to w stu procentach. W rzeczywistości nie możemy, ponieważ świat jest okrutny i nas nienawidzi, a same urządzenia lubią się zawieszać i należy przyjmować, że jeśli urządzenie podejrzanie długo nie odpowiada na żądanie, to z dużym prawdopodobieństwem po prostu przestało działać. Zatem nie obędzie się bez kombinowania. Czekamy np. 30s na odpowiedź, jeśli sterowanie wróciło z błędem timeoutu, urządzenie zmarło nam na rękach. Jeśli odpowie, ale niekompletnie, to czekamy znowu 30s na resztę odpowiedzi (niektóre urządzenia nawet mają zaimplementowane w protokole komunikaty typu "daj mi jeszcze czas") i powtarzamy proces.

Jedyną zasadniczą wadą tego rozwiązania jest to, że wymaga odmiennego podejścia. Nie jest ono obce przede wszystkim starszym programistom Windows, ponieważ m. in. pod Windows 3.x po prostu nie było wątków ani funkcji fork(), więc nie było jak przetwarzać danych z gniazdek sieciowych w pełni synchronicznie bez blokowania całego sprzetu. Od Windows 95 do ery pierwszych konsumenckich procesorów z wieloma logicznymi CPU takie rozwiązanie dalej miało sporo sensu, ponieważ przełączanie wątku kosztuje, dlatego namnażanie ich jest niewskazane. Programy które miały otwarte podobną liczbę połączeń, ale obsługiwały je asynchronicznie, nie marnowały czasu na obsługę kolejki wątków i ich rozkładanie w czasie, a więc i szybciej.

Łojej, rozpisałem się. Ale liczę, że choć jednej osobie od mojej pisaniny coś się odmieni, a ja sam nie będę musiał zanudzać w przyszłości, jedynie odeślę do tego wpisu.


Może Cię zainteresować...

Link | Skomentuj! | Programowanie, Tech, Techblog
Pokazuj komentarze.
Powered by:
Hellcore Mailer - polski program pocztowyOpera Web BrowserFreeBSD - The Power to Serve!Slackware
RSSy:
Sidekick:
Projekty:
O autorze:
Zobacz:
Kategorie:
Archiwum:
Szukaj: