Каким образом решали проблему синхронизации потоков в C#?

«Каким образом решали проблему синхронизации потоков в C#?» — вопрос из категории Многопоточность, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Выбор механизма синхронизации зависит от конкретной задачи. Вот основные подходы, которые я применяю на практике:

1. lock (ключевое слово для Monitor) Используется для простых сценариев синхронизации внутри одного процесса.

private readonly object _syncRoot = new object();

lock (_syncRoot)
{
    // Критическая секция. Например, изменение общего списка.
    _sharedList.Add(item);
}

Почему lock? Это самый простой и производительный способ для большинства случаев. Важно использовать выделенный приватный объект (_syncRoot), а не this или тип, чтобы избежать внешних deadlock.

2. SemaphoreSlim Идеален для ограничения количества потоков, одновременно получающих доступ к ресурсу, особенно в асинхронном коде.

private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5); // Макс 5 потоков

await _semaphore.WaitAsync();
try
{
    await AccessLimitedResourceAsync();
}
finally
{
    _semaphore.Release();
}

Почему SemaphoreSlim? Он легче, чем классический Semaphore, и имеет асинхронный API (WaitAsync), что предотвращает блокировку потоков пула.

3. ReaderWriterLockSlim Применяется для оптимизации сценариев «много читателей, редко писатель».

private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();

// Чтение (множественный доступ)
_rwLock.EnterReadLock();
try
{
    return _cache.Get(key);
}
finally
{
    _rwLock.ExitReadLock();
}

// Запись (эксклюзивный доступ)
_rwLock.EnterWriteLock();
try
{
    _cache.Set(key, value);
}
finally
{
    _rwLock.ExitWriteLock();
}

Почему ReaderWriterLockSlim? Он значительно повышает производительность, когда операции чтения преобладают над операциями записи.

4. Mutex Нужен для координации между процессами (например, чтобы гарантировать запуск только одного экземпляра приложения).

bool createdNew;
using var mutex = new Mutex(true, "Global\MyAppMutex", out createdNew);
if (!createdNew)
{
    // Другой экземпляр приложения уже запущен.
    return;
}
// Продолжаем работу...

Ключевые принципы, которых я придерживаюсь:

  • Всегда освобождайте блокировки в finally для гарантии избежания deadlock.
  • Минимизируйте время удержания блокировки — выполняйте внутри критической секции только необходимые операции.
  • Избегайте вложенных блокировок или строго соблюдайте единый порядок их захвата.
  • Для асинхронного кода предпочитайте SemaphoreSlim, async/await и конкурентные коллекции (ConcurrentBag, ConcurrentDictionary) вместо ручной синхронизации там, где это возможно.