C ++ 11 представил стандартизированную модель памяти, но что именно это означает? И как это повлияет на программирование на C ++?

Эта статья (от Гэвина Кларка , цитирующий Herb Sutter ), говорит, что:

Модель памяти означает, что код C ++ теперь имеет стандартизированную библиотеку для вызова независимо от того, кто создал компилятор и на какой платформе он работает. Есть стандартный способ контролировать, как разные потоки взаимодействуют с памятью процессора.

"Когда вы говорите о разделении [код] в разных ядрах, в стандарте мы говорим о модель памяти. Мы собираемся оптимизировать его, не нарушая Следуя предположениям, люди собираются сделать это в коде ", - сказал Саттер .

Что ж, я могу запомнить этот и аналогичные параграфы, доступные в Интернете (поскольку у меня была собственная модель памяти с рождения: P), и даже могу публиковать сообщения в качестве ответа на вопросы, заданные другими, но если честно , Я не совсем понимаю это.

Программисты на C ++ использовали для разработки многопоточных приложений еще раньше, так какое это имеет значение, потоки это POSIX, потоки Windows или потоки C ++ 11? Каковы преимущества? Я хочу понять мелкие детали.

У меня также возникает ощущение, что модель памяти C ++ 11 каким-то образом связана с поддержкой многопоточности C ++ 11, поскольку я часто вижу их вместе. Если да, то как именно? Почему они должны быть связаны?

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

2079
Nawaz 12 Июн 2011 в 03:30

8 ответов

Лучший ответ

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

Спецификация C ++ не ссылается на какой-либо конкретный компилятор, операционную систему или процессор. Он ссылается на абстрактную машину , которая является обобщением реальных систем. В мире Language Lawyer программист должен писать код для абстрактной машины; задача компилятора - реализовать этот код на конкретной машине. Жестко кодируя спецификацию, вы можете быть уверены, что ваш код будет компилироваться и запускаться без изменений в любой системе с совместимым компилятором C ++, будь то сегодня или через 50 лет.

Абстрактная машина в спецификации C ++ 98 / C ++ 03 в основном однопоточная. Таким образом, невозможно написать многопоточный код C ++, который был бы «полностью переносимым» в соответствии со спецификацией. В спецификации даже ничего не говорится об атомарности загрузки и сохранения памяти или порядке , в котором могут происходить загрузки и сохранения, не говоря уже о таких вещах, как мьютексы.

Конечно, вы можете писать многопоточный код на практике для конкретных конкретных систем, таких как pthreads или Windows. Но не существует стандартного способа написания многопоточного кода для C ++ 98 / C ++ 03.

Абстрактная машина в C ++ 11 спроектирована как многопоточная. У него также есть четко определенная модель памяти ; то есть, он говорит, что компилятор может и что не может делать, когда дело касается доступа к памяти.

Рассмотрим следующий пример, где к паре глобальных переменных одновременно обращаются два потока:

           Global
           int x, y;

Thread 1            Thread 2
x = 17;             cout << y << " ";
y = 37;             cout << x << endl;

Что может выводить Thread 2?

В C ++ 98 / C ++ 03 это даже не неопределенное поведение; сам вопрос бессмысленен , потому что стандарт не предусматривает ничего, называемого «потоком».

В C ++ 11 результатом будет Undefined Behavior, потому что загрузка и сохранение не обязательно должны быть атомарными в целом. Что может показаться незначительным улучшением ... Да и само по себе это не так.

Но с C ++ 11 вы можете написать это:

           Global
           atomic<int> x, y;

Thread 1                 Thread 2
x.store(17);             cout << y.load() << " ";
y.store(37);             cout << x.load() << endl;

Теперь все становится намного интереснее. Прежде всего, здесь определено поведение. Поток 2 теперь может печатать 0 0 (если он выполняется перед потоком 1), 37 17 (если он выполняется после потока 1) или 0 17 (если он выполняется после того, как поток 1 присваивает x, но до того, как он присваивается y).

Он не может печатать 37 0, потому что режим по умолчанию для атомарных загрузок / сохранений в C ++ 11 заключается в обеспечении последовательной согласованности . Это просто означает, что все загрузки и сохранения должны быть «как если бы» они происходили в том порядке, в котором вы их записали в каждом потоке, в то время как операции между потоками могут чередоваться, как угодно системе. Таким образом, поведение атомики по умолчанию обеспечивает как атомарность , так и упорядочивание для загрузки и сохранения.

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

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_relaxed);   cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed);   cout << x.load(memory_order_relaxed) << endl;

Чем современнее ЦП, тем больше вероятность, что он будет быстрее, чем в предыдущем примере.

Наконец, если вам просто нужно содержать в порядке определенные грузы и магазины, вы можете написать:

           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_release);   cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release);   cout << x.load(memory_order_acquire) << endl;

Это возвращает нас к упорядоченным загрузкам и хранению - так что 37 0 больше не является возможным выходом - но делает это с минимальными накладными расходами. (В этом тривиальном примере результат такой же, как при полномасштабной последовательной согласованности; в более крупной программе этого не было бы.)

Конечно, если вы хотите видеть только выходные данные 0 0 или 37 17, вы можете просто обернуть мьютекс вокруг исходного кода. Но если вы дочитали до этого места, держу пари, вы уже знаете, как это работает, и этот ответ уже длиннее, чем я предполагал :-).

Итак, итоги. Мьютексы великолепны, и C ++ 11 стандартизирует их. Но иногда по соображениям производительности вам нужны примитивы более низкого уровня (например, классический дважды проверенный шаблон блокировки). Новый стандарт предоставляет высокоуровневые гаджеты, такие как мьютексы и условные переменные, а также предоставляет низкоуровневые гаджеты, такие как атомарные типы и различные разновидности барьеров памяти. Итак, теперь вы можете писать сложные, высокопроизводительные параллельные подпрограммы полностью на языке, указанном в стандарте, и вы можете быть уверены, что ваш код будет компилироваться и работать без изменений как в сегодняшних системах, так и в будущих.

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

Подробнее об этом см. в этом сообщении в блоге < / а>.

2375
Nemo 16 Июн 2021 в 23:31

C и C ++ раньше определялись трассировкой выполнения хорошо сформированной программы.

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

Это означает, что эти определения языка не имеют никакого смысла, поскольку это не логический метод для смешивания этих двух подходов. В частности, разрушение мьютекса или атомарной переменной четко не определено.

-6
curiousguy 28 Июл 2019 в 20:09

Приведенные выше ответы касаются наиболее фундаментальных аспектов модели памяти C ++. На практике, большинство случаев использования std::atomic<> «просто работает», по крайней мере, до тех пор, пока программист не произведет чрезмерную оптимизацию (например, пытается расслабить слишком много вещей).

Есть одно место, где ошибки все еще распространены: блокировки последовательности . На странице имеется отличное и легко читаемое обсуждение проблем. https://www.hpl.hp.com/techreports/2012/HPL-2012-68.pdf. Блокировки последовательностей привлекательны тем, что читатель избегает записи в слово блокировки. Следующий код основан на рисунке 1 вышеупомянутого технического отчета и подчеркивает проблемы при реализации блокировок последовательности в C ++:

atomic<uint64_t> seq; // seqlock representation
int data1, data2;     // this data will be protected by seq

T reader() {
    int r1, r2;
    unsigned seq0, seq1;
    while (true) {
        seq0 = seq;
        r1 = data1; // INCORRECT! Data Race!
        r2 = data2; // INCORRECT!
        seq1 = seq;

        // if the lock didn't change while I was reading, and
        // the lock wasn't held while I was reading, then my
        // reads should be valid
        if (seq0 == seq1 && !(seq0 & 1))
            break;
    }
    use(r1, r2);
}

void writer(int new_data1, int new_data2) {
    unsigned seq0 = seq;
    while (true) {
        if ((!(seq0 & 1)) && seq.compare_exchange_weak(seq0, seq0 + 1))
            break; // atomically moving the lock from even to odd is an acquire
    }
    data1 = new_data1;
    data2 = new_data2;
    seq = seq0 + 2; // release the lock by increasing its value to even
}

Как бы неинтуитивно это ни казалось поначалу, data1 и data2 должны быть atomic<>. Если они не являются атомарными, то они могут быть прочитаны (в reader()) в то же время, что и записаны (в writer()). Согласно модели памяти C ++, это гонка , даже если reader() на самом деле никогда не использует данные . Кроме того, если они не являются атомарными, компилятор может кэшировать первое чтение каждого значения в регистре. Очевидно, вы этого не хотите ... вы хотите перечитывать на каждой итерации цикла while в reader().

Также недостаточно сделать их atomic<> и получить к ним доступ с помощью memory_order_relaxed. Причина этого в том, что операции чтения seq (в reader()) имеют только семантику получения . Проще говоря, если X и Y - обращения к памяти, X предшествует Y, X не является получением или освобождением, а Y - получением, то компилятор может переупорядочить Y перед X. Если Y было вторым чтением seq, а X было чтение данных, такое переупорядочение нарушило бы реализацию блокировки.

В статье дается несколько решений. Наилучшей производительностью сегодня, вероятно, является тот, который использует atomic_thread_fence с memory_order_relaxed до второго чтения seqlock. В документе это рисунок 6. Я не воспроизводю здесь код, потому что любой, кто дочитал до этого места, действительно должен прочитать статью. Он более точный и полный, чем этот пост.

Последняя проблема заключается в том, что было бы неестественно делать переменные data атомарными. Если вы не можете этого сделать в своем коде, вам нужно быть очень осторожным, потому что приведение из неатомарного в атомарный разрешено только для примитивных типов. В C ++ 20 предполагается добавить atomic_ref<>, который упростит решение этой проблемы.

Подводя итог: даже если вы думаете, что понимаете модель памяти C ++, вы должны быть очень осторожны перед установкой собственных блокировок последовательности.

6
Mike Spear 20 Дек 2019 в 03:56

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

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

Раньше атомики выполнялись с использованием встроенных функций компилятора или какой-либо библиотеки более высокого уровня. Заборы были бы выполнены с использованием инструкций, специфичных для процессора (барьеры памяти).

30
ninjalj 11 Июн 2011 в 23:49

Для языков, не определяющих модель памяти, вы пишете код для языка и модели памяти, определенной архитектурой процессора. Процессор может изменить порядок обращений к памяти для повышения производительности. Итак, если ваша программа имеет гонку данных (гонка данных - это когда несколько ядер / гиперпотоков могут одновременно обращаться к одной и той же памяти), тогда ваша программа не является кроссплатформенной из-за ее зависимости от модель памяти процессора. Вы можете обратиться к руководствам по программному обеспечению Intel или AMD, чтобы узнать, как процессоры могут изменять порядок доступа к памяти.

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

Интересно, что компиляторы Microsoft для C ++ имеют семантику получения / выпуска для volatile, которая является расширением C ++ для решения проблемы отсутствия модели памяти в C ++ http://msdn.microsoft.com/en-us/library/12a04hfd (v = vs.80) .aspx. Однако, учитывая, что Windows работает только на x86 / x64, это мало что говорит (модели памяти Intel и AMD позволяют легко и эффективно реализовать семантику получения / выпуска на языке).

59
Peter Mortensen 5 Ноя 2017 в 23:09

Это означает, что стандарт теперь определяет многопоточность и определяет, что происходит в контексте нескольких потоков. Конечно, люди использовали разные реализации, но это все равно, что спрашивать, почему у нас должен быть std::string, когда мы все можем использовать домашний класс string.

Когда вы говорите о потоках POSIX или Windows, это немного иллюзия, поскольку на самом деле вы говорите о потоках x86, поскольку это аппаратная функция, выполняемая одновременно. Модель памяти C ++ 0x дает гарантии, используете ли вы x86, ARM или MIPS или еще что нибудь придумать можно.

78
Peter Mortensen 5 Ноя 2017 в 23:06

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

Херб Саттер сделал трехчасовой доклад о модели памяти C ++ 11 под названием «Атомное <> оружие», доступный на сайте Channel9 - часть 1 и часть 2. Доклад носит довольно технический характер и охватывает следующие темы:

  1. Оптимизация, гонки и модель памяти
  2. Заказ - Что: приобретение и выпуск
  3. Порядок - как: мьютексы, атомики и / или ограждения
  4. Другие ограничения для компиляторов и оборудования
  5. Генерация кода и производительность: x86 / x64, IA64, POWER, ARM
  6. Расслабленная атомика

В докладе подробно рассматривается не API, а его рассуждения, предыстория, скрытые и негласные (знаете ли вы, что расслабленная семантика была добавлена ​​к стандарту только потому, что POWER и ARM не поддерживают эффективно синхронизированную загрузку?).

129
Peter Mortensen 5 Ноя 2017 в 23:17

Я просто приведу аналогию, с которой я понимаю модели согласованности памяти (или, для краткости, модели памяти). Он вдохновлен основополагающей статьей Лесли Лэмпорта «Время, часы и порядок событий в распределенной Система ". Эта аналогия уместна и имеет фундаментальное значение, но для многих может показаться излишней. Однако я надеюсь, что это дает мысленный образ (графическое представление), который облегчает рассуждения о моделях согласованности памяти.

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

Цитата из «Учебник по согласованности памяти и согласованности кеша»

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

Этот порядок глобальной памяти может варьироваться от одного запуска программы к другому и может быть неизвестен заранее. Характерной особенностью SC является набор горизонтальных срезов на диаграмме адрес-пространство-время, представляющих плоскости одновременности (то есть образы памяти). На данном плане все его события (или значения памяти) одновременны. Существует понятие абсолютного времени , в котором все потоки соглашаются, какие значения памяти являются одновременными. В SC в каждый момент времени существует только один образ памяти, совместно используемый всеми потоками. То есть в каждый момент времени все процессоры согласовывают образ памяти (то есть совокупное содержимое памяти). Это означает не только то, что все потоки просматривают одну и ту же последовательность значений для всех ячеек памяти, но также и то, что все процессоры наблюдают одни и те же комбинации значений всех переменных. Это то же самое, что сказать, что все операции с памятью (во всех ячейках памяти) наблюдаются в одном и том же общем порядке всеми потоками.

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

[Изображение из Википедии] Изображение из Википедии

Читатели, знакомые с специальной теорией относительности Эйнштейна, заметят то, о чем я говорю. Перевод слов Минковского в область моделей памяти: адресное пространство и время - это тени адресного пространства-времени. В этом случае каждый наблюдатель (т. Е. Поток) будет проецировать тени событий (т. Е. Хранилища / загрузки памяти) на свою собственную мировую линию (т. Е. Свою ось времени) и свою собственную плоскость одновременности (ось его адресного пространства). . Потоки в модели памяти C ++ 11 соответствуют < sizesabservers, которые движутся относительно друг друга в специальной теории относительности. Последовательная согласованность соответствует галилееву пространству-времени (то есть все наблюдатели согласны с одним абсолютным порядком событий и глобальным чувством одновременности).

Сходство между моделями памяти и специальной теорией относительности проистекает из того факта, что обе модели определяют частично упорядоченный набор событий, часто называемый причинным набором. Некоторые события (например, хранилища памяти) могут влиять на другие события (но не зависеть от них). Поток C ++ 11 (или наблюдатель в физике) - это не более чем цепочка (т. Е. Полностью упорядоченный набор) событий (например, загрузка и сохранение памяти по возможным различным адресам).

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

В модели памяти C ++ 11 аналогичный механизм (модель согласованности «получение-выпуск») используется для установления этих локальных причинно-следственных связей .

Чтобы дать определение согласованности памяти и мотивацию отказа от SC, я процитирую "Учебник о согласованности памяти и согласованности кеша »

Для машины с общей памятью модель согласованности памяти определяет архитектурно видимое поведение ее системы памяти. Критерий корректности для одного ядра процессора разделяет поведение между « одним правильным результатом » и « множеством неправильных альтернатив ». Это связано с тем, что архитектура процессора требует, чтобы выполнение потока преобразовывало заданное входное состояние в одно четко определенное выходное состояние, даже если ядро ​​вышло из строя. Однако модели согласованности с общей памятью относятся к загрузке и сохранению нескольких потоков и обычно позволяют много правильных выполнений , запрещая при этом много (больше) неправильных. Возможность множественного правильного выполнения связана с тем, что ISA позволяет нескольким потокам выполняться одновременно, часто с множеством возможных законных чередований инструкций из разных потоков.

В основе моделей согласованности памяти <▪Relaxed или < sizes (слабая) лежит тот факт, что упорядочивание памяти в большинстве сильных моделей не является необходимым. Если поток обновляет десять элементов данных, а затем флаг синхронизации, программисты обычно не заботятся о том, обновляются ли элементы данных по порядку относительно друг друга, а только о том, что все элементы данных обновляются до обновления флага (обычно реализуется с помощью инструкций FENCE ). Расслабленные модели стремятся уловить эту повышенную гибкость упорядочивания и сохранить только те порядки, которые программисты « требуют », чтобы получить как более высокую производительность, так и корректность SC. Например, в определенных архитектурах буферы записи FIFO используются каждым ядром для хранения результатов зафиксированных (исключенных) хранилищ перед записью результатов в кеши. Эта оптимизация увеличивает производительность, но нарушает SC. Буфер записи скрывает задержку обслуживания промаха сохранения. Поскольку магазины распространены, возможность избежать задержек в большинстве из них является важным преимуществом. Для одноядерного процессора буфер записи можно сделать архитектурно невидимым, гарантируя, что загрузка по адресу A возвращает значение самого последнего хранилища в A, даже если одно или несколько хранилищ в A находятся в буфере записи. Обычно это делается либо путем обхода значения самого последнего хранилища в А до загрузки из А, где «самое последнее» определяется порядком программы, либо путем остановки загрузки А, если хранилище в А находится в буфере записи. . При использовании нескольких ядер у каждого будет свой буфер записи обхода. Без буферов записи аппаратное обеспечение является SC, но с буферами записи это не так, что делает буферы записи архитектурно видимыми в многоядерном процессоре.

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

Поскольку согласованность кеша и согласованность памяти иногда путают, поучительно также иметь эту цитату:

В отличие от согласованности, согласованность кеша не видна программному обеспечению и не требуется. Coherence стремится сделать кеши системы с общей памятью такими же функционально невидимыми, как кеши в одноядерной системе. Правильная согласованность гарантирует, что программист не сможет определить, есть ли в системе кеши и где, путем анализа результатов загрузки и сохранения. Это связано с тем, что правильная согласованность гарантирует, что кеши никогда не активируют новое или иное < 1x функциональное поведение (программисты могут по-прежнему иметь возможность вывести вероятную структуру кеша, используя тайминги < / strong> информация). Основная цель протоколов согласования кэша - поддержание инвариантности «одна запись-несколько-считыватель» (SWMR) для каждой области памяти. Важное различие между согласованностью и согласованностью заключается в том, что согласованность указывается для на основе местоположения в памяти , тогда как согласованность указывается в отношении всех ячеек памяти.

Продолжая нашу мысленную картину, инвариант SWMR соответствует физическому требованию, чтобы в одном месте находилась не более одной частицы, но в любом месте могло быть неограниченное количество наблюдателей.

368
Community 20 Июн 2020 в 09:12