golang

Вытесняй и властвуй: еще раз про многозадачность

  • четверг, 19 февраля 2026 г. в 00:00:18
https://habr.com/ru/articles/1000924/

В последнее время мне довелось много заниматься распараллеливанием однопоточного кода и показалось уместным свести воедино более-менее всё, что нам известно про разные типы многозадачности, с примерами и комментариями.

Пролог, в котором автор пытается объяснить, зачем вообще всё это нужно

Представьте себе коммунальную кухню в советской квартире. Шесть конфорок, двенадцать жильцов, и у каждого — неотложная потребность сварить борщ именно сейчас. Вопрос распределения ресурсов встаёт ребром, причём ребром острым, способным поранить неосторожного соседа.

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

Первый подход называется вытесняющей многозадачностью (preemptive multitasking), второй — кооперативной (cooperative multitasking). И если вы думаете, что выбор между ними очевиден, то позвольте вас разочаровать: история вычислительной техники полна примеров того, как оба подхода приводили к катастрофическим последствиям, просто разными путями.

Глава первая: кооперативная многозадачность, или о доверии к ближнему своему

Кооперативная многозадачность — это система, построенная на вере в человеческую (точнее, программистскую) порядочность. Каждая задача получает процессор и держит его до тех пор, пока сама, добровольно, по велению совести и здравого смысла, не решит отдать управление другим. Звучит утопично? Так оно и есть.

В Ruby, языке, который исторически славится своей элегантностью и полным пренебрежением к производительности, кооперативная многозадачность реализована через файберы (Fiber):

# Классический пример кооперативной многозадачности в Ruby
# Два файбера, мирно делящих процессорное время

fiber_a = Fiber.new do
  3.times do |i|
    puts "Файбер A: итерация #{i}"
    puts "Файбер A: добровольно уступаю место"
    Fiber.yield  # Вот оно — добровольное отречение от власти
  end
  "Файбер A завершён"
end

fiber_b = Fiber.new do
  3.times do |i|
    puts "Файбер B: итерация #{i}"
    puts "Файбер B: я тоже умею делиться"
    Fiber.yield
  end
  "Файбер B завершён"
end

# Дирижёр, управляющий оркестром
6.times do
  fiber_a.resume if fiber_a.alive?
  fiber_b.resume if fiber_b.alive?
end

попробовать вживую

Обратите внимание на Fiber.yield — это момент истины, точка, в которой задача добровольно отдаёт бразды правления. Без этого вызова файбер будет крутиться вечно, как пластинка, застрявшая на одной дорожке, и никакая сила в мире не заставит его остановиться. Кроме, разумеется, завершения программы или отключения электричества.

В Go ситуация несколько сложнее. Язык позиционируется как современный и прогрессивный, но под капотом горутины (goroutines) демонстрируют черты обоих подходов. До версии 1.14 Go использовал кооперативную модель:

package main

import (
    "fmt"
    "runtime"
)

func cooperativeTask(name string) {
    for i := 0; i < 5; i++ {
        fmt.Printf("%s: выполняю работу %d\n", name, i)
        // Явная точка кооперации — без неё в старых версиях Go
        // другие горутины могли голодать до смерти
        runtime.Gosched()
    }
}

func main() {
    // Ограничиваем до одного процессора для наглядности
    runtime.GOMAXPROCS(1)
    
    go cooperativeTask("Горутина Alpha")
    go cooperativeTask("Горутина Beta")
    go cooperativeTask("Горутина Gamma")
    
    // Ждём завершения (грубо, но наглядно)
    var input string
    fmt.Scanln(&input)
}

попробовать вживую

Вызов runtime.Gosched() — это эквивалент того самого добровольного шага в сторону. Программист явно говорит: «Я, конечно, мог бы ещё поработать, но давайте дадим шанс другим». Благородно? Безусловно. Надёжно? Как карточный домик на сквозняке.

Проблема голодания: когда кооперация становится диктатурой

Главная беда кооперативной многозадачности — это программист, забывший (или намеренно отказавшийся) вызвать точку кооперации. Рассмотрим патологический случай:

# Файбер-эгоист, пожирающий все ресурсы

selfish_fiber = Fiber.new do
  counter = 0
  loop do
    counter += 1
    # Бесконечный цикл без Fiber.yield
    # Остальные файберы могут сушить вёсла
    puts "Эгоист: #{counter}" if counter % 1_000_000 == 0
  end
end

poor_fiber = Fiber.new do
  puts "Бедняга: я когда-нибудь выполнюсь?"
  # Спойлер: нет, не выполнится
end

selfish_fiber.resume  # Всё, приехали
# poor_fiber.resume никогда не будет вызван

В Java, где потоки традиционно работают в вытесняющем режиме, можно симулировать кооперативное поведение через Thread.yield():

public class CooperativeSimulation {
    
    static volatile boolean running = true;
    
    public static void main(String[] args) throws InterruptedException {
        Thread politeThread = new Thread(() -> {
            while (running) {
                System.out.println("Вежливый поток: работаю немного");
                doSomeWork(1000);
                Thread.yield(); // Кооперативная уступка
            }
        }, "PoliteThread");
        
        Thread rudeThread = new Thread(() -> {
            while (running) {
                System.out.println("Грубый поток: работаю МНОГО");
                doSomeWork(100_000_000); // Тяжёлая работа
                // Никакого yield — пусть другие подождут
            }
        }, "RudeThread");
        
        politeThread.start();
        rudeThread.start();
        
        Thread.sleep(5000);
        running = false;
    }
    
    static void doSomeWork(int iterations) {
        double result = 0;
        for (int i = 0; i < iterations; i++) {
            result += Math.sin(i) * Math.cos(i);
        }
    }
}

попробовать вживую — чтобы увидеть блокировку, нужно ограничить виртуальную машину одним процессором, или запустить много rudeThread, причем лучше всего, если это будет Java8.

Потому что Thread.yield() в Java — это лишь подсказка планировщику, а не приказ. JVM может проигнорировать её с олимпийским спокойствием, особенно на современных многоядерных системах, где вытесняющий планировщик операционной системы всё равно возьмёт своё.

Глава вторая: вытесняющая многозадачность, или диктатура планировщика

Вытесняющая многозадачность — это когда операционная система, подобно строгому учителю с секундомером, даёт каждой задаче определённый квант времени. Истёк квант — будь добр освободить место, независимо от того, закончил ты свои дела или нет. Никаких «ещё пять минуточек», никаких «я почти доделал».

Современные операционные системы — Windows, Linux, macOS — все используют вытесняющую многозадачность. И на то есть веские причины: система не должна зависеть от порядочности отдельных программ. Один зависший процесс не должен утащить за собой в небытие всю систему.

В современных виртуальных машинах Java потоки изначально работают в вытесняющем режиме:

public class PreemptiveDemo {
    
    public static void main(String[] args) {
        // Создаём потоки с разными приоритетами
        Thread highPriority = new Thread(() -> {
            long count = 0;
            while (count < 1_000_000_000L) {
                count++;
                // Никаких yield, никакой кооперации
                // Планировщик сам разберётся
            }
            System.out.println("Высокий приоритет: " + count);
        });
        highPriority.setPriority(Thread.MAX_PRIORITY);
        
        Thread lowPriority = new Thread(() -> {
            long count = 0;
            while (count < 1_000_000_000L) {
                count++;
            }
            System.out.println("Низкий приоритет: " + count);
        });
        lowPriority.setPriority(Thread.MIN_PRIORITY);
        
        // Оба стартуют одновременно
        highPriority.start();
        lowPriority.start();
        
        // Планировщик ОС распределит время между ними
        // Высокий приоритет получит больше, но низкий не умрёт с голоду
    }
}

В Go начиная с версии 1.14 горутины также получили настоящую вытесняющую многозадачность:

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func cpuBoundTask(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    
    start := time.Now()
    var counter int64
    
    // Чистые вычисления, никаких точек кооперации
    for counter < 1_000_000_000 {
        counter++
    }
    
    elapsed := time.Since(start)
    fmt.Printf("Горутина %d: завершена за %v\n", id, elapsed)
}

func main() {
    // Даже с одним процессором горутины будут вытесняться
    runtime.GOMAXPROCS(1)
    
    var wg sync.WaitGroup
    
    // Запускаем несколько CPU-bound горутин
    for i := 1; i <= 4; i++ {
        wg.Add(1)
        go cpuBoundTask(i, &wg)
    }
    
    // Эта горутина тоже получит процессорное время
    // благодаря асинхронному вытеснению
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println("Фоновая задача: я жива!")
            time.Sleep(100 * time.Millisecond)
        }
    }()
    
    wg.Wait()
}

посмотреть вживую

Накладные расходы: цена свободы

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

По крайней мере, так написано в книжках. На практике мне не удалось зафиксировать сколько-нибудь значимые потери.

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func measureContextSwitchOverhead() {
    const iterations = 1_000_000
    
    // Вариант 1: последовательное выполнение
    start := time.Now()
    sum := uint64(0)
    for i := uint64(0); i < iterations; i++ {
        sum += i
    }
    sequential := time.Since(start)
    
    // Вариант 2: параллельное выполнение с множеством горутин
    runtime.GOMAXPROCS(8) // Форсируем переключения
    
    start = time.Now()
    var wg sync.WaitGroup
    var mu sync.Mutex
    parallelSum := uint64(0)
    
    for i := uint64(0); i < iterations; i++ {
        wg.Add(1)
        go func(n uint64) {
            defer wg.Done()
            mu.Lock()
            parallelSum += n
            mu.Unlock()
        }(i)
    }
    wg.Wait()
    parallel := time.Since(start)
    
    fmt.Printf("Последовательно: %v\n", sequential.Nanoseconds())
    fmt.Printf("Параллельно (1 CPU): %v\n", parallel.Nanoseconds())
    fmt.Printf("Накладные расходы: %.2fx\n", float64(parallel)/float64(sequential))
}

func main() {
    measureContextSwitchOverhead()
}

посмотреть вживую

Глава третья: гибридные монстры и полумеры

Реальный мир редко бывает чёрно-белым, и многозадачность не исключение. Современные системы часто используют гибридные подходы, сочетающие элементы обоих методов.

Event Loop: кооперация под присмотром

Ruby с его EventMachine, Node.JS с его event loop, Python с asyncio — все они представляют собой вариации на тему кооперативной многозадачности, работающей внутри одного потока операционной системы. При этом сам поток управляется вытесняющим планировщиком ОС.

require 'async'

# Современный Ruby с async gem
# Кооперативная многозадачность внутри event loop

Async do |task|
  # Задача 1: HTTP-запрос (имитация)
  subtask1 = task.async do
    puts "Задача 1: начинаю запрос"
    sleep 2  # Async::Task.current.sleep под капотом
    puts "Задача 1: получен ответ"
    "Результат 1"
  end
  
  # Задача 2: работа с файлом (имитация)
  subtask2 = task.async do
    puts "Задача 2: читаю файл"
    sleep 1
    puts "Задача 2: файл прочитан"
    "Результат 2"
  end
  
  # Задача 3: вычисления
  subtask3 = task.async do
    puts "Задача 3: считаю"
    sleep 0.5
    puts "Задача 3: посчитал"
    "Результат 3"
  end
  
  # Все задачи выполняются конкурентно в одном потоке
  results = [subtask1.wait, subtask2.wait, subtask3.wait]
  puts "Все результаты: #{results}"
end

▸ вживую мне не известна ни одна песочница, позволяющая инсталлировать чужие джемы, поэтому вот картинка:

Задача 1: начинаю запрос
Задача 2: читаю файл
Задача 3: считаю
Задача 3: посчитал
Задача 2: файл прочитан
Задача 1: получен ответ
Все результаты: ["Результат 1", "Результат 2", "Результат 3"]
=> #<Async::Task:0x0000000000000168>

Ключевой момент: sleep в контексте async — это не блокирующий вызов, а точка кооперации. Задача говорит: «Мне нужно подождать, пусть пока другие поработают». Это элегантно, эффективно и… довольно хрупко.

Зелёные потоки: иллюзия многопоточности

Go с его горутинами, Erlang с его процессами, Java с виртуальными потоками (Project Loom) — все они реализуют концепцию «зелёных потоков»: множество легковесных задач, мультиплексируемых на меньшее количество системных потоков.

// Java 21+ с виртуальными потоками (Project Loom)

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.time.Duration;
import java.time.Instant;

public class VirtualThreadsDemo {
    
    public static void main(String[] args) throws Exception {
        final int TASK_COUNT = 1000;
        
        // Старый способ: пул обычных потоков
        Instant start = Instant.now();
        try (ExecutorService executor = Executors.newFixedThreadPool(100)) {
            for (int i = 0; i < TASK_COUNT; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    try {
                        Thread.sleep(100); // Имитация I/O
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    return taskId;
                });
            }
        }
        Duration platformDuration = Duration.between(start, Instant.now());
        
        // Новый способ: виртуальные потоки
        start = Instant.now();
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < TASK_COUNT; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    try {
                        Thread.sleep(100); // То же самое
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    return taskId;
                });
            }
        }
        Duration virtualDuration = Duration.between(start, Instant.now());
        
        System.out.printf("Обычные потоки: %s%n", platformDuration);
        System.out.printf("Виртуальные потоки: %s%n", virtualDuration);
    }
}

▸ результат выполнения:

jshell> VirtualThreadsDemo.main(new String[0])
Обычные потоки: PT1.012539805S
Виртуальные потоки: PT0.115042997S

Виртуальные потоки Java — это кооперативная многозадачность, замаскированная под привычный API потоков. Блокирующий вызов Thread.sleep() превращается в точку кооперации, позволяя планировщику переключиться на другой виртуальный поток.

Глава четвёртая: ловушки, западни и подводные камни

Инверсия приоритетов: когда демократия вредит

Представьте ситуацию: высокоприоритетная задача ждёт ресурс, захваченный низкоприоритетной. Средний приоритет вытесняет низкий. В итоге высокий приоритет ждёт среднего, который ждёт низкого. Это называется инверсией приоритетов, и это классическая проблема вытесняющей многозадачности.

import java.util.concurrent.locks.ReentrantLock;

public class PriorityInversionDemo {
    
    private static final ReentrantLock sharedResource = new ReentrantLock();
    
    public static void main(String[] args) throws InterruptedException {
        
        // Низкоприоритетный поток захватывает ресурс
        Thread lowPriority = new Thread(() -> {
            sharedResource.lock();
            try {
                System.out.println("Низкий: захватил ресурс");
                // Долгая работа
                simulateCpuWork(2000);
                System.out.println("Низкий: освобождаю ресурс");
            } finally {
                sharedResource.unlock();
            }
        }, "Low");
        lowPriority.setPriority(Thread.MIN_PRIORITY);
        
        // Высокоприоритетный поток хочет тот же ресурс
        Thread highPriority = new Thread(() -> {
            try { Thread.sleep(100); } catch (InterruptedException e) {}
            System.out.println("Высокий: хочу ресурс");
            sharedResource.lock();
            try {
                System.out.println("Высокий: получил ресурс!");
            } finally {
                sharedResource.unlock();
            }
        }, "High");
        highPriority.setPriority(Thread.MAX_PRIORITY);
        
        // Средний приоритет — вычислительная задача
        Thread mediumPriority = new Thread(() -> {
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            System.out.println("Средний: начинаю вычисления");
            simulateCpuWork(1000);
            System.out.println("Средний: закончил");
        }, "Medium");
        mediumPriority.setPriority(Thread.NORM_PRIORITY);
        
        lowPriority.start();
        highPriority.start();
        mediumPriority.start();
        
        lowPriority.join();
        highPriority.join();
        mediumPriority.join();
    }
    
    static void simulateCpuWork(int millis) {
        long start = System.currentTimeMillis();
        while (System.currentTimeMillis() - start < millis) {
            Math.random();
        }
    }
}

▸ результат выполнения:

jshell> PriorityInversionDemo.main(new String[0])
Низкий: захватил ресурс
Средний: начинаю вычисления
Высокий: хочу ресурс
Средний: закончил
Низкий: освобождаю ресурс
Высокий: получил ресурс!

Mars Pathfinder, космический аппарат NASA, пострадал от этой проблемы в 1997 году. Система периодически перезагружалась из-за инверсии приоритетов между задачей сбора данных и информационной шиной. Решение нашли дистанционно, включив наследование приоритетов в VxWorks. История поучительная: даже космические инженеры иногда забывают о подводных камнях многозадачности.

Гонки данных: хаос в параллельном мире

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

package main

import (
    "fmt"
    "sync"
)

// Классическая гонка данных
func raceConditionDemo() {
    counter := 0
    var wg sync.WaitGroup
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // Это не атомарная операция!
            // 1. Прочитать counter
            // 2. Увеличить значение
            // 3. Записать обратно
            // Планировщик может вытеснить на любом шаге
            counter++
        }()
    }
    
    wg.Wait()
    fmt.Printf("Ожидали 1000, получили: %d\n", counter)
}

// Исправленная версия
func fixedDemo() {
    var counter int64
    var wg sync.WaitGroup
    var mu sync.Mutex
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }
    
    wg.Wait()
    fmt.Printf("С мьютексом: %d\n", counter)
}

func main() {
    raceConditionDemo()
    fixedDemo()
}

посмотреть вживую

В кооперативной многозадачности гонки данных встречаются реже — вы контролируете точки переключения. Но «реже» не означает «никогда». Один забытый await, и хаос врывается в вашу упорядоченную вселенную.

Deadlock: взаимная блокировка, или философы за обедом

Классическая проблема обедающих философов демонстрирует, как легко создать ситуацию взаимной блокировки:

require 'thread'

# Проблема обедающих философов
# Пять философов, пять вилок, бесконечные размышления и еда

class DiningPhilosophers
  def initialize(count)
    @count = count
    @forks = Array.new(count) { Mutex.new }
    @philosophers = []
  end
  
  def start_deadlock_prone
    @count.times do |i|
      @philosophers << Thread.new do
        loop do
          think(i)
          # Каждый берёт сначала левую, потом правую вилку
          # Это прямой путь к deadlock
          left_fork = @forks[i]
          right_fork = @forks[(i + 1) % @count]
          
          left_fork.synchronize do
            puts "Философ #{i}: взял левую вилку"
            sleep(0.1) # Окно для deadlock
            right_fork.synchronize do
              puts "Философ #{i}: взял правую вилку, ем"
              eat(i)
            end
          end
        end
      end
    end
    
    @philosophers.each(&:join)
  end
  
  def start_safe
    @count.times do |i|
      @philosophers << Thread.new do
        loop do
          think(i)
          # Решение: всегда брать вилки в порядке возрастания номера
          first = [i, (i + 1) % @count].min
          second = [i, (i + 1) % @count].max
          
          @forks[first].synchronize do
            @forks[second].synchronize do
              puts "Философ #{i}: ем"
              eat(i)
            end
          end
        end
      end
    end
    
    @philosophers.each(&:join)
  end
  
  private
  
  def think(id)
    puts "Философ #{id}: размышляю о бренности бытия"
    sleep(rand * 0.5)
  end
  
  def eat(id)
    sleep(rand * 0.3)
  end
end

# dinner = DiningPhilosophers.new(5)
# dinner.start_deadlock_prone # Не запускайте, если цените своё время

▸ если все-таки запустить, никакие Ctrl+C не помогут:

…
Философ 3: взял правую вилку, ем


[6] pry(main)> Философ 1: взял левую вилку
Философ 3: размышляю о бренности бытия
Философ 2: взял правую вилку, ем


[6] pry(main)> Философ 4: взял левую вилку
Философ 0: взял левую вилку


[6] pry(main)> Философ 2: размышляю о бренности бытия
Философ 3: взял левую вилку
Философ 1: взял правую вилку, ем
…

Livelock: иллюзия прогресса

Если deadlock — это когда все стоят и ждут, то livelock — это когда все активно работают, но ничего не происходит. Как два вежливых человека в коридоре, бесконечно уступающих друг другу дорогу:

package main

import (
    "fmt"
    "sync"
    "time"
)

type PoliteWorker struct {
    name    string
    working bool
    mu      sync.Mutex
}

func (w *PoliteWorker) Work(other *PoliteWorker) {
    for i := 0; i < 10; i++ {
        w.mu.Lock()
        
        // Проверяем, не работает ли другой
        other.mu.Lock()
        if other.working {
            // Слишком вежливы — уступаем
            fmt.Printf("%s: вижу, что %s работает, уступаю\n", w.name, other.name)
            other.mu.Unlock()
            w.mu.Unlock()
            time.Sleep(time.Millisecond) // И снова пробуем
            continue
        }
        other.mu.Unlock()
        
        // Начинаем работать
        w.working = true
        fmt.Printf("%s: работаю!\n", w.name)
        time.Sleep(10 * time.Millisecond)
        w.working = false
        
        w.mu.Unlock()
    }
}

func main() {
    alice := &PoliteWorker{name: "Алиса"}
    bob := &PoliteWorker{name: "Борис"}
    
    var wg sync.WaitGroup
    wg.Add(2)
    
    go func() {
        defer wg.Done()
        alice.Work(bob)
    }()
    
    go func() {
        defer wg.Done()
        bob.Work(alice)
    }()
    
    wg.Wait()
}

посмотреть вживую

Глава пятая: практические рекомендации, или как не сойти с ума

Когда использовать кооперативную многозадачность

  1. I/O-bound задачи: Если ваша программа бо́льшую часть времени ждёт ответа от базы данных, сети или файловой системы, кооперативная многозадачность — отличный выбор. Накладные расходы минимальны, код остаётся предсказуемым.

require 'async'
require 'async/http/internet'

Async do
  internet = Async::HTTP::Internet.new
  
  urls = %w[
    https://example.com
    https://example.org  
    https://example.net
  ]
  
  # Все запросы выполняются конкурентно
  # Кооперативное переключение на await
  tasks = urls.map do |url|
    Async do
      response = internet.get(url)
      puts "#{url}: #{response.status}"
      response.finish
    end
  end
  
  tasks.each(&:wait)
ensure
  internet&.close
end
  1. Игры и симуляции: Когда вам нужен детерминизм и воспроизводимость. В игре важно, чтобы каждый кадр обрабатывался одинаково, независимо от загрузки системы.

  2. Встроенные системы с ограниченными ресурсами: Когда каждый байт памяти на счету, и вы не можете позволить себе накладные расходы вытесняющего планировщика.

Когда использовать вытесняющую многозадачность

  1. CPU-bound задачи: Если задача интенсивно использует процессор, вытесняющая многозадачность гарантирует, что другие задачи тоже получат своё время.

import java.util.concurrent.*;
import java.util.stream.*;

public class CpuBoundTasks {
    
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );
        
        // CPU-bound задачи: вычисление простых чисел
        List<Future<Long>> futures = IntStream.range(0, 8)
            .mapToObj(i -> executor.submit(() -> countPrimes(i * 1_000_000, (i + 1) * 1_000_000)))
            .collect(Collectors.toList());
        
        long total = 0;
        for (Future<Long> future : futures) {
            total += future.get();
        }
        
        System.out.printf("Всего простых чисел: %d%n", total);
        executor.shutdown();
    }
    
    static long countPrimes(int from, int to) {
        return IntStream.range(from, to)
            .filter(CpuBoundTasks::isPrime)
            .count();
    }
    
    static boolean isPrime(int n) {
        if (n < 2) return false;
        for (int i = 2; i <= Math.sqrt(n); i++) {
            if (n % i == 0) return false;
        }
        return true;
    }
}
  1. Ненадёжный код: Когда вы запускаете код, которому не доверяете (плагины, пользовательские скрипты), вытесняющая многозадачность защитит систему от зависаний.

  2. Интерактивные приложения: Пользовательский интерфейс должен отзываться на действия пользователя, даже если фоновые задачи выполняют тяжёлые вычисления.

Гибридный подход: лучшее из обоих миров

Современные приложения часто комбинируют оба подхода:

package main

import (
    "context"
    "fmt"
    "runtime"
    "sync"
    "time"
)

type HybridScheduler struct {
    cpuWorkers    int
    ioWorkers     int
    cpuTasks      chan func()
    ioTasks       chan func()
    wg            sync.WaitGroup
}

func NewHybridScheduler() *HybridScheduler {
    s := &HybridScheduler{
        cpuWorkers: runtime.NumCPU(),
        ioWorkers:  100, // Много легковесных для I/O
        cpuTasks:   make(chan func(), 100),
        ioTasks:    make(chan func(), 1000),
    }
    
    // CPU-воркеры: по одному на ядро
    for i := 0; i < s.cpuWorkers; i++ {
        s.wg.Add(1)
        go s.cpuWorker()
    }
    
    // I/O-воркеры: много легковесных
    for i := 0; i < s.ioWorkers; i++ {
        s.wg.Add(1)
        go s.ioWorker()
    }
    
    return s
}

func (s *HybridScheduler) cpuWorker() {
    defer s.wg.Done()
    for task := range s.cpuTasks {
        task()
    }
}

func (s *HybridScheduler) ioWorker() {
    defer s.wg.Done()
    for task := range s.ioTasks {
        task()
    }
}

func (s *HybridScheduler) SubmitCPU(task func()) {
    s.cpuTasks <- task
}

func (s *HybridScheduler) SubmitIO(task func()) {
    s.ioTasks <- task
}

func (s *HybridScheduler) Shutdown() {
    close(s.cpuTasks)
    close(s.ioTasks)
    s.wg.Wait()
}

func main() {
    scheduler := NewHybridScheduler()
    
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    var resultsWg sync.WaitGroup
    
    // CPU-bound задачи
    for i := 0; i < 10; i++ {
        resultsWg.Add(1)
        taskID := i
        scheduler.SubmitCPU(func() {
            defer resultsWg.Done()
            result := heavyComputation(taskID)
            fmt.Printf("CPU задача %d: результат %d\n", taskID, result)
        })
    }
    
    // I/O-bound задачи
    for i := 0; i < 50; i++ {
        resultsWg.Add(1)
        taskID := i
        scheduler.SubmitIO(func() {
            defer resultsWg.Done()
            simulateIO(taskID)
            fmt.Printf("I/O задача %d: завершена\n", taskID)
        })
    }
    
    done := make(chan struct{})
    go func() {
        resultsWg.Wait()
        close(done)
    }()
    
    select {
    case <-done:
        fmt.Println("Все задачи завершены")
    case <-ctx.Done():
        fmt.Println("Таймаут")
    }
    
    scheduler.Shutdown()
}

func heavyComputation(id int) int {
    sum := 0
    for i := 0; i < 10_000_000; i++ {
        sum += i % (id + 1)
    }
    return sum
}

func simulateIO(id int) {
    time.Sleep(time.Duration(50+id%50) * time.Millisecond)
}

Эпилог: нет серебряной пули

Выбор между вытесняющей и кооперативной многозадачностью — это не вопрос «что лучше», а вопрос «что подходит для данной задачи». Кооперативная многозадачность проще в реализации, предсказуема и эффективна для I/O-bound нагрузок. Вытесняющая многозадачность устойчива к ошибкам программиста, справедлива к ресурсам и необходима для CPU-bound задач.

Современные языки и runtime всё чаще предлагают гибридные решения: горутины Go, виртуальные потоки Java, async/await в Ruby — все они пытаются совместить простоту кооперативной модели с надёжностью вытесняющей.

Но какую бы модель мы ни выбрали, надо помнить: конкурентность — это не два байта переслать. Гонки данных, взаимные блокировки, инверсия приоритетов — эти проблемы не исчезают с выбором правильной модели многозадачности. Они лишь принимают разные формы, подобно многоголовой гидре, у которой на месте отрубленной головы вырастают две новые.

Единственное надёжное средство — это понимание. Понимание того, как работает ваш планировщик, где находятся точки переключения, какие данные разделяются между задачами. И, конечно, тесты. Много тестов. Очень много тестов.

А ещё — здоровая паранойя. Потому что в мире конкурентного программирования то, что может пойти не так, непременно пойдёт не так. Обычно в пятницу вечером.