Скажу сразу: я никогда не жду развёрнутого ответа на этот вопрос на собесах. Это глупо и в моем случае — эгоистично. Однако, на мой взгляд, помимо общего интереса к платформе, знать, как он работает очень полезно, т.к. это снимает целый ряд вопросов. Например, исключает вариант, когда разработчик считает, что Dispose вызывается автоматически и вызывать его самому не надо. Или же если разработчик более опытен, помогает ему автоматически, на уровне мышечной памяти писать код, приводящий к наименьшему количеству проблем.


Другой вопрос, что мне субъективно не очень нравится, как объясняется его работа. Потому, предлагаю альтернативный подход, описанный в моей книге, .NET Platform Architecture.


Если мы с вами будем досконально разбираться, почему были выбраны именно эти два алгоритма управления памятью: Sweep и Compact, нам для этого придётся рассматривать десятки алгоритмов управления памятью, которые существуют в мире: начиная обычными словарями, заканчивая очень сложными lock-free структурами. Вместо этого, оставив голову мыслям о полезном, мы просто обоснуем выбор и тем самым поймём, почему выбор был сделан именно таким. Мы более не смотрим в рекламный буклет ракеты-носителя: у нас на руках полный набор документации.


Спор взаимовыгоден: если будет не понятно, я подправлю не ясные моменты в книге, маленькой частью которой является данный текст.



Я выбрал формат рассуждения чтобы вы почувствовали себя архитекторами платформы и сами пришли к тем же самым выводам, к каким пришли реальные архитекторы в штаб-квартире Microsoft в Рэдмонде.

Исходя из классификации выделяемых объектов на основании их размера можно разделить места под выделение памяти на два больших раздела: на место с объектами размером ниже определенного порога и на место с размером выше этого порога и посмотреть, какую разницу можно внести в управление этими группами (исходя из их размера) и что из этого выйдет.


Если рассматривать вопросы управления условно "маленьких" объектов, то можно заметить, что если придерживаться идеи сохранения информации о каждом объекте, нам будет очень дорого поддерживать структуры данных управления памятью, которые будут хранить в себе ссылки на каждый такой объект. В конечном счёте может оказаться, что для того, чтобы хранить информацию об одном объекте понадобится столько же памяти, сколько занимает сам объект. Вместо этого стоит подумать: если при сборке мусора мы пляшем от корней, уходя вглубь графа через исходящие поля объекта, а линейный проход по куче нам понадобится только для идентификации мусорных объектов, так ли нам необходимо в алгоритмах менеджмента памяти хранить информацию о каждом объекте? Ответ очевиден: надобности в этом нет никакой. А значит, можно попробовать исходить из того, что такую информацию мы хранить не должны: пройти кучу линейно мы можем, зная размер каждого объекта и смещая указатель каждый раз на размер очередного объекта.


В куче нет дополнительных структур данных, которые хранят указатели на каждый объект, которым управляет куча.

Однако, тем не менее, когда память нам более не нужна, мы должны её освобождать. А при освобождении памяти нам становится трудно полагаться на линейное прохождение кучи: это долго и не эффективно. Как следствие, мы приходим к мысли, что надо как-то хранить информацию о свободных участках памяти.


В куче есть списки свободных участков памяти.

Если, как мы решили, хранить информацию о свободных участках, и при этом при освобождении памяти участки эти оказались слишком малы, то во-первых мы приходим к той-же проблеме хранения информации о свободных участках, с которой столкнулись при рассмотрении занятых (если по бокам от занятых освободился один объект, то чтобы хранить о нём информацию, надо в худшем случае 2/3 его размера. Указатель + размер против SyncBlockIndex + VMT + какое-либо поле — в случае объекта). Это снова звучит расточительно, согласитесь: не всегда выпадает удача освобождения группы объектов, следующих друг за другом. Обычно, они освобождаются в хаотичном порядке. Но в отличии от занятых участков, которые нам нет надобности линейно искать, искать свободные участки нам необходимо потому что при выделении памяти они нам снова могут понадобиться. А потому возникает вполне естественное желание уменьшить фрагментацию и сжать кучу, переместив все занятые участки на места свободных, образовав тем самым большую зону свободного участка, где можно выделять память.


Отсюда рождается идея алгоритма Compacting.

Но, подождите, скажите вы. Ведь эта операция может быть очень тяжёлой. Представьте только, что вы освободили объект в самом начале кучи. И что, скажете вы, надо двигать вообще всё?? Ну конечно, можно пофантазировать на тему векторных инструкций CPU, которыми можно воспользоваться для копирования огромного занятого участка памяти. Но это ведь только начало работы. Надо ещё исправить все указатели с полей объектов на объекты, которые подверглись передвижениям. Эта операция может занять дичайше длительное время. Нет, надо исходить из чего-то другого. Например, разделив весь отрезок памяти кучи на сектора и работать с ними по отдельности. Если работать отдельно в каждом секторе (для предсказуемости и масштабирования этой предсказмуемости — желательно, фиксированных размеров), идея сжатия уже не кажется такой уж тяжёлой: достаточно сжать отдельно взятый сектор и тогда можно даже начать рассуждать о времени, которое необходимо для сжатия одного такого сектора.


Теперь осталось понять, на основании чего делить на сектора. Тут надо обратиться ко второй классификации, которая введена на платформе: разделение памяти, исходя из времени жизни отдельных её элементов.


Деление простое: если учесть, что выделять память мы будем по мере возрастания адресов, то первые выделенные объекты становятся самыми старыми, а те, что находятся в старших адресах — самыми молодыми. Далее, проявив смекалку, можно прийти к выводам, что в приложениях объекты делятся на две группы: те, что создали для долгой жизни и те, которые были созданы жить очень мало. Например, для временного хранения указателей на другие объекты в виде коллекции. Или те же DTO объекты. Соответственно, время от времени сжимая кучу мы получаем ряд долгоживущих объектов — в младших адресах и ряд короткоживущих — в старших.


Таким образом мы получили поколения.

Разделив память на поколения, мы получаем возможность реже заглядывать в объекты старшего поколения, которых становится всё больше и больше.


Но возникает еще один вопрос: если мы будем иметь всего два поколения, мы получим проблемы. Либо мы будем стараться, чтобы GC отрабатывал маскимально быстро: тогда размер младшего поколения мы будем стараться делать минимальных размеров. Как результат — объекты будут случайно проваливаться в старшее поколение (если GC сработал "прям вот сейчас, во время яростного выделения памяти под множество объектов"). Либо, чтобы минимизировать случайное проваливание, мы увеличим размер младшего поколения. Тогда GC на младшем поколении будет работать достаточно долго, замедляя и подтормаживая приложение.


Выход — введение "среднего" поколения. Подросткового. Другими словами, если дожили до подросткового возраста, велика вероятность дожить до старости. Суть его введения сводится к получению баланса между получением минимального по размеру младшего поколения и максимально-стабильного старшего поколения, где лучше ничего не трогать. Это — зона, где судьба объектов еще не решена. Первое (не забываем, что мы считаем с нуля) поколение создается также небольшим и GC туда заглядывает реже. GC тем самым дает возможность объектам, которые находятся во временном, первом поколении, не уйти в старшее поколение, которое собирать крайне тяжело.


Так мы получили идею трёх поколений.

Следующий слой оптимизации — попытка отказаться от сжатия. Ведь если его не делать, мы избавляемся от огромного пласта работы. Вернемся к вопросу свободных участков.


Если после того, как мы израсходовали всю доступную в куче память и был вызван GC, возникает естественное желание отказаться от сжатия в пользу дальнейшего выделения памяти внутри освободившихся участков, если их размер достаточен для размещения некоторого количества объектов. Тут мы приходим к идее второго алгоритма освобождения памяти в GC, который называется Sweep: память не сжимаем, для размещения новых объектов используем пустоты от освобожденных объектов


Так мы описали и обосновали все основы алгоритмов GC.

Итак, спустя двое суток можно сделать кое-какие выводы. Я так понимаю, что большинство людей поняло большинство текста или же вообще весь. Часть людей ответили, что не поняли, часть, соответственно, поняла частично. Спор выиграла команда читателей, хоть и с небольшим перевесом, как говорится. Но, как я и говорил, выиграют от этого все: текст будет изменен и дополнен. Плюс, обновлён в обоих местах: и в книге и здесь, в статье.

Комментарии (66)


  1. kovserg
    20.08.2019 00:41

    Если сравнить программы использующие GC и с явным управление памятью, какие работают эффективнее и жрут меньше ресурсов? Что показывает имеющийся опыт?
    И какие штатные средства есть в C# для того что бы не потерять контроль над GC, как бывает иногда в java, которая через како-то время съедает всю доступную память и еще немного и бодро всё вешает.

    ps: Для чего в реализации Dispose столько костылей?

    pps: После прочтения стало еще больше непонятно.


    1. MonkAlex
      20.08.2019 06:52

      ps: Для чего в реализации Dispose столько костылей?
      это со старых версий, нынче статья не особо актуальна, имхо.
      Автор писал статью на хабре про него ( [DotNetBook] Реализация IDisposable: правильное использование ), и я с ним был сильно несогласен, привожу всё ту же ссылку на хороший разбор паттерна, если вам вдруг интересно — SO

      Кратко, тем кому лень ходить по ссылкам
      Нынче Dispose пишется элементарно — вызовом аналогичного метода у своих полей. Можно явно записать, что ресурсы уже освобождены, но не обязательно.
          public void Dispose()
          {
              if (isDisposed)
                  return;
      
              resource2.Dispose();
              resource1.Dispose();
      
              isDisposed = true;
          }


      1. sidristij Автор
        20.08.2019 09:44

        Del


        1. sidristij Автор
          20.08.2019 09:46

          Если мы откроем файл в конструкторе класса C#, и попытаемся закрыть его в деструкторе, получится плохо: деструктор вызывается лишь тогда, когда сборщик мусора убирает объект, то есть, непонятно когда, и может быть даже вообще никогда

          Потому что в C# нет деструктора. Деструктор вызывается "вручную" при ручном освобождении памяти. Финализатор да, вызывается когда угодно. Терминология важна: она держит нас в рамках правил и понимания


        1. sidristij Автор
          20.08.2019 09:48

          Пускай у вас есть в классе поле (ну или свойство), которое имплементирует IDisposable. В этом случае ваш класс тоже должен имплементировать IDisposable, чтобы во время своего Dispose вызвать Dispose и для внутреннего объекта.

          В общем случае, но не всегда.


        1. sidristij Автор
          20.08.2019 09:51

          Ресурсы делятся на управляемые (те, которые являются по сути .NET-объектами) и неуправляемые (они обычно являются хэндлами системы и хранятся в IntPtr, но в принципе могут быть любым объектом вне данного рантайма .NET, или даже просто чисто логической сущностью, наподобие права на показ нотификации пользователю).

          Неуправляемый объект — тот, который создан и менеджится вне подсистемы .NET. Нотификация — какое-то запутывание


        1. sidristij Автор
          20.08.2019 09:56

          Но в целом написано хорошо, живенько. Противоречий более не заметил в том числе и с книгой :)


      1. kovserg
        20.08.2019 11:04

        Конечно элементарно. Тогда почему такой код работает не так как ожидалось если не раскомментировать строки (1) или (2)

        using System;
        using System.Threading;
        
        class Program {
        	static volatile int iter = 0;
        	static volatile int count = 0;
        	class A : IDisposable {
        		public A() {
        			//Thread.MemoryBarrier(); // (1)
        			++count;
        			Thread.Sleep(0);
        		}
        		public void Dispose() {
        			--count;
        		}
        		//~A() {} // (2)
        	}
        	static void Main(string[] args) {
        		for(int i=0;i<2;i++) {
        			var t=new Thread(()=>{
        				for(;;) using(var a=new A()) iter++;
        			});
        			t.Start();
        			for(var it=iter;it==iter;) {}
        			t.Abort();
        			t.Join();
        		}
        		Console.WriteLine("count={0}",count);
        		if (count > 0) Console.Error.WriteLine("something wrong");
        		else Console.WriteLine("looks fine");
        	}
        }
        

        output:
        count=2
        something wrong
        

        Если объявить деструктор явно:
        count=0
        looks fine
        


        1. MonkAlex
          20.08.2019 11:16

          Потому что паттерн про ресурсы, а не про счетчики.

          Тем более с абортами треда, сомнительно, что это типичный кейс использования, обычно класс или thread-safe и там такое предусмотрено, или ровно наоборот, как у вас и получилось.


  1. alexxz
    20.08.2019 01:29

    Простите, я совершенно не из мира C# разработки. Однако, предложение поспорить пригласило меня прочитать статью.
    Поясните, пожалуйста, как из предложения разделить все объекты по размерам следует, что делить надо именно на две независимые группы из маленьких и больших объектов? Почему этим надо управлять используя именно термины поколений? И, вопрос от дилетанта, почему бы не сделать несколько пулов под объекты разного размера и аллоцировать запись из наиболее подходящего пула? Карту использования элементов конкретного пула держать в его заголовке в битовой карте. Это, в принципе, несколько упрощает вопросы фрагментации памяти. Остаются только вопросы выделения памяти под большие объекты и под объекты с заранее неизвестным размером.
    Может быть статью надо было начинать с того как устроено управление памятью, прежде чем рассказывать про сборщик мусора?


    1. creker
      20.08.2019 15:30

      Поясните, пожалуйста, как из предложения разделить все объекты по размерам следует, что делить надо именно на две независимые группы из маленьких и больших объектов?

      Если почитать литературу, было проведено множество исследований за многие годы (сборка мусора очень старая концепция) и два поколения это более менее оптимальный вариант деления для реальных нагрузок. Естественно люди пробовали самые разные способы деления, метрики, количество поколений и т.д. и т.п. Как всегда все упирается в компромиссы.

      Почему этим надо управлять используя именно термины поколений?

      Опять же, после многочисленных исследований реальных нагрузок образовалась закономерность, которую мы везде и слышим. Вероятность того, что объект умрет, тем выше, чем меньше возраст объекта. Чем объект старше, тем больше вероятность, что он и продолжит жить. Поэтому и появилась концепция молодного и старого поколения, к которым применяются сильно разные алгоритмы.

      И, вопрос от дилетанта, почему бы не сделать несколько пулов под объекты разного размера и аллоцировать запись из наиболее подходящего пула?

      Да полно таких аллокаторов. Только такие хитрости всегда требуют чем-то платить. Обычно это пропускная способность. Все эти пулы нужно держать в консистентном состоянии, особенно в условиях многопоточности, а с современными параллельными конкурентными сборщиками это означает сильную нагрузку на барьеры, которые и так далеко не дешевые. Да и сама аллокация станет дороже. В итоге сильно просядет пропускная способность, которая у конкурентных сборщиков и так не очень высокая. В итоге бывает проще сделать тупой абсолютно аллокатор, который делает процедуру выделения памяти практически бесплатной (у многих сборщиков оно именно так, выделение памяти намного дешевле типичного malloc), вплоть до тупого сдвига указателя. Естественно за это приходится платить в других местах, той же фрагментацией и необходимость компактить объекты.

      Тоже самое касается большего количества поколений. Перенос объектов между поколениями процедура не бесплатная. Еще более неприятным является тот факт, что сборщику надо отслеживать ссылки на объекты сквозь поколения. Объект может лежать в одном поколении, а ссылаться на объект в другом. Я не помню уже деталей, но это создает очень большие проблемы. Естественно, чем больше поколений, тем больше ссылок подобных.


    1. Vadem
      20.08.2019 20:25

      Почему этим надо управлять используя именно термины поколений?

      В какое поколение попадёт объект зависит от его возраста.
      От размера объекта зависит в какую кучу он попадет.
      В .net есть как минимум две управляемых кучи(и несколько неуправляемых) — обычная куча(GC heap или ephemeral heap) и куча для больших объектов(large objects heap).


    1. kovserg
      20.08.2019 23:50
      +2

      Может быть статью надо было начинать с того как устроено управление памятью, прежде чем рассказывать про сборщик мусора?
      Да не мешало бы:
      42. Memory management
      43. Implementing Tracing Garbage Collectors
      44. Copying Garbage Collection
      45. Generational Garbage Collection


  1. screwer
    20.08.2019 02:44

    Карту использования элементов конкретного пула держать в его заголовке в битовой карте

    Это необязательно. Посмотрите как устроен SLUB аллокатор в ядре линукса.


    Остаются только вопросы выделения памяти под большие объекты и под объекты с заранее неизвестным размером.

    Юзермодные аллокаторы для больших размеров вызывают маппинг памяти средствами ОС, в обход основной логики. Ядерные аллокаторы поступают аналогично, запрашивая сразу пачку страниц.


    Про "неищвесный размер" не понял. В момент вызова он обязан быть известным, иначе что аллоцируем ?


    1. alexxz
      20.08.2019 07:53

      Про неизвестный размер я написал, удерживая в голове мысль, что, вероятно, будет нужен аналог realloc. То есть паттерн увеличения размера объекта. Но написал не очень понятно. Да.


  1. ybqwer
    20.08.2019 09:59

    Хм, не думаю что такой взгляд наилучший для любой задачи. Если копнуть глубже, стоит задуматься с какой стати вообще одни обьекты исчезают в то время как другие живут долго, и почему

    Обычно, они освобождаются в хаотичном порядке
    . И вообще не лучше ли представить обьекты как структуры примитивных величин (массивы, списки, хеш таблица) и определить весь функционал векторной обработки обьектов неким аналогом Numpy а вышеупомянутый подход годится для небольшой кучи непредсказуемых обьектов.


  1. AnnaElli
    20.08.2019 09:59

    Вот жеж полезла смотреть, что значит «эгоисточно»)


  1. svr_91
    20.08.2019 11:52

    А я не понимаю, почему не делают подход как в питоне, когда все на умных указателях, а то, что не удалось почистить умными указателями, чистится gc?


    1. ybqwer
      20.08.2019 15:33

      а как работают умные указатели?


    1. creker
      20.08.2019 15:38
      +2

      Потому что у всего есть свои недостатки. Подсчет ссылок это такая же сборка мусора, но сильно примитивная. Циклы не умеет чистить, поэтому туда добавляют mark sweep или еще чего. Подсчет ссылок может быть довольно дорог в плане необходимости постоянно обновлять счетчик, что требует хитро его упаковывать, синхронизировать между потоками, бороться с промахами кэша процессора и т.д. и т.п. Так же удаление объектов может давать непредсказуемые задержки из-за рекурсивности этой процедуры — большое дерево объектов может удаляться довольно долго. Естественно для всего есть свои хитрые способы оптимизации (вроде упаковки счетчиков в общую какую-то структуру, отложенное удаление объектов), но в конечном счете может быть проще полностью перейти на GC.


      1. svr_91
        20.08.2019 16:18

        Но тем не менее нигде кроме питона это реализовано не было (или я не знаю). Неужели все настолько плохо?


        1. creker
          20.08.2019 16:27

          В swift автоматический подсчет ссылок, но циклические ссылки самому надо искать и исправлять средствами языка. Я не видел конкретных исследований, которые бы пролили свет на такую малую популярность — неужели недостатки действительно настолько непреодолимые. Есть только народная мудрость, что подсчет ссылок это обычно медленно, все эти атомарные счетчики постоянно обновлять, кэш промахи ловить, и единичные примеры, подтверждающие это. Как недавний проект по реализации user-space сетевого драйвера на разных языках, где Go и C# в пух и прах уделали swift. Причиной назвали как раз подсчет ссылок.


          1. ybqwer
            20.08.2019 19:59

            действительно, представь оверхед на доступ к значению в памяти где число ссылок, инкремент или ещё что там делают. Где нибудь вообще делают инлайн обьектов с превращением в обычные массивы?


            1. creker
              20.08.2019 20:02

              Сорь, не понял коммент. Переформулируй.


              1. ybqwer
                20.08.2019 20:37

                какой момент не понятен. Насколько это я себе примерно представляю, при доступе к памяти грузится вся страница, которая будет грузиться при доступе к одной переменной счётчика. А инлайн обьектов это вроде Structure of Arrays с векторным выделением/удалением памяти под обьекты чтобы не считать для каждого отдельно. Так можно если очень мало обьектов и на производительность вообще забили.


                1. creker
                  20.08.2019 20:56

                  которая будет грузиться при доступе к одной переменной счётчика

                  Да, это одна из больших проблем подсчета ссылок. Да и не только, в сборке мусора тоже бывают добавляют какие-то метаданные в объекты и начинается экономия на page fault'ах. И даже если страница загружена, то словишь промах кэша. Собственно, поэтому счетчики можно упаковать в специальную структуру линейную отдельно от объектов. Вроде ObjC хранит счетчики в отдельной структуре. Swift, судя по комментариям в исходниках, хранит счетчик внутри объекта, но может при определенных условиях вынести его в отдельную таблицу.

                  А уж mark sweep сборщики мусора так точно все хранят в одном месте, а не помечают какие-то биты внутри каждого объекта. Вроде card table всяких.


                  1. ybqwer
                    20.08.2019 21:18

                    да как с самого начала как услышишь про этот концепт понятно что геморно. Тем более для мобильного девайса у которого итак не круто с производительностью.

                    да тогда лучше сразу в обьекте хранить.
                    кстати, ещё, как происходит наблюдение и удаление обьекта в случае когда счётчик достигает нуля? И кошмар — можно ли случайно написать так, чтобы что то нарушить, например ложно увеличить счётчик


                    1. creker
                      20.08.2019 21:24

                      как происходит наблюдение и удаление обьекта в случае когда счётчик достигает нуля? И кошмар — можно ли случайно написать так, чтобы что то нарушить, например ложно увеличить счётчик

                      Если речь про ObjC/Swift, то там такое невозможно. Операции со счетчиком компилятор вставляет автоматически. Поэтому и ARC, automatic reference counting

                      Мобильные девайсы не такие уж медленные, зато у них мало памяти. Тут, я думаю, основной профит от подсчета ссылок. В памяти просто не болтается ничего лишнего. Объект если больше не нужен, то сразу удаляется. mark sweep сборщики мало того, что не сразу все удаляют, так еще требуют обычно много свободного места в куче, чтобы комфортно работать.


                      1. ybqwer
                        20.08.2019 21:31

                        Операции со счетчиком компилятор вставляет автоматически

                        куда и как вставляет? Предположим там что то вроде
                        if not obj.counter: obj.destructor()
                        В каких местах кода это прописывается, во всех где затрагивается обьект что ли?
                        А если знать место в памяти где счётчики или если они в самом обьекте, можно специально влесть туда и подменить, сделав шикарный лик памяти…


                        1. creker
                          20.08.2019 21:48

                          Да. ARC имеет определенную семантику, которой придерживается компилятор и должен понимать программист. Например, передаем объект как аргумент в функцию. Перед вызовом счетчик повышается на единицу (вставляется вызов функции retain) — функция как бы берет strong ссылку на объект. При выходе из функции счетчик уменьшается на единицу (вставляется вызов функции release). Таким образом объект гарантированно останется жив, пока функция выполняется. Есть ряд таких правил, которые позволяют полностью автоматически в режиме компиляции расставить операции со ссылками. Программист же имеет иллюзию, что управление память происходит автоматически. Естественно в коде release находится код проверки — если счетчик ушел до нуля, то объект удаляется. У объектов есть что-то типа деструктора, но компилятор его заполняет автоматически — в нем вставляются release вызовы для всех полей объекта. Работает это безусловно, объект и все его поля исчезают моментально. Вплоть до того, что объект не доживает даже до конца функции — компилятор может вставить release вызов ровно в том месте, с которого переменная более не используется.

                          Так же есть weak ссылки, которые автоматически превращаются в null, когда объект удаляется. Так циклические ссылки разруливаются и нет проблемы с dangling pointer.

                          Это конечно не все, но все остальное это детали для частных хитрых случаев. В общем все работает именно так. Если дизассемблировать код на Objc или swift, то код будет буквально усеян вызовами retain/release. Как можно представить, это далеко не бесплатно. Особенно, если учесть, что все операции со ссылками потокобезопасны. retain/release сильно полагается на атомарные инструкции.


                          1. ybqwer
                            20.08.2019 22:31

                            Работает это безусловно, объект и все его поля исчезают моментально. Вплоть до того, что объект не доживает даже до конца функции — компилятор может вставить release вызов ровно в том месте, с которого переменная более не используется.


                            А стоп, ты имеешь ввиду это разрешается в компайлтайме?

                            Тогда не настолько плачевно. А как разрулится рекурсия в компайлтайме с неизвестным количеством вложений?


                            1. creker
                              20.08.2019 22:35

                              Тогда не настолько плачевно

                              Ну как, если учесть, что retain/release вставляются пачками на каждый вызов функции, то получается накладно. Совсем не удивительно, что mark-sweep сборщик может работать намного быстрее.

                              А как разрулится рекурсия в компайлтайме с неизвестным количеством вложений?

                              А конкретнее? Не вижу проблемы.


                              1. ybqwer
                                20.08.2019 22:45

                                А конкретнее? Не вижу проблемы.

                                в компайл тайме не известно сколько вложений в рекурсию. То есть компилятор не может вставить retain/release без проверки условия что счётчик упал до нуля. И после падения до нуля, обьект удаляется но далее в теле рекурсии опять происходит эта проверка, компилятор то не знает как и что, то есть там даже
                                if obj. counter == 0 and obj.not_destroyed(): object.destroy()


                                1. creker
                                  20.08.2019 22:56

                                  Если речь про рекурсивную функцию, то компилятор вот так вставит вызовы рантайма

                                  func foo(obj: ObjectType) {
                                      swiftRetain(obj)
                                      if obj.isDone() {
                                          swiftRelease(obj)
                                          return
                                      }
                                      swiftRelease(obj)
                                  
                                      swiftRetain(obj)
                                      foo(obj)
                                      swiftRelease(obj)
                                  }
                                  
                                  let obj = ObjectType() //тут аллокация, счетчик изначально единице равен
                                  swiftRetain(obj)
                                  foo(obj)
                                  swiftRelease(obj)
                                  
                                  swiftRelease(obj) //объект больше не используется, можно удалить
                                  


                                  Опять же, в чем проблема то? Поведение этого кода полностью детерминировано, компилятору все равно, рекурсия тут или нет.


                                  1. ybqwer
                                    20.08.2019 23:00

                                    if obj.isDone() {
                                    swiftRelease(obj)
                                    return
                                    }

                                    тут нельзя return если несколько обьектов, а так да этих retain/release будет больше чем собственно других операторов =)


                                    1. creker
                                      20.08.2019 23:04

                                      Каких объектов и почему нельзя? Все можно. Этот код будет абсолютно таким же хоть сколько тут объектов, аргументов. Вставляй только retain/release для каждого. А внутреннее устройство ObjectType вообще не имеет значения.

                                      if obj.isDone() {
                                          swiftRelease(obj)
                                          return
                                      }

                                      Тут, если что, вызов release нужен, потому что isDone вызвано и нам надо быть уверенным, что на момент вызова объект будет жив. Для этого retain был, который мы тут и компенсируем.


                                      1. ybqwer
                                        20.08.2019 23:15

                                        func foo(obj: ObjectType, obj2: ObjectType,) {
                                        разве тут не:
                                        if obj.isDone() and obj2.isDone() {
                                        return
                                        }


                                        1. creker
                                          20.08.2019 23:21

                                          obj.isDone я сам добавил, чтобы было какое-то условие окончания рекурсии. От компилятора тут только swiftRetain и swiftRelease. Это лишь пример. Вот как выглядел бы оригинальный код

                                          func foo(obj: ObjectType) {
                                              if obj.isDone() {
                                                  return
                                              }
                                              foo(obj)
                                          }
                                          
                                          let obj = ObjectType()
                                          foo(obj)
                                          


                                          Это корректный Swift код. Программисту и думать не надо ни о каких ссылках.


                                          1. ybqwer
                                            20.08.2019 23:41

                                            напиши свой вариант для двух обьектов

                                            func foo(obj: ObjectType, obj2: ObjectType,)

                                            и вообще, если там массив из 10000 обьектов?


                                            1. creker
                                              20.08.2019 23:55

                                              Оригинал

                                              func foo(obj: ObjectType, obj2: ObjectType) {
                                                  if obj.isDone() {
                                                      return
                                                  }
                                              
                                                  obj2.mutate()
                                              
                                                  foo(obj: obj, obj2: obj2)
                                              }
                                              
                                              let obj = ObjectType()
                                              let obj2 = ObjectType()
                                              foo(obj: obj, obj2: obj2)
                                              


                                              После вставок компилятора
                                              func foo(obj: ObjectType, obj2: ObjectType) {
                                                  swiftRetain(obj)
                                                  if obj.isDone() {
                                                      swiftRelease(obj)
                                                      return
                                                  }
                                                  swiftRelease(obj)
                                              
                                                  swiftRetain(obj2)
                                                  obj2.mutate()
                                                  swiftRelease(obj2)
                                              
                                                  swiftRetain(obj)
                                                  swiftRetain(obj2)
                                                  foo(obj: obj, obj2: obj2)
                                                  swiftRelease(obj)
                                                  swiftRelease(obj2)
                                              }
                                              
                                              let obj = ObjectType()
                                              let obj2 = ObjectType()
                                              
                                              swiftRetain(obj)
                                              swiftRetain(obj2)
                                              foo(obj: obj, obj2: obj2)
                                              swiftRelease(obj)
                                              swiftRelease(obj2)
                                              
                                              swiftRelease(obj)
                                              swiftRelease(obj2)
                                              

                                              (поправил синтаксис вызова функций, чтобы было действительно как в Swift)

                                              и вообще, если там массив из 10000 обьектов?

                                              Тут мы начинаем лезть в дебри языка. В Swift есть структуры, и они передаются по значению, т.е. копируются. Счетчика ссылок, соответственно, у них нет. Массив в swift реализован как раз как структура.

                                              Но если мы говорим об Objective-C, то там есть реализация массива NSArray, который ссылочный тип. Количество объектов в нем не имеет значения. При передаче в функцию будет вставлен один вызов retain, чтобы увеличить счетчик ссылок экземпляра NSArray. Объектов внутри это никак не касается. Их счетчик ссылок это дело самого объекта NSArray — когда внутрь него кладешь объект, то он делает ему retain один раз и все. При удалении объекта из массива делаем один вызов release.


                                              1. ybqwer
                                                21.08.2019 01:03

                                                ага.
                                                имхо, разве корректно

                                                if obj.isDone() {
                                                swiftRelease(obj)
                                                return
                                                }

                                                если для foo(obj: obj, obj2: obj2) может быть нормально что один обьект уже null (не вызовет NPE)
                                                а если foo получает массив, для всех значений каждый раз вызывается retain/release, но для метода нормально если некоторые из значений уже null?


                                                1. creker
                                                  21.08.2019 01:33

                                                  null это сложная тема, т.к. swift это null-безопасный язык. В приведенном коде аргументы помечены так, что null они равны не могут быть. Честно, в особенности реализации я не вдавался. В ObjC все просто. Все может быть null, поэтому retain/release для них это no-op. Там есть свои особенности с null, из-за чего NPE тоже не встретишь особо, но это отдельная тема.

                                                  а если foo получает массив, для всех значений каждый раз вызывается retain/release

                                                  Если речь об NSArray, т.е. ссылочном типе, то нет, при передаче его экземпляра в функцию у его элементов retain/release не будет вызываться. Это не имеет смысла и пустая трата ресурсов.

                                                  Если речь о swift массивах, которые структуры, то работать будет явно иначе. При передаче в функцию будет создана копия массива, что скорее всего приведет к инкременту счетчика ссылок каждого элемента в нем, если эти элементы ссылочные. Я это, если честно, не проверял, но по логике должно быть именно так. Ведь у нас теперь два массива и оба имеют ссылки на одни и теже элементы. Счетчик у всех как минимум 2 должен быть.

                                                  И про копии это на самом деле семантика языка. Компилятор волен делать, что ему вздумается. Если копия не имеет смысла, то код будет оптимизирован. В godbolt это я похоже смог увидеть. У swift иммутабельность на уровне языка поддерживается, поэтому у компилятора много свободы для оптимизации.


                                                  1. ybqwer
                                                    21.08.2019 01:57

                                                    ладно, сколько головной боли и странных действий. Вместо чёткого явного выделения массива обьектов в начале действия, и явного удаления в конце.

                                                    При передаче в функцию будет создана копия массива, что скорее всего приведет к инкременту счетчика ссылок каждого элемента в нем, если эти элементы ссылочные.

                                                    запомнил — не писать тяжёлый рекурсивный алгоритм типа сортировки на Swift. Или вообще не писать слишком много требовательного… функциями


                                                    1. creker
                                                      21.08.2019 02:15

                                                      Вся эта головная боль и не нужна. Пиши и не думай об этом. Компилятор лишние копии удалит, лишние обновления счетчиков тоже. Писать код, исходя из таких предположений, это путь в никуда. Опять же, в godbolt я это действительно увидел, оптимизация копирований осуществляется.

                                                      запомнил — не писать тяжёлый рекурсивный алгоритм типа сортировки на Swift. Или вообще не писать слишком много требовательного… функциями

                                                      Запоминать это не надо, это неправильное предположение. Стоит все таки про язык еще почитать. Swift очень навороченный, но и очень интересный язык. Если речь о сортировке, то она, мало того, что уже реализована для встроенного типа массивов, а он тут дженерики использует. Так еще пользуется особенностью — специальный модификатор mutating у методов структур говорит о том, что метод будет структуру менять, а значит this/self должен быть ссылочным и мутабельным. Никаких копий, никаких проблем. По-умолчанию, методы структур не могут менять себя, для них this/self иммутабельный.

                                                      Если хочется свою сортировку написать, то для этого есть extension'ы


                                                      1. ybqwer
                                                        21.08.2019 02:52

                                                        Если речь о сортировке, то она, мало того, что уже реализована для встроенного типа массивов, а он тут дженерики использует.

                                                        ну я то не сомневаюсь что операции высокой производительности реализованы нативно, иначе б так вообще ничего не фурычило.
                                                        Запоминать это не надо, это неправильное предположение.

                                                        ну представь себе какой нибудь qsort. Рекурсивный. Каждый раз с проверками всех счётчиков.


                                                        1. creker
                                                          21.08.2019 03:20

                                                          ну представь себе какой нибудь qsort. Рекурсивный. Каждый раз с проверками всех счётчиков.

                                                          Почему? В swift есть inout модификатор аргумента, что заставит передать массив по ссылке. Никаких копирований, а значит и элементы с их счетчиками трогать не надо. Вот тебе и быстрый qsort.

                                                          ну я то не сомневаюсь что операции низкого уровня реализованы нативно, иначе б так вообще ничего не фурычило.

                                                          И реализовано оно все на самом Swift. Внутри timsort и как раз использует inout модификаторы + небезопасные указатели. Если надо, swift позволяет опуститься довольно низко.


                          1. 0xd34df00d
                            22.08.2019 21:51

                            У объектов есть что-то типа деструктора, но компилятор его заполняет автоматически — в нем вставляются release вызовы для всех полей объекта.

                            А как всё это разруливается, если для одного и того же типа кое-где escape analysis может сказать, что он вот точно будет удалён из выхода из скоупа, а где-то — нет?


                            Или нет никаких escape analysis'ов, тупо всегда фигачим подсчёт ссылок?


                            Или компилятор сохраняет достаточно семантики, чтобы после escape analysis удалить подсчёт ссылок только там, где надо?


                            1. creker
                              22.08.2019 22:06

                              Не уверен, если честно. В swift — очень может быть. Там компилятор из-за специфики языка довольно умный (достаточно почитать про инициализаторы, правильное написание которых компилятор отслеживает очень строго), могут много чего под капотом делать хитрого. Теже копии оптимизирует, несмотря на семантику структур.

                              В ObjC не похоже, чтобы анализ какой-то делался. Все предельно прямолинейно из того, что я видел. Но там и язык очень динамический и непредсказуемый. У компилятора вообще нет никакой свободы что-то делать с кодом, который оперирует объектами.

                              В конечном итоге, главное тут семантика для программиста. Что там под капотом дело уже десятое. Если у тебя есть strong ссылка, то ты уверен, что объект у тебя под ногами не исчезнет.

                              Ну и не знаю, при чем тут цитата про деструктор объекта. Объект владеет своими полями, т.е. счетчик у них как минимум равен 1, пока родитель жив. Они никогда не удалятся преждевременно.


    1. ShadowTheAge
      20.08.2019 16:30

      Потому что счетчик ссылок дороже чем GC.

      При счетчике ссылок «платится» за каждую передачу объекта (присваивание, передачу аргументом, и т.п.), тогда как при GC «платится» когда объект переживает сборку мусора (плата, правда, побольше, и зависит от того сколько внутри объекта указателей)

      Объекты присваиваются и передаются аргументом намного чаще чем переживают GC.

      Плюс, счетчик ссылок очень медленный в случае наличия больше чем одного потока.


      1. Videoman
        20.08.2019 19:49

        А можно объяснить следующую ситуацию: вот я создал объект и передал ссылку на него трем разным потокам.- разве GC не нужен атомарный счетчик для определения что объект все еще используется?


        1. creker
          20.08.2019 20:10

          Нет, GC нужно лишь знать, что значение в памяти это указатель и этот указатель в его ведении. Как он узнает что это его указатель и это вообще указатель (ведь это всего лишь число) — тут куча вариантов разных. Тегирование указателей, указатель в какую-то область попадает (например, в диапазоне адресов кучи), метаданные, создаваемые на этапе компиляции, четко говорят, что есть что (языки со сборкой встроенной обычно так и делают). Можно вообще наугад помечать, если компилятор не может никак помочь. Не страшно, если мы пометим как объект что-то, что таковым не является. mark фаза сборки как раз проходится по всем местам, где могут быть указатели, и помечает их. Как она помечает и где — тут тоже много вариантов разных.


          1. Videoman
            20.08.2019 20:24

            Не уверен что правильно вас понял. Какие указатели, обычно же у нас ссылки на объект. Вы хотите сказать что в моем случае объект не знает что на него ссылаются из трех разных мест, так?


            1. creker
              20.08.2019 20:27

              Ссылка это указатель как в С/С++, адрес в памяти. Объект не знает, что на него ссылается кто-то. Зачем ему это знать?


              1. Videoman
                20.08.2019 23:54

                Я не знаю, а просто вам задаю вопросы, в надежде что вы поможете разобраться. Я пишу на С++ и никогда не сталкиваюсь с GC, слава богу. Все что нужно понимать, что есть три точки треугольника: объем памяти — скорость выделения — скорость освобождения. Все аллокаторы будут иметь характеристики где-то в этом треугольнике. Все зависит от задачи. Также у С++ деструкторов как и у ARC есть неоспоримое преимущество — это детерминированное время освобождение неуправляемых ресурсов. В моей практике это 99% того что нужно освобождать (чисто с «сырой» памятью не работал уже давно), а если вдруг нужно, то для оставшихся 1% задач я всегда могу реализовать самый оптимальный, заточенный под нее, алгоритм аллокации. Не могли бы вы дать мне ссылку, где бы наиболее понятно и правильно описывалась «теория построения современных GC», по вашему мнению?
                Честно говоря, статья не очень помогла в этом вопросе.


                1. creker
                  21.08.2019 00:12

                  Это был риторический вопрос. Не к тому, что сам не знаю, а к тому, что зачем вы сами думаете оно может быть нужно. Потому что объекту не нужно знать, кто на него ссылается. Тут наоборот. Важно знать, куда объект ссылается, какие ссылки в его полях содержатся. По этим ссылкам сборщику мусора и ходить потом.

                  Да, детерминированность это слабое место GC, но на практике оно не имеет значения. Для всех ресурсов, для которых это важно, вроде файлов, сокетов, есть отдельные вызовы Close/Disconnect или конструкции uses/IDisposable как в C#. Если речь просто о памяти под объекты, то не важно вообще, когда оно там освободится. Иначе бы GC языки не правили миром.

                  Если пишете на С++, то тем более должно быть понятно, что ссылка на объект это всего лишь адрес в памяти. Что в С++, что C#, хоть где. Сборщику на mark фазе нужно эти адреса и найти. Смотрит он на регистры процессора, стек, поля объектов — это все просто числа в памяти. Среди них надо найти адреса и пометить у себя, что по такому адресу объект жив. Самим объектам знать ничего про это не нужно. И количество ссылок тоже считать не нужно. Сборщик мусора строит граф живых объектов, начиная от root set. В этом как раз отличие от подсчета ссылок, где об иерархии объектов ничего неизвестно. Поэтому и циклы не разрулить никак, что даже С++ касается с его shared_ptr. Если что-то в графе живых объектов отсутствует, то это смело можно удалять. Сборщикам не надо даже знать, какие там объекты не живые. Многие просто чистят память большими блоками и пофиг что там было. Проблемы создают разве что финализаторы, которые надо заранее вызвать.

                  А почитать есть одна замечательная книга. Можно сказать энциклопедия по теме сборки мусора во всех ее формах и проявлениях The Garbage Collection Handbook: The Art of Automatic Memory Management, Richard Jones, Antony Hosking, Eliot Moss www.amazon.com/dp/B01MRDA69B

                  объем памяти — скорость выделения — скорость освобождения

                  Все несколько сложнее. Это метрики аллокатора, который является всего лишь частью системы сборки мусора. Конкурентные сборщики вставляют еще барьеры так называемые (компилятор это делает) — при чтении и/или изменении какой-либо ссылки выполняет маленький кусок кода. Это нужно, чтобы параллельно работающий юзерский код и сборщик мусора не конфликтовали между собой и не нужно было stop the world делать. От этого падает throughput — ресурсы процессора банально тратятся на эти барьеры. Зато выигрывает еще одна метрика латентность — ведь если stop the world нет, то и пауз нет, которые так любят припоминать сборщикам мусора. Современные сборщик имеют жалкие микросекундные паузы, т.к. практически всю работу они делают параллельно с пользовательским кодом. В книге выше, кстати, в том числе и о таких штуках есть. Go, Java (два новых экспериментальных сборщика), C# наверное тоже — все они такое умеют нынче.


                  1. Videoman
                    21.08.2019 11:48

                    Вот теперь понятно и есть над чем поразмыслить, спасибо большое. С чем-то согласен с чем-то нет, но не суть. В настоящий момент мне интересна именно техническая сторона вопроса.


                  1. QtRoS
                    22.08.2019 19:58

                    Хм, а если по ссылке всего лишь число, то как рекурсивно искать всего его ссылки на другие объекты (числа)? Нужно же понимать структуру этого объекта, смещения полей…
                    Кстати, может быть помните — недавно была статья на Хабре, где бы странная бага в V8, и выяснилось, что сборщик мусора принял за объект какие-то данные. Не могу ее найти...


                    1. creker
                      22.08.2019 20:23

                      Как я писал парой комментов выше, есть разные способы. Но язык со сборщиком, который заложен в нем с самого начала, скорее всего просто вставит нужную информацию во время компиляции. У типа объекта будет описание всех полей. Для каждой функций будет описание содержимого стека (stack map) и может быть даже регистров (register map). Таким образом, сборщик на любом этапе знает однозначно, где указатель, а где нет. Такой сборщик называют еще precise.

                      V8 скорее всего precise сборщик имеет и для него такая бага действительно проблема. Есть conservative сборщики, которые могут использовать другие упомянутые мной механизмы. Они могут принять за объект то, что таковым не является. Но это не особо страшно, если так подумать. Просто будет в памяти мусор лежать и не освобождаться. Если писать сборщик для какого-нить С++, где помощи от компилятора никакой, указатели не выравнены, все куда зря и как хочет может указывать, то там только conservative сборщик и получится сделать.


                    1. qw1
                      22.08.2019 21:34
                      -1

                      Хм, а если по ссылке всего лишь число
                      В языках типа java/c# такого быть не может. Число будет завёрнуто в объект System.Int32 — наследника System.Object, поэтому тут будут все обычные заголовки объекта.


                      1. creker
                        22.08.2019 21:54

                        Я думаю в C# не так все просто. In32 это value тип. Без боксинга будет храниться просто его содержимое, что может как раз привести к тому, что поле класса выглядит как указатель, а на самом деле это value тип какой-нибудь.


                        1. qw1
                          23.08.2019 07:16

                          Да, ошибся. Int32 — value type.

                          Но сути не меняет. Насколько я знаю, синтаксически нельзя изобразить в объекте ссылку на Value Type. Можно ссылку на Object, и тут будет боксинг, и ссылка будет на объект с заголовком, а не на просто число.


                          1. creker
                            23.08.2019 11:37

                            Не, я про другое. Вот сравним два варианта. В первом, класс имеет поле типа long. Во втором, класс имеет поле типа object. Без метаинформации мы не сможем различить эти два варианта — поле типа long может содержать число, которое является корректными указателем в куче, хотя это всего лишь совпадение. Сборщик мусора не может просто пойти по указателю и посмотреть, что там лежит. Тут или все таки читать метаинформацию и разбираться, какого типа поле, или считать любое похоже число за указатель от греха подальше.


                1. 0xd34df00d
                  22.08.2019 21:55

                  Также у С++ деструкторов как и у ARC есть неоспоримое преимущество — это детерминированное время освобождение неуправляемых ресурсов.

                  Но вы не знаете, когда именно этот деструктор вызовется.


                  Если у вас объект, скажем, завёрнут в shared_ptr, и где-то рядом есть ещё один умный указатель на него, то при смерти вашего указателя ничего не произойдёт. А вот если других указателей нет, то тут удалится ваш объект уже.


                  1. Videoman
                    23.08.2019 10:46

                    Ну это же не деструктор объекта, а деструктор shared_ptr. Вам не кажется что это и есть желаемое поведение в этом случае. Все что мне нужно знать, что если у меня есть N указателей shared_ptr на один и тот же объект и я их все разрушу, после этой точки у меня гарантированно разрушится сам объект. Все, больше мне ничего не нужно знать. Shared_ptr специально спроектирован для случаев когда неизвестен момент разрушения объекта. Вот заставить GC детерминировано вызывать деструкторы невозможно, иначе, как я понял, придется все же хранить счетчик ссылок, а это удар по производительности.


                    1. creker
                      23.08.2019 11:30

                      Не то что даже хранить счетчик придется. mark-sweep сборщики не работают постоянно. Разные метрики используют, но как один из вариантов это прирост размера кучи на какое-то определенное число. Только тогда запускается цикл сборки, который в современных сборщиках будет работать параллельно с кодом какое-то время. Если код будет активно выделять память и менять ссылки (присваивать ссылочным переменным какие-то значения), то это может еще более удлинить цикл сборки, т.к. на каждое такое изменение выполняется барьер, который подкидывает сборщику в соседнем потоке новую работу. Так и получается, что понять, когда объект реально из памяти исчезнет, невозможно. Если куча не меняет свой размер существенно, то сборщик может вообще не запускаться.

                      Проблему детерминированности зато решает IDisposable паттерн в C#. Чрезвычайно удобная и полезная штука.


                      1. Videoman
                        23.08.2019 15:16

                        Только тогда запускается цикл сборки, который в современных сборщиках будет работать параллельно с кодом какое-то время.
                        Можно еще вопрос: а как она производится параллельно, если, например, уплотняется память и все ссылки начинают плыть? Что-то тут противоречит логике.
                        Проблему детерминированности зато решает IDisposable паттерн в C#.
                        Разговор об автоматическом вызове, а IDisposable это какой-то эразц, уж простите. Я вообще не пойму как строить архитектуру с таким подходом. Вот например был у меня класс Object и в нем не было управляемых ресурсов. Пришли месяцы, проект разросся и вдруг этому классу понадобился IDisposable. Как такое решается на практике: везде где используется Object вставляется using и везде где используются классы которые используют Object и т.д., или как?