Подтвердить что ты не робот

Безопасно "одалживать" блок памяти другому потоку в C, не допуская "одновременного доступа",

Проблема

Я хочу выделить память в одном потоке и безопасно "одарить" указатель на другой поток, чтобы он мог читать эту память.

Я использую язык высокого уровня, который переводится в C. Язык высокого уровня имеет потоки (нестандартного API потоковой передачи, поскольку он кросс-платформенный - см. Ниже) и поддерживает стандартные примитивы C с несколькими потоками, такие как атомный обмен-обмен, но он не документирован (примеры использования не используются). Сложности этого языка высокого уровня:

  • Каждый поток выполняет бесконечный цикл обработки событий.
  • Каждый поток имеет свою собственную локальную кучу, управляемую некоторым пользовательским распределителем.
  • Каждый поток имеет одну "входную" очередь сообщений, которая может содержать сообщения из любого количества других потоков.
  • Очереди передачи сообщений:
    • Для сообщений фиксированного типа
    • Использование копирования

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

  • Сообщение (либо запрос, либо ответ) может либо хранить "полезную нагрузку" inline (скопированный, фиксированный лимит на общий размер значений), либо указатель на данные в кучке отправителя
  • Содержимое сообщения (данные в куче отправителя) принадлежит потоку отправки (выделяет и освобождает)
  • Принятый поток отправляет ack в отправляющий поток, когда они сделаны с содержимым сообщения.
  • "Посылающие" потоки не должны изменять содержимое сообщения после отправки, до получения (ack).
  • Никогда не должно быть одновременного доступа к чтению на записываемой памяти до того, как запись будет выполнена. Это должно быть гарантировано рабочим потоком очереди сообщений.

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


соображения о переносимости

Поскольку мой язык высокого уровня должен быть кросс-платформенным, мне нужен ответ для работы:

  • Linux, MacOS и, при необходимости, Android и iOS
    • с использованием примитивов pthreads для блокировки очередей сообщений: pthread_mutex_init и pthread_mutex_lock + pthread_mutex_unlock
  • Окна
    • с использованием объектов критической секции для блокировки очередей сообщений: InitializeCriticalSection и EnterCriticalSection + LeaveCriticalSection

Если это помогает, я предполагаю следующие архитектуры:

  • Архитектура ПК Intel/AMD для Windows/Linux/MacOS (?).
  • Неизвестный (ARM?) для iOS и Android

И используя следующие компиляторы (вы можете предполагать "последнюю" версию всех из них):

  • MSVC в Windows
  • clang on Linux
  • Xcode В MacOS/iOS
  • CodeWorks для Android на Android

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


Попытка решения

Вот мой предполагаемый рабочий поток:

  • Прочитайте все сообщения из очереди, пока он не будет пустым (только если он был полностью пуст).
  • Назовите здесь "забор памяти"?
  • Прочитайте содержимое сообщений (цель указателей в сообщениях) и обработайте сообщения.
    • Если сообщение является "запросом", оно может обрабатываться, а новые сообщения буферизуются как "ответы".
    • Если сообщение является "ответом", содержимое сообщения исходного "запроса" может быть освобождено (неявный запрос "ack" ).
    • Если сообщение является "ответом", и оно содержит указатель на "ответный контент" (вместо "встроенного ответа" ), то также необходимо отправить "ответ-акс".
  • Назовите здесь "забор памяти"?
  • Отправлять все буферизованные сообщения в соответствующие очереди сообщений.

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

static pointer p = null
static mutex m = ...
static thread_A_buffer = malloc(...)

Thread-A:
  do:
    // Send pointer to data
    int index = findFreeIndex(thread_A_buffer)
    // Assume different value (not 42) every time
    thread_A_buffer[index] = 42
    // Call some "memory fence" here (after writing, before sending)?
    lock(m)
    p = &(thread_A_buffer[index])
    signal()
    unlock(m)
    // wait for processing
    // in reality, would wait for a second signal...
    pointer p_a = null
    do:
      // sleep
      lock(m)
      p_a = p
      unlock(m)
    while (p_a != null)
    // Free data
    thread_A_buffer[index] = 0
    freeIndex(thread_A_buffer, index)
  while true

Thread-B:
  while true:
    // wait for data
    pointer p_b = null
    while (p_b == null)
      lock(m)
      wait()
      p_b = p
      unlock(m)
    // Call some "memory fence" here (after receiving, before reading)?
    // process data
    print *p_b
    // say we are done
    lock(m)
    p = null
    // in reality, would send a second signal...
    unlock(m)

Будет ли это решение работать? Реформируя вопрос, делает ли Thread-B "42"? Всегда, на всех рассмотренных платформах и ОС (pthreads и Windows CS)? Или мне нужно добавить другие примитивы потоков, такие как заграждения памяти?


Research

Я потратил часы, глядя на многие связанные вопросы SO, и прочитал некоторые статьи, но я все еще не совсем уверен. Основываясь на комментарии @Art, мне, вероятно, ничего не нужно делать. Я полагаю, что это основано на этом заявлении от стандарта POSIX, 4.12 Синхронизация памяти:

[...], используя функции, которые синхронизируют выполнение потоков, а также синхронизируют память по отношению к другим потокам. Следующие функции синхронизируют память по отношению к другим потокам.

Моя проблема заключается в том, что в этом предложении четко не указано, означают ли они "все доступную память" или "только память, доступная между блокировкой и разблокировкой". Я читал людей, спорящих по обоим случаям, и даже некоторые из них предполагали, что это было написано неточно специально, чтобы дать разработчикам компилятора больше свободы в их реализации!

Кроме того, это относится к pthreads, но мне нужно знать, как это относится к потоковой обработке Windows.

Я выберу любой ответ, основанный на кавычках или ссылках из стандартной документации или какого-либо другого высоконадежного источника, либо докажет, что мне не нужны заборы, либо показывает, какие ограждения мне нужны, в соответствии с вышеупомянутыми конфигурациями платформ, по крайней мере для случая Windows/Linux/MacOS. Если в этом случае потоки Windows будут вести себя как pthreads, я бы хотел получить ссылку/цитату.

Ниже приведены некоторые (из лучших) связанных вопросов/ссылок, которые я читал, но наличие противоречивой информации заставляет меня сомневаться в моем понимании.

4b9b3361

Ответ 1

Мой обзор документации C++11 и аналогичной формулировки в C11: n1570.pdf приводит меня к следующему пониманию.

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

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

От n1570

Оценка. Межпоточная передача происходит до оценки B, если A синхронизируется с B, A является зависимым от нормы до B, или для некоторой оценки X:

- A синхронизируется с X и X секвенируется до B,

- A секвенирован до того, как X и X межпоточность произойдет до B, или

- Между потоками происходит до того, как X и X межпоточность произойдет до B

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

  • Мьютекс, доступ к блокировке
  • Блокированная запись на продюсере + блокированное чтение у потребителя

Блокированная запись приводит к тому, что все предыдущие операции в потоке A будут упорядочены и очищены от кеша, прежде чем поток B увидит чтение.

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

Оба стандарта С++ и C предназначены для формализации существующего поведения компиляторов и процессоров. Поэтому, хотя в использовании pthreads и стандартов C99 существует меньше официальных гарантий, ожидается, что они будут согласованы.

Из вашего примера

Тема A

int index = findFreeIndex(thread_A_buffer)

Эта строка является проблематичной, поскольку она не отображает никаких примитивов синхронизации. Если механизм findFreeIndex полагается только на память, написанную потоком A, то это сработает. Если поток B или какой-либо другой поток изменяет память, тогда требуется дополнительная блокировка.

lock(m)
p = &(thread_A_buffer[index])
signal()
unlock(m)

Это покрывается....

15 Оценка A является упорядоченной по заказу до оценки B, если

- A выполняет операцию освобождения на атомарном объекте M, а в другом потоке B выполняет операцию потребления на M и считывает значение, записанное любым побочным эффектом в последовательности освобождения, возглавляемой A, или

- для некоторой оценки X, A является зависимым от нормы до X и X несет a зависимость от B.

и

18 Оценка A происходит до оценки B, если A секвенирован до B или interthread происходит до B.

Операции перед синхронизацией "произойдут до" синхронизации и гарантированно будут видны после синхронизации в другом потоке.

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

thread_A_buffer[index] = 42;      // happens before 

В настоящий момент поток memory_A_buffer памяти отображается на A, но для чтения его на B вызывает поведение undefined.

lock(m);  // acquire

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

p = &thread_A_buffer[index];
unlock(m);

Весь поток команд A теперь виден B (из-за его синхронизации с m).

thread_A_buffer[index] = 42;  << This happens before and ...
p = &thread_A_buffer[index];  << carries a dependency into p
unlock(m);

Все элементы в теперь видны B, потому что

Оценка. Межпоточность происходит до оценки B, если A синхронизируется с B, A является зависимым от заказа до B или для некоторой оценки X

- A синхронизируется с X и X секвенируется до B,

- A секвенирован до того, как X и X межпоточность произойдет до B, или

- Между потоками происходит до того, как X и X межпоточные события произойдут до B.

pointer p_a = null
do:
  // sleep
  lock(m)
  p_a = p
  unlock(m)
while (p_a != null)

Этот код полностью безопасен, значение, считанное в p_a, будет упорядочено с другим потоком и не будет равно null после синхронной записи в потоке b. Опять же, блокировка/разблокировка вызывает строгий порядок, который гарантирует, что прочитанное значение будет записываться.

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

Если бы A должен был модифицировать объект после того, как он дал объект B, то он не сработает, если не произойдет некоторая дополнительная синхронизация.

Ответ 2

Я также использую Nim для личных проектов. У Nim есть сборщик мусора, и вы должны избегать его для ваших процедур обработки памяти потоков, используя его. C invocation:

https://nim-lang.org/docs/backends.html

В Linux malloc использует внутренне мьютексы, чтобы избежать повреждения при одновременном доступе. Я думаю, Windows делает то же самое. Вы можете свободно использовать память, но вам нужно избегать множественных "свободных" или коллизий доступа (вы должны гарантировать, что только один поток использует память и может "освободить" ее).

Вы упомянули, что используете пользовательскую реализацию кучи. Эта куча, вероятно, доступна из других потоков, но вы должны проверить, не будет ли эта библиотека "свободна" для указателя, который обрабатывается другим потоком. Если эта пользовательская реализация кучи является сборщиком мусора Nim, тогда вы должны избегать ее любой ценой и выполнять пользовательскую реализацию C для доступа к памяти и использовать вызов Nim C для памяти malloc и бесплатно.

Ответ 3

Если вы хотите иметь независимость от платформы, вам нужно использовать несколько концентратов os и c:

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