Проверенная реализация алгоритма блокировки Петерсона?

кто-нибудь знает о хорошей/правильной реализации алгоритм блокировки Петерсона в C? Я не могу найти это. Спасибо.

2 ответов


Я не буду делать никаких утверждений о том, насколько хороша или правильна реализация, но она была протестирована (кратко). Это прямой перевод алгоритма, описанного в Википедии.

struct petersonslock_t {
    volatile unsigned flag[2];
    volatile unsigned turn;
};
typedef struct petersonslock_t petersonslock_t;

petersonslock_t petersonslock () {
    petersonslock_t l = { { 0U, 0U }, ~0U };
    return l;
}

void petersonslock_lock (petersonslock_t *l, int p) {
    assert(p == 0 || p == 1);
    l->flag[p] = 1;
    l->turn = !p;
    while (l->flag[!p] && (l->turn == !p)) {}
};

void petersonslock_unlock (petersonslock_t *l, int p) {
    assert(p == 0 || p == 1);
    l->flag[p] = 0;
};

Грег указывает, что в архитектуре SMP со слегка расслабленной когерентностью памяти (например, x86), хотя нагрузки в одно и то же место памяти в порядке, нагрузки в разные места на одном процессоре могут показаться не в порядке на другой процессор.

Йенс Gustedt и ninjalj рекомендуем изменить исходный алгоритм, чтобы использовать atomic_flag тип. Это означает, что установка флагов и поворотов будет использовать atomic_flag_test_and_set и очистка их будет использовать atomic_flag_clear из C11. Кроме того, может быть установлен барьер памяти между обновлениями до flag.

Edit: первоначально я попытался исправить это, написав в одно и то же место памяти для всех состояний. ninjalj отметил, что побитовые операции превратили государственные операции в RMW вместо загрузки и хранения исходного алгоритма. Таким образом, требуются атомарные побитовые операции. C11 предоставляет такие операторы, как и GCC со встроенными модулями. Алгоритм ниже использует встроенные модули GCC, но завернутые в макросы, чтобы его можно было легко изменить на другую реализацию. Однако, изменение исходного алгоритма выше, является предпочтительным решением.

struct petersonslock_t {
    volatile unsigned state;
};
typedef struct petersonslock_t petersonslock_t;

#define ATOMIC_OR(x,v)   __sync_or_and_fetch(&x, v)
#define ATOMIC_AND(x,v)  __sync_and_and_fetch(&x, v)

petersonslock_t petersonslock () {
    petersonslock_t l = { 0x000000U };
    return l;
}

void petersonslock_lock (petersonslock_t *l, int p) {
    assert(p == 0 || p == 1);
    unsigned mask = (p == 0) ? 0xFF0000 : 0x00FF00;
    ATOMIC_OR(l->state, (p == 0) ? 0x000100 : 0x010000);
    (p == 0) ? ATOMIC_OR(l->state, 0x000001) : ATOMIC_AND(l->state, 0xFFFF00);
    while ((l->state & mask) && (l->state & 0x0000FF) == !p) {}
};

void petersonslock_unlock (petersonslock_t *l, int p) {
    assert(p == 0 || p == 1);
    ATOMIC_AND(l->state, (p == 0) ? 0xFF00FF : 0x00FFFF);
};

алгоритм Петерсона не может быть правильно реализован в C99, как объясняется в кто заказал заборы памяти на x86.

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

LOCK:

interested[id] = 1                interested[other] = 1
turn = other                      turn = id

while turn == other               while turn == id
  and interested[other] == 1        and interested[id] == 1


UNLOCK:

interested[id] = 0                interested[other] = 0

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

также, как и в каждом замке, доступ к памяти в критическом разделе не может быть поднят мимо вызова lock () или погружен мимо unlock (). Т. е.: блокировка() должен иметь по крайней мере приобретать семантику, и Unlock (), должен иметь, по крайней мере, освобождения семантики.

в C11 самый простой способ достичь этого - использовать последовательно согласованный порядок памяти, который заставляет код работать так, как если бы это было простое чередование потоков, запущенных в порядке программы (предупреждение: полностью непроверенный код, но это похоже на пример Дмитрия Вьюкова Relacy Race На Детектор):

lock(int id)
{
    atomic_store(&interested[id], 1);
    atomic_store(&turn, 1 - id);

    while (atomic_load(&turn) == 1 - id
           && atomic_load(&interested[1 - id]) == 1);
}

unlock(int id)
{
    atomic_store(&interested[id], 0);
}

это гарантирует, что компилятор не делает оптимизаций, которые нарушают алгоритм (путем подъема/опускания нагрузок/магазинов через атомарные операции), и выдает соответствующие инструкции CPU, чтобы гарантировать, что CPU также не нарушает алгоритм. Модель памяти по умолчанию для атомарных операций C11 / c++11, которые явно не выбирают модель памяти, - это последовательно согласованная память модель.

C11 / c++11 также поддерживает более слабые модели памяти, позволяя как можно больше оптимизации. Ниже приведен перевод на C11 перевода на C++11 по Энтони Уильямс алгоритма первоначально Дмитрия Вьюкова в синтаксисе его собственного детектора гонки относительности [petersons_lock_with_C++0x_atomics] [непостижимая-c-модель-памяти]. Если этот алгоритм неверен, это моя вина (предупреждение: также непроверено код, но на основе хорошего кода от Дмитрия Вьюкова и Энтони Уильямса):

lock(int id)
{
    atomic_store_explicit(&interested[id], 1, memory_order_relaxed);
    atomic_exchange_explicit(&turn, 1 - id, memory_order_acq_rel);

    while (atomic_load_explicit(&interested[1 - id], memory_order_acquire) == 1
           && atomic_load_explicit(&turn, memory_order_relaxed) == 1 - id);
}

unlock(int id)
{
    atomic_store_explicit(&interested[id], 0, memory_order_release);
}

обратите внимание на обмен с семантикой приобретения и выпуска. Обмен-это атомная операция RMW. Атомарные операции RMW всегда считывают последнее сохраненное значение перед записью в операцию RMW. Кроме того, приобретение на атомарном объекте это считывает запись из выпуска на том же атомарном объекте (или позже напишите на этом объекте из потока, который выполнил выпуск или любой более поздней версии писать из любой атомной операции RMW) создает отношение synchronizes-with между выпуском и приобретением.

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

таким образом, у нас есть последовательное отношение между магазином к interested[id] и обмен в/turn, a синхронизирует-с отношением между двумя последовательные обмены от / до turn, и отношения с последовательностью до между обменом от / до turn и нагрузка interested[1 - id]. Этот суммы к случается-до отношения между обращениями к interested[x] in разные темы, с turn обеспечение синхронизации между потоками. Это заставляет весь порядок, необходимый для работы алгоритма.

Итак, как это было сделано до C11? Она включала используя компилятор и ЦП-конкретной магии. В качестве примера рассмотрим довольно сильно упорядоченный x86. IIRC, все нагрузки x86 приобретают семантику, и все магазины имеют выпуск семантика (save non-temporal moves, in SSE, используется именно для достижения большего производительность за счет ocassionally необходимости выдавать заборы CPU для достижения согласованность между процессорами). Но этого недостаточно для алгоритма Петерсона, так как Бартош Milewsky объясняет в who-ordered-memory-fences-on-an-x86 , для Алгоритм Петерсона для работы нам нужно установить порядок между доступ к turn и interested, неспособность сделать это может привести к появлению нагрузок от interested[1 - id] прежде чем пишет interested[id], что плохо.

таким образом, способ сделать это в GCC / x86 будет (предупреждение: хотя я тестировал что-то похожее на следующее, На самом деле измененная версия кода в неправильно-реализация-из-Петерсонс-алгоритм , тестирование и близко обеспечение корректности многопоточного кода):

lock(int id)
{
    interested[id] = 1;
    turn = 1 - id;
    __asm__ __volatile__("mfence");

    do {
        __asm__ __volatile__("":::"memory");
    } while (turn == 1 - id
           && interested[1 - id] == 1);
}

unlock(int id)
{
   interested[id] = 0;
}

на MFENCE предотвращает магазины и нагрузки к различным адресам памяти От быть переупорядоченный. В противном случае напишите interested[id] может быть в очереди в магазине буфер при загрузке interested[1 - id] продолжается. На многих текущих микроархитектуры a SFENCE может быть достаточно, так как он может быть реализован как хранить буферный сток, но IIUC SFENCE не нужно реализовывать таким образом, и может просто предотвратить переупорядочивание между хранилище. Так что SFENCE может быть недостаточно везде, и нам нужен полный MFENCE.

барьер компилятора (__asm__ __volatile__("":::"memory")) предотвращает компилятор от решения, что он уже знает значение turn. Мы сообщая компилятору, что мы заблокировали память, поэтому все значения кэшируются в регистры должны быть перезагружены из памяти.

P. S: Я чувствую, что это нуждается в заключительном абзаце, но мой мозг истощен.