Что такое иллюзия многопоточности?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Что такое иллюзия многопоточности?
Это фундаментальное понимание того, как работает многопоточность в современных операционных системах. Расскажу про разницу между тем, что мы видим и тем, что на самом деле происходит.
Миф 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 | ...
...
Иллюзия по-прежнему! На каждом ядре ОС переключает контекст.
Почему это иллюзия, а не реальность?
Потому что:
-
Не знаем когда переключится: нет гарантии когда произойдёт context switch
for(int i = 0; i < 1000000; i++) { // Может быть прервано в любой момент counter++; // Race condition! } -
Видим чередование, а не истинный параллелизм:
// На 1 ядре это неправда: std::thread t1([]{ x = 1; }); std::thread t2([]{ y = x + 1; }); // y может быть 1 или 2 в зависимости от schedule! -
Одна ошибка в синхронизации — и программа взрывается:
// 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) 5 минут
- Быстро идёт к столу 2 (поток 2) на 5 минут
- Клиенты думают что его двое (иллюзия!)
- На самом деле один, но очень быстро переключается
-
Два официанта (2 ядра):
- Один обслуживает стол 1 (поток 1)
- Другой обслуживает стол 2 (поток 2)
- Теперь это настоящий параллелизм!
-
Один официант, 4 стола:
- Иллюзия многопоточности
- Переключается каждые 5 минут
- Если столы хотят поговориться (общие данные), нужны правила (mutex)
Вывод
Иллюзия многопоточности — это:
- На однопроцессорных системах: настоящая иллюзия (context switching)
- На многоядерных: частичная иллюзия (если потоков больше чем ядер)
- Суть: ОС чередует потоки на одном ядре, создавая впечатление параллелизма
- Последствия: нужна синхронизация (mutex, atomic) чтобы избежать race conditions
Понимание этого необходимо для написания правильных многопоточных программ!