System Internals Concurrency

Multithreading Fundamentals

Глибокий розбір класу Thread, життєвого циклу потоків, пріоритетів та основ багатопотокового програмування в .NET

Multithreading Fundamentals (Основи Багатопотоковості)

Вступ та Контекст

Проблема: Чому Нам Потрібна Багатопотоковість?

Уявіть, що ви розробляєте desktop-додаток для обробки великих файлів. Користувач натискає кнопку "Обробити", і... інтерфейс замерзає на 30 секунд. Навіть переміщення вікна або натискання кнопки "Скасувати" не працює. Це класичний симптом однопотокової архітектури (single-threaded design).

// ❌ Поганий приклад: UI thread заблокований
private void ProcessButton_Click(object sender, EventArgs e)
{
    // Користувач натискає кнопку
    ProcessLargeFile("data.csv");  // Займає 30 секунд
    // UI повністю заморожений весь цей час!
    ShowCompletionMessage();
}

void ProcessLargeFile(string path)
{
    // Важка обчислювальна робота
    for (int i = 0; i < 10_000_000; i++)
    {
        // Обробка кожного рядка...
        Thread.Sleep(1);  // Симуляція роботи
    }
}
Frozen UI (заморожений інтерфейс) — це не просто незручність. Це критична проблема UX, яка змушує користувачів думати, що програма "зависла", і часто призводить до примусового завершення через Task Manager.

Багатопотоковість (multithreading) вирішує цю проблему, дозволяючи виконувати важкі операції у фоновому потоці (background thread), залишаючи головний потік (UI thread) вільним для взаємодії з користувачем.

Еволюція: Від Однопроцесорних Систем до Multi-Core

Loading diagram...
timeline
    title Еволюція Паралельного Програмування
    1970s : Single-core CPU
          : Preemptive multitasking
          : Time-slicing illusion
    1990s : Symmetric Multiprocessing (SMP)
          : Multiple CPUs in servers
          : Thread per request model
    2005+ : Multi-core mainstream
          : Intel Core Duo, AMD Athlon X2
          : Parallelism becomes essential
    2010+ : Many-core era
          : 8-16-32+ cores on desktop
          : GPU computing (CUDA, OpenCL)
    2020+ : Heterogeneous computing
          : Big.LITTLE ARM cores
          : Specialized accelerators

Чому це важливо?

ЕраПідхід до продуктивностіРоль багатопотоковості
До 2005Підняти частоту CPUОпціональна (UI responsiveness)
Після 2005Додати ядраОбов'язкова для продуктивності
СьогодніГетерогенні системиКритична для будь-якого C application
Закон Амдала: Максимальне прискорення програми обмежене часткою коду, яку можна виконати паралельно. Якщо 50% коду послідовні, максимальне прискорення — лише 2x, незалежно від кількості ядер.

Process vs Thread vs Task

Перш ніж заглиблюватись у потоки, розберімо ключову термінологію:

КонцепціяВизначенняРесурсиСтворення
Process (Процес)Екземпляр запущеної програмиВласний address space, handles, threadsДороге (~MB пам'яті)
Thread (Потік)Одиниця виконання всередині процесуСпільний address space, власний stackПомірне (~1MB stack)
TaskАбстракція асинхронної операціїМоже переключатись між threadsЛегке (pooled threads)
Loading diagram...
graph TD
    subgraph "Process (MyApp.exe)"
        A[Main Thread<br/>UI, Entry Point]
        B[Worker Thread 1<br/>Background processing]
        C[Worker Thread 2<br/>Network I/O]
        D[ThreadPool Thread<br/>Task execution]

        subgraph "Shared Resources"
            E[Heap Memory]
            F[Static Fields]
            G[File Handles]
        end
    end

    A --- E
    B --- E
    C --- E
    D --- E

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#64748b,stroke:#334155,color:#ffffff
Коли що використовувати:
  • Process — ізоляція (sandbox, security boundaries)
  • Thread — CPU-bound parallel tasks, низькорівневий контроль
  • Task — I/O-bound async operations, високорівневе API (рекомендовано для 95% випадків)

Клас Thread: Фундаментальний API

Створення та Запуск Потоків

Клас System.Threading.Thread — це базовий будівельний блок багатопотоковості в .NET. Він надає прямий доступ до managed threads, що виконуються на рівні операційної системи.

ThreadStart: Найпростіший Спосіб

ThreadStart — делегат без параметрів і повернення:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // Створюємо новий потік з методом DoWork
        Thread workerThread = new Thread(DoWork);

        // Потік створено, але ще НЕ запущено!
        Console.WriteLine($"Thread state: {workerThread.ThreadState}");  // Unstarted

        // Запускаємо потік
        workerThread.Start();

        // Головний потік продовжує виконання паралельно
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Main thread: {i}");
            Thread.Sleep(100);
        }

        // Чекаємо завершення worker thread
        workerThread.Join();          Console.WriteLine("Worker thread завершився");
    }

    static void DoWork()
    {
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Worker thread: {i}");
            Thread.Sleep(150);
        }
    }
}

Декомпозиція коду:

  • Рядок 9: new Thread(DoWork) — створюємо об'єкт Thread, передаючи делегат ThreadStart. Метод DoWork буде entry point нового потоку.
  • Рядок 16: Start() — фактично запускає потік. До цього виклику потік існує лише як об'єкт у пам'яті.
  • Рядок 25: Join() — блокує поточний потік (Main) до завершення workerThread. Без цього Main може завершитись раніше.
Важливо про порядок виконання: Немає гарантії, який рядок "Main thread: 0" чи "Worker thread: 0" виведеться першим. Це залежить від планувальника ОС (scheduler).

ParameterizedThreadStart: Передача Даних

Для передачі параметрів у потік використовується ParameterizedThreadStart:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // Створюємо потік з параметризованим делегатом
        Thread thread = new Thread(ProcessData);

        // Передаємо об'єкт як параметр
        thread.Start("Hello from Main!");  
        thread.Join();
    }

    // Метод приймає object? (може бути null)
    static void ProcessData(object? data)
    {
        if (data is string message)
        {
            Console.WriteLine($"Received: {message}");
        }
    }
}
Обмеження ParameterizedThreadStart:
  1. Приймає тільки один параметр типу object?
  2. Потребує boxing для value types
  3. Потребує cast у методі
Для типобезпечної передачі кількох параметрів використовуйте lambda expressions або closure.

Lambda Expressions: Сучасний Підхід (Рекомендовано)

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        string fileName = "data.csv";
        int maxLines = 1000;

        // Lambda захоплює локальні змінні (closure)
        Thread thread = new Thread(() =>          {
            ProcessFile(fileName, maxLines);
        });

        thread.Start();
        thread.Join();

        Console.WriteLine("Processing complete");
    }

    static void ProcessFile(string path, int limit)
    {
        Console.WriteLine($"Processing {path} with limit {limit}");
        // Імітація роботи
        Thread.Sleep(1000);
    }
}

Переваги lambda:

  • ✅ Типобезпечність — компілятор перевіряє типи
  • ✅ Множинні параметри — можна захопити будь-яку кількість
  • ✅ Читабельність — код поруч з логікою створення потоку
Closure Pitfall (пастка замикання):
// ❌ ПОМИЛКА: всі потоки побачать i = 5!
for (int i = 0; i < 5; i++)
{
    new Thread(() => Console.WriteLine(i)).Start();
}

// ✅ ПРАВИЛЬНО: створюємо локальну копію
for (int i = 0; i < 5; i++)
{
    int localI = i;  // Копія для кожної ітерації
    new Thread(() => Console.WriteLine(localI)).Start();
}
Це відбувається тому, що lambda захоплює зміннуi, а не її значення на момент створення closure.

Отримання Результатів з Потоку

Thread не має вбудованого механізму повернення значення. Ось кілька підходів:

Підхід 1: Shared Variable (Спільна Змінна)

using System;
using System.Threading;

class Program
{
    static int _result;  // Спільна змінна для результату

    static void Main()
    {
        Thread thread = new Thread(Calculate);
        thread.Start();
        thread.Join();  // Чекаємо завершення

        Console.WriteLine($"Result: {_result}");  // Result: 42
    }

    static void Calculate()
    {
        // Важкі обчислення...
        Thread.Sleep(500);
        _result = 42;
    }
}
Thread Safety: Цей підхід безпечний тільки коли один потік пише, інший читає післяJoin(). При конкурентному доступі потрібна синхронізація.

Підхід 2: Object Instance (Об'єкт-контейнер)

using System;
using System.Threading;

class Calculator
{
    public int Result { get; private set; }

    public void Calculate()
    {
        Thread.Sleep(500);
        Result = 42;
    }
}

class Program
{
    static void Main()
    {
        var calculator = new Calculator();
        Thread thread = new Thread(calculator.Calculate);

        thread.Start();
        thread.Join();

        Console.WriteLine($"Result: {calculator.Result}");  // Result: 42
    }
}

Підхід 3: Task (Рекомендовано)

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        // Task<T> надає вбудовану підтримку результатів
        Task<int> task = Task.Run(() =>
        {
            Thread.Sleep(500);
            return 42;
        });

        int result = await task;  // Або task.Result (блокуючий)
        Console.WriteLine($"Result: {result}");
    }
}
Рекомендація: Для нового коду використовуйте Task<T> замість Thread для операцій, що повертають результат. Thread доречний для fine-grained control або специфічних сценаріїв (наприклад, STA apartment).

Background vs Foreground Threads

Ключова Відмінність

.NET розрізняє два типи потоків за їх впливом на життєвий цикл процесу:

ХарактеристикаForeground ThreadBackground Thread
Завершення процесуПроцес чекає завершенняПримусово завершується
Defaultnew Thread()❌ Потрібно встановити
ThreadPool threads❌ Ніколи✅ Завжди
Use caseКритична роботаДопоміжні операції
Loading diagram...
sequenceDiagram
    participant Main as Main Thread (Foreground)
    participant FG as Foreground Worker
    participant BG as Background Worker
    participant Process as Process

    Main->>FG: Start()
    Main->>BG: Start()
    Main->>Main: Work complete
    Main-->>Process: Main exits

    Note over Process: Waiting for Foreground
    FG->>FG: Still working...
    FG-->>Process: FG exits

    Note over BG: ABORTED!
    Process->>BG: Terminate
    Process->>Process: Exit

Приклад: Вплив на Завершення Процесу

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // Foreground thread (default)
        Thread foregroundThread = new Thread(() =>
        {
            Console.WriteLine("Foreground: starting work");
            Thread.Sleep(3000);  // 3 секунди роботи
            Console.WriteLine("Foreground: work complete");
        });

        // Background thread
        Thread backgroundThread = new Thread(() =>
        {
            Console.WriteLine("Background: starting work");
            Thread.Sleep(5000);  // 5 секунд роботи
            Console.WriteLine("Background: work complete");  // НЕ ВИКОНАЄТЬСЯ!
        });
        backgroundThread.IsBackground = true;  
        foregroundThread.Start();
        backgroundThread.Start();

        Console.WriteLine("Main: exiting");
        // Після виходу Main, процес чекає foreground thread (3 сек)
        // Background thread буде примусово завершено
    }
}

/* Вивід:
Main: exiting
Foreground: starting work
Background: starting work
Foreground: work complete
(процес завершується, background aborted)
*/

Декомпозиція:

  • Рядок 24: IsBackground = true — перетворює потік на background
  • Процес завершується після Main + всі foreground threads
  • Background thread не встигає завершити 5-секундну роботу
Коли використовувати Background Threads:
  • Логування та телеметрія
  • Кешування та prefetch
  • Heartbeat та health checks
  • Будь-яка робота, яку можна безпечно перервати
Коли НЕ використовувати Background:
  • Запис у файл або базу даних (втрата даних!)
  • Мережеві транзакції
  • Будь-яка операція, що вимагає атомарності

Життєвий Цикл Потоку (Thread Lifecycle)

ThreadState Enum

System.Threading.ThreadState — це flags enum, що описує поточний стан потоку:

[Flags]
public enum ThreadState
{
    Running = 0,           // Виконується
    StopRequested = 1,     // Запит на зупинку
    SuspendRequested = 2,  // Запит на призупинення (deprecated)
    Background = 4,        // Фоновий потік
    Unstarted = 8,         // Ще не запущений
    Stopped = 16,          // Завершений
    WaitSleepJoin = 32,    // Очікує (Sleep, Join, lock)
    Suspended = 64,        // Призупинений (deprecated)
    AbortRequested = 128,  // Запит на Abort (deprecated)
    Aborted = 256          // Перерваний (deprecated)
}
Deprecated Methods: Thread.Suspend(), Thread.Resume(), Thread.Abort() є obsolete у .NET Core/.NET 5+ через небезпечність (можуть залишити об'єкти в inconsistent state). Використовуйте CancellationToken для cooperative cancellation.

Діаграма Переходів Станів

Loading diagram...
stateDiagram-v2
    [*] --> Unstarted: new Thread()

    Unstarted --> Running: Start()

    Running --> WaitSleepJoin: Sleep() / Join() / lock
    WaitSleepJoin --> Running: Timeout / Signal / Lock acquired

    Running --> Stopped: Method returns / Exception
    WaitSleepJoin --> Stopped: Exception

    Running --> Background: IsBackground = true
    Background --> Running: IsBackground = false

    Stopped --> [*]

    note right of Running
        Активне виконання коду
    end note

    note right of WaitSleepJoin
        Потік очікує:
        - Thread.Sleep()
        - other.Join()
        - Monitor.Enter()
        - WaitHandle.WaitOne()
    end note

Перевірка Стану Потоку

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(Worker);

        PrintState(thread, "After new");        // Unstarted

        thread.Start();
        Thread.Sleep(50);  // Дати час запуститись
        PrintState(thread, "After Start");      // Running або WaitSleepJoin

        thread.Join();
        PrintState(thread, "After Join");       // Stopped
    }

    static void Worker()
    {
        Thread.Sleep(100);
    }

    static void PrintState(Thread t, string label)
    {
        Console.WriteLine($"{label}: {t.ThreadState}");
    }
}
Composite States: ThreadState є flags enum, тому потік може мати кілька станів одночасно. Наприклад, Background, WaitSleepJoin означає фоновий потік, що очікує.

Thread Priorities (Пріоритети Потоків)

ThreadPriority Enum

public enum ThreadPriority
{
    Lowest = 0,       // Мінімальний пріоритет
    BelowNormal = 1,  // Нижче нормального
    Normal = 2,       // Стандартний (default)
    AboveNormal = 3,  // Вище нормального
    Highest = 4       // Максимальний пріоритет
}

Як Працює Scheduler

Операційна система використовує preemptive scheduling — вона виділяє процесорний час потокам на основі пріоритетів:

Loading diagram...
graph TD
    A[OS Scheduler] --> B{Ready Threads?}
    B -->|Yes| C[Select Highest Priority]
    C --> D[Allocate Time Slice]
    D --> E[Thread Executes]
    E --> F{Time Slice Exhausted?}
    F -->|Yes| A
    F -->|No| G{Thread Blocked?}
    G -->|Yes| A
    G -->|No| E
    B -->|No| H[Idle]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff

Key Points:

  1. Пріоритет — це підказка, не гарантія
  2. Потоки з однаковим пріоритетом чергуються (round-robin)
  3. Високопріоритетні потоки можуть "голодувати" (starve) низькопріоритетні
  4. Пріоритет успадковується від батьківського потоку за замовчуванням

Приклад: Вплив Пріоритету

using System;
using System.Threading;

class Program
{
    static long _lowCount = 0;
    static long _highCount = 0;
    static bool _running = true;

    static void Main()
    {
        Thread lowThread = new Thread(LowPriorityWork)
        {
            Priority = ThreadPriority.Lowest,
            IsBackground = true
        };

        Thread highThread = new Thread(HighPriorityWork)
        {
            Priority = ThreadPriority.Highest,
            IsBackground = true
        };

        lowThread.Start();
        highThread.Start();

        // Даємо потокам працювати 2 секунди
        Thread.Sleep(2000);
        _running = false;

        lowThread.Join();
        highThread.Join();

        Console.WriteLine($"Low priority iterations:  {_lowCount:N0}");
        Console.WriteLine($"High priority iterations: {_highCount:N0}");
        Console.WriteLine($"Ratio: {(double)_highCount / _lowCount:F2}x");
    }

    static void LowPriorityWork()
    {
        while (_running)
        {
            Interlocked.Increment(ref _lowCount);
        }
    }

    static void HighPriorityWork()
    {
        while (_running)
        {
            Interlocked.Increment(ref _highCount);
        }
    }
}

/* Типовий вивід (залежить від системи):
Low priority iterations:  12,345,678
High priority iterations: 98,765,432
Ratio: 8.00x
*/
Priority Inversion: Класична проблема, коли високопріоритетний потік чекає на ресурс, заблокований низькопріоритетним потоком, який не може отримати CPU через потоки середнього пріоритету. Це призвело до збою Mars Pathfinder у 1997!

Best Practices для Пріоритетів

РекомендаціяОбґрунтування
❌ Не використовуйте HighestМоже заблокувати інші потоки
❌ Не покладайтесь на пріоритети для логікиПоведінка різна на різних ОС
✅ Залишайте Normal для 99% випадківOS scheduler зазвичай оптимальний
BelowNormal для background tasksМенше впливає на UI responsiveness
✅ Використовуйте Thread Pool замість пріоритетівPool сам оптимізує розподіл

Ключові Методи Thread

Thread.Sleep(): Призупинення Виконання

// Призупинити поточний потік на вказаний час
Thread.Sleep(1000);           // 1 секунда
Thread.Sleep(TimeSpan.FromSeconds(1.5));  // 1.5 секунди

// Спеціальні значення:
Thread.Sleep(0);      // Yield: віддати timeslice, але залишитись ready
Thread.Sleep(-1);     // Infinite: еквівалент Timeout.Infinite
Thread.Sleep(0) vs Thread.Yield():
  • Sleep(0) — поступається іншим потокам будь-якого пріоритету
  • Yield() — поступається тільки потокам рівного або вищого пріоритету на тому ж процесорі

Thread.Join(): Очікування Завершення

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(() =>
        {
            Console.WriteLine("Worker: starting");
            Thread.Sleep(2000);
            Console.WriteLine("Worker: complete");
        });

        thread.Start();

        // Варіант 1: Чекати безмежно
        thread.Join();

        // Варіант 2: Чекати з timeout
        bool completed = thread.Join(1000);  // Чекати макс. 1 сек
        if (!completed)
        {
            Console.WriteLine("Thread не завершився за 1 секунду");
        }

        // Варіант 3: TimeSpan timeout
        thread.Join(TimeSpan.FromSeconds(5));
    }
}
Deadlock Risk: Якщо потік A викликає threadB.Join(), а потік B викликає threadA.Join(), обидва заблокуються назавжди — це deadlock.

Thread.Interrupt(): Переривання Очікування

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(() =>
        {
            try
            {
                Console.WriteLine("Worker: going to sleep...");
                Thread.Sleep(10000);  // 10 секунд
                Console.WriteLine("Worker: woke up normally");
            }
            catch (ThreadInterruptedException)              {
                Console.WriteLine("Worker: was interrupted!");
            }
        });

        thread.Start();
        Thread.Sleep(1000);  // Чекаємо 1 секунду

        thread.Interrupt();  // Перериваємо Sleep        thread.Join();
    }
}

/* Вивід:
Worker: going to sleep...
Worker: was interrupted!
*/
Interrupt() працює тільки коли потік у стані WaitSleepJoin. Якщо потік активно виконується, виняток буде викинуто при наступному блокуючому виклику.

Thread-Local Storage (Локальне Сховище Потоку)

Іноді потрібні дані, унікальні для кожного потоку. .NET надає кілька механізмів:

ThreadStatic Attribute

using System;
using System.Threading;

class Program
{
    [ThreadStatic]      static int _threadId;  // Унікальне значення для кожного потоку

    static void Main()
    {
        Thread t1 = new Thread(() => { _threadId = 1; PrintId("T1"); });
        Thread t2 = new Thread(() => { _threadId = 2; PrintId("T2"); });

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();
    }

    static void PrintId(string name)
    {
        Thread.Sleep(100);  // Дати іншому потоку час
        Console.WriteLine($"{name}: _threadId = {_threadId}");
    }
}

/* Вивід:
T1: _threadId = 1
T2: _threadId = 2
*/
Обмеження ThreadStatic:
  • Не можна ініціалізувати в оголошенні (static int _id = 1; — кожен потік побачить 0!)
  • Не працює з instance fields
  • Немає підтримки async/await контексту

ThreadLocal: Сучасна Альтернатива

using System;
using System.Threading;

class Program
{
    // Ініціалізатор викликається для кожного потоку окремо
    static ThreadLocal<int> _threadId = new ThreadLocal<int>(() =>      {
        return Thread.CurrentThread.ManagedThreadId;
    });

    static void Main()
    {
        Thread t1 = new Thread(() => Console.WriteLine($"T1: {_threadId.Value}"));
        Thread t2 = new Thread(() => Console.WriteLine($"T2: {_threadId.Value}"));

        t1.Start();
        t2.Start();

        t1.Join();
        t2.Join();

        // Важливо: звільнити ресурси
        _threadId.Dispose();
    }
}

Переваги ThreadLocal<T>:

  • ✅ Підтримує lazy initialization
  • ✅ Можна ініціалізувати в оголошенні
  • ✅ Реалізує IDisposable
  • ✅ Має властивість Values для доступу до всіх потоків

AsyncLocal: Для Async/Await

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    // Значення "перетікає" через await boundaries
    static AsyncLocal<string> _correlationId = new AsyncLocal<string>();  
    static async Task Main()
    {
        _correlationId.Value = "REQ-123";
        Console.WriteLine($"Before await: {_correlationId.Value}");

        await Task.Delay(100);  // Може виконатись в іншому потоці!
        Console.WriteLine($"After await: {_correlationId.Value}");  // Все ще "REQ-123"

        await ProcessAsync();
    }

    static async Task ProcessAsync()
    {
        Console.WriteLine($"In ProcessAsync: {_correlationId.Value}");  // "REQ-123"
    }
}
Use Case для AsyncLocal: Correlation IDs для distributed tracing, culture settings для localization, ambient transaction context.

Порівняння та Рекомендації

Thread vs Task: Коли Що Використовувати

СценарійРекомендаціяОбґрунтування
CPU-bound robotaTask.Run()Використовує ThreadPool, ефективніше
I/O-bound операціїasync/awaitНе блокує потоки
Fine-grained controlThreadПріоритети, apartment state
STA COM objectsThreadПотрібен STA apartment
Long-running backgroundThread або Task.Factory.StartNew(TaskCreationOptions.LongRunning)Не виснажує ThreadPool
Legacy code interopThreadСумісність з старим кодом

Антипаттерни

// ПОГАНО: витрачає CPU на нічого
while (!dataReady)
{
    // Spinning - навантажує процесор
}

// ПРАВИЛЬНО: використовуйте signaling
AutoResetEvent signal = new AutoResetEvent(false);
signal.WaitOne();  // Потік засинає до сигналу

Практичні Завдання

Рівень 1: Початковий

Рівень 2: Середній

Рівень 3: Просунутий


Підсумки

У цьому розділі ми розглянули фундаментальні концепції низькорівневого багатопотокового програмування:

  • Thread — базовий примітив для створення потоків
  • Foreground vs Background — вплив на життєвий цикл процесу
  • ThreadState — стани потоку та переходи між ними
  • ThreadPriority — підказки для OS scheduler
  • Thread-local storage — дані, унікальні для кожного потоку
Наступний крок: У наступному розділі ми розглянемо Synchronization Primitives — механізми захисту спільних ресурсів від race conditions.

Корисні Посилання