Подозрения о состоянии многопоточности в виртуальных вызовах C++ с реализацией vtable

У меня есть подозрение, что в определенной ситуации многопоточности C++ может возникнуть состояние состязания, включающее вызовы виртуальных методов в реализации динамической диспетчеризации vtable (для которой указатель vtable хранится как скрытый элемент в объекте с виртуальными методами). Я хотел бы подтвердить, является ли это на самом деле проблемой, и я указываю библиотеку потоков Boost, чтобы мы могли принять некоторую систему отсчета.

Предположим, что объект "O" имеет элемент boost::mutex, для которого весь его конструктор / деструктор и методы заблокированы по области (аналогично шаблону параллелизма Monitor). Поток "A" создает объект "O" в куче без внешней синхронизации (т. Е. БЕЗ общего мьютекса, включающего "новую" операцию, для которой он может синхронизироваться с другими потоками; однако, обратите внимание, что все еще существует "внутренний"). Монитор "мьютекс, блокирующий область его конструктора). Затем поток A передает указатель на экземпляр "O" (который он только что сконструировал) в другой поток "B" с помощью синхронизированного механизма - например, синхронизированной очереди читателей-записчиков (примечание: только указатель на объект передается, а не сам объект). После создания ни один из потоков "A" или какие-либо другие потоки не выполняют никаких операций записи для экземпляра "O", созданного "A".

Поток "B" считывает значение указателя объекта "O" из синхронизированной очереди, после чего он немедленно покидает критическую секцию, охраняющую очередь. Затем поток "B" выполняет вызов виртуального метода для объекта "O". Здесь я думаю, что проблема может возникнуть.

Теперь мое понимание вызовов виртуальных методов в [весьма вероятной] реализации динамической диспетчеризации vtable заключается в том, что вызывающий поток "B" должен разыменовать указатель на "O", чтобы получить указатель виртуальной таблицы, хранящийся как скрытый член его объекта. и что это происходит ДО того, как тело метода будет введено (естественно, потому что тело метода для выполнения не определяется безопасно и точно до тех пор, пока не будет получен доступ к указателю vtable, сохраненному в самом объекте). Предполагая, что вышеупомянутые утверждения, возможно, верны для такой реализации, разве это не условие гонки?

Поскольку указатель vtable извлекается потоком "B" (путем разыменования указателя на объект "O", расположенный в куче) до выполнения каких-либо операций, гарантирующих видимость памяти (т. Е. Получения переменной-члена mutex в объекте "O") тогда нет уверенности в том, что "B" будет воспринимать значение указателя vtable, которое "A" изначально записало в конструкции объекта "O", верно? (т. е. вместо этого он может воспринимать значение мусора, приводящее к неопределенному поведению, верно?).

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

И - аналогично - поскольку стандарт не зависит от реализации vtable, как можно гарантировать, что указатель vtable безопасно виден другим потокам до виртуального вызова? Я полагаю, что можно внешне синхронизировать ("внешне", как, например, "окружая общим блокированием mutex lock()/unlock()") вызов конструктора, а затем, по крайней мере, начальный вызов виртуального метода в каждом из потоков, но это похоже на какое-то ужасно диссонирующее программирование.

Итак, если мои подозрения верны, то, возможно, более элегантным решением было бы использование встроенных, не виртуальных функций-членов, которые блокируют мьютекс члена, а затем перенаправляют на виртуальный вызов. Но - даже тогда - можем ли мы гарантировать, что конструктор инициализировал указатель vtable в границах lock () и unlock (), защищающих само тело конструктора?

Если бы кто-то мог помочь мне разобраться в этом и подтвердить / опровергнуть мои подозрения, я был бы очень благодарен.

РЕДАКТИРОВАТЬ: код, демонстрирующий выше

class Interface
{
    public:
    virtual ~Interface() {}
    virtual void dynamicCall() = 0;
};

class Monitor : public Interface
{
    boost::mutex mutex;
    public:
    Monitor()
    {
        boost::unique_lock<boost::mutex> lock(mutex);
        // initialize
    }
    virtual ~Monitor()
    {
        boost::unique_lock<boost::mutex> lock(mutex);
        // destroy
    }
    virtual void dynamicCall()
    {
        boost::unique_lock<boost::mutex> lock(mutex);
        // do w/e
    }
};

// for simplicity, the numbers following each statement specify the order of execution, and these two functions are assumed
// void passMonitorToSharedQueue( Interface * monitor )
//        Thread A passes the 'monitor' pointer value to a 
//        synchronized queue, pushes it on the queue, and then 
//        notifies Thread B that a new entry exists
// Interface * getMonitorFromSharedQueue()
//        Thread B blocks until Thread A notifies Thread B
//        that a new 'Interface *' can be retrieved,at which
//        point it retrieves and returns it
void threadBFunc()
{
    Interface * if = getMonitorFromSharedQueue(); // (1)
    if->dynamicCall(); // (4) (ISSUE HERE?)
}
void threadAFunc()
{
    Interface * monitor = new Monitor; // (2)
    passMonitorToSharedQueue(monitor); // (3)
}

- в точке (4) у меня сложилось впечатление, что значение указателя vtable, которое "поток A" записал в память, может быть невидимым для "потока B", так как я не вижу причин предполагать, что компилятор будет генерировать код так, чтобы указатель vtable был записан в заблокированном блоке мьютекса конструктора.

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

Возможно, я неправильно понимаю смысл статьи, но разве это не означает, что запись "A" указателя vtable на построенный объект (и нет никаких признаков того, что эта запись происходит в заблокированном блоке мьютекса конструктора) может не будут восприняты "B", прежде чем "B" читает указатель vtable? Если и A, и B выполняются на разных ядрах ("A" на core0 и "B" на core1), механизм согласованности кэша может переупорядочить обновление значения указателя vtable в кэше core1 (обновление, которое сделает его непротиворечивым). со значением указателя vtable в кэше core0, который написал "A"), так что это происходит после чтения "B"... если я правильно интерпретирую статью.

4 ответа

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

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

Если вы действительно хотели убедиться, что получили правильный vtable при звонке getMonitorFromSharedQueue() у вас должен быть барьер для чтения перед звонком if->dynamicCall(),

Если я попытаюсь понять ваше эссе, я думаю, что вы спрашиваете это:

Поток "A" создает объект "O" в куче без внешней синхронизации

// global namespace
SomeClass* pClass = new SomeClass;

В то же время вы говорите, что поток -A передает вышеуказанный экземпляр потоку-B. Это означает, что экземпляр SomeClass полностью построен или вы пытаетесь пройти this указатель из ctor SomeClass на поток -B? Если да, то у вас определенно проблемы с виртуальными функциями. Но это никак не связано с условиями гонки.

Если вы обращаетесь к глобальной переменной экземпляра в потоке -B без прохождения ее потоком-A, то существует вероятность возникновения условий гонки. Инструкция 'new' выкладывается большинством компиляторов, таких как....

pClass = // Step 3
operator new(sizeof(SomeClass)); // Step 1
new (pClass ) SomeClass; // Step 2

Если только Шаг-1 завершен, или если только Шаг-1 и Шаг-2 завершены, то доступ pClass не определено

НТН

Я не совсем понимаю, но есть две возможности, я думаю, вы имели в виду:

A) "O" полностью сконструирован (конструктор возвращен) перед передачей его в синхронизированную очередь на "B". В этом случае нет проблем, потому что объект полностью построен, включая указатель vtable. Память в этом месте будет иметь vtable, потому что она находится внутри одного процесса.

Б) "О" еще не полностью построено, но, например, вы проходите this из конструктора в синхронизированную очередь. В этом случае указатель vtable все еще должен быть установлен до того, как тело конструктора будет вызвано в потоке "A", потому что допустимо вызывать виртуальные функции из конструктора - он просто вызовет версию метода текущего класса, не самый производный. Таким образом, я бы не ожидал увидеть состояние гонки в этом случае. Если вы на самом деле мимоходом this другому потоку из его конструктора вы можете пересмотреть свой подход, поскольку кажется опасным делать вызовы для объектов, которые не были полностью построены.

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

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

http://www.justsoftwaresolutions.co.uk/threading/implementing-a-thread-safe-queue-using-condition-variables.html

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