niedziela, 29 kwietnia 2012

WebBrowser i cieknąca pamięć

Jeśli kiedykolwiek używaliście domyślnego WebBrowsera w C# to za pewne zauważyliście, że ilość pamięci zużywanej przez wasz program rośnie wraz z użytkowaniem tej kontrolki w zasadzie w nieskończoność. Dlaczego tak się dzieje? Cóż dokładnie nie wiadomo, wiadomo jednak, że jest to spowodowane głównie cachowaniem zawartości odwiedzonych stron... dodajmy cachowaniem, którego nie można w całości wyłączyć/wyczyścić. Oczywiście można by sądzić, iż problem ten wynika z leciwości tej kontrolki, jednak ku memu wielkiemu zaskoczeniu to samo dzieje się z WebBrowserem dostępnym wewnątrz WPF (obie kontrolki używają tego samego silnika IE).
Ale po co w ogóle używać kontrolkę WebBrowser? Poza oczywistym zastosowaniem - umożliwienia "przeglądania" Internetu (klikania w łącza, oglądania obrazków, czytania tekstów) można również używać go do parsowania ściągniętych plików HTML, z którymi natywne parsery .NET nie dają sobie rady z racji niezgodnego ze standardem użycia tagów.

Oczywiście to do czego chcemy używać WebBrowser jest sprawą drugorzędną, gdyż wpływa jedynie na to czy istnieje jakieś rozwiązanie alternatywne, czy też skazani jesteśmy na WebBrowsera, który pożre przy sprzyjających dla siebie warunkach wszelkie ilości dostępnej pamięci nie dając w zamian absolutnie nic. W tym wpisie przedstawię kilka sposobów na ograniczenie ilości wyciekającej pamięci, więc jeśli ktoś liczy na to, że znajdzie tu sposób na całkowite zażegnanie tego problemu to spotka go rozczarowanie, gdyż takie sposobu nie znam, ani nigdzie nie mogłem znaleźć... Obalę również kilka mitów krążących po sieci związanych z "magicznymi sposobami" na ograniczenie używanej pamięci. Oczywiście zawsze można zaprzestać używania natywnych kontrolek i wykorzystać kontrolki stworzone w pocie czoła innych programistów (dla porównania w dalszej części przedstawię właśnie kontrolkę Awesomium):

Navigate

Aby WebBrowser otworzył wskazaną przez nas stronę musimy użyć metody "Navigate". Jest to metoda, którą będziemy wykorzystywać we wszystkich wariacjach przedstawionych w tym wpisie, toteż postanowiłem pochylić się chwilę nad jej opisem. W ogólnym ujęciu nie ma znaczenia, czy mówimy o Navigate() pochodzącym z WebBrowsera z WinForms czy też z WPF - oczywiście kontrolki te posiadają pewne unikalne dla siebie metody (i niestety WinForms dają zdecydowanie większą kontrolę nad tym co się w samej kontrolce dzieje), ale generalna zasada ich działania jest identyczna.

Metoda Navigate() informuje kontrolkę aby ta rozpoczęła otwieranie wskazanego w argumencie metody dokumentu (URI, String). Kontrolka sama dba o to by pobrać dokument oraz wszelkie zawarte w nim obrazy, podłączone skrypty i arkusze stylu - w tym kontekście działa jak zwykła przeglądarka. Problemem (dla niektórych) jest to, że WebBrowser nie obsługuje flasha (ani innych technologi wymagających dodatkowych wtyczek).

Ale o czym z punktu widzenia kodu kontrolka nas informuje?
  • Navigating - event podnoszony po wywołaniu Navigate() a przed faktycznym rozpoczęciem pobierania wskazanego dokumentu.
  • Navigated - event podnoszony po wywołaniu Navigate() w momencie gdy rozpoczęto pobieranie wskazanego dokumentu (fragmenty dokumentu są już dostępne przez kontekst kontrolki)
  • DocumentCompleted - event podnoszony po wywołaniu Navigate() w momencie gdy żądany dokument został w całości pobrany i jest możliwy do przeglądania wewnątrz kontrolki
Oczywiście do tego dochodzi cała masa zdarzeń związanych z obsługą kontrolki jako kontrolki, a które nie mają bezpośredniego związku z faktem pobierania i prezentacji danych. Tak więc najprostszy mechanizm, który pozwala nam na "przeglądanie Internetu" można ująć w takim oto kodzie:

            WebBrowser wb = new WebBrowser();
            wb.ScriptErrorsSuppressed = true;
            wb.DocumentCompleted += new WebBrowserDocumentCompletedEventHandler(wb_DocumentCompleted);
            wb.Navigate(uri); 
Z tych 4 linii chyba jedynie właściwość "ScriptErrorsSuppressed" może być dla niektórych zagadką. Jednak by ustrzec się przed wyskakiwaniem takich niespodzianek jak to okienko zaprezentowane z boku tekstu (które jednocześnie wstrzymują wykonanie kodu do momentu wybrania "Tak" lub "Nie") warto tą jedną właściwość ustawić na "true". Niestety kontrolka WPF nie pozwala na manipulację tą właściwością.

Dispose

Dość powszechnie proponowanym remedium na problemy z cieknącą pamięcią jest używanie metody Dispose(), która w teorii powinna zwolnić całą używaną przez kontrolkę pamięć... w praktyce wygląda to diametralnie inaczej. Konkretniej porównując ilość pamięci zajętej po odwiedzeniu 100 i 1000 różnych stron a następnie usunięciu kontrolek (usunięcie ich ze zbioru renderowanych elementów w oknie a następnie wykonanie metody Dispose) można jednoznacznie stwierdzić, że ta kontrolka jest zepsuta do "szpiku kości".
Metoda100 URI1000 URI
Navigate69 MB415 MB
Dispose86 MB451 MB
Oczywiście w tym przykładzie zanurzyliśmy się w pewne ekstremum, gdyż metoda Dispose była wykonywana po każdej przeładowanej stronie - nie jest to szczyt wydajności, ale jak widać ujawnia istnienie problemu w samej strukturze kontrolki, która nie zwalnia zasobów, które zaalokowała podczas swej pracy i/lub podczas swego tworzenia.
        void disposeNavigated_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
        {
            wb.Dispose();
            wb = new WebBrowser();
            wb.ScriptErrorsSuppressed = true;
            wb.DocumentCompleted += new WebBrowserDocumentCompletedEventHandler(disposeNavigated_DocumentCompleted);
            wb.Navigate(uri);
        }

Clear

Ciekawym sposobem, który odnalazłem już sam nie wiem gdzie była próba zmuszenia WebBrowsera do nie uwzględniania otwieranego dokumentu w historii silnika przeglądarki. Dodajmy od razu próba ta w żaden sposób nie ogranicza ilości zajętej pamięci - a wręcz działa odwrotnie.
Metoda100 URI1000 URI
Navigate69 MB415 MB
Clear86 MB447 MB
        void clearNavigated_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
        {
            if (wb.Document != null)
                wb.Document.OpenNew(true);
            wb.Navigate(uri);
        }
metoda OpenNew() odnosi się co prawda do dokumentu a nie samej kontrolki, ale jej opis faktycznie daje odrobinę nadziei: "OpenNew will clear the previous loaded document, including any associated state, such as variables.".

Łatka z Microsoftu

Oczywiście nie jestem kolumbem i to nie ja odkryłem ten problem... nawet nie twierdzę, że znalazłem jego rozwiązanie. Otóż rozwiązanie, które trochę ogranicza problem przedstawił sam Microsoft (link) w 2009 roku. Artykuł ten prezentuje sposób na wyczyszczenie cache naszej przeglądarki działającej w ramach kontrolki WebBrowser. Bardziej przystępną formę kodu z kolei można znaleźć na blogu Gut Instinct. Cóż można powiedzieć - to faktycznie działa i czyści cache... tylko, że problem w dużej części dalej pozostaje co widać po ilości zajętej pamięci:
Metoda100 URI1000 URI
Navigate69 MB415 MB
Ms fix65 MB408 MB
        void MSFixNavigated_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
        {
            wb.ClearCache();
            wb.Navigate(uri);
        }
metoda wb.ClearCache() jest moim rozszerzeniem funkcjonalności kontrolki i w zasadzie wykonuje kod przygotowany przez Microsoft a zmodyfikowany przez Jamesa Craiga (autora wspomnianego powyżej bloga).

Stop

Innym zaskakująco prostym w użyciu rozwiązaniem (porównując implementację łatki z MS) jest wykonanie metody Stop() po zakończeniu wczytywania każdego dokumentu. Metoda ta powoduje, że przeglądarka przestaje wczytywać nowe dane i zostawia kontrolkę z tym co do tej pory zdołała pobrać.
Metoda100 URI1000 URI
Navigate69 MB415 MB
Stop66 MB409 MB
        void stopNavigated_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
        {
            wb.Stop();
            wb.Navigate(uri);
        }
Jak to się dzieje, że zatrzymanie wczytywania elementów po ukończeniu przetwarzania dokumentu ma wpływ na to ile pamięci zaoszczędzimy? Ciężko jednoznacznie stwierdzić, głównym podejrzanym są tu różnego rodzaju efekty działania skryptów i obrazki, które ładowane są już po pobraniu pliku dokumentu (bo obiektywnie patrząc to obrazki ważą najwięcej), a które nie są poprawnie obsługiwane przez system zarządzania zasobami wbudowany w kontrolkę.

Łatka ze stopem

Łącząc dwie poprzednie metody nie uzyskujemy jakiś większych oszczędności pamięci (ilość używanej pamięci jest porównywalna), ale jeśli zmuszeni jesteśmy do korzystania z WebBrowsera to na pewno nie zaszkodzi używać obu metod jednocześnie.
Metoda100 URI1000 URI
Navigate69 MB415 MB
Ms fix65 MB408 MB
Stop66 MB409 MB
Ms fix + stop63 MB408 MB

WPF

Jak już wspomniałem we wstępie wpisu problemów z pamięcią nie unika również kontrolka WPF. Bez zbędnego lania wody zaprezentuję od razu wyniki pomiarów:
Metoda100 URI1000 URI
Navigate69 MB415 MB
WPF128 MB485 MB
Dodam jedynie, że są to wyniki już z wywoływaniem metody "Stop()" po wczytaniu każdego dokumentu - bez tego ilość wyciekającej pamięci była jeszcze większa. W kwestii formalnej należy jednak zauważyć, że sama obsługa kontrolki (jej istnienie i wykonanie jednego zapytania) na "dzień dobry" zjada 20kB więcej niż kontrolka z WinForms.

WebRequestObject

Jeśli używamy kontrolki WebBrowsera jedynie by wykorzystać jej wbudowany parser (który mimo swych wad bardzo ładnie radzi sobie z błędnym kodem HTML), to na dobrą sprawę możemy ograniczyć ilość wyciekającej pamięci do minimum.
Metoda100 URI1000 URI
Navigate69 MB415 MB
WebRequestObject50 MB53 MB
        private void requestButton_Click(object sender, EventArgs e)
        {
            wb.DocumentCompleted += this.requestNavigated;
            wb.Navigate("");
        }

        void requestNavigated_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
        {
            // Open a connection
            HttpWebRequest WebRequestObject = (HttpWebRequest)HttpWebRequest.Create(uri);

            WebRequestObject.UserAgent = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 2.0.50727; .NET CLR 1.1.4322; .NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648)";

            // Request response:
            WebResponse Response = WebRequestObject.GetResponse();

            // Open data stream:
            Stream WebStream = Response.GetResponseStream();

            // Create reader object:
            StreamReader Reader = new StreamReader(WebStream);

            // Read the entire stream content:
            HtmlDocument doc = wb.Document;
            doc.Body.InnerHtml = Reader.ReadToEnd();

            // Cleanup
            Reader.Close();
            WebStream.Close();
            Response.Close();
        }
Ten kawałek kodu tworzy nam bardzo uproszczony odpowiednik zapytania wysyłanego przez WebBrowsera i generalnie nie pozwala nam na cieszenie się w pełni jego funkcjonalnościami. To jednak co uzyskujemy tutaj to:
  • zawartość otwieranego dokumentu (Reader.ReadToEnd())
  • dostęp do modelu DOM otwartego dokumentu (wb.Document i jego metody: GetElementsBy...)
  • znikome wycieki pamięci z racji nie ładowania niczego poza samym dokumentem
Minusem jest konieczność wykonania metody Navigate("") przed każdym przypisaniem zawartości pobranego dokumentu do obiektu HTMLDocument. Wynika to z tego, że WebBrowser parsuje dokument i w swoich trzewiach przetrzymuje informacje o np. używanych identyfikatorach - i te dane nie są czyszczone przez samo nadpisanie treści dokumentu, lecz konieczne jest wykonanie metody Navigate().

Awesomium

Na sam koniec pokażę iż są alternatywy, które nie tylko realizują to co potrafi WebBrowser, ale również o wiele więcej... a do tego ilość pamięci, którą zużywają jest niewspółmiernie mniejsza od rozwiązań pochodzących ze stajni MS.
Metoda100 URI1000 URI10000 URI
Navigate69 MB415 MB1.6 GB+
Ms fix65 MB408 MB1.6 GB+
Stop66 MB409 MB1.6 GB+
Ms fix + stop63 MB408 MB1.6 GB+
WebRequestObject50 MB53 MB57 MB
Awesomium (WebControl)59 (54) MB 60 (59) MB 67 (65) MB
Awesomium (WebView)71 (58) MB 74 (64) MB 82 (63) MB
Wyniki w nawiasach oznaczają ilość pamięci zajętej przez program po wyłączeniu rdzenia Awesomium (WebCore.Shutdown()). W przypadku Awesomium należy zwrócić uwagę na taki szczegół, że podczas działania ich kontrolek w systemie uruchamiany jest dodatkowy proces, który tak naprawdę zajmuje się całą obsługą przeglądarki. Dodajmy, że proces ten po pobraniu 10k różnych adresów stron pożerał jedynie ok. 100 MB pamięci i po usunięciu kontrolek cała ta pamięć została zwolniona.

Słowo na koniec

Jeśli kiedykolwiek będziecie musieli zaimplementować możliwość przeglądania dokumentów HTML wewnątrz swojego programu to zdecydowanie owo zadanie powinniście rozpocząć od znalezienia kontrolki, która nie będzie cieknąć pamięcią, bo jak widać natywne obiekty ową pamięć pożerają i co gorsza nie pozwalają na jej wykorzystanie aż do momentu zakończenia procesu.

Plik z solucją (VS 2010), kodem źródłowym i wersją skompilowaną jak zwykle do pobrania z mojego chomika: (link)

Brak komentarzy:

Prześlij komentarz