Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

Lock-free конструкции данных. Внутри. Схемы управления памятью

Anna | 24.06.2014 | нет комментариев
Как я упоминал в своих предыдущих заметках, основными сложностями при реализации lock-free конструкций данных являются ABA-задача и удаление памяти. Я разделяю эти две задачи, хоть они и связаны: дело в том, что существуют алгорифмы, решающие только одну из них.
В этой статье я дам обзор знаменитых мне способов неопасного удаления памяти (safe memory reclamation) для lock-free контейнеров. Показывать использование того либо другого способа я буду на классической lock-free очереди Майкла-Скотта [MS98].

Tagged pointers

Схема меченых указателей (tagged pointers) была предложена IBM для решения ABA-задачи. Вероятно, это 1-й знаменитый алгорифм решения данной задачи.
Согласно этой схеме, всякий указатель представляет собой неделимую пару из собственно адреса ячейки памяти и его тега – 32-битового целого числа.

template <typename T>
struct tagged_ptr {
    T * ptr ;
    unsigned int tag ;

    tagged_ptr(): ptr(nullptr), tag(0) {}
    tagged_ptr( T * p ): ptr(p), tag(0) {}
    tagged_ptr( T * p, unsigned int n ): ptr(p), tag(n) {}

    T * operator->() const { return ptr; }
};

Тег выступает как номер версии и инкрементируется при всякой операции CAS над меченым указателем иникогда не сбрасывается, то есть сурово повышается. При удалении элемента из контейнера взамен физического удаления элемент следует помещать в определенный список свободных элементов free-list. При этом абсолютно возможно, если к удаленному элементу, находящемуся во free-list, будет обращение: конструкции у нас lock-free, так что пока один поток удаляет элемент X, иной поток может иметь локальную копию меченого указателя на X и обращаться к полям элемента. Следственно free-list должен быть обособленный для всякого типа T, больше того, вызывать деструктор данных типа T при помещении элемента во free-list во многих случаях неприемлемо (по причине параллельного доступа – во время работы деструктора иной поток может читать данные этого элемента).
Схема tagged pointer имеет следующие недочеты:

  • Схема реализуема на платформах, на которых существует атомарный примитив CAS над двойным словом (dwCAS). Для 32-битных современных платформ это требование выполняется, так как dwCAS работает с 64-битными словами, а все современные архитектуры имеют полный комплект 64-битных инструкций. Но для 64-битного режима работы требуется 128-битный (либо, по крайней мере, 96-битный) dwCAS, что реализовано не на всех архитектурах.

    Да вы ерунду написали, батенька!

    Особенно искушенные в lock-free могут возразить, что для реализации tagged pointers не непременно иметь широкий 128-битный (либо 96-битный) CAS. Дозволено обойтись 64-битным, если учесть, что современные процессоры применяют только 48 бит для адресации, старшие 16 бит адреса свободны и могут быть использованы для хранения счетчика-тега. Что ж, подлинно, могут. Так сделано вboost.lockfree.
    В таком подходе есть два «но»:

    • Кто гарантирует, что в грядущем эти старшие 16 бит адреса не будут задействованы? Как только наступит следующий прорыв в области чипов памяти и её объем усилится прыжком на порядок, — вендоры сразу выпустят новые процессоры с всецело 64-разрядной адресацией
    • Довольно ли 16 бит для хранения тега? На данный счет проводились изыскания, и итог этих изысканий таков: 16 бит неудовлетворительно, абсолютно допустимо переполнение, что допустимо может привести к происхождению ABA-задачи. А вот 32 бита – довольно.
      Подлинно, 16 бит – это значения тега 0 – 65535. В современных операционных системах квант времени, отводящийся потоку, порядка 300 – 500 тысяч ассемблерных инструкций (взято из внутренней переписки разработчиков linux), и с увеличением продуктивности процессоров данный квант может только расти. Получается, за один квант абсолютно дозволено исполнить 65 тысяч даже таких тяжелых операций, как CAS (ну, если немыслимо сегодня, то будет допустимо теснее завтра). Таким образом, при 16-битовом теге мы абсолютно рискуем получить ABA-задачу.
  • Реализация free-list – это lock-free стек либо lock-free очередь, что вносит свой негативный взнос в эффективность: ещё как минимум по одному вызову CAS при извлечении элемента из free-list и добавлении в него. С иной стороны, присутствие free-list может содействовать увеличению продуктивности, таконец очереди никогда не будут равны NULL. Знаком пустоты очереди является условие m_Head == m_Tail && m_Tail->next == NULL (строки D6-D8). Последнее условие (m_Tail->next == NULL) является значительным, так как в процессе добавления в очередь мы не изменяемm_Tail, — строка E9 лишь меняет m_Tail->next. Таким образом, на 1-й взор способ enqueue нарушает конструкцию очереди. На самом деле, метаморфоза хвоста m_Tail происходит в ином способе и/или ином потоке: операция enqueue перед добавлением элемента проверяет (строка E8), что m_Tailуказывает на конечный элемент (то есть m_Tail->next == NULL), и если это не так, пытается передвинуть указатель на конец (строка E12); верно так же, операция dequeue перед выполнением своих «непосредственных обязанностей» изменяет m_Tail, если он указывает не на конец очереди (строка D9). Это показывает нам общеизвестный подход в lock-free программировании — взаимопомощь потоков (helping): алгорифм одной операции является «размазанным» по каждому операциям контейнера и одна операция значительно полагается на то, что её работу закончит дальнейший вызов (допустимо, иной) операции в (допустимо) ином потоке.Ещё одно принципиальное слежение: операции запоминают в локальных переменных значения указателей, которые им требуются для работы (строки E5-E6, D2-D4). После этого (строки E7, D5) только что считанные значения сверяются с оригиналами — классический прием lock-free, избыточный для неконкурентного программирования: за время, прошедшее с момента считывания подлинные значения могут измениться. Дабы компилятор не оптимизировал доступ к разделяемым данным очереди (а слишком «разумный» компилятор горазд и совсем удалить строки сопоставления E7 либо D5), m_Headи m_Tail обязаны быть объявлены в C 11 как atomic (в псевдокоде – volatile). Помимо того, припомним, что CAS-примитив сверяет значение целевого адреса с заданным, и если они равны, изменяет данные по целевому адресу на новое значение. Следственно для CAS-примитива неизменно следует указывать локальную копию нынешнего значения; вызов CAS(&val, val, newVal) будет преуспевающим примерно неизменно, что для нас является оплошностью.Сейчас посмотрим, когда в способе dequeue происходит копирование данных (строка D11): передисключением элемента из очереди (строка D12). Рассматривая, что исключение элемента (движениеm_Head в строке D12) может обиход неудачным, копирование данных (D11) может быть многократным. С точки зрения C , это обозначает, что данные, хранимые в очереди, не обязаны быть слишком трудными, напротив убыточные расходы на оператор присвоения в строке D11 будут слишком огромны. Соответственно, в условиях высокой нагрузки возрастает вероятность неудачи примитива CAS. Попытка «оптимизировать» алгорифм выносом строки D11 за пределы цикла приведет к ошибке: элемент nextможет быть удален иным потоком. Так как рассматриваемая реализация очереди основана на схеме tagged pointer, в которой элементы никогда не удаляются, то такая «оптимизация» приведет к тому, что мы можем возвратить неправильные данные (не те, которые были в очереди на момент удачного выполнения строки D12).

    Специфика M&S-очереди

    Вообще, алгорифм MSQueue увлекателен тем, что m_Head неизменно указывает на dummy node, а первым элементом непустой очереди является дальнейший за m_Head элемент. При dequeue из непустой очереди читается значение первого элемента, то есть m_Head.next, dummy-элемент удаляется, а новым dummy-элементом (и новой головой) становится дальнейший элемент, то есть тот самый, значение которого мы возвращаем. Получается, что физически удалить элемент дозволено только позже дальнейшей операцииdequeue.
    Эта специфика может доставить много хлопот, если вы захотите применять интрузивный вариант очередиcds::intrusive::MSQueue.

    Epoch-based reclamation

    Фразер [Fra03] предложил схему, основанную на эрах. Используется отложенное удаление в неопасный момент времени, когда еk!spoiler_text”>честности ради следует подметить, что существуют «громадные» конструкции данных, требующие свыше 64 hazard pointer’ов. В качестве примера дозволено привести skip-list (cds::container::SkipListMap) – вероятностную конструкцию данных, по сути, список списков, с переменной высотой всякого элемента. Такие контейнеры не дюже подходят для схемы Hazard Pointer, правда в libcds есть реализация skip-list для этой схемы.

Псевдокод схемы Hazard Pointers [Mic02]:

// Константы
// P : число потоков
// K : число hazard pointer в одном потоке
// N : всеобщее число hazard pointers = K*P
// R : batch size, R-N=т удалены.

Объявление указателя как Hazard Pointer, как правило, выполняется дальнейшим образом:
std::atomic<T *> atomicPtr ;
…
T * localPtr ;
do {
    localPtr = atomicPtr.load(std::memory_order_relaxed);
    HP[i] = localPtr ;
} while ( localPtr != atomicPtr.load(std::memory_order_acquire));

 Мы читаем атомарный указатель atomicPtr в локальную переменную localPtr (с которой после этого будем трудиться) и в вольный слот HP[i] массива HP hazard-указателей нынешнего потока. Позже этого следует проверить, что за время, пока мы читали atomicPtr, его значение не было изменено иным потоком, для чего мы опять читаем atomicPtr и сопоставляем его с ранее прочитанным значением localPtr. Так происходит до тех пор, пока мы не разместим в массив HP правдивое (на момент объявления как hazard) значение atomicPtr. Пока указатель находится в массиве Hazard Pointer’ов (то есть объявлен как hazard), он не может быть физически удален никаким потоком, следственно обращение по указателю не приведет к чтению мусора либо записи в свободную область памяти.

 Схема Hazard Pointer (HP-схема) подробно проанализирована с точки зрения атомарных операций C 11 и memory ordering в работе [Tor08].

MSQueue в исполнении Hazard Pointer

Lock-free очередь Майкла-Скотта [MS98] в исполнении Hazard Pointer. Тут я привожу “чистый” псевдокод, без специфики libcds:

template <typename T>
class MSQueue {
    struct node {
        std::atomic<node *>  next ;
        T data;

        node(): next(nullptr) {}
        node( T const& v): next(nullptr), data(v) {}
    };
    std::atomic<node *> m_Head;
    std::atomic<node *> m_Tail;

public:
    MSQueue()
    {
        node * p = new node;
        m_Head.store( p, std::memory_order_release );
        m_Tail.store( p, std::memory_order_release );
    }

    void enqueue( T const& data )
    {
       node * pNew = new node( data );
       while (true) { 
	    node * t = m_Tail.load(std::memory_order_relaxed);

          // объявление указателя как hazard. HP – thread-private массив
 	    HP[0] = t;		
          // непременно проверяем, что m_Tail не изменился!
	    if (t != m_Tail.load(std::memory_order_acquire) continue;					

          node * next = t->next.load(std::memory_order_acquire);
	    if (t != m_Tail) continue;
	    if (next != nullptr) { 
              // m_Tail указывает  на конечный элемент
              // продвигаем m_Tail
 	        m_Tail.compare_exchange_weak(
                t, next, std::memory_order_release); 
	        continue;
          } 
          node * tmp = nullptr;
          if ( t->next.compare_exchange_strong(
               tmp, pNew, std::memory_order_release))
              break;
       }
       m_Tail.compare_exchange_strong( t, pNew, std::memory_order_acq_rel );
       HP[0] = nullptr; // обнуляем hazard pointer
    }

    bool dequeue(T& dest) 
    { 
       while true { 
	    node * h = m_Head.load(std::memory_order_relaxed);
          // Устанавливаем Hazard Pointer
	    HP[0] = h;
          // Проверяем, что m_Head не изменился
 	    if (h != m_Head.load(std::memory_order_acquire)) continue;

	    node * t = m_Tail.load(std::memory_order_relaxed);
	    node * next = h->next.load(std::memory_order_acquire);

          // head->next также подмечаем как Hazard Pointer
	    HP[1] = next;

          // Если m_Head изменился – начинаем все снова
	    if (h != m_Head.load(std::memory_order_relaxed))
            continue;

          if (next == nullptr) { 
             // Очередь пуста
	       HP[0] = nullptr; 
	       return false;
 	    } 
          if (h == t) { 
            // Помогаем способу enqueue – продвигаем m_Tail
	      m_Tail.compare_exchange_strong( t, next,
                   std::memory_order_release); 
            continue;
	    } 
          dest = next->data;
	    if ( m_Head.compare_exchange_strong(h, next,
                       std::memory_order_release))
             break;
       }
       // Обнуляем Hazard Pointers
       HP[0] = nullptr; 
       HP[1] = nullptr;
       // Помещаем ветхую голову очереди в массив
       // готовых к удалению данных.
       RetireNode(h); 
    }
};


 Является ли схема Hazard Pointer многофункциональной? То есть применима ли она для всех конструкций данных? В том виде, как она описана тут, — нет. Повод примитивна: число hazard pointer’ов ограничено константой K. Для большинства конструкций данных это условие – ограниченное число hazard pointer – выполняется, причем, как было подмечено, число hazard-указателей традиционно достаточно немного. Но существуют алгорифмы, для которых немыслимо априори оценить число единовременно требуемых hazard-указателей. Пример – отсортированный список Харриса [Har01]. В алгорифме удаления элемента этого списка может удаляться цепочка неопределенной длины, что делает HP-схему непрvmk!
 Это немножко упрощенный подход, но суть, я думаю, ясна.

 

Если вы хотите применять контейнеры на базе HP-схемы из библиотеки libcds, то довольно легко объявить в main() объект типа cds::gc::HP и подключить его к всякому потоку, использующему контейнеры, основанные на HP-схеме, как я описал в предыдущей статье. Другое дело, если требуется написать личный класс контейнера, учрежденный наcds::gc::HP. В этом случае нужно знать API HP-схемы.

 

API класса cds::gc::HP

Все способы класса cds::gc::HP – статические, что подчеркивает, что класс является оберткой над синглтоном.

  • Конструктор
    HP(size_t nHazardPtrCount = 0, 
       size_t nMaxThreadCount = 0, 
       size_t nMaxRetiredPtrCount = 0,          
       cds::gc::hzp::scan_type nScanType = cds::gc::hzp::inplace);
    

    nHazardPtrCount – наивысшее число hazard pointer’ов (константа K схемы)
    nMaxThreadCount – наивысшее число потоков (константа P схемы)
    nMaxRetiredPtrCount – размерность массива retired-указателей (константа R = 2KP схемы)
    nScanType – маленькая оптимизация: значениеcds::gc::hzp::classic указывает, что следует верно следовать псевдокоду алгорифма Scan; значение cds::gc::hzp::inplace разрешает избавиться в Scan() от масива new_dlist и применять взамен него dlist (он чай может только уменьшаться).

    Следует помнить, что объект cds::gc::HPможет быть только один. Конструктор на самом деле вызывает статические функции инициализации ядра, так что попытка объявить два объекта класса cds::gc::HP не приведет к созданию 2-х схем Hazard Pointer, а лишь к повторной инициализации, что неопасно, но напрасно.

  • Помещение указателя в массив retired (отложенное удаление) нынешнего потока
    template <class Disposer, typename T>
    static void retire( T * p ) ;
    template <typename T>
    static void retire( T * p, void (* pFunc)(T *) )
    

    Довод Disposer (либо pFunc) задают функтор удаления (диспозер). Вызов в первом случае достаточно высокопарный:

    struct Foo { … };
    struct fooDisposer {
       void operator()( Foo * p ) const { delete p; }
    };
    
    // Вызов диспозера myDisposer для указателя на Foo
    Foo * p = new Foo ;
    cds::gc::HP::retire<fooDisposer>( p );
    
  • static void force_dispose();
    

    Принудительный вызов алгорифма Scan() схемы Hazard Pointer. Не знаю, для чего это может потребоваться в реальной жизни, но в тестах libcds это бывает нужно.

Помимо этого, в классе cds::gc::HP объявлены три значимых подкласса:

  • thread_gc – представляет собой обертку вокруг кода инициализации приватных для потока данных (thread data), относящихся к схеме Hazard Pointer. Данный класс предоставляет только конструктор, осуществляющий подключение HP-схемы к потоку, и деструктор, отключающий поток от схемы
  • Guard – собственно hazard pointer
  • template <size_t Count> GuardArray – массив hazard pointer’ов. При использовании HP-схемы дюже Зачастую требуется объявить несколько hazard-указателей сразу. Больше результативно сделать это сразу одним массивом, а не несколькими объектами типаGuard

Классы Guard и GuardArray<N> представляют собой надстройку над внутренним массивом Hazard Pointer, приватным для потока. Они работают как аллокаторы из этого внутреннего массива.

Класс Guard суть hazard-слот и имеет следующее API:

  • template <typename T>
    T protect( CDS_ATOMIC::atomic<T> const& toGuard );
    
    template <typename T, class Func>
    T protect( CDS_ATOMIC::atomic<T> const& toGuard, Func f );
    

    Объявление атомарного указателя (тип T – традиционно указатель) как hazard. Внутри этих способов спрятан цикл, тот, что я описывал ранее: читается значение атомарного указателя toGuard, присваивается hazard pointer’у и после этого проверяется, что прочитанный указатель не изменился.
    2-й синтаксис (с функтором Func) нужен, если нам требуется объявить как hazard не сам указатель на T *, а некоторый производный от него указатель. Это особенность интрузивных контейнеров, в которых контейнер управляет указателями на узел (node), а указатель на настоящие данные может от него отличаться (скажем, node может являться полем реальных данных). Функтор Func имеет такую сигнатуру:

    struct functor {
       value_type * operator()( T * p ) ;
    };
    

    Оба способа возвращают указатель, тот, что они объявили как hazard.

  • template <typename T> 
    T * assign( T * p );
    
    template <typename T, int Bitmask>
    T * assign( cds::details::marked_ptr<T, Bitmask> p );
    

    Эти способы объявляют указатель p как hazard. Тут, в различие от protect, нет никакого цикла, — p легко помещается в hazard-слот.
    2-й синтаксис предуготовлен длямаркированных указателейcds::details::marked_ptr. В marked-указателях применяются младшие 2-3 бита (которые неизменно 0 на выравненных данных) в качестве хранилища битовых флагов, — дюже общеизвестный прием в lock-free программировании. Данный способ помещает в hazard-слот указатель с обнуленными битами флагов (по маске Bitmask).
    Способы возвращают указатель, тот, что они объявили как hazard.

  • template <typename T>
    T * get() const;
    

    Читаем значение нынешнего hazard-слота. Изредка это бывает необходимо.

  • void copy( Guard const& src );
    

    Копирует значение hazard-слота из src вthis. В итоге два hazard-слота содержат одно и то же значение.

  • void clear();
    

    Обнуление значения hazard-слота. То е самое делает деструктор класса Guard.

Класс GuardArray владеет аналогичным API, только указывается ещё индекс в массиве:

template <typename T>
T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard );

template <typename T, class Func>
T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard, Func f )

template <typename T>
T * assign( size_t nIndex, T * p );

template <typename T, int Bitmask>
T * assign( size_t nIndex, cds::details::marked_ptr<T, Bitmask> p );

void copy( size_t nDestIndex, size_t nSrcIndex );

void copy( size_t nIndex, Guard const& src );

template <typename T>
T * get( size_t nIndex) const;

void clear( size_t nIndex);

Внимательный читатель подметит неизвестноеCDS_ATOMIC – что это?
Это макрос, объявляющий подходящее пространство имен для std::atomic. Если компилятор поддерживает (и реализует) C 11atomic, то CDS_ATOMIC есть std. Если не поддерживает – это cds::cxx11_atomics, пространство имен, в котором располагаются личные libcds-велосипеды для atomic. В грядущей версии libcds дозволено будет применять boost.atomic, тогда CDS_ATOMICбудет равно boost.

 

 

Hazard Pointer with Reference Counter



Недостатком схемы Hazard Pointer является то, что она предуготовлена для охраны только локальных ссылок на узлы lock-free контейнера. Всеобщии ссылки, требующиеся, скажем, для реализации доктрины итераторов, она защитить не может: для этого понадобился бы в тезисе неограниченwait-free и lock-free. Wait-free алгорифм требует dwCAS (CAS над двойным словом), что делает алгорифм зависящим от поддержки dwCAS на целевой платформе. Lock-free алгорифм увлекателен тем, что может трудиться только при изменении данных. Если данные (guard'ы и retired-указатели) остаются постоянными между вызовами lock-free версии Liberate, допустимо зацикливание (вернее, алгорифм не удалит все допустимые retired-данные, следственно его необходимо будет вызывать ещё и ещё). Обстановка неизменности данных наступает в конце программы, когда в деструкторе синглтона PTB-схемы происходит чистка, в том числе вызывается Liberate.

Довольно помучившись в свое время с зацикливанием, я решил изменить алгорифмLiberate PTB-схемы, сделав его по аналогии с HP-схемой. В итоге реализация PTB в libcds стала больше схожей на вариант HP-схемы с произвольным числом hazard-указателей и цельным массивом retired-данных. На продуктивности это сказалось слабо: «чистая» HP-схема все же немножко стремительней, чем PTB, но PTB может быть предпочтительнее за счет отсутствия ограничений на число guard’ов.

 

Реализация в libcds

В библиотеке libcds PTB-схему представляет класс cds::gc::PTB, детали реализации находятся в namespace cds::gc::ptb. API класса cds::gc::PTB всецело аналогиченcds::gc:::HP, за исключением доводов конструктора. Конструктор принимает следующие доводы:

PTB( size_t nLiberateThreshold = 1024, size_t nInitialThreadGuardCount = 8 );
  • nLiberateThreshold — порог вызова Liberate. Как только всеобщий массив retired-данных достигнет этого размера, вызывается Liberate
  • nInitialThreadGuardCount — исходное число quard’ов при создании потока (вернее, при подсоединении потока к внутренней инфраструктуре libcds). В дальнейшем в случае нехватки guard’ов механически создаются новые

 

 

Завершение


В этой статье я дал обзор знаменитых мне алгорифмов safe memory reclamation, сделав специальный упор на схемы типа Hazard Pointer. HP-схема и её разновидности предоставляют недурной метод обеспечения безвредного управления памятью в lock-free конструкциях данных.

Все вышенаписанное относится больше к области создания lock-free конструкций данных. Если вы легко используете библиотеку libcds, то довольно только инициализировать выбранную схему, не забывать подключать (attach) потоки к схеме и подставлять класс схемы в качестве первого довода GC контейнера из библиотеки libcds. Охрана ссылок, вызов Scan() / Liberate() и пр. — всё это теснее зашито внутри реализации контейнера.

За бортом остался ещё один увлекательный алгорифм — RCU, отличающийся от HP-like схем, — но о нем я расскажу в одной из следующих статей.

 

Ссылки

[Fra03] Keir Fraser Practical Lock Freedom, 2004; technical report is based on a dissertation submitted September 2003 by K.Fraser for the degree of Doctor of Philosophy to the University of Cambridge, King’s College

[GPST05] Anders Gidenstam, Marina Papatriantafilou, Hakan Sundell, Philippas Tsigas Practical and Efficient Lock-Free Garbage Collection Based on Reference Counting, Technical Report no. 2005-04 in Computer Science and Engineering at Chalmers University of Technology and Goteborg University, 2005

[Har01] Timothy Harris A pragmatic implementation of Non-Blocking Linked List, 2001

[HLM02] M. Herlihy, V. Luchangco, and M. Moir The repeat offender problem: A mechanism for supporting
dynamic-sized lockfree data structures
 Technical Report TR-2002-112, Sun Microsystems
Laboratories, 2002.

[HLM05] M.Herlihy, V.Luchangco, P.Martin, and M.Moir Nonblocing Memory Management Support for Dynamic-Sized Data Structure, ACM Transactions on Computer Systems, Vol. 23, No. 2, May 2005, Pages 146–196.

[Mic02] Maged Michael Safe Memory Reclamation for Dynamic Lock-Free Objects Using Atomic Reads and Writes, 2002

[Mic03] Maged Michael Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects, 2003

[MS98] Maged Michael, Michael Scott Simple, Fast and Practical Non-Bloking and Blocking Concurrent Queue Algorithms, 1998

[Tor08] Johan Torp The parallelism shift and C ’s memory model, chapter 13, 2008

 

 

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB