Исходя из C++, я просто пытаюсь лучше понять модель памяти в безопасных языках, таких как C#.
Представьте себе следующее:
Мой вопрос:
На самом деле я ожидаю, что C# никогда не будет аварийно завершать работу или иметь неопределенное поведение, даже если не используются механизмы синхронизации.
Не попадайтесь в ловушку поиска соответствий поведения между языками. Очистите свой разум и начните с нуля, прочитав, как ведет себя новый язык, снизу вверх. Управление жизненным циклом в .Net принципиально отличается: ссылочная семантика + сборка мусора (потокобезопасная). Но не ожидайте, что все будет безопасно
Связанный: Требуется ли блокировка при замене ссылки на переменную в многопоточном приложении?
Кроме того, в C# нет глобальных переменных.
Пожалуйста, не обращайте внимания на два длинных бессвязных ответа. Тео лучший.
@Enigmativity под «глобальным» ОП, вероятно, означает public static.
@TheodorZoulias - Да, согласен. Это техническая сторона.
@Enigmativity также ОП исходит из мира C++, так что, вероятно, длинный и полный информации об оборудовании ответ — это именно то, что они надеялись получить. :-)





Даже если поток, создавший объект, перед записью глобального указателя убедился, что этот объект полностью создан, этого недостаточно, чтобы полностью гарантировать, что читатель увидит его именно таким. Этот вопрос требует от нас рассмотреть семантику вычислительного оборудования, в частности модель памяти.
Но это только начало: поток записи может убедиться, что он завершил создание объекта, прежде чем обновлять глобальный указатель, чтобы он указывал на этот новый объект. Поскольку язык управляет компилятором (или, другими словами, компилятор создается для языка), нет необходимости беспокоиться об изменении порядка компилятором. Именно по этой причине это не разрешено: перед обновлением глобального указателя он должен выдать код, в котором объект полностью создан.
Но есть то, что кажется значительной дырой. Если поток чтения считывает глобальный указатель и видит новое значение, как он гарантирует, что, следуя по указателю до данных объекта, он увидит самую свежую память, а не старые и недействительные данные? Я считаю, что в этом суть вашего вопроса. Это особенно актуально для архитектур со слабой моделью памяти.
С архитектурой x86/x64 здесь проблем не возникнет, поскольку этого не может произойти. Это потому, что у него сильная модель памяти. А в архитектуре модели строгой памяти store не может передавать store, а load не может передавать load, одно из которых должно произойти, чтобы было видно несоответствие.
Однако у машины со слабой архитектурой памяти возникнут проблемы.
По сути, из-за этого C# не может работать на слабой архитектуре модели памяти. К счастью, такой архитектуры больше не существует. Все машины, которые мы считаем слабой моделью памяти (например, ARM), на самом деле представляют собой слабую модель памяти с упорядочиванием зависимостей данных.
Какая слабая модель памяти с упорядочением зависимостей данных гарантирует, что если поток следует за указателем, то указанная память будет по крайней мере такой же новой, как и память, в которой был указатель. Это именно то, что нужно, чтобы закрыть дыру.
Смотрите https://preshing.com/20120930/weak-vs-strong-memory-models/
в котором упоминается:
Эти семейства имеют модели памяти, которые во многих отношениях почти так же слабы, как модели Alpha, за исключением одной общей детали, представляющей особый интерес для программистов: они поддерживают порядок зависимостей данных. Что это значит? Это означает, что если вы пишете A->B на C/C++, вы всегда гарантированно загружаете значение B, которое, по крайней мере, столь же новое, как и значение A.
(Очевидно, что другим вариантом было бы для C# иметь какие-то примитивы синхронизации практически везде при воздействии на глобальные/общие переменные, но очевидно, что это выглядит как плохой выбор, учитывая, сколько накладных расходов это добавит).
Это ужасный ответ, полный подробностей, которые просто не нужны для ответа на вопрос. Это долго и запутанно.
Как C# гарантирует, что программа не выйдет из строя из-за того, что поток B прочитает не полностью инициализированный объект? (Если он дает такую гарантию, что, я полагаю, так и есть).
‼Это не так. Если вы читаете и записываете в общее расположение из нескольких потоков без синхронизации, ваша программа ошибочна и ее поведение неопределенно.¹
Для многопоточности начального уровня рекомендуемый способ обеспечения корректности вашей программы — использовать оператор lock каждый раз, когда вы взаимодействуете с общим состоянием вашей программы, используя один и тот же объект шкафчика. Оператор lock вставляет соответствующие барьеры памяти при получении и освобождении блокировщика, так что потоки, которые последовательно взаимодействуют с общим состоянием, имеют единообразное представление о нем.
Для расширенной многопоточности (низкая блокировка/без блокировки) вы можете использовать ключевое слово Volatile или класс Volatile , чтобы обновить общее поле и быть уверенным, что все потоки увидят полностью инициализированные объекты. Однако многопоточность без блокировок считается очень сложной.
¹ From the perspective of a code reviewer who goes by the ECMA specification of the C# language, prioritizes correctness and portability, and is willing to make zero assumptions about hardware and CLR implementation.
Я не думаю, что это верно в данном случае. Рассмотрим foo = new List() - конструктор new List() наверняка запускается до того, как имя объекта [адрес] будет присвоено имени foo.
@AKX не будь так уверен. Компилятору C# и .NET Jitter разрешено изменять порядок инструкций, если это изменение не оказывает побочного эффекта на однопоточную программу. В вашем примере вполне допустимо, чтобы назначение foo произошло до завершения инициализации List<T>, при условии, что foo не было объявлено как volatile.
Я не уверен, что смогу полностью ответить на ваш вопрос (потому что я считаю, что в том, чего вы не понимаете, есть некоторые недостающие детали, которые вы не сформулировали вслух). И все же позвольте мне попробовать.
Первое и самое главное — хотя любой современный компилятор действительно агрессивно меняет порядок инструкций программы (хотя и аппаратное обеспечение делает это «во время выполнения» — независимо от того, что делал компилятор), он делает это, сохраняя определенные инварианты. В зависимости от конкретных инвариантов (и их точных определений) получаются существенно разные модели памяти (которые в настоящее время классифицируются как слабые и вообще сильные). Итак, вы не говорите о переупорядочении как таковом; вы говорите о переупорядочении, допускаемом конкретной моделью памяти - и у каждого языка могут быть свои собственные (а у некоторых их вообще нет - по крайней мере, они не формализованы должным образом; на самом деле это было в случае с C++ до #include stdatomic.h).
Существует множество способов определения модели памяти. Разумеется, наибольшая точность достижима только математическими средствами. Помимо математики, существуют и несколько менее понятные (по крайней мере, на мой взгляд) определения, такие как следующие:
Модель памяти C# позволяет переупорядочивать операции с памятью в методе при условии, что поведение однопоточного выполнения не изменится.
Это демонстрирует типичный подход, общий для многих языков программирования: что бы ни было переупорядочено, это не должно влиять на однопоточное выполнение программы (и может - часто будет - совершенно нормально влиять на многопоточное выполнение). Это сразу же приводит нас к пониманию того, что ваш первоначальный вопрос не совсем полон (поскольку вы рассуждаете о том, что может наблюдать многопоточная программа, а не однопоточная - и это не имеет смысла в C#: вам нужно добавить больше контекста к вопросу, что и делает).
В общем случае, чтобы сохранить желаемый инвариант относительно многопоточного выполнения, нужно иметь дело с тем, что предлагает #include stdatomic.h. Спасения нет. Необходимо объявить _Atomic ячейки памяти и читать/записывать в них/из них, используя соответствующий API (который компилятор должен учитывать, избегая множества «вредных» переупорядочений и оставляя только безвредные - если они вообще есть для конкретной программы и оборудования).
C# делает это немного по-другому:
Спецификация C# ECMA гарантирует, что следующие типы будут записаны атомарно: ссылочные типы, bool, char, byte, sbyte, short, ushort, uint, int и float. Значения других типов, включая типы значений, определяемые пользователем, могут быть записаны в память за несколько атомарных операций записи. В результате поток чтения мог наблюдать разорванное значение, состоящее из кусков разного значения.
В частности, это означает, что var foo = new Foo(whatever, else, it, does, not, really, matter) должен: 1) гарантировать любым действительным недокументированным аппаратно-совместимым способом, что Foo полностью инициализирован (со всеми разрешенными переупорядочениями; например, мы не знаем, как whatever, else, it, does, not Аргументы , really, matter будут записаны как переменные private внутри его конструктора - это может произойти буквально в любом порядке, потому что любой из них будет работать одинаково по отношению к однопоточным reads); 2) атомарно поменять var foo и указать на начало только что созданного и инициализированного new Foo. Последний бит — атомарность свопа — в конечном итоге гарантируется аппаратным обеспечением, и для другого оборудования потребуются отдельные инструкции (или инструкции), чтобы обеспечить такой уровень уверенности.
Больше сказать. Типичный (разумный) способ опубликовать глобальную переменную в C# (и во многих других языках, вплоть до старого доброго C) — пометить ее как static. Компиляторы, конечно, общеизвестно чувствительны к таким маркерам - по многим причинам, включая сохранение гарантий модели памяти, которые они должны реализовывать и поддерживать. Таким образом, на данном этапе вас не должен удивлять следующий факт:
Другой способ безопасно опубликовать значение в нескольких потоках — записать значение в статическое поле в статическом инициализаторе или статическом конструкторе.
Это безопасно именно потому, что любой современный компилятор будет относиться к static иначе, чем к не-static.
P.S. Я ссылаюсь на довольно устаревшую документацию, написанную для .NET Framework. С тех пор появился достойный .NET Core. Тем не менее, мне не известно о каких-либо изменениях в модели памяти, реализованной обоими — она не могла измениться хотя бы потому, что точно такая же кодовая база, которая нормально работала под Framework, должна работать (и, по-видимому, действительно работает!) точно так же и под Core. время выполнения и его гарантии.
П.П.С. Я предлагаю не изучать этот предмет, следуя документации C++ по stdatomic.h и его внутреннему устройству. Как и в C#, им не хватает формализма и четких определений, что делает практически невозможным правильно понять предмет для новичка. Научные круги разработали довольно мощные и гораздо более ясные теоретические модели, с которыми можно справиться за счет изучения логики и математических обозначений, чтобы иметь возможность анализировать их определения.
Это ужасный ответ, полный подробностей, которые просто не нужны для ответа на вопрос. Это долго и запутанно.
Это намеренно сделано так, чтобы было понятно все. Смотрите краткие ответы ниже или просто дайте свой собственный :)
Объект будет полностью создан до того, как ссылка будет обновлена для ссылки на него, поэтому код никогда не сможет обнаружить незавершенный объект.