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

Эта проблема

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

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

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

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

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

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


Вопросы переносимости

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

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

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

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

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

  • MSVC в Windows
  • лязг на линуксе
  • Xcode на MacOS / iOS
  • CodeWorks для Android на Android

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


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

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

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

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

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)? Или мне нужно добавить другие потоковые примитивы, такие как заборы памяти?


Исследование

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

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

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

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

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

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

3 ответа

Решение

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

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

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

От n1570

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

- А синхронизируется с Х и Х секвенируется до В,

- A секвенируется до того, как X и X между потоками происходит до B, или

- Межпотоковое происходит до того, как X и X межпотоковое происходит до B

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

  • Mutex доступ к замку
  • Блокированная запись у производителя + заблокированная запись у потребителя

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

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

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

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

Нить А

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 несет зависимость к B.

а также

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

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

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

thread_A_buffer[index] = 42;      // happens before 

В данный момент память thread_A_buffer видна на A, но чтение ее на B вызывает неопределенное поведение.

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);

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

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

- А синхронизируется с Х и Х секвенируется до В,

- 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, будет упорядочено с другим потоком и будет не нулевым после синхронизированной записи в потоке b. Опять же, блокировка / разблокировка вызывают строгий порядок, который гарантирует, что прочитанное значение будет записанным значением.

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

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

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

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

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

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

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

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

Другие вопросы по тегам