подозрения о состоянии многопоточности в виртуальных вызовах c ++ с реализацией vtable - PullRequest
0 голосов
/ 06 июля 2010

У меня есть подозрение, что в определенной ситуации многопоточности 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, сохраненному в самом объекте). Предполагая, что вышеупомянутые утверждения, возможно, верны для такой реализации, разве это не условие гонки?

Поскольку указатель 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) У меня сложилось впечатление, что значение указателя виртуальной таблицы, которое «поток А» записал в память, может быть невидимым для «потока В», так как я не вижу никаких оснований предполагать, что компилятор будет генерировать код таким образом, чтобы указатель виртуальной таблицы записывается в заблокированном блоке мьютекса конструктора.

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

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

Ответы [ 4 ]

0 голосов
/ 13 июля 2010

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

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

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

0 голосов
/ 06 июля 2010

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

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

// global namespace
SomeClass* pClass = new SomeClass;

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

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

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

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

НТН

0 голосов
/ 06 июля 2010

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

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

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

у вас должен быть барьер для чтения.
0 голосов
/ 06 июля 2010

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

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

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

...