Пишешь многопоточный код. В голове красивая картинка: задачи выполняются параллельно, ресурсы распределены, всё масштабируется. Запускаешь и начинается хаос.

Знакомо?

Дело в плохом коде не всегда в плохом коде. Дело в том, что разные языки пришли к разным моделям многопоточности и каждая решает проблему по-своему. Разбираем специфику каждой модели.

Почему вообще существует несколько моделей?

Начнём с небольшого экскурса в историю. В самом начале все было просто: один поток, одна задача. Затем появились ОС-треды (OS threads), и разработчики обрадовались: теперь можно делать несколько дел одновременно! Но тут выяснилось, что каждый OS thread — это дорого:

  • ~1 МБ памяти на стек каждого потока

  • Дорогое создание (~1ms на поток)

  • Context switch через kernel space (тоже недешево)

Когда у вас 10 потоков — это не проблема. Но что делать, если нужно обработать 100 000 одновременных соединений? Вот здесь и начинается самое интересное.

Разные языки и платформы выбрали разные пути решения этой проблемы. И появились три основные модели многопоточности.


Модель 1: Event Loop (Python, JavaScript/Node.js)

Философия: "Один за всех"

Event Loop — это как ресторан с одним официантом, который обслуживает много столиков одновременно. Он не ждет, пока приготовится заказ для одного стола, а сразу идет принимать следующий заказ.

Как это работает

┌─────────────────────────────────────────────┐
│           Single Event Loop Thread          │
│                                             │
│  ┌──────────────────────────────────────┐   │
│  │        Event Queue (FIFO)            │   │
│  │  [Task1] [Task2] [Task3] [Task4]     │   │
│  └──────────────────────────────────────┘   │
│              ↓                              │
│  ┌──────────────────────────────────────┐   │
│  │      Event Loop (while True)         │   │
│  │    1. Poll task from queue           │   │
│  │    2. Execute synchronous part       │   │
│  │    3. Register async callbacks       │   │
│  │    4. Continue to next task          │   │
│  └──────────────────────────────────────┘   │
│              ↓                              │
│  ┌──────────────────────────────────────┐   │
│  │       Callback Queue                 │   │
│  │  Completed async ops return here     │   │
│  └──────────────────────────────────────┘   │
└─────────────────────────────────────────────┘
         ↑                        ↓
    I/O Operations        Thread Pool
   (async, non-blocking)  (for CPU tasks)

Особенность #1: GIL в Python

В CPython есть Global Interpreter Lock (GIL) — глобальная блокировка, которая позволяет исполнять байткод только одному потоку за раз. Это сделано для упрощения управления памятью через reference counting.

import asyncio

async def fetch_data(id):
    print(f"Start fetching {id}")
    await asyncio.sleep(1)  # Имитация I/O
    print(f"Done fetching {id}")
    return f"Data {id}"

async def main():
    # Запускаем 3 задачи "параллельно"
    tasks = [fetch_data(i) for i in range(3)]
    results = await asyncio.gather(*tasks)
    print(results)

# Выполнится примерно за 1 секунду, не за 3
asyncio.run(main())

Особенность #2: Однопоточность JavaScript

JavaScript изначально однопоточный — event loop обрабатывает задачи последовательно. Поэтому появились паттерны типа async/await и Promises.

// JavaScript/Node.js - Event Loop
async function processUsers() {
    const users = await db.getUsers();  // I/O - не блокирует event loop
    
    for (const user of users) {
        await sendEmail(user);  // I/O - event loop обрабатывает другие задачи
    }
}

// Пока ждем БД или отправку email, 
// event loop обрабатывает другие HTTP запросы

Масштабирование

Проблема: Один инстанс = одно ядро процессора.

Решение:

# Запускаем 16 процессов для использования 16 ядер
for i in {1..16}; do
    node server.js &
done

# + Nginx как балансировщик нагрузки

Плюсы

  • Простой код — нет классических data races между потоками.

  • Не нужны сложные примитивы синхронизации.

  • Идеально для I/O-bound нагрузки (веб-серверы, микросервисы).

  • Низкое потребление памяти.

Минусы

  • Усложненная инфраструктура (процессы вместо потоков).

  • Процессы изолированы — нет shared memory.

  • Сложнее использовать многоядерность.

  • CPU-bound задачи блокируют весь event loop.

Когда использовать

Event Loop отлично подходит для:

  • Веб-серверов с большим количеством одновременных соединений

  • REST API и микросервисов

  • Real-time приложений (чаты, уведомления)

  • Приложений с преобладанием I/O операций

Модель 2: Platform Threads (Traditional Java, C++)

Философия: "Каждому гостю — отдельный официант"

Это классическая модель: создали thread — получили OS thread. Прямое отображение 1:1.

Как это работает

┌──────────────────────────────────────────────────────┐
│              Operating System Scheduler              │
│                                                      │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│  │ OS Thread│  │ OS Thread│  │ OS Thread│  ...       │
│  │    #1    │  │    #2    │  │    #3    │            │
│  │ ~1MB     │  │ ~1MB     │  │ ~1MB     │            │
│  │ stack    │  │ stack    │  │ stack    │            │
│  └────↕─────┘  └────↕─────┘  └────↕─────┘            │
│       │             │             │                  │
│       ↓             ↓             ↓                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
│  │   Task1  │  │   Task2  │  │   Task3  │            │
│  │  Running │  │  Running │  │  Blocked │            │
│  └──────────┘  └──────────┘  └──────────┘            │
│                                                      │
│  Context Switch через Kernel Space = дорого          │
└──────────────────────────────────────────────────────┘

              CPU Core 1    CPU Core 2    CPU Core 3
                   ↓             ↓             ↓
              Thread #1     Thread #2     Thread #3

Реальность:

  • Создание потока: ~1ms

  • Стек каждого потока: ~1MB

  • Context switch: kernel space (дорого)

Код на Java

public class TraditionalServer {
    private static final ExecutorService threadPool = 
        Executors.newFixedThreadPool(200);  
    
    public void handleRequest(Request request) {
        threadPool.submit(() -> {
            // Каждый запрос = один поток из пула
            String data = database.query(request);  // Поток блокируется
            processData(data);
            return response;
        });
    }
}

// Проблема: только 200 одновременных запросов
// Если БД медленная - все потоки заняты ожиданием

Thread Pools: спасение или костыль?

Из-за дороговизны создания потоков появились Thread Pools — пулы потоков, которые переиспользуются:

// Настройка ThreadPool
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,      // core pool size
    100,     // maximum pool size
    60L,     // keep alive time
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<Runnable>(1000)
);

// Но это не решает главную проблему: 
// при блокирующих операциях потоки простаивают

Плюсы

  • Простая инфраструктура — один процесс использует все ядра.

  • Shared memory между потоками (быстрый обмен данными).

  • Привычная модель программирования.

  • Настоящая параллельность для CPU-bound задач.

Минусы

  • Race conditions, deadlocks, visibility/atomicity проблемы.

  • Многопоточный код сложен в тестировании и отладке.

  • Ограничение на количество потоков (~тысячи, не миллионы).

  • Блокирующие операции тратят OS thread впустую.

  • Высокое потребление памяти при большом количестве потоков.

Когда использовать

Platform Threads подходят для:

  • Legacy приложений

  • CPU-intensive задач (вычисления, обработка данных)

  • Небольшого количества одновременных задач

  • Когда нужна максимальная производительность на ядро

Модель 3: Virtual Threads / Goroutines (Java 21+, Go)

Философия: "Миллион официантов на одной кухне"

Это гибрид: берем лучшее от Event Loop (легковесность) и от Platform Threads (простой код). Runtime сам мультиплексирует легковесные потоки на OS threads.

Архитектура: M:N mapping

┌────────────────────────────────────────────────────────┐
│               Application Code Level                   │
│                                                        │
│  [VThread1] [VThread2] ... [VThread1000000]            │
│  Миллионы виртуальных потоков в heap                   │
│  Каждый ~few KB памяти                                 │
│                                                        │
└────────────────┬───────────────────────────────────────┘
                 │
                 ↓
┌────────────────────────────────────────────────────────┐
│              JVM/Go Runtime Scheduler                  │
│         (M:N multiplexing, work stealing)              │
│                                                        │
│  Умный планировщик:                                    │
│  - При блокировке I/O → unmount VThread                │
│  - Carrier thread берет другой VThread                 │
│  - При завершении I/O → remount VThread                │
│                                                        │
└────────────────┬───────────────────────────────────────┘
                 │
                 ↓
┌────────────────────────────────────────────────────────┐
│          Carrier Threads (Platform Threads)            │
│                                                        │
│  [Carrier #1] [Carrier #2] ... [Carrier #N]            │
│  Обычно N = количество CPU ядер                        │
│  ~1MB stack каждый                                     │
│                                                        │
└────────────────┬───────────────────────────────────────┘
                 │
                 ↓
┌────────────────────────────────────────────────────────┐
│              OS Thread Scheduler                       │
└────────────────────────────────────────────────────────┘

Как работает магия

В Java (Project Loom):

public class VirtualThreadsServer {
    public void handleRequest(Request request) {
        // Создаем виртуальный поток - дёшево
        Thread.startVirtualThread(() -> {
            // Блокирующий код - НО carrier thread не блокируется
            String data = database.query(request);  
            // При I/O виртуальный поток unmount'ится
            // Carrier thread обрабатывает другие VThreads
            
            processData(data);
            return response;
        });
    }
}

// Можем создать МИЛЛИОНЫ таких потоков

Механизм работы (Java Virtual Threads)

Mount и Unmount — ключевой мехиназм. Это привязка и отвязка виртуального потока к реальному OS потоку.

  • Mount (монтирование, "привязка"): JVM назначает Virtual Thread на Carrier Thread (платформенный OS поток) для выполнения. В этот момент код действительно исполняется на CPU.

  • Unmount (размонтирование, "отвязка"): При блокирующей операции (чтение из БД, сетевой запрос, ожидание файла) Virtual Thread "отвязывается" от Carrier Thread и сохраняет своё состояние в heap памяти.

  • Carrier Thread освобождается: Пока первый VThread ждёт I/O, его Carrier Thread не простаивает — JVM сразу "сажает" на него другой готовый Virtual Thread.

  • Remount (повторное монтирование): Когда I/O операция завершается, Virtual Thread становится готов к выполнению и JVM монтирует его на любой свободный Carrier Thread (не обязательно тот же самый).

Производительность: бенчмарки

Посмотрим на реальные цифры (тест на 100K параллельных HTTP запросов с I/O):

Подход

Время (сек)

Память (MB)

Throughput (req/s)

Event Loop (Vert.x)

5.6

~200

17,857

Virtual Threads (Java 21)

7.5

~400

13,333

Platform Threads (Pool 200)

7.0

~800

14,285

Single Threaded

16.5

~100

6,060

CPU-bound задачи (обработка данных):

Подход

Время (мс)

Platform Threads (parallel)

79,055

Virtual Threads

82,340

Event Loop (Node.js)

156,000+

Подводные камни

Pinning Problem (на Java)

// ПЛОХО - synchronized прибивает VThread к carrier thread
synchronized (lock) {
    database.query();  // Блокируем carrier thread
}

// ХОРОШО - используем ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    database.query();  // VThread может unmount'иться
} finally {
    lock.unlock();
}
🎉 Проблема почти решена в Java 25

Хорошие новости: начиная с Java 24 (JEP 491: "Synchronize Virtual Threads without Pinning"), проблема прикрепления виртуальных потоков при использовании synchronized прак��ически полностью решена. Это улучшение перенесено в Java 25 LTS.

JVM теперь позволяет виртуальным потокам захватывать, удерживать и освобождать мониторы независимо от carrier threads

При блокирующей операции внутри synchronized виртуальный поток может unmount'иться Carrier thread остается свободным для выполнения других виртуальных потоков.

Остались ли случаи pinning? Да, но очень редкие:

  • Native методы (JNI calls)

  • Foreign функции (Foreign Function API)

  • Некоторые специфичные случаи с Unsafe

Thread-local переменные

// ThreadLocal создаёт копию данных для каждого потока. 
// С миллионом Virtual Threads это миллион копий в памяти → огромный overhead.
ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();

// Лучше использовать ScopedValue (Java 21+). 
// Данные привязаны к области видимости, а не к потоку
ScopedValue<Connection> connection = ScopedValue.newInstance();

Monopolization

// ПЛОХО - долгая CPU-intensive работа
Thread.startVirtualThread(() -> {
    // Монополизирует carrier thread на минуты
    // Другие VThreads ждут освобождения carrier'а
    for (long i = 0; i < 10_000_000_000L; i++) {
        Math.sqrt(i);
    }
});

// ХОРОШО - используйте отдельный thread pool для CPU задач
ExecutorService cpuPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

Плюсы

  • Нет лимита на количество одновременных задач.

  • Простой императивный код (как обычные threads).

  • Shared memory доступна.

  • Эффективное использование ресурсов.

  • Идеально для I/O-bound задач.

Минусы

  • Проблемы многопоточности остаются (race conditions, deadlocks).

  • В Java есть pinning проблема.

  • Thread-local нужно использовать аккуратно.

  • Монополизация CPU требует внимания.

  • Относительно новая технология (меньше материалов и опыта).

Когда использовать

Virtual Threads/Goroutines идеальны для:

  • Высоконагруженных веб-сервисов

  • Микросервисной архитектуры

  • Приложений с большим количеством одновременных соединений

  • Когда нужна простота Platform Threads + эффективность Event Loop


Сравнительная таблица

Характеристика

Event Loop

Platform Threads

Virtual Threads

Количество параллельных задач

Тысячи-десятки тысяч

Сотни-тысячи

Миллионы

Потребление памяти

Низкое (~100MB на инстанс)

Высокое (~1MB на thread)

Среднее (несколько KB на VThread)

Создание задачи

Очень быстро

Медленно (~1ms)

Очень быстро

Context switch

Быстро (userspace)

Медленно (kernel)

Быстро (userspace)

Сложность кода

Средняя (async/await)

Низкая (императивный код)

Низкая (императивный код)

Race conditions

Почти нет

Много

Много

Shared memory

Нет (процессы)

Да

Да

CPU-bound задачи

Плохо

Отлично

Хорошо

I/O-bound задачи

Отлично

Средне

Отлично

Масштабирование

Горизонтальное (процессы)

Вертикальное (threads)

Вертикальное (VThreads)

Примеры

Node.js, Python asyncio

Java <21, C++ threads

Java 21+, Go

Заключение

Разобрали три фундаментально разных подхода к многопоточности:

  1. Event Loop — один поток жонглирует задачами. Простой код, но нужно масштабировать процессами.

  2. Platform Threads — классика, но дорогая. Каждый thread = OS thread. Есть лимиты.

  3. Virtual Threads/Goroutines — современный гибрид. Простота Platform Threads + эффективность Event Loop.

Больше материалов о Spring Boot, Java и backend-разработке ищите в нашем телеграм-канале Java Insider.