1. Введение
В многопоточном приложении общий ресурс — это всё, к чему могут одновременно получить доступ два и более потока. Это может быть:
- Переменная (например, глобальный счётчик или список).
- Объект (например, коллекция пользователей).
- Файл или сетевой сокет.
- Любая структура данных, изменяемая разными потоками.
В нашем консольном приложении мы чаще всего будем натыкаться на переменные и объекты, которые "шарятся" между потоками.
Аналогия
Представьте двух человек, которые одновременно стараются записать что-то в одну и ту же тетрадь, не договорившись по очереди. В лучшем случае — получится кривая запись, в худшем — кто-то перепишет чужие данные. В программировании ситуация абсолютно такая же, только эти “человеки” — потоки.
Кратко о типичных ресурсах с гонками данных
В таблице ниже — наиболее частые ресурсы, опасные для совместного доступа из разных потоков:
| Ресурс | Группы проблем | Пример |
|---|---|---|
| Переменные типа int | Некорректное увеличение/уменьшение | Счётчики, индексы |
| Общие коллекции | Потеря/повреждение элементов, исключения | Общий список заказов |
| Объекты | Непоследовательные изменения состояния | Флаги, свойства |
| Файлы | Повреждение данных, некорректное чтение/запись | Лог-файлы, конфигурация |
2. Состояние гонки: как оно проявляется?
Пример: Счётчик посещений
Допустим, мы хотим посчитать, сколько раз пользователь нажал кнопку (или, в нашем примере, сколько раз разные потоки инкрементировали переменную). Простая версия кода:
int counter = 0;
void Increment() {
counter++;
}
Теперь создадим два потока, в каждом из которых по 100 000 раз вызывается Increment():
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Ожидали: 200000, получили: {counter}");
}
}
Сколько раз логически должен быть увеличен counter? 200000! Но если вы запустите этот код несколько раз, почти наверняка увидите разные числа: 185000, 192500, 198765… Почему?
3. Почему counter++ — это не атомарная операция?
Как реально работает counter++
В C# и других языках высокого уровня программа транслируется в набор машинных инструкций. Оператор counter++, к сожалению, не превращается в одну магическую команду "прибавь 1 к переменной". Вот что происходит реально:
- Поток ЧИТАЕТ значение из памяти (counter).
- Увеличивает это значение на 1 (в регистре процессора).
- Записывает новое значение обратно в память (counter).
Если два потока делают это почти одновременно, они могут оба прочитать одно и то же старое значение, увеличить его, и оба записать результат обратно, потеряв один инкремент.
Сценарий гонки
Допустим, counter был равен 1000. Оба потока прочитали это значение (шаг 1), оба у себя увеличили его до 1001 (шаг 2), а затем оба записали обратно 1001 (шаг 3). Какой ужас: один инкремент просто потерян!
Визуализация гонки
| Момент времени | Поток 1 | Поток 2 | Значение counter |
|---|---|---|---|
| 1 | Чтение 1000 | 1000 | |
| 2 | Чтение 1000 | 1000 | |
| 3 | Инкремент до 1001 | Инкремент до 1001 | 1000 (пока не было записи) |
| 4 | Запись 1001 | 1001 | |
| 5 | Запись 1001 | 1001 |
В итоге, за два инкремента значение увеличилось всего на 1!
4. Ещё немного примеров: "невидимые баги"
А что если race condition происходит не с числами?
Давайте теперь представим, что несколько потоков добавляют элементы в один и тот же список:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Ожидали: 20000, получили: {numbers.Count}");
}
}
Этот код тоже может дать разные результаты на каждом запуске: иногда программа завершится аварией (исключение), иногда вы увидите меньше элементов, чем ожидалось.
Почему? Дело в том, что коллекция List<T> из коробки не потокобезопасна. То есть когда два потока одновременно вызывают Add, может произойти повреждение внутренней структуры списка.
5. Атомарность операций
Что такое атомарная операция?
Атомарной называется операция, которая выполняется целиком, без возможности быть прерванной другим потоком посредине. Это вроде “транзакции”: либо всё, либо ничего.
- Операции присваивания типа int myVar = 42; на большинстве платформ атомарны (если только это не огромный объект).
- А вот counter++ не атомарна — это три последовательных действия.
Специальные атомарные операции
В .NET есть специальные классы для атомарных операций: например, Interlocked. Такой способ мы рассмотрим в следующих лекциях.
Пример атомарного инкремента с помощью Interlocked.Increment:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // атомарная операция!
6. Почему ловить race condition сложно?
Race condition опасен тем, что:
- Может проявляться только под высокой нагрузкой.
- Ловится не в 100%, а в 5% или даже 0.01% случаев.
- Падает “рандомно” и возникает там, где его никто не ждёт.
Как распознать проблему?
Если при каждом запуске программы вы получаете разные (и неправильные) результаты, стоит заподозрить гонку данных.
Шутки программистов
"Если ошибка появляется редко и исправляется добавлением Thread.Sleep(50) — у вас проблемы посерьёзнее, чем кажется."
7. Полезные нюансы
Синхронизация
Чтобы защитить критические секции (участки кода, где происходит работа с общими ресурсами), их нужно синхронизировать. Но это тема следующих лекций. Сейчас главное — научиться замечать и объяснять проблему.
Типичные ошибки новичков
Многие начинающие программисты думают: “У меня counter++ — что тут может пойти не так?” Увы, как только у вас появляется более одного потока, всё может пойти не так! Даже казалось бы простые вещи: чтение и запись переменных, добавление элементов в список, изменение состояния объекта и многое другое.
Место гонок данных в реальной разработке
В современных многопоточных приложениях (например, в серверных API, обработке веб-запросов, играх и мобильных приложениях) почти всегда есть общие ресурсы. Без синхронизации гонки данных приводят к неправильной обработке заказов, сбоям, утечкам памяти и огромным сложностям при отладке.
На собеседованиях для позиций middle/senior обязательно спросят: “Что такое состояние гонки? Как его избежать?” Если вы сможете привести приведённые выше примеры — и объяснить механику — рекрутеры будут довольны!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ