C#/CLR: MemoryBarrier и разорванные чтения
Просто играл с параллелизмом в свое свободное время и хотел попытаться предотвратить разрывное чтение без использования блокировок на стороне читателя, чтобы одновременные читатели не мешали друг другу.
Идея состоит в том, чтобы сериализовать записи через блокировку, но использовать только барьер памяти на стороне чтения. Вот многократно используемая абстракция, которая заключает в себе подход, который я придумал:
public struct Sync<T>
where T : struct
{
object write;
T value;
int version; // incremented with each write
public static Sync<T> Create()
{
return new Sync<T> { write = new object() };
}
public T Read()
{
// if version after read == version before read, no concurrent write
T x;
int old;
do
{
// loop until version number is even = no write in progress
do
{
old = version;
if (0 == (old & 0x01)) break;
Thread.MemoryBarrier();
} while (true);
x = value;
// barrier ensures read of 'version' avoids cached value
Thread.MemoryBarrier();
} while (version != old);
return x;
}
public void Write(T value)
{
// locks are full barriers
lock (write)
{
++version; // ++version odd: write in progress
this.value = value;
// ensure writes complete before last increment
Thread.MemoryBarrier();
++version; // ++version even: write complete
}
}
}
Не беспокойтесь о переполнении переменной версии, я избегаю этого другим способом. Так правильно ли мое понимание и применение Thread.MemoryBarrier в приведенном выше? Есть ли необходимость в барьерах?
2 ответа
Я внимательно посмотрел на ваш код, и он мне кажется правильным. Одна вещь, которая сразу бросилась в глаза, это то, что вы использовали установленный шаблон для выполнения операции с низким уровнем блокировки. Я вижу, что вы используете version
как своего рода виртуальный замок. Четные числа освобождаются, а нечетные числа приобретаются. А поскольку вы используете монотонно увеличивающееся значение для виртуальной блокировки, вы также избегаете проблемы ABA. Однако наиболее важным является то, что вы продолжаете цикл при попытке чтения до тех пор, пока значение виртуальной блокировки не станет одинаковым до начала чтения по сравнению с его завершением. В противном случае вы считаете это неудачным чтением и попробуйте снова и снова. Так что да, хорошо проделана работа по основной логике.
Так что насчет размещения генераторов барьера памяти? Ну, это все выглядит довольно хорошо. Все Thread.MemoryBarrier
звонки требуются. Если бы мне пришлось придираться, я бы сказал, что вам нужен еще один Write
метод, чтобы это выглядело так.
public void Write(T value)
{
// locks are full barriers
lock (write)
{
++version; // ++version odd: write in progress
Thread.MemoryBarrier();
this.value = value;
Thread.MemoryBarrier();
++version; // ++version even: write complete
}
}
Добавленный вызов здесь гарантирует, что ++version
а также this.value = value
не меняйся местами. Теперь спецификация ECMA технически допускает такой порядок перестановки команд. Тем не менее, реализация Microsoft CLI и аппаратного обеспечения x86 уже имеет изменчивую семантику при записи, поэтому в большинстве случаев это не понадобится. Но, кто знает, может быть, это понадобится во время выполнения Mono для процессора ARM.
На Read
сторона вещей я не могу найти недостатков. На самом деле, размещение звонков у вас именно там, где я бы их сделал. Некоторые люди могут задаться вопросом, почему вам не нужен один перед первым чтением version
, Причина в том, что внешний цикл будет ловить случай, когда первое чтение было кэшировано из-за Thread.MemoryBarrier
дальше.
Так что это приводит меня к дискуссии о производительности. Это действительно быстрее, чем жесткая блокировка в Read
метод? Ну, я провел довольно обширное тестирование вашего кода, чтобы помочь ответить на этот вопрос. Ответ окончательный да! Это немного быстрее, чем жесткая блокировка. Я проверил с помощью Guid
как тип значения, потому что он 128 бит и поэтому он больше, чем собственный размер слова моей машины (64 бита). Я также использовал несколько разных вариантов количества писателей и читателей. Ваша техника низкого запирания последовательно и значительно превосходила технику жесткого запирания. Я даже попробовал несколько вариантов, используя Interlocked.CompareExchange
читать осторожно, и все они были медленнее. На самом деле, в некоторых ситуациях это было на самом деле медленнее, чем захват жесткого замка. Я должен быть честным. Меня это совсем не удивило.
Я также провел довольно серьезное тестирование. Я создал тесты, которые будут выполняться довольно долго, и ни разу я не видел разорванного чтения. А затем в качестве контрольного теста я бы настроить Read
метод таким образом, что я знал, что это будет неправильно, и я снова запустил тест. На этот раз, как и ожидалось, разорванные чтения стали появляться случайно. Я переключил код обратно на то, что у вас есть, и разорванные чтения исчезли; снова, как и ожидалось. Казалось, это подтверждает то, что я уже ожидал. То есть ваш код выглядит правильно. У меня нет большого разнообразия сред выполнения и аппаратных средств для тестирования (и у меня нет времени), поэтому я не готов дать ему 100% одобрение, но я думаю, что могу дать вашей реализации два больших пальца теперь.
Наконец, со всем, что сказал, я все еще избегал бы запуска этого в производство. Да, это может быть правильно, но следующий парень, который должен поддерживать код, вероятно, не поймет его. Кто-то может изменить код и сломать его, потому что он не понимает последствий своих изменений. Вы должны признать, что этот код довольно хрупкий. Даже малейшее изменение может сломать его.
Кажется, вы интересуетесь реализацией без блокировок и без ожидания. Давайте начнем с этого обсуждения, например: многопоточность без блокировки предназначена для настоящих экспертов по многопоточности