wtorek, listopada 11, 2008

I gdzie jest błąd?

Ostatnio na liście mailingowej haskell-cafe kilka razy padło pytanie o niedziałający z jakiegoś powodu program. W każdym z tych okazało się że problem leżał po stronie programisty :-)

Zadanie jest proste: uruchamiamy pewien proces, karmimy go danymi i oczekujemy od niego odpowiedzi. Naiwny kod wygląda tak:

> (p_stdin, p_stdout, p_stderr, p_handle) <- runInteractiveCommand jakas_komenda
> hPut p_stdin jakies_dane
> odpowiedz <- hGet p_stdout

Powyższy kod może, ale nie musi zadziałać. Co więcej: może raz działać dla danego programu, a raz nie działać. Prowadzi to do trudnych do debugowania i przez to irytujących błędów. Niestety: problem wynika z nieznajomości semantyki kanałów (ang. pipe - moje tłumaczenie jest chałupnicze) pomiędzy procesami.

Połączenia te mają ograniczoną pojemność "bufora". Po jego zapełnieniu proces który próbuje go "przepełnić" jest usypiany, aż bufor zostanie nieco opróżniony. Jest to (całkowicie słuszny) środek zaradczy przeciwko zużyciu przez nadgorliwy proces wszelkich zasobów systemu - w przeciwnym wypadku system musiałby przechować dowolnie dużo danych które zapisał dany proces. Bufor ten może być dość niewielki, np. 300 kb.

Jaki to ma związek z powyższym kodem? Ano taki, że w wyniku takiego właśnie działania kanałów kod ten powoduje często deadlocks - zakleszczenia.

Oto co się dzieje.

Uruchamiamy wysyłanie danych do naszego procesu:

> hPut p_stdin jakies_dane

Ale ów proces nie konsumuje ich na raz w całości. Zamiast tego zaczyna wysyłać częściowe porcje danych które wędrują do p_stdout. Wysyła ich na tyle dużo, że bufor p_stdout zapełnia się. Zostaje więc uśpiony. Aby został obudzony musimy odebrać z p_stdout porcję danych. Ale nie możemy tego zrobić - jeszcze nie skończyliśmy wysyłać mu danych na p_stdin!

Rozwiązanie jest proste: należy uruchomić wysyłanie danych w innym wątku:
> forkIO (hPut p_stdin jakies_dane)

W tym momencie jeden wątek będzie realizował wysyłanie danych, a drugi odbieranie. Co prawda jeden z nich może zostać uśpiony (bo np. proces nie odebrał jeszcze wszystkich danych i wykonuje teraz jakieś obliczenia) ale nie spowoduje to zakleszczenia.

Scenariusz może się jeszcze bardziej skomplikować, jeżeli interesuje nas równocześnie wyjście z p_stderr. W tym momencie ten kod także będzie błędny:

> (p_stdin, p_stdout, p_stderr, p_handle) <- runInteractiveCommand jakas_komenda
> hPut p_stdin jakies_dane
> odpowiedz <- hGet p_stdout
> odpowiedz_stderr <- hGet p_stderr

Dlaczego? Proces może zapełnić bufor p_stderr i zostać uśpiony zanim zamknie swoje standardowe wyjście (dzięki czemu wywołanie "hGet p_stdout" się skończyłoby się i zaczelibyśmy opróżniać p_stderr).

Rozwiązanie w tym przypadku jest nieco bardziej skomplikowane, jednak zaczyna się tutaj pojawiać pewien schemat:

> (p_stdin, p_stdout, p_stderr, p_handle) <- runInteractiveCommand jakas_komenda
> forkIO (hPut p_stdin jakies_dane)
> mv <- newEmptyMVar :: IO (MVar String)
> forkIO (hGet p_stdout >>= putMVar mv)
> odpowiedz_stderr <- hGet p_stderr
> odpowiedz <- takeMVar mv

(Dla opisu MVar przeczytaj ten post)

Co się tutaj wydarzyło? To co poprzednio: dodaliśmy nowy wątek który zajmuje się obsługą wejścia/wyjścia dla dokładnie jednego uchwytu (Handle).

Zauważmy, że w poprawnym kodzie mamy dokładnie jeden wątek dla jednego uchwytu: jeden "główny" oraz dwa utworzone przez forkIO. Jest to ogólna reguła by unikać tego typu zakleszczeń.

Uważny czytelnik zauważy, że w pewnym momencie odszedłem od słowa "kanał" (pipe) na korzyść słowa "uchwyt" (handle). O ile te pierwsze występują w przypadku komunikacji między procesami - i ten przypadek rozważamy - o tyle ten typ błędu występuje ogólnie dla typu uchwytów, które w GHC wykorzystywane są dla wielu typów operacji wejścia wyjścia - w szczególności dla połączeń sieciowych. W ich przypadku również może dochodzić do tego typu błędów.

W każdym z powyżej zacytowanych kawałków kodu jest czai się jeszcze jeden typ błędu, wynikający z semantyki uchwytów w GHC. Jak możemy przeczytać w dokumentacji nieużywany uchwyt jest automatycznie zamykany przez odśmiecacz (GC - garbage collector). Ma to ważną implikację: nie mamy gwarancji kiedy to nastąpi. Dlatego też może się zdarzyć, że otworzymy zbyt wiele plików na raz i system odmówi nam otwarcia nowych deskryptorów pliku. RTS wyrzuci nam w tym momencie wyjątek którego prawdopodobnie nie złapiemy - i nasz program zostanie zabity. Stąd ważny nawyk programistyczny: nieużywane uchwyty zamykamy tak szybko jak tylko przestają nam być potrzebne i nie liczymy w tym przypadku na pomoc systemu.

Dla kompletności oto poprawny (mam nadzieję...) kod:

> (p_stdin, p_stdout, p_stderr, p_handle) <- runInteractiveCommand jakas_komenda
> forkIO (hPut p_stdin jakies_dane >> hClose p_stdin)
> mv <- newEmptyMVar :: IO (MVar String)
> forkIO (hGet p_stdout >>= putMVar mv >> hClose p_stdout)
> odpowiedz_stderr <- hGet p_stderr
> odpowiedz <- takeMVar mv

Brak komentarzy:

Prześlij komentarz