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

Являются ли конструкторы потоками безопасными в С++ и/или С++ 11?

Из этого вопроса и связанных с этим вопросом:

Если я построю объект в одном потоке, а затем передаю ссылку/указатель на него в другой поток, не будет ли он потоком небезопасным для этого другого потока для доступа к объекту без явных блокировок/барьеров памяти?

// thread 1
Obj obj;

anyLeagalTransferDevice.Send(&obj);
while(1); // never let obj go out of scope

// thread 2
anyLeagalTransferDevice.Get()->SomeFn();

Альтернативно: существует ли какой-либо законный способ передачи данных между потоками, которые не обеспечивают упорядочение памяти в отношении всего остального, затронутого нитью? С аппаратной точки зрения я не вижу причин, по которым это невозможно.

Прояснить; вопрос в том, что касается согласованности кэша, упорядоченности памяти и много чего. Может ли Thread 2 получить и использовать указатель перед представлением памяти 2-го уровня, включая записи, связанные с построением obj? Пропустить цитату Alexandrescu (?) "Мог ли разработчик вредоносного процессора и писатель компилятора построить стандартную систему соответствия, которая сделает этот разрыв?"

4b9b3361

Ответ 1

Обоснование безопасности потоков может быть затруднено, и я не являюсь экспертом в модели памяти С++ 11. К счастью, ваш пример очень прост. Я переписываю пример, потому что конструктор не имеет значения.

Упрощенный пример

Вопрос: Правилен ли следующий код? Или может привести к выполнению undefined поведения?

// Legal transfer of pointer to int without data race.
// The receive function blocks until send is called.
void send(int*);
int* receive();

// --- thread A ---
/* A1 */   int* pointer = receive();
/* A2 */   int answer = *pointer;

// --- thread B ---
           int answer;
/* B1 */   answer = 42;
/* B2 */   send(&answer);
           // wait forever

Ответ: Может существовать гонка данных в ячейке памяти answer, и, следовательно, выполнение приводит к поведению undefined. Подробнее см. Ниже.


Реализация передачи данных

Конечно, ответ зависит от возможных и правовых реализаций функций send и receive. Я использую следующую реализацию без использования данных. Обратите внимание, что используется только одна атомная переменная, а во всех операциях памяти используется std::memory_order_relaxed. В основном это означает, что эти функции не ограничивают переопределение памяти.

std::atomic<int*> transfer{nullptr};

void send(int* pointer) {
    transfer.store(pointer, std::memory_order_relaxed);
}

int* receive() {
    while (transfer.load(std::memory_order_relaxed) == nullptr) { }
    return transfer.load(std::memory_order_relaxed);
}

Порядок операций с памятью

В многоядерных системах поток может видеть изменения памяти в другом порядке, как то, что видят другие потоки. Кроме того, как компиляторы, так и процессоры могут изменить порядок операций памяти в одном потоке для эффективности - и они делают это все время. Атомные операции с std::memory_order_relaxed не участвуют ни в какой синхронизации и не налагают никакого упорядочения.

В приведенном выше примере компилятору разрешено изменять порядок операций потока B и выполнять B2 перед B1, поскольку переупорядочение не влияет на сам поток.

// --- valid execution of operations in thread B ---
           int answer;
/* B2 */   send(&answer);
/* B1 */   answer = 42;
           // wait forever

Гонка данных

С++ 11 определяет гонку данных следующим образом (N3290 С++ 11 Draft): "Выполнение программы содержит гонку данных, если она содержит два конфликтующих действия в разных потоках, по крайней мере один из которых не является атомарным, и это не происходит до другого. Любая такая гонка данных приводит к поведению undefined". И этот термин происходит раньше, чем ранее в том же документе.

В приведенном выше примере B1 и A2 являются конфликтующими и неатомными операциями, и они не происходят до другого. Это очевидно, потому что я показал в предыдущем разделе, что оба могут произойти одновременно.

Это единственное, что имеет значение в С++ 11. Напротив, модель памяти Java также пытается определить поведение, если есть расы данных, и потребовалось почти десять лет, чтобы придумать разумную спецификацию. С++ 11 не допустил ошибку.


Дополнительная информация

Я немного удивлен, что эти основы не очень хорошо известны. Конечным источником информации является раздел многопоточных исполнений и расчётов данных в стандарте С++ 11. Однако спецификацию трудно понять.

Хорошей отправной точкой являются переговоры Ханса Бёма - например, доступны в виде онлайн-видео:

Также есть много других хороших ресурсов, о которых я упоминал в другом месте, например:

Ответ 2

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

  • Тема 1 запускает выполнение Obj::Obj().
  • Thread 1 завершает выполнение Obj::Obj().
  • В потоке 1 передается ссылка на память, занятую obj, на поток 2.
  • Тема 1 никогда не делает ничего с этой памятью (вскоре после этого она попадает в бесконечный цикл).
  • Thread 2 выбирает ссылку на память, занятую obj.
  • Thread 2 предположительно делает с ней что-то, невозмутимое нитью 1, которая все еще бесконечно зацикливается.

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

Ответ 3

Как указывали другие, единственный способ, по которому конструктор не является потокобезопасным, - это что-то каким-то образом получает указатель или ссылку на него до завершения конструктора, и единственный способ, который может произойти, - это сам конструктор имеет код, который регистрирует указатель this для некоторого типа контейнера, который совместно используется потоками.

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

Ответ 4

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

Ответ 5

Прочтите этот вопрос до сих пор... Все еще опубликуйте мои комментарии:

Статическая локальная переменная

Существует надежный способ построения объектов, когда вы находитесь в многопоточной среде, использующей статическую локальную переменную (статическая локальная переменная -CppCoreGuidelines),

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

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

Ниже приведен пример, который я написал, чтобы показать Construct On First Use Idiom, о чем в основном говорится в этой статье.

#include <iostream>
#include <thread>
#include <vector>

class ThreadConstruct
{
public:
    ThreadConstruct(int a, float b) : _a{a}, _b{b}
    {
        std::cout << "ThreadConstruct construct start" << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(2));
        std::cout << "ThreadConstruct construct end" << std::endl;
    }

    void get()
    {
        std::cout << _a << " " << _b << std::endl;
    }

private:
    int _a;
    float _b;
};


struct Factory
{
    template<class T, typename ...ARGS>
    static T& get(ARGS... args)
    {
        //thread safe object instantiation
        static T instance(std::forward<ARGS>(args)...);
        return instance;
    }
};

//thread pool
class Threads
{
public:
    Threads() 
    {
        for (size_t num_threads = 0; num_threads < 5; ++num_threads) {
            thread_pool.emplace_back(&Threads::run, this);
        }
    }

    void run()
    {
        //thread safe constructor call
        ThreadConstruct& thread_construct = Factory::get<ThreadConstruct>(5, 10.1);
        thread_construct.get();
    }

    ~Threads() 
    {
        for(auto& x : thread_pool) {
            if(x.joinable()) {
                x.join();
            }
        }
    }

private:
    std::vector<std::thread> thread_pool;
};


int main()
{
    Threads thread;

    return 0;
}

Вывод:

ThreadConstruct construct start
ThreadConstruct construct end
5 10.1
5 10.1
5 10.1
5 10.1
5 10.1