Program musi obsłużyć kilka połączeń sieciowych naraz? Albo równocześnie wyświetlać interfejs i wykonywać jakieś przetwarzanie? Albo pośredniczyć w komunikacji między kilkoma klientami a kilkoma serwerami? Albo wysyłać maile. Albo ściągać dane z kilku źródeł. Albo... Oczywiste - trzeba użyć wątków!
To bywa dobre rozwiązanie, ale ma swój koszt. Nie będę się teraz szerzej rozwodził nad problemami synchronizacyjnymi, zakleszczeniami, kosztem przełączeń kontekstu, czy nadmiernym zużyciem pamięci (każdy wątek ma swój stos). Chcę pokazać, że jest inny sposób - nie tylko bardziej wydajny, ale często także łatwiejszy.
Mam na myśli programowanie zdarzeniowe. To ten model kodowania, dzięki któremu nginx czy lighttpd obsługują tysiące równoczesnych połączeń na maszynach, na których tradycyjne serwery WWW mają problem z setką.
nginx był pisany przede wszystkim, by obsłużyć ruch na bardzo obciążonych serwerach, ale przydaje się też w zupełnie innej sytuacji. Używam go od kiedy korzystam z VPS, gdzie przy niewielkim ruchu wykorzystuje raptem kilka megabajtów pamięci RAM.
Programowanie zdarzeniowe jest proste. Mamy jeden wątek przetwarzania, który po prostu oczekuje i reaguje na wszelkiego typu zdarzenia (ktoś się połączył, odebrano jakieś dane, coś można już wysyłać, jakieś okno wymaga przemalowania, ...). Uporawszy się z obsługą jednego zdarzenia, program sprawdza, czy nadeszło następne. I tak dalej. Nie ma żadnej synchronizacji czy rywalizacji o zasoby, bo w każdym momencie działa tylko jedna funkcja (i nic jej nie przerywa).
W systemach wieloprocesorowych czy wielordzeniowych, czysty program zdarzeniowy będzie korzystał z tylko jednego procesora/rdzenia. Dlatego w takich wypadkach uruchamia się najczęściej kilka procesów lub wątków - jeden dla każdego procesora.
Oczywiście jest w tym pewien kłopot. Jeśli któraś z procedur obsługi potrwa minutę (a nawet - sekundę), przez ten czas nic innego się nie dzieje. Połączenia sieciowe nie są przyjmowane. Okna się nie odmalowują. Strony się nie wyświetlają.
Aby tego uniknąć, trzeba programować w specyficzny sposób, tworząc sekwencje krótkich, lekkich funkcji, powiązanych zdarzeniami lub callbackami. I tu mamy wytłumaczenie popularności wątków. W C i C++ zdarzeniowy styl programowania jest bardzo kłopotliwy, generuje nieczytelny kod. Nawet, gdy korzystamy z dobrego frameworku (jak ACE).
Ale - to jest specyfika C i C++! Języków o bardzo statycznej strukturze.
Najpopularniejszym pythonowym frameworkiem do programowania zdarzeniowego jest Twisted. Nie będę się o nim rozpisywał bardzo szeroko, pokażę za to na jego przykładzie ewolucję czytelności kodu programu zdarzeniowego.
Twisted ma niezłą i obszerną (anglojęzyczną) dokumentację. Osobom mającym niewiele czasu, a chcącym zorientować się o co chodzi, polecałbym szczególnie artykuły Asynchronous Programming with Twisted oraz Writing a TCP server i Writing a TCP client.
Napiszę prościutkiego robaczka sieciowego. Robaczek ten wykona to samo zapytanie na polskim i amerykańskim Google, wyłuska adres pierwszej odpowiedzi, połączy się z tą stroną i pobierze jej tytuł. Po czym zwróci tenże tytuł i adres. Aha: będzie w stanie wykonywać wiele takich zapytań równocześnie.
Na początek kod tradycyjny - zapis, jaki w Twisted był stosowany od zawsze, zapis bardzo podobny do kodu, jaki mógłbym napisać np. w C++ przy pomocy ACE. Cytuję cały skrypt (który działa, można go uruchomić), ale ilustracją jest tak naprawdę tylko procedura lookup.
from twisted.internet import reactor, defer from twisted.web.client import getPage import re def lookup(country, search_term): main_d = defer.Deferred() def first_step(): query = "http://www.google.%s/search?q=%s" % (country,search_term) d = getPage(query) d.addCallback(second_step, country) d.addErrback(failure, country) def second_step(content, country): m = re.search('<div id="?res.*?href="(?P<url>http://[^"]+)"', content, re.DOTALL) if not m: main_d.callback(None) return url = m.group('url') d = getPage(url) d.addCallback(third_step, country, url) d.addErrback(failure, country) def third_step(content, country, url): m = re.search("<title>(.*?)</title>", content) if m: title = m.group(1) main_d.callback(dict(url = url, title = title)) else: main_d.callback(dict(url=url, title="{not-specified}")) def failure(e, country): print ".%s FAILED: %s" % (country, str(e)) main_d.callback(None) first_step() return main_d def printResult(result, country): if result: print ".%s result: %s (%s)" % (country, result['url'], result['title']) else: print ".%s result: nothing found" % country def runme(): all = [] for country in ["com", "pl", "nonexistant"]: d = lookup(country, "Twisted") d.addCallback(printResult, country) all.append(d) defer.DeferredList(all).addCallback(lambda _: reactor.stop()) reactor.callLater(0, runme) reactor.run()
Nie jest to może straszne, ale ilość callback-ów i sama ilość funkcji tłumaczy niechętne podejście do kodu zdarzeniowego.
Jeśli kogoś wystraszyły deferred-y, zachęcam do przejrzenia Asynchronous Programming with Twisted. W skrócie: są to obietnice wyniku - obiekty, do których można przypisać callback, który zostanie wywołany, gdy jakieś zdarzenie (tu - ściąganie strony po HTTP) się zakończy. Funkcja getPage pochodzi z frameworku Twisted i jest przykładem funkcji asynchronicznej - kończy się od razu i zwraca obietnicę wyniku (wewnętrznie getPage zaczyna proces rozwikływania nazwy DNS i rejestruje callback, który - gdy to nastąpi - zacznie się łączyć po HTTP z podaną maszyną, rejestrując callback, który wyśle treść żądania, ... - itd).
Lata minęły, tak Python, jak Twisted się rozwinęły i - możliwy jest obecnie zapis taki jak poniżej (wymaga Pythona 2.5 lub nowszego). Znowu cytuję cały skrypt, ale tylko funkcja lookup się zmieniła.
from twisted.internet import reactor, defer from twisted.web.client import getPage import re @defer.inlineCallbacks def lookup(country, search_term): try: query = "http://www.google.%s/search?q=%s" % (country,search_term) content = yield getPage(query) m = re.search('<div id="?res.*?href="(?P<url>http://[^"]+)"', content, re.DOTALL) if not m: defer.returnValue(None) url = m.group('url') content = yield getPage(url) m = re.search("<title>(.*?)</title>", content) if m: defer.returnValue(dict(url=url, title=m.group(1))) else: defer.returnValue(dict(url=url, title="{not-specified}")) except Exception, e: print ".%s FAILED: %s" % (country, str(e)) def printResult(result, country): if result: print ".%s result: %s (%s)" % (country, result['url'], result['title']) else: print ".%s result: nothing found" % country def runme(): all = [] for country in ["com", "pl", "nonexistant"]: d = lookup(country, "Twisted") d.addCallback(printResult, country) all.append(d) defer.DeferredList(all).addCallback(lambda _: reactor.stop()) reactor.callLater(0, runme) reactor.run()
Powyższy kod wygląda ... normalnie. Jeśli zapomnieć o okazyjnie pojawiającym się słowie yield i zwracaniu wartości funkcją returnValue, można nań patrzeć jak na zwykłą synchroniczną procedurę. Ale - powyższy skrypt nadal wykonuje trzy ciągi przetwarzania równocześnie, nie korzystając z żadnych wątków.
Oczywiście trzeba pamiętać, że każde yield zawiesza wykonywanie się funkcji i daje możliwość działania innym procedurom (które mogą np. zmienić jakieś globalne zmienne). Niemniej jednak - kod jest bardzo czytelny.
Jakim cudem to działa? Cóż, zapisyield
to technika tworzenia generatorów (obecna w Pythonie od 2.4). Zwracanie przezyield
wyniku to zasilanie generatora (obecne w Pythonie od 2.5). Całość przekształca naszą funkcjęlookup
w generator (deferred
-ów). DekoratorinlineCallbacks
w odpowiedni sposób konsumuje dane zwracane z tego generatora, wewnętrznie wyzwalając odpowiedniecallback
-i. Całość stanowi śliczny przykład użycia zaawansowanych mechanizmów Pythona - śliczny, bo ukrywający komplikacje przed korzystającym z biblioteki programistą.