Pull to refresh

Comments 77

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

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

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

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

        resource2.Dispose();
        resource1.Dispose();

        isDisposed = true;
    }

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

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

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

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

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

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

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

Конечно элементарно. Тогда почему такой код работает не так как ожидалось если не раскомментировать строки (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
Потому что паттерн про ресурсы, а не про счетчики.

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

Вопрос интересный, так как логически ни финализатор (~A()), ни барьер не должны менять поведения в данном случая.


  1. Финализатор не вызывает Dispose. Фактически, Dispose и финализатор не связаны ничем, кроме рекомендаций и здравого смысла.
  2. MemoryBarrier не должен влиять, так как count объявлена как volatile и JIT сам добавит нужные инструкции.

Видимо, причина различия в поведении на практике в следующем. Когда происходит создание объекта с финализатором на управляемой куче, он должен быть поставлен в очередь финализации. Поскольку надо выполнить больше действий, то больше вероятность, что прерывание выполнения потока из-за Thread.Abort произойдёт не между выполнением конструктора и Dispose, а где-то ещё. Можно в этом убедиться, немного изменив цикл и вывод в вашем примере:


int i;
for (i = 0; i < 200 && count == 0; 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}, iter={1}, i={2}", count, iter, i);

У меня в варианте с раскомментироваными строками (1) и (2) точно так же count становится не 0, просто это происходит не каждый раз, а спустя 3-20 итераций.

Тут всё просто обычная гонка. Дело в том что using Abort не безопасный. И если Abort вызывается внутри using то Dispose этому объекту не вызывают.
В C# есть механизм запрета на Abort
try {} finally { abort_safe_code(); }
Но в C# на это забили и using этот механизм не использует.
Вместо этого просто пишут в документации «The Thread.Abort method should be used with caution»
А барьер и деструктор просто смещают точку гонки, т.к. они довольно длительные операции.
Но в C# на это забили и using этот механизм не использует.

using (ResourceType resource = expression) statement эквивалентен следующему (если ResourceType — ссылочный тип и не dynamic):


{
    ResourceType resource = expression;
    try {
        statement;
    }
    finally {
        if (resource != null) ((IDisposable)resource).Dispose();
    }
}

Это требование спецификации языка, и это выполняется, если выполнение дошло до finally, то Dispose выполняется полностью.


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

Барьер — согласен. Финализатор же вызывается асинхронно сборщиком мусора, и смещение происходит не за счёт вызова как такового, а за счёт постановки объекта в очередь финализации. То есть это эффект не на уровне C# или IL, а на уровне среды выполнения, и ещё не факт, что будет проявляться во всех реализациях .NET.

Что мешало в спецификации языка using (ResourceType resource = expression) statement выглядеть так?
{
    ResourceType resource = null;
    try {
        try {} finally { resource=expression; }
        statement;
    }
    finally {
        if (resource != null) ((IDisposable)resource).Dispose();
    }
}
expression может вычисляться неопределённо долго.

Типичный
using (var file = new StreamReader("\\NetworkShare...
И что — это повод делать дырявый using.
Кстати если приложение запросило не существующий сетевой путь — это один из способов запретить закрытие приложения, ибо будет висеть до потери пульса.
Дырявость тут в первую очередь в Thread.Abort. Не удивительно, что в .net core этот метод просто не поддерживается. Его не сделать безопасным и предсказуемым. Таже самая история c TerminateThread, где можно вообще систему повесить, но там уже поздно удалять, о чем команда МС жалеет даже, что повелась на просьбы такое добавить когда-то.

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

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

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

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

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

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

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

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

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

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


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

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


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

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

Хм, не думаю что такой взгляд наилучший для любой задачи. Если копнуть глубже, стоит задуматься с какой стати вообще одни обьекты исчезают в то время как другие живут долго, и почему
Обычно, они освобождаются в хаотичном порядке
. И вообще не лучше ли представить обьекты как структуры примитивных величин (массивы, списки, хеш таблица) и определить весь функционал векторной обработки обьектов неким аналогом Numpy а вышеупомянутый подход годится для небольшой кучи непредсказуемых обьектов.
Вот жеж полезла смотреть, что значит «эгоисточно»)
А я не понимаю, почему не делают подход как в питоне, когда все на умных указателях, а то, что не удалось почистить умными указателями, чистится gc?
а как работают умные указатели?
Потому что у всего есть свои недостатки. Подсчет ссылок это такая же сборка мусора, но сильно примитивная. Циклы не умеет чистить, поэтому туда добавляют mark sweep или еще чего. Подсчет ссылок может быть довольно дорог в плане необходимости постоянно обновлять счетчик, что требует хитро его упаковывать, синхронизировать между потоками, бороться с промахами кэша процессора и т.д. и т.п. Так же удаление объектов может давать непредсказуемые задержки из-за рекурсивности этой процедуры — большое дерево объектов может удаляться довольно долго. Естественно для всего есть свои хитрые способы оптимизации (вроде упаковки счетчиков в общую какую-то структуру, отложенное удаление объектов), но в конечном счете может быть проще полностью перейти на GC.
Но тем не менее нигде кроме питона это реализовано не было (или я не знаю). Неужели все настолько плохо?
В swift автоматический подсчет ссылок, но циклические ссылки самому надо искать и исправлять средствами языка. Я не видел конкретных исследований, которые бы пролили свет на такую малую популярность — неужели недостатки действительно настолько непреодолимые. Есть только народная мудрость, что подсчет ссылок это обычно медленно, все эти атомарные счетчики постоянно обновлять, кэш промахи ловить, и единичные примеры, подтверждающие это. Как недавний проект по реализации user-space сетевого драйвера на разных языках, где Go и C# в пух и прах уделали swift. Причиной назвали как раз подсчет ссылок.
действительно, представь оверхед на доступ к значению в памяти где число ссылок, инкремент или ещё что там делают. Где нибудь вообще делают инлайн обьектов с превращением в обычные массивы?
Сорь, не понял коммент. Переформулируй.
какой момент не понятен. Насколько это я себе примерно представляю, при доступе к памяти грузится вся страница, которая будет грузиться при доступе к одной переменной счётчика. А инлайн обьектов это вроде Structure of Arrays с векторным выделением/удалением памяти под обьекты чтобы не считать для каждого отдельно. Так можно если очень мало обьектов и на производительность вообще забили.
которая будет грузиться при доступе к одной переменной счётчика

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

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

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

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

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

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

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

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


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

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

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

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

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

в компайл тайме не известно сколько вложений в рекурсию. То есть компилятор не может вставить retain/release без проверки условия что счётчик упал до нуля. И после падения до нуля, обьект удаляется но далее в теле рекурсии опять происходит эта проверка, компилятор то не знает как и что, то есть там даже
if obj. counter == 0 and obj.not_destroyed(): object.destroy()
Если речь про рекурсивную функцию, то компилятор вот так вставит вызовы рантайма
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) //объект больше не используется, можно удалить


Опять же, в чем проблема то? Поведение этого кода полностью детерминировано, компилятору все равно, рекурсия тут или нет.
if obj.isDone() {
swiftRelease(obj)
return
}

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

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

Тут, если что, вызов release нужен, потому что isDone вызвано и нам надо быть уверенным, что на момент вызова объект будет жив. Для этого retain был, который мы тут и компенсируем.
obj.isDone я сам добавил, чтобы было какое-то условие окончания рекурсии. От компилятора тут только swiftRetain и swiftRelease. Это лишь пример. Вот как выглядел бы оригинальный код
func foo(obj: ObjectType) {
    if obj.isDone() {
        return
    }
    foo(obj)
}

let obj = ObjectType()
foo(obj)


Это корректный Swift код. Программисту и думать не надо ни о каких ссылках.
напиши свой вариант для двух обьектов
func foo(obj: ObjectType, obj2: ObjectType,)

и вообще, если там массив из 10000 обьектов?
Оригинал
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.
ага.
имхо, разве корректно
if obj.isDone() {
swiftRelease(obj)
return
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

И реализовано оно все на самом Swift. Внутри timsort и как раз использует inout модификаторы + небезопасные указатели. Если надо, swift позволяет опуститься довольно низко.
UFO just landed and posted this here
Не уверен, если честно. В swift — очень может быть. Там компилятор из-за специфики языка довольно умный (достаточно почитать про инициализаторы, правильное написание которых компилятор отслеживает очень строго), могут много чего под капотом делать хитрого. Теже копии оптимизирует, несмотря на семантику структур.

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

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

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

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

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

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

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

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

Да, детерминированность это слабое место 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# наверное тоже — все они такое умеют нынче.
Вот теперь понятно и есть над чем поразмыслить, спасибо большое. С чем-то согласен с чем-то нет, но не суть. В настоящий момент мне интересна именно техническая сторона вопроса.

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

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

V8 скорее всего precise сборщик имеет и для него такая бага действительно проблема. Есть conservative сборщики, которые могут использовать другие упомянутые мной механизмы. Они могут принять за объект то, что таковым не является. Но это не особо страшно, если так подумать. Просто будет в памяти мусор лежать и не освобождаться. Если писать сборщик для какого-нить С++, где помощи от компилятора никакой, указатели не выравнены, все куда зря и как хочет может указывать, то там только conservative сборщик и получится сделать.
Хм, а если по ссылке всего лишь число
В языках типа java/c# такого быть не может. Число будет завёрнуто в объект System.Int32 — наследника System.Object, поэтому тут будут все обычные заголовки объекта.
Я думаю в C# не так все просто. In32 это value тип. Без боксинга будет храниться просто его содержимое, что может как раз привести к тому, что поле класса выглядит как указатель, а на самом деле это value тип какой-нибудь.
Да, ошибся. Int32 — value type.

Но сути не меняет. Насколько я знаю, синтаксически нельзя изобразить в объекте ссылку на Value Type. Можно ссылку на Object, и тут будет боксинг, и ссылка будет на объект с заголовком, а не на просто число.
Не, я про другое. Вот сравним два варианта. В первом, класс имеет поле типа long. Во втором, класс имеет поле типа object. Без метаинформации мы не сможем различить эти два варианта — поле типа long может содержать число, которое является корректными указателем в куче, хотя это всего лишь совпадение. Сборщик мусора не может просто пойти по указателю и посмотреть, что там лежит. Тут или все таки читать метаинформацию и разбираться, какого типа поле, или считать любое похоже число за указатель от греха подальше.
UFO just landed and posted this here
Ну это же не деструктор объекта, а деструктор shared_ptr. Вам не кажется что это и есть желаемое поведение в этом случае. Все что мне нужно знать, что если у меня есть N указателей shared_ptr на один и тот же объект и я их все разрушу, после этой точки у меня гарантированно разрушится сам объект. Все, больше мне ничего не нужно знать. Shared_ptr специально спроектирован для случаев когда неизвестен момент разрушения объекта. Вот заставить GC детерминировано вызывать деструкторы невозможно, иначе, как я понял, придется все же хранить счетчик ссылок, а это удар по производительности.
Не то что даже хранить счетчик придется. mark-sweep сборщики не работают постоянно. Разные метрики используют, но как один из вариантов это прирост размера кучи на какое-то определенное число. Только тогда запускается цикл сборки, который в современных сборщиках будет работать параллельно с кодом какое-то время. Если код будет активно выделять память и менять ссылки (присваивать ссылочным переменным какие-то значения), то это может еще более удлинить цикл сборки, т.к. на каждое такое изменение выполняется барьер, который подкидывает сборщику в соседнем потоке новую работу. Так и получается, что понять, когда объект реально из памяти исчезнет, невозможно. Если куча не меняет свой размер существенно, то сборщик может вообще не запускаться.

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

Для этого барьеры (если что, барьер в GC и барьер в многопоточном коде это совершенно разные вещи), в том числе, и нужны, чтобы ссылки обновлялись корректно в условиях перемещения объектов. Одна из идей это резервировать в начале объекта поле для forwarding pointer, который будет указывать на новое местоположение объекта, если он перемещен. Там много хитростей и тут лучше податься в упомянутую книжку или посмотреть презентации про конкретные реализации. Про shenandoah в Java есть хороший доклад на русском даже. Он как раз умеет параллельно с пользовательским кодом и помечать, и перемещать объекты.

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

Пример явно притянутый за уши. Не то что на практике, мне такая проблема даже в голову не приходила. Не говоря о том, что IDisposable нужен довольно редко. Я бы даже сказал, что это не техническая проблема, а проблема организации самого процесса разработки.
А так, да. Везде, где используется класс, нужно или вставить using, если это возможно, либо реализовать у использующего класса IDisposable. Рекомендация такова, что если у класса есть поле, реализующее IDisposable, то твой класс тоже должен реализовать этот интерфейс, чтобы дернуть Dispose у полей. Но это всего лишь рекомендация, т.к. сборщик мусора в любом случае все почистит. Главный вопрос, важно ли это делать конкретно здесь и сейчас, что встречается крайне редко.
Пример явно притянутый за уши.
Ну уж ладно вам, ну какие уши. Вот мы и имеем ситуацию когда управляемые языки съедают все ресурсы системы за милую душу.
Не то что на практике, мне такая проблема даже в голову не приходила.
Это говорит лишь о сложности решаемых проблем.
Не говоря о том, что IDisposable нужен довольно редко. Я бы даже сказал, что это не техническая проблема, а проблема организации самого процесса разработки.
Понятия не имею, т.к. очень мало приходилось программировать на управляемых языках.
А так, да. Везде, где используется класс, нужно или вставить using, если это возможно, либо реализовать у использующего класса IDisposable.
Ну это вот и является большим недостатком для той ниши применения где используется С++, где все ресурсы нужно беречь и освобождать их как можно быстрее, как только они перестали быть нужны. Потом using решает только часть проблем, которые помогает решить автоматический детерминированный вызов деструктора.
Есть еще куча парных вызовов: Open/Close, Lock/Unlock, Acquire/Release, и т.д. которые также автоматизируются в случае исключения или ошибки.
В С++ я один раз проектирую класс. В какое окружение не помести этот класс, в случае чего он гарантированно очистит всю свою память и ресурсы. Если я меняю класс, то должен контролировать только его код. Мне не нужно бегать по коду и в зависимости от изменения логики руками вставлять/убирать using и finally.
Вот мы и имеем ситуацию когда управляемые языки съедают все ресурсы системы за милую душу.

Язык здесь практически не при чем. Сборщик мусора — может быть, он сам по себе требует больше памяти чисто для комфортной работы себе. Хром вон на С++ написан, а все ресурсы тоже съедает. Так уж получилось, что именно на управляемых языках, а не с++, пишут огромные энтерпрайзы с фреймворками на миллионы строк. Сложно в таких условиях использование памяти контролировать. С++ тут тоже будет жрать непомерно.

Это говорит лишь о сложности решаемых проблем.

Какой сложности? IDisposable паттерн это базовое понятие языка. Он встречается повсеместно в .Net. Такой проблемы просто нет на практике, вот и все.

Ну это вот и является большим недостатком для той ниши применения где используется С++, где все ресурсы нужно беречь и освобождать их как можно быстрее, как только они перестали быть нужны

Эта ниша чрезвычайно мала даже для С++ и там уже больше С используют.

Есть еще куча парных вызовов: Open/Close, Lock/Unlock, Acquire/Release, и т.д. которые также автоматизируются в случае исключения или ошибки

Все это намного проще решается в C# в силу того, что сам язык намного проще и предсказуемый. Есть using, lock, async/await, finally и еще куча всего. Опять же, все эти проблемы притянуты за уши и на практике чрезвычайно редко хотя как-то заставляют о них задумываться, хоть я и постоянно используют сокеты, файлы, таймеры и все то, что IDisposable требует и не терпит отсрочивания освобождения ресурсов.

В С++ я один раз проектирую класс.

С++ ничем выгодно не отличается в этом плане.

В какое окружение не помести этот класс, в случае чего он гарантированно очистит всю свою память и ресурсы

Я более чем уверен, что очень много кода в мире совершенно не exception-safe на С++. В том числе такой код скорее всего есть и у вас. И это реальная проблема, про которую много написано. IDisposable же просто есть, с ним все просто и понятно.

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

Язык здесь не при чем.

Мне не нужно бегать по коду и в зависимости от изменения логики руками вставлять/убирать using и finally.

Мне тоже не нужно, потому что ваш пример надуманный.
Главное не понять, а помнить эти тонкости до следующего идиотского собеседования! ;)
Sign up to leave a comment.