Ręczne i automatyczne odśmiecanie

0

Mam już pewne doświadczenie z C++ i C#, w obu językach można programować obiektowo i jedną z zasadniczych różnic jest to, że w C++ obiekty tworzy się i niszczy ręcznie, w sposób kontrolowany, a w C# nie ma możliwości kontroli niszczenia obiektów, jedynie można zgubić obiekt, (celowo spowodować wyciek pamięci), a obiekt zostanie zniszczony nie wiadomo kiedy.

Te dwa podejścia mają zalety i wady. Może wiecie, dlaczego ani Microsoft (twórca C#), ani Oracle (twórca Java), ani twórcy C++ nie mogą lub nie chcą połączyć tych dwóch podejść? A może już coś takiego istnieje, tylko ja o tym nie wiem?

Chodzi o to, że obiekt może być zarówno ręcznie, jak i przez automat (GC). Dla Microsoftu i Oracle to tylko dodać polecenie typu "delete", które uruchamia destruktor obiektu i niszczy go, dokładnie tak samo, jak GC w przypadku trafienia na nieosiągalny obiekt. Wtedy GC usuwałby skutki wycieków pamięci. Spotkałem się z sytuacjami, że program w C# zajmuje coraz więcej pamięci właśnie przez częste tworzenie obiektów, a wystarczyłoby, żeby w miejscu, w którym wiadomo, że obiekt nie jest już potrzebny, wywołać jego zniszczenie, a nie zdawać się na działanie GC. Już tyle wersji .NET i JVM było, a tego "drobiazgu" ani Microsoft, ani Oracle nie kwapi się dorobić. Może są jakieś przeszkody techniczne? Pomijam fakt, że taki "puchnący" program to często źle przemyślany program.

A jeżeli już musi być GC, to dlaczego nie może być jednocześnie zliczanie referencji i Mark&Sweep? Wtedy w takim C# obiekt znikałby zaraz po zgubieniu referencji do niego, a M&S przyda się do przypadków obiektów z referencjami cyklicznymi, których nie usunie metoda zliczania referencji.

Rozumiem, że Microsoft uznał, że programiści C++ zapominają o niszczeniu obiektów i wymyslił C# z Garbage Collector odporny na takie błędy. Tylko nie rozumiem, dlaczego nie mógł wyposażyć w ręczne usuwanie obiektów tak samo, jak w C++?

5

Wtedy w takim C# obiekt znikałby zaraz po zgubieniu referencji do niego, a M&S przyda się do przypadków obiektów z referencjami cyklicznymi, których nie usunie metoda zliczania referencji.

Dotnetowy GC nie działa metodą zliczania referencji, i z tego co mi wiadomo, radzi sobie z cyklicznymi referencjami bez problemu.

Dlaczego nie dodadzą zliczania referencji - i w ogóle, dlaczego nie łączy się różnych rodzajów GC? Nie wiem, być może to się nie sprawdza. Możesz spróbować napisać prototypową implementację ;-)

Ale GC dotyczy tylko pamięci. Są inne zasoby systemowe (np. połączenia sieciowe, połączenia z bazą danych, obiekty niezarządzane nie podlegające kolektorowi). Dlatego .NET udostępnia ręczny mechanizm zwalniania obiektów: interfejs IDisposable i jego metoda Dispose, której użycie jest w C# zautomatyzowane konstrukcją using.
Każdy obiekt klasy implementującej IDisposable powinien być zwalniany wywołaniem Dispose() albo użyty wewnątrz using.

0
Azarien napisał(a):

Wtedy w takim C# obiekt znikałby zaraz po zgubieniu referencji do niego, a M&S przyda się do przypadków obiektów z referencjami cyklicznymi, których nie usunie metoda zliczania referencji.

Dotnetowy GC nie działa metodą zliczania referencji, i z tego co mi wiadomo, radzi sobie z cyklicznymi referencjami bez problemu.

Właśnie z tego powodu domyślam się, że GC w .NET jest typu Mark&Sweep lub jakiś jego wariant, dla którego kształt grafu referencji nie ma żadnego znaczenia. Dlatego właśnie sam obiekt nie znika od razu po zgubieniu wszystkich referencji.

Azarien napisał(a):

Dlaczego nie dodadzą zliczania referencji - i w ogóle, dlaczego nie łączy się różnych rodzajów GC? Nie wiem, być może to się nie sprawdza. Możesz spróbować napisać prototypową implementację ;-)

Może faktycznie byłyby problemy wydajnościowe, chociaż na pierwszy rzut oka nie widać problemu. Mark&Sweep i tak wstrzymuje działanie programu, robi swoje czynności i wznawia działanie programu.

Azarien napisał(a):

Ale GC dotyczy tylko pamięci. Są inne zasoby systemowe (np. połączenia sieciowe, połączenia z bazą danych, obiekty niezarządzane nie podlegające kolektorowi). Dlatego .NET udostępnia ręczny mechanizm zwalniania obiektów: interfejs IDisposable i jego metoda Dispose, której użycie jest w C# zautomatyzowane konstrukcją using.
Każdy obiekt klasy implementującej IDisposable powinien być zwalniany wywołaniem Dispose() albo użyty wewnątrz using.

Wydaje się, że ten Dispose() tylko częściowo rozwiązuje problem, bo wprawdzie zamyka różne połączenia, pliki, elementy niezarządzane itd, ale nie usuwa samego obiektu. Wydaje mi się, że to jest trochę bez sensu, bo po Dispose() obiekt nie ma racji bytu i komplikuje sprawę. Obiekt ma konstruktor i destruktor i to destruktor jest po to, żeby wykonać wszystkie czynności związane z końcem życia obiektu. W C++ tak właśnie jest, robi się "delete" i uruchamia się destruktor, a potem "oficjalnie" obiekt znika (prawdopodobnie w rzeczywistości zawartość pamięci pozostaje nieruszona, ale obszar jest uznany za wolny i może być użyty do utworzenia innego obiektu). Gdzieś czytałem, że nie da się wymusić zniszczenia obiektu, bo destruktor uruchomi się "w swoim czasie", nawet, jak się zrobi Dispose(), a klasa obiektu musi implementować metodę Dispose(). W C++ jest konstruktor i destruktor, prościej się nie da, a w C# takie dziwolągi, dwie procedury do zakończenia żywota obiektu.

0

Gdyby dało się ręcznie skasować obiekt, to dałoby się zrobić use after free i wywalić program z powodu niepoprawnego dostępu do pamięci. Kod w C# ma być weryfikowalny, jeżeli programista chce ręcznie zarządzać obiektami, to ma blok unsafe i tam może robić cokolwiek (wrzucać obiekty referencyjne na stos, pisać własny alokator pamięci i takie tam).

andrzejlisek napisał(a):

Może faktycznie byłyby problemy wydajnościowe, chociaż na pierwszy rzut oka nie widać problemu. Mark&Sweep i tak wstrzymuje działanie programu, robi swoje czynności i wznawia działanie programu.

M&S nie musi wstrzymywać działania programu. Łączenie M&S ze zliczaniem referencji moim zdaniem nie ma sensu — to drugie nie potrafi sprzątnąć cykli, więc musimy i tak raz na jakiś czas przeskanować całość, więc zliczanie referencji tylko by zwiększało czas działania.

0

Właśnie z tego powodu domyślam się, że GC w .NET jest typu Mark&Sweep lub jakiś jego wariant, dla którego kształt grafu referencji nie ma żadnego znaczenia. Dlatego właśnie sam obiekt nie znika od razu po zgubieniu wszystkich referencji.

.NET ma “Generational GC”. Począwszy zdaje się od wersji 4 może on działać w tle, bez wstrzymywania programu.

Wydaje się, że ten Dispose() tylko częściowo rozwiązuje problem, bo wprawdzie zamyka różne połączenia, pliki, elementy niezarządzane itd, ale nie usuwa samego obiektu.

No bo "usunięcie samego obiektu" to kwestia zwolnienia pamięci, a pamięcią zajmuje się GC. Dispose służy tylko do wykonania akcji których sam GC nie zrobi.

0
Azarien napisał(a):

.NET ma “Generational GC”. Począwszy zdaje się od wersji 4 może on działać w tle, bez wstrzymywania programu.

Nie może, jest w stanie sprzątać z mniejszymi pauzami, ale i tak raz na jakiś czas musi zatrzymać świat (na przykład gdy kończy się pamięć). Ale w ogólności da się to zrobić, Zing JVM chwali się, że nie ma zatrzymania świata.

2
andrzejlisek napisał(a):

Te dwa podejścia mają zalety i wady. Może wiecie, dlaczego ani Microsoft (twórca C#), ani Oracle (twórca Java), ani twórcy C++ nie mogą lub nie chcą połączyć tych dwóch podejść? A może już coś takiego istnieje, tylko ja o tym nie wiem?

Kiedyś słyszałem, że w Objective C jest i GC i ręczne zarządzanie.

Pomijam fakt, że taki "puchnący" program to często źle przemyślany program.

Skoro programista ma problemy z automatycznym zarządzaniem pamięcią, to jaką masz pewność, że zarządzając pamięcią ręcznie, napisze ten sam kod lepiej?

Rozumiem, że Microsoft uznał, że programiści C++ zapominają o niszczeniu obiektów i wymyslił C# z Garbage Collector odporny na takie błędy. Tylko nie rozumiem, dlaczego nie mógł wyposażyć w ręczne usuwanie obiektów tak samo, jak w C++?

Ideą języków z automatycznym zarządzaniem pamięcią jest to, że automatycznie zarządzają pamięcią.
Jeśli chcesz mieć kabriolet z nieruchomym dachem, nie kupuj kabrioletu.

andrzejlisek napisał(a):

W C++ tak właśnie jest, robi się "delete" i uruchamia się destruktor, a potem "oficjalnie" obiekt znika (prawdopodobnie w rzeczywistości zawartość pamięci pozostaje nieruszona, ale obszar jest uznany za wolny i może być użyty do utworzenia innego obiektu).

Czyli pamięć jest poszatkowana niezaalokowanymi obszarami? Dziwne...

W C++ jest konstruktor i destruktor, prościej się nie da, a w C# takie dziwolągi, dwie procedury do zakończenia żywota obiektu.

Nie ma "dwóch procedur". Dispose jest od zwalniania zasobów niezarządzanych, a resztą zajmuje się GC.

A ręcznie też da się wymusić sprzątanie, służy do tego metoda GC.Collect();.

7

Takie połączenie GC z ręcznym zarządzaniem jak Ty proponujesz miałoby wady obydwu rozwiązań:

  • możliwość use-after-free i wywalenia aplikacji
  • nadal masz okresowo odpalane GC i potencjalne problemy wydajnościowe z tym związane
  • dodatkowy narzut na zrobienie tego ręcznego "free"

Z drugiej strony udostępnienie ręcznego free/delete jako alternatywy dla GC w ogóle nic nie daje, bo

  • nowoczesne GC nie poświęcają ani jednego cyklu na dealokację obiektu, który nie jest już potrzebny (z wyjątkiem obiektów podlegających finalizacji); więc nic tym GC nie pomożesz
  • zwolniona ręcznie pamięć i tak nie spowodowałoby późniejszego uruchomienia GC, bo systemy z GC alokują z ciągłego obszaru pamięci i uruchamiają się, kiedy ten obszar się prawie skończy; a Twoje ręczne free poszatkowałoby tylko stertę poza tym obszarem, więc nie miałoby żadnego wpływu na częstotliwość uruchamiania GC
  • nawet jeśli byś umożliwił alokację z obszarów zwolnionych ręcznie aby zmniejszyć częstotliwość odpalania GC, to zapłacisz znacznie wyższym kosztem alokacji (jakieś 10x)
  • trzymanie informacji o zajętych/zwolnionych obszarach na potrzeby ręcznej alokacji / dealokacji tylko zwiększyłoby zużycie pamięci i pogorszyło efektywność cache
  • no i na deser całość byłaby znacznie bardziej skomplikowana, tak jakby samo GC nie było dostatecznie skomplikowane

Podsumowując:

  • szanse na to że Twoje podejście działałoby w ogólnym przypadku lepiej niż samo GC albo sama ręczna alokacja są bliskie zeru
  • stopień skomplikowania jest bardzo wysoki
    Wniosek: nie ma sensu się na to porywać.

Sens łączenia GC z ręczną alokacją jest, ale robi się to w zupełnie inny sposób - wydziela się osobną stertę zarządzaną GC i osobną stertę zarządzaną ręcznie.
Stertę wybierasz przy alokacji. Jeśli coś alokujesz ze sterty ręcznej, to masz obowiązek zwolnić ręcznie i żadne GC Ci tego nie dotknie.

U nas używamy tego schematu w taki sposób, że stertę zarządzaną ręcznie mamy dla obiektów danych (bufory). One są o tyle wdzięczne w zarządzaniu, że mają zawsze jednego właściciela i nie mają wskaźników w sobie, więc ryzyko błędów jest niewielkie. Natomiast całą resztę, jakieś małe obiekty tymczasowe alokujemy normalnie na stercie JVM (wiem, że to nie .NET, ale w .NET jest podobnie).

Zalety:

  • małe obiekty tymczasowe (które stanowią 99% wszystkich obiektów) są zarządzane przez GC, powodując, że większość kodu jest łatwa do napisania
  • sterta zarządzana zajmuje ledwie kilkadziesiąt MB
  • czas pełnego odśmiecania mieści się w pojedynczych milisekundach, a typowo pauzy spowodowane GC są poniżej 1 ms
  • serwer przewala gigabajty danych na sekundę, a GC robi jedynie "zieeeew".
0
andrzejlisek napisał(a):

Wydaje się, że ten Dispose() tylko częściowo rozwiązuje problem, bo wprawdzie zamyka różne połączenia, pliki, elementy niezarządzane itd, ale nie usuwa samego obiektu.

Obiekty z IDisposable najczesciej jeszcze implementuja finalizery, ktore np. zamykaja poleczenia. A finalizer jest wolany przez GC przy usuwaniu obiektu jezeli wczesniej metoda Dispose() nie byla zawolana. Np. z dokumentacji TcpClient:
https://msdn.microsoft.com/en[...]lient.finalize(v=vs.110).aspx
The TcpClient class finalizer closes the TCP connection and releases all managed resources associated with the TcpClient

Czas zamkniecia takiego poleczenia nie jest deterministyczny i nie powinno sie polegac na GC w przypadku obiektow innych niz pamiec (chyba ze lubisz fixowac losowe bugi w swoim programie lub na prawde wiesz co robisz).

0

Jeszcze jedna istotna sprawa:

Automatyczne zarządzanie pamięcią != GC

Zarówno w C++ jak i w Ruscie jest możliwość automatycznego zarządzania pamięcią bez GC. Dzieje się to za pomocą RAII w czasie kompilacji. Przy czym w C++ jest to opt-in tak w Ruscie specjalnie nie ma się wyboru (chyba, że się zrobi opt-in poprzez unsafe).

W Ruscie mamy podobną funkcjonalność jak IDisposable i Dispose z C#, odpowienio trait Drop oraz fn drop<T>(_: T) {} (przy czym implementacja drop wygląda dokładnie tak jak tutaj pokazane). Mimo iż nie mamy żadnego GC w języku (chyba, że ktoś jako GC policzy Rc i Arc z biblioteki standardowej) to dalej mamy automatyczne zarządzanie pamięcią, z tą różnicą, że możemy wszystko traktować jakby było zawinięte w using z C#pa.

Przykładowo:

struct Foo(&'static str);

impl Drop for Foo {
    fn drop(&mut self) { println!("Drop the {}", self.0) }
}

fn main() {
    let a = Foo("a");
    let b = Foo("b");
    {
        let c = Foo("c");
    }

    drop(a);
}

Play

Co prawda nie mamy tutaj żadnej wartości na stosie, ale ogólna idea jest zachowana.

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0