Hitech logo

Кейсы

Практика примитивов синхронизации в .NET

TODO:
Катя Литвинова18 августа 2021 г., 08:18

Одной из основных задач операционных систем является эффективное управление многозадачностью, что включает управление работой процессов и потоков. В своей книге «Современные операционные системы» Эндрю Таненбаум описывает эту задачу как «искусство управления конкурентным доступом», особенно когда речь идет о синхронизации между параллельно выполняемыми потоками. Основной проблемой таких ситуаций является состояние гонки, возникающей между потоками при их попытке получить доступ к общим ресурсам, будь то файл или область памяти. В таких случаях возникает необходимость в синхронизации, от которой пойдет речь в данной статье. Артем Рудяков, признанный эксперт в сфере разработки ПО, раскрыл детали синхронизации в .NET.

Самые интересные технологические и научные новости выходят в нашем телеграм-канале Хайтек+. Подпишитесь, чтобы быть в курсе.

Для обеспечения таких синхронизаций операционные системы имеют механизмы блокировок — примитивы синхронизации, регулирующие доступ потоков к общим ресурсам и предотвращающие их одновременное использование другими потоками. Существуют как простые (например, семафоры и мьютексы), так и более сложные (барьеры и условные переменные) примитивы. И те, и другие управляют доступом потоков к разделяемой памяти, файлам и другим ресурсам, помогая избегать ошибок с параллельным доступом.

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

Как и в любом языке программирования, поддерживающем многопоточное программирование, в C# имеются собственные реализации описанных выше механизмов. В том или ином виде они не отличаются по реализации от общеизвестных механизмов, однако знание нюансов все равно необходимо для успешной работы.

Самостоятельная оптимизация многозадачности в .NET

Существует множество примитивов синхронизации «из коробки» в .NET как старые, так и относительно новые. И хотя может показаться что всё уже придумано до нас и можно использовать готовые решения библиотек — разнообразные ситуации в каждодневной работе разработчика показывают, что это далеко не так. В этой статье я покажу несколько разнообразных подходов к решению разнообразных ситуаций в области параллелизма.

Легковесный семафор

Один из подходов к оптимизации многозадачности заключается в уменьшении времени блокировки и повышении параллелизма. Обычные блокировки, такие как Monitor или lock, могут блокировать потоки, даже если изменение общего ресурса минимально. В отличие от этого, Interlocked предоставляет более лёгкие атомарные операции для работы с числовыми значениями, что позволяет избегать блокировок и позволяет создать «собственный» примитив для тех случаев, где состояние ограничено числовыми значениями.

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

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

Распределенная блокировка

Распределённая блокировка с использованием Mutex и облачных сервисов — это подход, позволяющий синхронизировать доступ к общим ресурсам между процессами, работающими на разных серверах. Этот метод особенно полезен в системах с микросервисной архитектурой и кластерами, где доступ к ресурсам может требоваться одновременно многим сервисам, например, для предотвращения одновременной записи или модификации одних и тех же данных. В кластерах микросервисов и облачных сред, где разные сервисы или экземпляры приложений могут пытаться получить доступ к одному и тому же ресурсу (например, записи в базе данных, облачному файлу или общему кэшу), возникает риск состояния гонки. Это означает, что одновременные обращения к ресурсу могут вызвать ошибки или нежелательные состояния. Обычный Mutex отлично подходит для синхронизации доступа между процессами на одном сервере, но в случае распределенных систем он не подходит, так как Mutex привязан к локальной ОС. Чтобы масштабировать синхронизацию на несколько серверов, можно использовать Mutex в связке с облачными сервисами, такими как Redis, DynamoDB, Azure Blob или SQL-база данных, где хранятся ключи блокировки.

Конкурентный кэш

В заключение хочу показать вам более приближенный к реальным задачам, но оттого не менее важный и интересный пример применения примитивов синхронизации. Итак, проблема «Cache Stampede» в системах с кэшированием данных, таких как высоконагруженные веб-приложения или API, может возникнуть, когда кэшированные данные устаревают или очищаются, и множество потоков одновременно пытаются обновить их, что приводит к перегрузке серверов базы данных или других источников данных. Для решения этой проблемы можно использовать примитив синхронизации ReaderWriterLockSlim, который позволяет организовать доступ к ресурсу с приоритетом для операций чтения, сохраняя одновременный доступ к актуальному кэшу для множества потоков, но ограничивая операцию записи единственным потоком. Но давайте ближе к коду.

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

Заключение

Примитивы синхронизации в .NET — это не просто инструменты для управления многопоточностью, но и мощные механизмы оптимизации работы распределенных систем, предотвращения состояния гонки и повышения производительности. В статье мы рассмотрели, как с их помощью можно решать сложные задачи, от предотвращения «штурма кэша» с помощью ReaderWriterLockSlim до реализации распределённой блокировки на основе Mutex и облачных сервисов. Эти подходы показывают, что правильное использование синхронизации позволяет не только стабилизировать многозадачность, но и раскрыть потенциал современных архитектур приложений. Освоив эти техники, разработчики могут создавать системы, которые одновременно просты, эффективны и способны выдерживать высокие нагрузки.