Эрик Липперт упомянул в этом посте в блоге несколько лет назад, что:
Компилятору C#, JIT-компилятору и процессору разрешено выполнять оптимизацию при условии, что оптимизация не обнаруживается текущим потоком. В частности, операции чтения и записи переменных могут быть переупорядочены или полностью исключены при условии, что это не будет наблюдаться в текущем потоке.
Это означает, что следующая программа может законно войти в бесконечный цикл, потому что мутация в x
никогда не наблюдается основным потоком (и на самом деле в .NET 6 это действительно происходит при сборке Release на моей машине):
class Program
{
static void Main(string[] args)
{
int x = 0;
int z = 0;
Task.Run(() =>
{
Thread.Sleep(1000);
x = 1;
});
while (x == 0)
unchecked { z = z + 1; }
}
}
Теперь мне интересно, в серверном приложении, таком как ASP.NET Core, которое по своей сути поддерживает параллелизм и является многопоточным, такое переупорядочивание компилятора/кэширование регистров или любые другие закулисные оптимизации могут привести к тонким ошибкам? Например, предположим, что у меня есть подобная одноэлементная служба, к которой обращаются одновременно, когда сервер обрабатывает запросы:
public class SingletonService
{
private string _cachedValue;
public async Task<string> GetStringValue()
{
if (IsValueExpired(_cachedValue))
{
_cachedValue = await GetNewValue();
}
return _cachedValue;
}
...
}
// when configuring the DI services, e.g. in Startup.cs
services.AddSingleton<SingletonService>;
Возможно ли, чтобы мутация _cachedValue
никогда не наблюдалась потоком обработки запросов, даже спустя долгое время после того, как значение было обновлено другим потоком? Если это так, то это означает, что этот тип кэширования подвержен тонким и трудно воспроизводимым ошибкам, и мне, вероятно, потребуется заключить чтение/запись _cachedValue
в блокировки (которые, как я понимаю, обеспечивают барьеры памяти, которые гарантируют что мутации сбрасываются во все потоки).
В частности, я хотел бы знать, является ли комментарий Эрика выше по-прежнему правильным или актуальным в .NET Core/.NET 5+ и мире async/await сегодня? И если да, то как лучше всего кэшировать такие вещи в памяти в ASP.NET Core? Нужно ли мне для безопасности закрывать весь доступ к общей памяти блокировками?
P.S. Я понимаю разницу между «многозадачностью» и «многопоточностью» и понимаю, что конвейер обработки запросов в ASP.NET Core полностью асинхронен и не привязан к конкретному потоку. Но это не имеет отношения к вопросу выше, поэтому простите меня за использование термина «многопоточность» немного вольно.
Возможно ли, чтобы изменение _cachedValue никогда не наблюдалось потоком обработки запросов, даже спустя долгое время после того, как значение было обновлено другим потоком?
Возможно, но крайне маловероятно. Если поток запроса выполняет что-либо, кроме цикла проверки значения, он, скорее всего, рано или поздно выдаст загрузку значения.
Мне, вероятно, нужно будет обернуть чтение/запись _cachedValue в блокировки
Я считаю, что volatile
должно быть достаточно для этого конкретного случая, но, как упоминает Эрик Липперт, блокировки в целом безопаснее и проще для понимания, чем volatile.
Нужно ли мне для безопасности закрывать весь доступ к общей памяти блокировками?
Как правило, рекомендуется использовать блокировки всякий раз, когда речь идет о доступе к общей памяти. Мое эмпирическое правило: «если сомневаетесь, используйте замок». Просто избегайте взаимоблокировок. Неоспариваемая блокировка занимает около 25 нс, поэтому вам, вероятно, придется обслуживать огромное количество запросов, прежде чем это станет проблемой, при условии, что вам нужно удерживать блокировку только в течение короткого времени.
Но для кеширования есть встроенный кеш памяти , который, как я полагаю, является потокобезопасным. Существуют также такие классы, как ConcurrentDictionary , которые могут подойти для сценария многопоточного кэширования. Или Lazy<T>, который можно использовать для кэширования значений и, возможно, потокобезопасный.
Хороший ответ; Я бы усилил ваш совет использовать блокировки, а не volatile. Volatile — это острый инструмент для экспертов, которые знают, как, когда и зачем его использовать. Мое эмпирическое правило заключается в том, что если решение головоломки, связанной ниже, не очевидно для вас сразу, то вы (как и я!) не понимаете volatile достаточно хорошо, чтобы использовать его безопасно. ericlippert.com/2014/03/26/reordering-optimizations
@scharnyw: Самый важный совет в этом ответе - использовать готовые детали, а не изобретать свои собственные. Если вам нужен синглтон с ленивой инициализацией, безопасный для потоков, эксперты Microsoft уже написали для вас правильный и высокопроизводительный синглтон в Lazy<T>
. Не тратьте время и силы на неправильную, медленную версию их работы.
@EricLippert Спасибо, Эрик, за комментарии! Хорошо приняты советы (которые согласуются с предложениями в ваших сообщениях в блоге).
Спасибо за предложения. Я решил, что хорошей практикой будет активно обеспечивать волатильность, а не полагаться на удачу. Большое спасибо!