Почему из 3-х способов чтения связанных списков из разделяемой памяти третий самый быстрый? - PullRequest
8 голосов
/ 28 марта 2010

У меня есть «серверная» программа, которая обновляет множество связанных списков в общей памяти в ответ на внешние события. Я хочу, чтобы клиентские программы замечали обновления в любом из списков как можно быстрее (минимальная задержка). Сервер помечает узел связанного списка state_ как FILLED, как только его данные заполнены, а его следующий указатель установлен в правильное местоположение. До этого его state_ составляет NOT_FILLED_YET. Я использую барьеры памяти, чтобы убедиться, что клиенты не видят state_ как FILLED до того, как данные в действительности будут готовы (и, похоже, это работает, я никогда не вижу поврежденных данных). Кроме того, state_ является изменчивым, чтобы убедиться, что компилятор не поднимает проверку клиента из циклов.

Сохраняя код сервера точно таким же, я предложил 3 различных метода для клиента, чтобы сканировать связанные списки на предмет изменений. Вопрос в том, почему третий метод самый быстрый?

Метод 1: циклически перебирать все связанные списки (называемые «каналами») непрерывно, проверяя, изменились ли какие-либо узлы на «ЗАПОЛНЕНО»:

void method_one()
{
    std::vector<Data*> channel_cursors;
    for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
    {
        Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
        channel_cursors.push_back(current_item);
    }

    while(true)
    {
        for(std::size_t i = 0; i < channel_list.size(); ++i)
        {   
            Data* current_item = channel_cursors[i];

            ACQUIRE_MEMORY_BARRIER;
            if(current_item->state_ == NOT_FILLED_YET) {
                continue;
            }

            log_latency(current_item->tv_sec_, current_item->tv_usec_);

            channel_cursors[i] = static_cast<Data*>(current_item->next_.get(segment));
        }
    }
}

Метод 1 дал очень низкую задержку, когда число каналов было небольшим. Но когда количество каналов выросло (250K +), оно стало очень медленным из-за зацикливания на всех каналах. Итак, я попытался ...

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

void method_two()
{
    std::vector<Data*> channel_cursors;
    for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
    {
        Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
        channel_cursors.push_back(current_item);
    }

    UpdateID* update_cursor = static_cast<UpdateID*>(update_channel.tail_.get(segment));

    while(true)
    {   
            ACQUIRE_MEMORY_BARRIER;
        if(update_cursor->state_ == NOT_FILLED_YET) {
            continue;
        }

        ::uint32_t update_id = update_cursor->list_id_;

        Data* current_item = channel_cursors[update_id];

        if(current_item->state_ == NOT_FILLED_YET) {
            std::cerr << "This should never print." << std::endl; // it doesn't
            continue;
        }

        log_latency(current_item->tv_sec_, current_item->tv_usec_);

        channel_cursors[update_id] = static_cast<Data*>(current_item->next_.get(segment));
        update_cursor = static_cast<UpdateID*>(update_cursor->next_.get(segment));
    }   
}

Метод 2 дал ужасную задержку. В то время как метод 1 может дать задержку до 10 °, метод 2 необъяснимо часто дает задержку в 8 мс! Используя gettimeofday, кажется, что изменение в update_cursor-> state_ было очень медленным, чтобы распространяться от представления сервера к представлению клиента (я нахожусь на многоядерной коробке, поэтому я предполагаю, что задержка вызвана кешем). Поэтому я попробовал гибридный подход ...

Способ 3: сохранить список обновлений. Но постоянно проходите по всем каналам, и в каждой итерации проверяйте, обновлялся ли список обновлений. Если это так, идите с номером на нем. Если это не так, проверьте канал, к которому мы в настоящее время перешли.

void method_three()
{
    std::vector<Data*> channel_cursors;
    for(ChannelList::iterator i = channel_list.begin(); i != channel_list.end(); ++i)
    {
        Data* current_item = static_cast<Data*>(i->get(segment)->tail_.get(segment));
        channel_cursors.push_back(current_item);
    }

    UpdateID* update_cursor = static_cast<UpdateID*>(update_channel.tail_.get(segment));

    while(true)
    {
        for(std::size_t i = 0; i < channel_list.size(); ++i)
        {
            std::size_t idx = i;

            ACQUIRE_MEMORY_BARRIER;
            if(update_cursor->state_ != NOT_FILLED_YET) {
                //std::cerr << "Found via update" << std::endl;
                i--;
                idx = update_cursor->list_id_;
                update_cursor = static_cast<UpdateID*>(update_cursor->next_.get(segment));
            }

            Data* current_item = channel_cursors[idx];

            ACQUIRE_MEMORY_BARRIER;
            if(current_item->state_ == NOT_FILLED_YET) {
                continue;
            }

            found_an_update = true;

            log_latency(current_item->tv_sec_, current_item->tv_usec_);
            channel_cursors[idx] = static_cast<Data*>(current_item->next_.get(segment));
        }
    }
}

Задержка этого метода была такой же, как и у метода 1, но масштабировалась на большое количество каналов. Проблема в том, что я понятия не имею, почему. Просто для того, чтобы разбираться с вещами: если я раскомментирую часть «найдено через обновление», она будет печататься между КАЖДЫМ СООБЩЕНИЕМ В ЖУРНАЛЕ LATENCY. Что означает, что вещи только когда-либо найдены в списке обновлений! Поэтому я не понимаю, как этот метод может быть быстрее, чем метод 2.

Полный скомпилируемый код (требуется GCC и boost-1.41), который генерирует случайные строки в качестве тестовых данных по адресу: http://pastebin.com/0kuzm3Uf

Обновление: все 3 метода эффективно блокируются, пока не произойдет обновление. Разница в том, сколько времени им требуется, чтобы заметить, что произошло обновление. Они все постоянно нагружают процессор, так что это не объясняет разницу в скорости. Я тестирую на 4-ядерном компьютере, на котором больше ничего не работает, поэтому серверу и клиенту не с чем конкурировать. Я даже сделал версию кода, где обновления сигнализируют о состоянии и заставляют клиентов ждать условия - это не помогло задержке любого из методов.

Обновление 2: Несмотря на то, что существует 3 метода, я пробовал только 1 за раз, поэтому только 1 сервер и 1 клиент соревнуются за элемент state_.

Ответы [ 4 ]

1 голос
/ 28 марта 2010

Гипотеза: метод 2 каким-то образом блокирует обновление от записи сервером.

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

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

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

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

1 голос
/ 28 марта 2010

Я не знаю, читали ли вы когда-нибудь колонки о параллелизме Херба Саттера. Они довольно интересны, особенно когда вы сталкиваетесь с проблемами с кешем.

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

Однако на самом деле может произойти такая строка кэша:

Line of cache = [ID1, ID2, ID3, ID4, ...]
                  ^         ^
            client          server

Что тогда вызывает раздор.

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

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

0 голосов
/ 15 апреля 2010

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

Проблема в том, что между моментом, когда я установил свою начальную точку в списке обновлений, и моей начальной точкой в ​​каждом из списков данных, будет некоторое отставание, потому что эти операции занимают время. Помните, списки растут все время, пока это происходит. Рассмотрим простейший случай, когда у меня есть 2 списка данных, A и B. Когда я устанавливаю свою начальную точку в списке обновлений, в ней оказывается 60 элементов, из-за 30 обновлений в списке A и 30 обновлений в списке B. Скажите, что они чередовался:

A
B
A
B
A // and I start looking at the list here
B

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

0 голосов
/ 28 марта 2010

Я заметил, что как в методе 1, так и в методе 3 у вас есть строка ACQUIRE_MEMORY_BARRIER, которая, как я полагаю, как-то связана с многопоточностью / условиями гонки?

В любом случае, метод 2 не спит, что означает следующий код ...

while(true)
{   
    if(update_cursor->state_ == NOT_FILLED_YET) {
        continue;
    }

собирается забить процессор. Типичный способ выполнить такую ​​задачу производителя / потребителя - использовать какой-то семафор, чтобы сообщить читателю, что список обновлений изменился. Поиск многопоточности производитель / потребитель должен дать вам большое количество примеров. Основная идея здесь заключается в том, что это позволяет потоку перейти в спящий режим, пока он ожидает изменения состояния update_cursor->. Это предотвращает кражу этой нитью всех циклов процессора.

...