методы повторного использования выделенных буферов памяти libuv

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

На самом базовом уровне, доступном пользователю libuv, возникает необходимость указать обратный вызов выделения буфера вместе с настройкой считывателя дескрипторов:

UV_EXTERN int uv_read_start(uv_stream_t*, uv_alloc_cb alloc_cb, uv_read_cb read_cb);

где uv_alloc_cb

typedef void (*uv_alloc_cb)(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf);

Но вот в чем проблема: этот обратный вызов с выделением памяти вызывается каждый раз, когда новое сообщение поступает через дескриптор (скажем, принимается каждая дейтаграмма UDP из uv_udp_t дескриптора), и прямое выделение нового буфера для каждой входящей дейтаграммы UDP кажется очень несовершенным. по памяти.

Поэтому я прошу общих методов C (возможно, в контексте отложенного выполнения, введенного системой обратного вызова libuv) повторного использования одной и той же выделенной памяти, когда это возможно.

Кроме того, я хотел бы, чтобы по возможности оставался портативным для Windows.

Примечания:

  • мне известен этот вопрос: Предоставляет ли libuv какие-либо средства для присоединения буфера к соединению и его повторного использования; принятый ответ не отвечает, как на самом деле правильно распределить память с помощью libuv, кроме того, что указывается тот факт, что статически выделенный буфер не работает. В частности, он не распространяется на безопасность (с обратными вызовами отложенной записи в том же буфере, которые могут перекрываться с другим вызовом обратного вызова чтения через несколько итераций основного цикла libuv) аспект буфера, прикрепленного к дескриптору (либо через структуру оболочки, либо через дескриптор-> контекст данных).
  • # P8 #
    # P9 #
    # P10 #

person Alexander Tumin    schedule 14.02.2015    source источник


Ответы (2)


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

Цель

  1. Реализуйте пул буферов, способный выполнять две операции - получение и освобождение.

  2. Базовая стратегия пула:

    • acquire withdraws a buffer from the pool effectively reducing the available number of buffers by 1;
    • If there are no buffers available, two options arise:
      • grow the pool and return a newly created buffer; or
      • создать и вернуть фиктивный буфер (поясняется ниже).
    • release возвращает буфер в пул.
  3. Пул может быть фиксированного или переменного размера. «Переменная» означает, что изначально имеется M предварительно выделенных буферов (например, ноль), а пул может увеличиваться по запросу до N. «Фиксированный» означает, что все буферы заранее выделяются при создании пула (M = N).

  4. Реализуйте обратный вызов, который получает буферы для libuv.

  5. Не допускайте бесконечного роста пула при сохранении работоспособности пула ни при каких обстоятельствах, за исключением ситуаций нехватки памяти.

Выполнение

А теперь давайте подробнее рассмотрим все это.

Состав бассейна:

#define BUFPOOL_CAPACITY 100

typedef struct bufpool_s bufpool_t;

struct bufpool_s {
    void *bufs[BUFPOOL_CAPACITY];
    int size;
};

size - текущий размер пула.

Сам буфер представляет собой блок памяти с префиксом следующей структуры:

#define bufbase(ptr) ((bufbase_t *)((char *)(ptr) - sizeof(bufbase_t)))
#define buflen(ptr) (bufbase(ptr)->len)

typedef struct bufbase_s bufbase_t;

struct bufbase_s {
    bufpool_t *pool;
    int len;
};

len - длина буфера в байтах.

Выделение нового буфера выглядит так:

void *bufpool_alloc(bufpool_t *pool, int len) {
    bufbase_t *base = malloc(sizeof(bufbase_t) + len);
    if (!base) return 0;
    base->pool = pool;
    base->len = len;
    return (char *)base + sizeof(bufbase_t);
}

Обратите внимание, что возвращенный указатель указывает на следующий после заголовка байт - область данных. Это позволяет иметь указатели буфера, как если бы они были выделены стандартным вызовом malloc.

Распределение происходит наоборот:

void bufpool_free(void *ptr) {
    if (!ptr) return;
    free(bufbase(ptr));
}

Обратный вызов выделения для libuv выглядит так:

void alloc_cb(uv_handle_t *handle, size_t size, uv_buf_t *buf) {
    int len;
    void *ptr = bufpool_acquire(handle->loop->data, &len);
    *buf = uv_buf_init(ptr, len);
}

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

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

#define DUMMY_BUF_SIZE 8000

void *bufpool_dummy() {
    return bufpool_alloc(0, DUMMY_BUF_SIZE);
}

Операция получения:

void *bufpool_acquire(bufpool_t *pool, int *len) {
    void *buf = bufpool_dequeue(pool);
    if (!buf) buf = bufpool_dummy();
    *len = buf ? buflen(buf) : 0;
    return buf;
}

Операция выпуска:

void bufpool_release(void *ptr) {
    bufbase_t *base;
    if (!ptr) return;
    base = bufbase(ptr);
    if (base->pool) bufpool_enqueue(base->pool, ptr);
    else free(base);
}

Здесь есть две функции - bufpool_enqueue и bufpool_dequeue. В основном они выполняют всю работу бассейна.

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

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

Внутреннее устройство бассейна выглядит следующим образом:

#define BUF_SIZE 64000

void *bufpool_grow(bufpool_t *pool) {
    int idx = pool->size;
    void *buf;
    if (idx == BUFPOOL_CAPACITY) return 0;
    buf = bufpool_alloc(pool, BUF_SIZE);
    if (!buf) return 0;
    pool->bufs[idx] = 0;
    pool->size = idx + 1;
    return buf;
}

void bufpool_enqueue(bufpool_t *pool, void *ptr) {
    int idx;
    for (idx = 0; idx < pool->size; ++idx) {
        if (!pool->bufs[idx]) break;
    }
    assert(idx < pool->size);
    pool->bufs[idx] = ptr;
}

void *bufpool_dequeue(bufpool_t *pool) {
    int idx;
    void *ptr;
    for (idx = 0; idx < pool->size; ++idx) {
        ptr = pool->bufs[idx];
        if (ptr) {
            pool->bufs[idx] = 0;
            return ptr;
        }
    }
    return bufpool_grow(pool);
}

Нормальный размер буфера составляет 64000 байт, потому что я хочу, чтобы он удобно помещался в блок размером 64 КБ с его заголовком.

И, наконец, процедуры инициализации и деинициализации:

void bufpool_init(bufpool_t *pool) {
    pool->size = 0;
}

void bufpool_done(bufpool_t *pool) {
    int idx;
    for (idx = 0; idx < pool->size; ++idx) bufpool_free(pool->bufs[idx]);
}

Обратите внимание, что эта реализация упрощена для иллюстративных целей. Здесь нет политики сокращения пула, тогда как в реальном сценарии она, скорее всего, потребуется.

использование

Теперь вы должны иметь возможность писать свои обратные вызовы libuv:

void read_cb(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf) {
    /* ... */
    bufpool_release(buf->base); /* Release the buffer */
}

Инициализация цикла:

uv_loop_t *loop = malloc(sizeof(*loop));
bufpool_t *pool = malloc(sizeof(*pool));
uv_loop_init(loop);
bufpool_init(pool);
loop->data = pool;

Операция:

uv_tcp_t *tcp = malloc(sizeof(*tcp));
uv_tcp_init(tcp);
/* ... */
uv_read_start((uv_handle_t *)tcp, alloc_cb, read_cb);

ОБНОВЛЕНИЕ (2 августа 2016 г.)

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

void alloc_cb(uv_handle_t *handle, size_t size, uv_buf_t *buf) {
    int len = size; /* Requested buffer size */
    void *ptr = bufpool_acquire(handle->loop->data, &len);
    *buf = uv_buf_init(ptr, len);
}

void *bufpool_acquire(bufpool_t *pool, int *len) {
    int size = *len;
    if (size > DUMMY_BUF_SIZE) {
        buf = bufpool_dequeue(pool);
        if (buf) {
            if (size > BUF_SIZE) *len = BUF_SIZE;
            return buf;
        }
        size = DUMMY_BUF_SIZE;
    }
    buf = bufpool_alloc(0, size);
    *len = buf ? size : 0;
    return buf;
}

P.S. В этом фрагменте нет необходимости в buflen и bufpool_dummy.

person neoxic    schedule 28.02.2015
comment
Хотите ли вы выпустить этот код с использованием лицензий MIT / BSD или LGPL v3? - person Carlos Melo; 27.05.2015
comment
Снова наступает кошмар лицензирования ... =) Я считаю, что все на SO по умолчанию находится в соответствии с условиями лицензии Creative Commons. Я не знаю, как я могу, кроме того, выпустить эту гениальную часть своей разработки под лицензией MIT / BSD, но я не возражаю. - person neoxic; 27.05.2015
comment
Да, это лицензирование - кошмар, и в сочетании с тем, что я параноик, только усугубляет ситуацию. :) Спасибо. - person Carlos Melo; 27.05.2015
comment
bufpool_enqueue и bufpool_dequeue могут быть очень медленными из-за цикла для поиска свободных слотов. Использование стека или связанного списка было бы лучшим выбором и помогло бы повторно использовать недавно использованные буферы для помощи с кэшированной памятью. - person edwinc; 26.02.2016
comment
@edwinc Они не будут очень медленными, поскольку их возможности ограничены. По сути, они O (1). Также в статье об этом есть специальное примечание. - person neoxic; 26.02.2016
comment
Допустим, цикл событий обслуживает 1000 сокетов. Первые 900 буферов каким-то образом попадают в сокеты, обслуживающие трафик к мобильным телефонам, и не возвращаются вовремя. Цикл может повторяться сотни раз для получения новых буферов. Даже задержка 1u может стоить дополнительно 1 с каждую секунду на высокопроизводительном сервере, обрабатывающем 1M сообщений в секунду. Я полагаю, что «медленно» относится к некоторым. В финансовой индустрии это будет медленным, но, вероятно, приемлемым для приложений типа веб-браузера. Связанный список будет включать изменение двух указателей при постановке в очередь / удалении из очереди, независимо от количества буферов. - person edwinc; 27.02.2016
comment
Ну, ситуация, когда количество буферов должно быть эквивалентно количеству сокетов, то есть проблема O (n), НЕ МОГУ ОТВЕЧАТЬ. Как я уже упоминал в ответе и комментарии выше, в этом УПРОЩЕННОМ подходе количество буферов ВСЕГДА является постоянным, то есть не зависит от количества обслуживаемых сокетов. Когда для цикла событий требуется больше буферов, чем доступно, используются так называемые фиктивные буферы. Опять же, это может быть НЕ мелкозернистый подход для каждого возможного случая, включая ваш, и этого не должно быть, потому что его основная цель - просвещение. - person neoxic; 28.02.2016
comment
Спасибо за отличную рецензию. Я подумываю использовать это как шаблон для чего-то, над чем я работаю. Одна гнида: в bufpool_grow(), я думаю, должно быть pool->bufs[idx] = 0;. Поскольку вы немедленно возвращаете новый буфер, вы хотите, чтобы его пространство в пуле было пустым. В противном случае вы можете ударить assert в bufpool_enqueue. - person Kevin Richardson; 08.03.2016
comment
@KevinRichardson Спасибо за ваш комментарий! Вы правы насчет bufpool_grow(). В моем собственном коде я храню все указатели выделенных буферов в pool->bufs и управляю их занятостью через отдельную очередь. Здесь я немного упростил этот подход и совместил обе задачи. Похоже, я пропустил этот момент в bufpool_grow(). Фиксированный! - person neoxic; 08.03.2016

Если вы работаете в Linux, вам повезло. Ядро Linux обычно по умолчанию использует так называемый SLAB Allocator. Преимущество этого распределителя заключается в том, что он сокращает фактическое выделение памяти за счет поддержки пулов повторно используемых блоков. Для вас это означает, что если вы всегда выделяете буферы одинакового размера (в идеале pow2 размером PAGE_SIZE), вы можете использовать malloc() в Linux.

Если вы не работаете в Linux (FreeBSD или Solaris) или разрабатываете кроссплатформенное приложение, вы можете рассмотреть возможность использования glib и его Memory Slices, которые представляют собой кроссплатформенную реализацию распределителя SLAB. Он использует собственные реализации на платформах, которые его поддерживают, поэтому его использование в Linux не принесет никаких преимуществ (я сам провел несколько тестов). Я уверен, что есть другие библиотеки, которые могут сделать то же самое, или вы можете реализовать это самостоятельно.

person dtoux    schedule 22.05.2015
comment
Если вы измеряете производительность malloc() по умолчанию в Linux для больших блоков размером более 16 КБ, вы увидите, насколько он медленнее по сравнению с меньшими размерами. С другой стороны, libuv по умолчанию требует размер буфера 64 КБ. Так что выделение такого большого буфера со значением по умолчанию malloc() не является чем-то особенным, если вас беспокоит мелкозернистая производительность. - person neoxic; 27.05.2015
comment
@neoxic Мне нужно будет запустить несколько тестов с большими размерами блоков, но общая идея заключается в том, что если вы выполните malloc(), затем free(), а затем снова malloc() с тем же размером блока, во второй раз все должно пройти намного быстрее. - person dtoux; 28.05.2015
comment
Пожалуйста, сделай. Это именно то, что я сделал, и был очень разочарован. Потом я узнал, что маленькие и большие размеры обрабатываются / хранятся отдельно по стандарту malloc(). - person neoxic; 28.05.2015