Комментарии 42
После прочтения первой статьи у меня появились некоторые вопросы к ссылочной модели языка.
Я уже задавал в предыдущей статье, но к сожалению так и не понял причину (необходимость) выделения константных ссылок в отдельный класс, так как в реализации они ни чем не отличаются "обычных не владеющих ссылок".
Может быть сейчас поясните, зачем нужно выделять ссылки на константные объекты в отдельный тип?
В Аргентуме нет константных объектов. В аргентуме есть неизменяемые объекты. У неизменяемого объекта есть ограниченный временем жизненный цикл - создали, заполнили данными, сделали неизменяемым, попользовались в разных иерархиях, удалили за ненадобностью. Для того, чтобы удалять ненужные неизменяемые объекты (точнее учитывать, что они все еще кому-то нужны) и существуют агрегатные*T
ссылки.
На уровне синтаксиса в Аргентуме есть константы, но это понятие ортогонально неизменяемым объектам.
У меня наверно не получается правильно сформулировать вопрос, т.к. вы опять рассказываете, как у вас сделаны ссылки на неизменяемые объекты.
Я же спрашиваю, зачем выделять ссылки на неизменяемые объекты в отдельную категорию с отдельным синтаксисом, если они практически идентичны обычным не владеющим ссылкам?
Ответ на вопрос "зачем": "Для того, чтобы удалять ненужные неизменяемые объекты (точнее учитывать, что они все еще кому-то нужны) и существуют агрегатные*T
ссылки. "
*T
-ссылка означает: Вот этот объект я пока держу, пока не удайте его, он мне нужен. можете шарить этот объект с другими местами в программе, где он тоже нужен, его можно шарить, т.к. я обязуюсь его не изменять.
&T
-ссылка означает: Вот этим объектом я буду пользоваться иногда, пока его не удалят те кто его держит.
Приведите пример, в котором по вашему мнению вместо шарено-владеющей ссылки *T
можно применить невладеющую ссылку &T
.
Если я вас правильно понял, то &Т ссылка НЕ увеличивает счетчик ссылок (является слабой), но при захвате может изменять объект, тогда как *Т ссылка увеличивает счетчик ссылок (т.е является сильной, но не может изменять объект).
И если это так, то для *Т ссылок вы постулируете отсутствие зацикленности вложенных ссылок только тем, что объект не изменяемый?
Я все правильно понял?
Все так.
Спасибо, теперь со ссылками разобрался.
Но появился другой вопрос, Каким образом неизменяемость объекта обеспечивает отсутствие зацикленных ссылок?
Разве объект не может содержать ссылку на точно такой-же, но не изменяемый объект?
И если может, то что мешает в этом поле указать ссылку на самого себя? Ведь при определении объекта это же можно сделать?
При определении объекта этого объекта еще нет. В инициализаторе поля this не доступен. После создания объекта он изменяемый. Все ссылки на него - временные стековые T
, которые нельзя присваиваеть его *T
полям. А если его заморозить оператором *
, который вернет на него *T
ссылку, ты получишь объект, который нельзя изменять. Поэтому объект не может ссылаться сам на себя ничем, кроме не-владеющей &T
ссылки.
А что происходит в таком сценарии?
один поток создаёт объект
передаёт слабую ссылку другому потоку
другой поток разыменова её и пошёл работать
в этот момент первый поток очистил память
Там где-то неявно запирания вставляются?
Ссылка на объект из другого потока (все равно откуда ее получили) разыменуется как null. Чтобы получить объект по этой ссылке нужно или послать ей асинхронный таск, или передать ее в тот поток, где находится ее таргет, и там разыменовать.
У меня есть ссылка на объект, как получить из него данные? Данные мне нужны в моём потоке, а не в том, который владеет объектом. И перехватывать владение мне не надо.
Если ты хочешь передать из потока в поток какие-то данные, просто передай этот объект, вместо ссылки на него. Это дешево, это не копирование.
Если у тебя есть какой-то объект, данные которого нужны одновременно множеству потоков, сделай этот объект незименяемым, и передай *-ссылку на него всем желающим.
Если этот объект изменяемый, каждый читатель состояния должен видеть непротиворечивое состяние этого объекта и всех его подобъектов. Нужна версионность. Пусть на каждое изменение порождает неизменяемый снапшот, ссылку на который этот объект выдает другим потокам по требованию.
Если важно, чтобы объект жил в одном потоке, а использовался из другого, ак микросервис, нужно продумать API запросов-тасков к этому объекту.
Если нужно "прям тут" наколхозить вытаскивание текущего значения поля из объекта другого потока, это можно сделать посылкой асинхронного таска, вытаскиващего одно поле, с асинхронным коллбэком.
Кто и когда освободит память этого неизменяемого объекта?
Ну и, кстати, делать снепшот большого словаря слишком дорого как и гонять туда-сюда таски для каждого поля.
Кто и когда освободит память этого неизменяемого объекта?
Этим занимаются агрегатные *-ссылки.
Ну и, кстати, делать снепшот большого словаря слишком дорого как и гонять туда-сюда таски для каждого поля.
Поэтому я предложил множество решений, какие-то подойдут для одних случаев, какие-то для других.
И вообще, многопоточность - для CPU-bounded задач.
Она используется для разбиения задачи на части и передачи их воркерам. В этом случае нужно однократно передать задачу, принять задачу и иногда обеспечить совместный доступ воркеров к общему неизменяемому состоянию.
Второе применение многопоточности - микросервисная модель. Когда логгеру, графической сцене, сетевой подсистеме и другим сервисам, у которых есть свое внутреннее состояние, передаются очереди запросов.
Оба сценария использования многопоточности поддержаны ссылочной моделью Аргентума нативно. Если в вашем приложении есть какой-то другой стиль использования потоков, буду признателен, если вы опишете его, и тогда мы сможем устроить мозговой штурм, чтобы выработать хорошее решение.
Конечно есть, модель пол. Сейчас уточню. У вас описана модель пуш, поток посылает асинхронный таск в mpsc очередь другого потока, тот таск исполняет и таким же образом посылает результат первому потоку в его очередь. Пол работает наоборот. У меня есть поток который генерирует таски и кладет их в СВОЮ spmc очередь, а другие потоки забирают таски на обработку из этой очереди, а результат кладут в свою очередь, первый(хотя чаще ещё какой то) поток периодически опрашивает очереди рабочих потоков на предмет результата.
Если кажется что разницы, нет то это только кажется. В модели пуш сбалансировать нагрузку потоков нетривиальная задача, а в модели пол это происходит автоматически.
Спасибо, очень верное замечание. У воркеров (потоков без состояния) должна быть общая очередь для балансирования нагрузки.
Общая очередь это деталь реализации тред пула, проблема у вас в языке, чтобы что то кому то послать нужно знать адресата, а в модели пол у нас нет адресата, поэтому она (согласно изложенному в статье) на данный момент не реализуема. Это архитектурное ограничение вашего языка.
Мне кажется что ваша модель памяти в общем и целом разумна, но многопоточность не вписывается сюда. Я бы вам предложил скрыть многопоточность также как и в модели памяти указатели и следовать бест практисам. Т.е. я говорю о поддержке корутин вместо потоков.
Конкретно в вашей модели памяти операция лок для ассоциативной ссылки должна возвращать не опшионал стэковой ссылки, а корутину которая возвращает стэковою ссылку.
авэйт на такой корутине спрячет пересылку таска в поток владелец, заморозку объекта и отправку его назад в поток который лочит ссылку, это то как вы это видите сейчас. С точки зрения плюсовика это конечно очень много накладных расходов, но в целом рабочий вариант. Но есть альтернатива которая прекрасно работает под той же абстракцией корутин, юзер мод средства синхронизации. На самом деле нет вселенской необходимости слать асинхронный таск обрабатывать его и так же слать ответ просто что бы получить доступ к данным, можно заложить объект сделать глубокую копию и разложить обьект, копию отдать тому кто лочит ассоциативную ссылку. Это намного эффективнее чем асинхронные таски, но требует корутин, т.к. лок ассоциативной ссылки может привести к юзер мод блокировке.
Мой поинт в том что ваша модель неполна без корутин и поэтому накладывает очень серьезные архитектурные ограничения.
У вас все строится на остовании того, что в доменной модели есть абсолютный корень. Но это же не так. Если у системы есть несколько бизнес процессов, то для каждого можно попытаться выделить свой локальный корень и то не факт что один.
Корень модели данных приложения - это объект какого-то класса. Все локальные корни подсистем и бизнес-процессов могут быть полями в это классе. Ну не в глобальные же переменные их складывать в XXI веке.
Обычная бухгалтерская программа:
кадровик - корень сотрудник
склад - корни товар и оветственное лицо
зарплата - корень табельный номер
и все они ссылаются друг на друга да еще и есть и организация и департаменты и счета
никакого общего класса тут не может быть
PS: Ваше предложение создать один класс для корня называется "god object" и является антипаттерном.
Кажется мы друг друга не поняли. Один класс корня называется "Application" и в нем есть под-объекты: система логгирования, модель данных, UI, стейт-менеджер, messaging. Где-то в модели данных есть подобъекты: локальный кеш, persistent storage, политики репликации и вытеснения. Где-то в локальном кеше будут когда надо появлятся и пропадать сотрудники, товары и прочие мелкие части модели данных. А "god object" - это совсем про другое.
Если предположить, что вы имеете в виду не корневой объект в модели данных приложения, о которой идет речь в статье, а корень иерархии наследования классов, то правило единого корня дейтствует во всех managed-системах - в C#, Java, JS, Python и многих других. Все объекты независимо от их полезной нагрузки имеют общее поведение, используемое в управляемой среде исполнения, и это поведение заключено в корневом классе.
Я правильно понимаю, что при ассоциации ассоциативная ссылка - это внутри ссылка на поле класса + ссылка на счётчик ссылок класса (для отслеживания времени жизни)?
Можно поподробнее рассказать про счётчики внутри объектов? Компилятор для всех композитов их генерирует? Или дело обстоит несколько хитрее?
Отсутствие изменяемости при разделяемости - это минус языка. Иногда такая возможность необходима. А чтобы не было гонок, нужно использовать какие-либо примитивы синхронизации. Как Mutex в Rust, например.
В целом у меня вызывает некий скепсис подход с реализацией различных моделей владения/ссылок на уровне языка. По моему опыту, различных подходов может быть весьма много и всё в язык тащить будет невозможно. Посему лучше реализовывать те же подходы на уровне библиотеки.
В Ü, например, я реализовал несколько видов библиотечных контейнеров:
shared_ptr_final - разделяемый указатель на неизменяемые данные. Внутри есть счётчик ссылок.
shared_ptr - разделяемый указатель на изменяемые данные. Внутри есть счётчик ссылок и счётчик доступа. Доступ осуществляется через lock объект. Программа убивается, если обнаруживается, что на объект создано более одной ссылки на изменение, или ссылка на изменение при наличии ссылок на чтение.
shared_ptr_mt_final - аналог shared_ptr_final для многопоточного использования. Счётчик атомарен.
shared_ptr_mt - аналог shared_ptr для многопоточного использования. Внутри вместо lock объекта примитив синхронизации rwlock. Вместо аварийного завершения программы при нарушении правил доступа тут происходит блокировка, возможно даже мёртвая.
С многопоточностью Ü обходится достаточно хитро. Типы, которые под капотом что-то там потоконебезопасно меняют (тт же shared_ptr), помечаются специальным тегом. При агрегации этот тег наследуются. И при попытке передать объект типа с наличием этого тега в другой поток компилятор породит ошибку.
Я тоже пришел к очень похожим выводам, что модель распределения памяти получается не полная и как минимум не учитывает необходимость синхронизации доступа к объектам из разных потоков + привязка к деталям реализации аргентума.
Хотя и с вами не совсем согласен, в частности насчет реализации модели владения на уровне библиотеки.
Поддержка данной функциональности на уровне языка позволяет диагностировать ошибки уже во время компиляции приложения, что затруднительно сделать в случае использования библиотечных функций.
Поэтому не смотря на некоторый скепсис к конкретной реализации, сама по себе идея реализовать принципы владения объектами на уровне синтаксиса языка для исключения накладных расходов из-за GC очень заманчивая.
Не понял - а почему реализация на уровне библиотеки не позволяет обнаруживать ошибки во время компиляции?
не "не позволяет", а "затрудняет".
И вы сами писали:
Программа убивается, если обнаруживается, что на объект создано более одной ссылки на изменение, или ссылка на изменение при наличии ссылок на чтение.
Вместо аварийного завершения программы при нарушении правил доступа тут происходит блокировка, возможно даже мёртвая.
Что означает, что часть проверок происходит во время выполнения, а не при компиляции.
Теперь понял. Да, shared_ptr-типы имеют внутри себя проверки времени выполнения. Примерно так же, как и какой-нибудь RecCell в Rust. И просто взять и сделать эти проверки статическими невозможно по своей сути.
Но shared_ptr - штука весьма нишевая. В моём языке Ü кроме этого есть и статические проверки. Почитать о том, как это работает, можно в документации.
И просто взять и сделать эти проверки статическими невозможно по своей сути.
Как раз это и имелось в виду, что реализация модели управления памятью и доступом на уровне синтаксиса языка позволит (в теории) выполнять данные проверки не во время выполнения, а во время компиляции или по крайней мере, большую часть из них.
Сейчас уже и так большая часть проверок (что по ссылке выше) происходят во время компиляции. А что касается shared_ptr-подобных классов, они по самой своей сути используются там, где статически проверить что-то не возможно.
Взгляните хоть на пример Rust. Уж там то было бы всё статично, если это было бы теоретически возможно. Но нет, возможно это не всегда, потому то и существует RefCell.
они по самой своей сути используются там, где статически проверить что-то не возможно
Это из-за модели управления памятью. В С++ она отсутствует (точнее используется ручное управление), а в Rust реализовано в виде библиотечных вызовов. Поэтому ни тот, ни другой язык не показатель.
А вот Аргентум попробовал реализовать некую модель управления памятью на уровне синтаксиса. И хоть у него тоже не все гладко, но видно, что в этой идее есть потенциал.
ручное управление
Заблуждение, которое может исходить только от того, кто на C++ толком не писал. Нету там ничего ручного - всё разруливается конструкторами/деструкторами библиотечных контейнеров.
А вот Аргентум попробовал реализовать некую модель управления памятью на уровне синтаксиса
Ну пока что рано говорить, реализовал ли, ибо язык находится только в начали пути развития. И пока что видны существенные проблемы, вроде негибкости и чрезмерных затрат памяти и лишних инструкций во времени исполнения.
Что-то вы скатываетесь в какой-то деструктив. На С/С++ я пишу уже третий десяток лет, так что ваше предположение мимо.
Конструкторы/деструкторы, в какой то мере автоматизирует управление памятью, но библиотечные контейнеры и шаблоны, это и есть ручное управление,только в красивой обертке.
Что же касается Аргентума, пусть он и на начальном пути развития, да с корявой, и не полной моделью управления память, которая имеет существенные проблемы, но но её уже реализовал.
Внутреннее устройство объектов и счетчиков будет постоянно изменяться.
Сейчас в каждом объекте есть три служебных поля - ссылка на класс, счетчик стековых ссылок +1, если есть композитная ссылка или для неизменяемых объектов - счетчик агрератных ссылок и признак многопоточности, указатель на партент-объект (он поддерживается автоматически и доступен приложениям) или указатель на weak-block.
Для объектов, на которые есть ассоциативные weak-ссылки, дополнительно аллоцирован weak-block, в нем есть указатель на таргет, счетчик &-ссылок и флаг многопоточности, идентификатор треда таргета, эвакуированный из таргета указатель на партент-объект.
Это что выходит, у каждого агрегата ещё в нагрузку идут служебные поля? А не жирновато ли?
Смотря с чем сравнивать. Например в C++ у каждого объекта есть vmt_ptrs на каждую независимую базу + offsets на каждую виртуальную базу. А std::shared_ptr - это два машинных слова + каждый объект, на который он указывает должен иметь отдельную динамически аллоцированную структуру со счетчиками. Так что все относительно.
Указатели на таблицу виртуальных функций существуют только у полиморфных классов (как минимум с одним виртуальным методом). Большинство классов не такие.
shared_ptr же тоже используется не часто, посему затраты на счётчики ссылок внутри него терпимы. В Argentum же, насколько я понимаю, у всех объектов есть подобные накладные расходы, как в какой-нибудь (прости Господи) Java.
Разработчик этого языка также обещает «memory safety without a borrow checker, generations, reference counting, or tracing GC», и утверждает, что его подход возможно реализовать в C++.
Какие преимущества у вашего языка по сравнению с подходом Vale?
Язык val.
Исходники тут: https://github.com/val-lang/val
Сайт проекта тут: https://www.val-lang.dev/
Ни так ти там я не нашел ничего, что позволило бы понять, как будут организованы структуры данных в хипе. Все про стек. Вся работа основывается на Swift standard library, значит будет в хипе будет все как в Swift.
Язык val.Это другой язык. :)(:
Сайт проекта тут: www.val-lang.devВот цитата непосредственно оттуда:
Our goals overlap substantially with that of Rust and other commendable efforts, such as Zig or Vale.Собственно в этой цитате есть ссылка на сайт языка Vale.
[А ещё есть Vala. Это три различных языка программирования! (Val, Vala и Vale)]
Действительно, другой язык, спасибо. Давайте сравним. Для ассоциации vale использует generational references, которые просто убивают приложение, если встречается ссылка на удаленный объект. Плюс нет возможности защитить объект от удаления на время использования. Для композиции vale использует особый указатель с move-семантикой. Нет защиты от закольцованной композиции - владения самим собой. Агрегация не поддержана вовсе.
Как эксперт по плюсам хочу обозначить несколько неточностей с вашей стороны
С++ был спроектирован в эпоху однопоточности и с учётом этого важного контекста он обсалютно прекрасно поддерживает композицию. Дефолтный выбор для реализации композиции это родительский объект владеет дочерним по значению. Ассоциативная ссылка как и у вас это вик поинтер, агрегатная ссылка это забыл точное название из буста, смарт поинтер на объект со внутренним счётчиком ссылок. Как так, спросите вы, композит же это значение как создать вик поинтер на значение? Да очень просто рутовый объект должен реализовывать шаред фром зис, а все дочерние объекты используют его счётчик при создании ассоциативной ссылки. Это та редко используемая специализация конструктора стд шаред поинтер о которой мало кто знает и совсем мало кто понимает как это работает и зачем это нужно. Вот именно для этого оно и нужно.
У вас есть единственное разумное возражение - полиморфизм. Да в таком случае композит это шаред поинтер, а ассоциативная ссылка на него вик поинтер, но есть альтернатива известная ещё с античных(си) времён - полиморфизм на вариантах или полиморфизм в функциональном стиле. Это когда рут объект имеет по значению правильно выравненный достаточного (для хранения любого возможного полиморфного типа) размера массив байт + дескриминатор. Полиморфные объекты размещаются внутри этого массива через инплэйс нью.
Глубокое копирование при использовании значений для агрегатов идёт почти из коробки, да ассоциативные ссылки внутри одного дерева объектов нужно поправить руками в конструкторе/присваивании после копирования.
Что я хочу сказать, с++ позволяет сделать все по уму почти из коробки, да как это делать правильно и не ошибаться понимают мало людей, но ваше заявление что в плюсах это всегда русская рулетка основано на недостаточных знаниях о с++.
Да, многопоточность в плюсах это отдельная боль и все становится намного сложнее когда объекты используются в разных потоках, тут я соглашусь что без специальных смарт поинтеров могут возникать не иллюзорные проблемы удаления объектов не в тех потоках где надо.
Реализация ссылочной модели в языке программирования Аргентум