Синхронизация очень быстрых потоков

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

Я использую condition_variable для синхронизации этих двух, поэтому в идеале более быстрый поток будет тратить некоторое время на ожидание медленного. Однако переменные условия, похоже, не выполняют эту работу, если один из потоков завершает итерацию в течение очень небольшого промежутка времени. Кажется, что он быстро восстанавливает блокировку мьютекса до wait в другом потоке может приобрести его. Хотя notify_one называется

#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>
#include <functional>
#include <mutex>
#include <condition_variable>

using namespace std;

bool isMultiThreaded = true;

struct RenderThread
{
    RenderThread()
    {
        end = false;
        drawing = false;
        readyToDraw = false;
    }

    void Run()
    {
        while (!end)
        {
            DoJob();
        }
    }

    void DoJob()
    {
        unique_lock<mutex> lk(renderReadyMutex);
        renderReady.wait(lk, [this](){ return readyToDraw; });
        drawing = true;

        // RENDER DATA
        this_thread::sleep_for(chrono::milliseconds(15)); // simulated render time
        cout << "frame " << count << ": " << frame << endl;
        ++count;

        drawing = false;
        readyToDraw = false;

        lk.unlock();
        renderReady.notify_one();
    }

    atomic<bool> end;

    mutex renderReadyMutex;
    condition_variable renderReady;
    //mutex frame_mutex;
    int frame = -10;
    int count = 0;

    bool readyToDraw;
    bool drawing;
};

struct UpdateThread
{
    UpdateThread(RenderThread& rt)
        : m_rt(rt)
    {}

    void Run()
    {
        this_thread::sleep_for(chrono::milliseconds(500));

        for (int i = 0; i < 20; ++i)
        {
            // DO GAME UPDATE

            // when this is uncommented everything is fine
            // this_thread::sleep_for(chrono::milliseconds(10)); // simulated update time

            // PREPARE RENDER THREAD
            unique_lock<mutex> lk(m_rt.renderReadyMutex);
            m_rt.renderReady.wait(lk, [this](){ return !m_rt.drawing; });

            m_rt.readyToDraw = true;

            // SUPPLY RENDER THREAD WITH DATA TO RENDER
            m_rt.frame = i;

            lk.unlock();
            m_rt.renderReady.notify_one();

            if (!isMultiThreaded)
                m_rt.DoJob();
        }        

        m_rt.end = true;
    }

    RenderThread& m_rt;
};

int main()
{
    auto start = chrono::high_resolution_clock::now();

    RenderThread rt;
    UpdateThread u(rt);

    thread* rendering = nullptr;
    if (isMultiThreaded)
        rendering = new thread(bind(&RenderThread::Run, &rt));

    u.Run();

    if (rendering)
        rendering->join();

    auto duration = chrono::high_resolution_clock::now() - start;
    cout << "Duration: " << double(chrono::duration_cast<chrono::microseconds>(duration).count())/1000 << endl;


    return 0;
}

вот источник этого маленького пример кода, и, как вы можете видеть, даже при запуске ideone выход frame 0: 19 (это означает, что поток визуализации завершил одну итерацию, в то время как поток обновления завершил все 20 его).

если мы раскомментируем строку 75 (т. е. имитируем некоторое время для цикла обновления), все работает нормально. Каждой итерации обновления итерации отрисовки.

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

4 ответов


Если я правильно понимаю, вы хотите, чтобы 2 потока работали поочередно: updater дождаться завершения визуализации, прежде чем повторять итерацию, и визуализатор дождаться завершения обновления, прежде чем повторять снова. Часть вычислений может быть параллельной,но число итераций должно быть одинаковым между ними.

вам нужно 2 замки:

  • для обновления
  • для рендеринг

Updater:

wait (renderingLk)
update
signal(updaterLk)

рендерер:

wait (updaterLk)
render
signal(renderingLk)

редактировать:

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

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

реализация должна заботиться также о начальном состоянии: поэтому рендеринг не выполняется до первого обновления.

прекращение обоих потоков: поэтому никто не останется заблокированным или петлей бесконечно после завершения другого.


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

здесь std::mutex сочетается с std::condition_variable имитировать поведение семафора. Подход, который кажется вполне разумным. Вы, вероятно, не будете считать вверх и вниз, а скорее переключать true и false переменную с должен перекроить семантика.

Для справки:


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

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


Это потому, что вы используете отдельный drawing переменная, которая устанавливается только тогда, когда поток рендеринга повторно запрашивает мьютекс после wait, что может быть слишком поздно. Проблема исчезает, когда drawing переменная удаляется и проверка на wait в потоке обновления заменяется на ! m_rt.readyToDraw (который уже установлен потоком обновления и, следовательно, не подвержен логической гонке.

измененный код и результаты

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