← Назад к вопросам

Что такое иллюзия многопоточности?

2.2 Middle🔥 201 комментариев
#Многопоточность и синхронизация

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI30 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Что такое иллюзия многопоточности?

Это фундаментальное понимание того, как работает многопоточность в современных операционных системах. Расскажу про разницу между тем, что мы видим и тем, что на самом деле происходит.

Миф vs Реальность

Мы думаем так:

std::thread t1(threadFunc1);  // Поток 1 работает
std::thread t2(threadFunc2);  // Поток 2 работает ОДНОВРЕМЕННО
std::thread t3(threadFunc3);  // Поток 3 работает ОДНОВРЕМЕННО
// На моем 4-ядерном ПК все три работают параллельно!

На самом деле на однопроцессорной системе:

Время:  0ms    5ms    10ms   15ms   20ms
        |-------|-------|-------|----|
Процессор:
  t1   XXXX           XXXX           XX
  t2           XXXX           XXXX
  t3                XXXX           XXXX
       (context switch каждые 5ms)

Что происходит в OS?

Без многопоточности (однопроцессорная система):

Время:    0ms                    1000ms
         |--------------------|
Линейное выполнение:
  Task A: |=========| (500ms)
  Task B:           |=========| (500ms)

С многопоточностью (симуляция на 1 ядре):

Время:    0ms  20ms  40ms  60ms  80ms  100ms
         |--------|--------|--------|-----|
Чередование (context switch):
  t1     |XXXX|          |XXXX|          |
  t2          |XXXX|          |XXXX|
  t3               |XXXX|          |XXXX|
  (ОС переключает контекст каждые 20ms)

Из перспективы каждого потока: кажется, что они работают параллельно! Из перспективы CPU: он просто быстро переключается между ними.

Это и есть иллюзия многопоточности.

Механизм Context Switch

ОС делает следующее каждые ~10-100ms:

// Упрощённо, что происходит в ядре ОС
void contextSwitch() {
    // 1. Сохраняем state текущего потока
    Thread* current = scheduler.getCurrentThread();
    current->saveRegisters();      // EAX, EBX, EIP, ESP, ...
    current->saveMemory();         // TLB, cache
    current->setState(RUNNABLE);   // Поток готов к выполнению
    
    // 2. Выбираем следующий поток для выполнения
    Thread* next = scheduler.selectNext();  // Round-robin или приоритет
    next->setState(RUNNING);
    
    // 3. Восстанавливаем состояние следующего потока
    next->restoreRegisters();      // Реставрируем EAX, EBX, ...
    next->restoreMemory();         // Восстанавливаем cache
    
    // 4. Прыгаем на инструкцию где этот поток остановился
    jumpTo(next->instructionPointer);
}

Пример: что видит программист

#include <thread>
#include <iostream>
#include <chrono>

void work(int id) {
    for(int i = 0; i < 3; i++) {
        std::cout << "Thread " << id << " iteration " << i << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

int main() {
    std::thread t1(work, 1);
    std::thread t2(work, 2);
    t1.join();
    t2.join();
    return 0;
}

Вывод может быть:

Thread 1 iteration 0
Thread 2 iteration 0
Thread 1 iteration 1
Thread 2 iteration 1
Thread 1 iteration 2
Thread 2 iteration 2

Иллюзия: "Оба потока работают одновременно!"

Реальность: На однопроцессорной системе они по очереди выполняют по 1-2 инструкции, потом переключаются.

На многоядерных системах

Современный CPU (например, Intel Core i7 с 8 ядрами):

Каждое ядро может выполнять свой поток настоящим параллелизмом:

Время:    0ms                    100ms
         |------------------------|
Ядро 0:  t1: |====================|  (настоящий параллелизм)
Ядро 1:  t2: |====================|
Ядро 2:  t3: |====================|
Ядро 3:  (свободно)
Ядро 4:  (свободно)
...

Но что если потоков больше, чем ядер?

Есть 8 ядер и 16 потоков:

Время:    0ms  10ms  20ms  30ms  40ms
Ядро 0:   |t1 |  t1 |  t1 |  t1 |  ...
Ядро 1:   |t2 |  t2 |  t2 |  t2 |  ...
Ядро 2:   |t3 |  t4 |  t3 |  t4 |  ...  (context switch)
Ядро 3:   |t5 |  t6 |  t5 |  t6 |  ...
...

Иллюзия по-прежнему! На каждом ядре ОС переключает контекст.

Почему это иллюзия, а не реальность?

Потому что:

  1. Не знаем когда переключится: нет гарантии когда произойдёт context switch

    for(int i = 0; i < 1000000; i++) {  // Может быть прервано в любой момент
        counter++;  // Race condition!
    }
    
  2. Видим чередование, а не истинный параллелизм:

    // На 1 ядре это неправда:
    std::thread t1([]{ x = 1; });
    std::thread t2([]{ y = x + 1; });
    // y может быть 1 или 2 в зависимости от schedule!
    
  3. Одна ошибка в синхронизации — и программа взрывается:

    // Race condition — потому что нет истинного параллелизма в гарантиях
    std::vector<int> shared = {1, 2, 3};
    
    std::thread t1([&]{ shared[0] = 100; });  // Может быть interrupted
    std::thread t2([&]{ int x = shared[0]; }); // Может получить что угодно
    

Как это влияет на код?

Ошибка: забыли mutex

int counter = 0;

void increment() {
    for(int i = 0; i < 1000000; i++) {
        counter++;  // NOT ATOMIC!
        // Может быть прервано в середине:
        // LOAD counter → EAX
        // INC EAX
        // STORE EAX → counter
        // Другой поток может загрузить counter между этими инструкциями!
    }
}

std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();

// counter должна быть 2000000, но может быть 1999999!
// (потому что context switch вызвал race condition)

Правильно: используем mutex

std::mutex mtx;
int counter = 0;

void increment() {
    for(int i = 0; i < 1000000; i++) {
        std::lock_guard<std::mutex> lock(mtx);  // Критическая секция
        counter++;  // Теперь безопасно
        // Другой поток не может зайти сюда пока we hold lock
    }
}

Реальный жизненный пример

Высокочастотная торговля (HFT):

// Две программы на одном сервере обновляют цены акций
struct Stock {
    double price;  // Разделяемая память
    long timestamp;
};

Stock stocks[1000];

// Program A (обновляет prices с биржи)
void updatePrices() {
    stocks[0].price = 100.50;  // Может быть interrupted
    stocks[0].timestamp = now();
}

// Program B (читает prices)
double getPrice(int id) {
    return stocks[id].price;  // Может получить inconsistent state!
    // Может быть прочитана цена во время её обновления
}

Без синхронизации: видит неполные данные (иллюзия атомарности нарушена).

На самом глубоком уровне

Что ОС должна гарантировать:

  • Context switch может произойти в любой момент (в середине инструкции даже, если инструкция не атомарна)
  • Каждый поток видит свой "виртуальный CPU" (но на самом деле это чередование)
  • Синхронизация (mutex, atomic, volatile) — это способ договориться с ОС: "Здесь нельзя прерывать"
std::atomic<int> counter;  // Говорим: это atomic операция
// ОС гарантирует что counter++ неразрывна (даже на многоядерной системе)

Итоговая аналогия

Многопоточность как ресторан:

  1. Один официант (1 ядро):

    • Обслуживает стол 1 (поток 1) 5 минут
    • Быстро идёт к столу 2 (поток 2) на 5 минут
    • Клиенты думают что его двое (иллюзия!)
    • На самом деле один, но очень быстро переключается
  2. Два официанта (2 ядра):

    • Один обслуживает стол 1 (поток 1)
    • Другой обслуживает стол 2 (поток 2)
    • Теперь это настоящий параллелизм!
  3. Один официант, 4 стола:

    • Иллюзия многопоточности
    • Переключается каждые 5 минут
    • Если столы хотят поговориться (общие данные), нужны правила (mutex)

Вывод

Иллюзия многопоточности — это:

  • На однопроцессорных системах: настоящая иллюзия (context switching)
  • На многоядерных: частичная иллюзия (если потоков больше чем ядер)
  • Суть: ОС чередует потоки на одном ядре, создавая впечатление параллелизма
  • Последствия: нужна синхронизация (mutex, atomic) чтобы избежать race conditions

Понимание этого необходимо для написания правильных многопоточных программ!

Что такое иллюзия многопоточности? | PrepBro