Вытесняй и властвуй: еще раз про многозадачность
- четверг, 19 февраля 2026 г. в 00:00:18
В последнее время мне довелось много заниматься распараллеливанием однопоточного кода и показалось уместным свести воедино более-менее всё, что нам известно про разные типы многозадачности, с примерами и комментариями.
Представьте себе коммунальную кухню в советской квартире. Шесть конфорок, двенадцать жильцов, и у каждого — неотложная потребность сварить борщ именно сейчас. Вопрос распределения ресурсов встаёт ребром, причём ребром острым, способным поранить неосторожного соседа.
В мире вычислительной техники роль коммунальной кухни исполняет процессор, а жильцами выступают процессы и потоки, каждый из которых свято убеждён в собственной исключительной важности. И точно так же, как в коммуналке, существует два принципиально разных подхода к решению проблемы: либо назначить коменданта с секундомером, который будет безжалостно сгонять зазевавшихся с плиты, либо положиться на сознательность граждан и их готовность добровольно уступать место проголодавшимся соседям.
Первый подход называется вытесняющей многозадачностью (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() }
Реальный мир редко бывает чёрно-белым, и многозадачность не исключение. Современные системы часто используют гибридные подходы, сочетающие элементы обоих методов.
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, и хаос врывается в вашу упорядоченную вселенную.
Классическая проблема обедающих философов демонстрирует, как легко создать ситуацию взаимной блокировки:
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: взял правую вилку, ем …
Если 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() }
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
Игры и симуляции: Когда вам нужен детерминизм и воспроизводимость. В игре важно, чтобы каждый кадр обрабатывался одинаково, независимо от загрузки системы.
Встроенные системы с ограниченными ресурсами: Когда каждый байт памяти на счету, и вы не можете позволить себе накладные расходы вытесняющего планировщика.
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; } }
Ненадёжный код: Когда вы запускаете код, которому не доверяете (плагины, пользовательские скрипты), вытесняющая многозадачность защитит систему от зависаний.
Интерактивные приложения: Пользовательский интерфейс должен отзываться на действия пользователя, даже если фоновые задачи выполняют тяжёлые вычисления.
Современные приложения часто комбинируют оба подхода:
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 — все они пытаются совместить простоту кооперативной модели с надёжностью вытесняющей.
Но какую бы модель мы ни выбрали, надо помнить: конкурентность — это не два байта переслать. Гонки данных, взаимные блокировки, инверсия приоритетов — эти проблемы не исчезают с выбором правильной модели многозадачности. Они лишь принимают разные формы, подобно многоголовой гидре, у которой на месте отрубленной головы вырастают две новые.
Единственное надёжное средство — это понимание. Понимание того, как работает ваш планировщик, где находятся точки переключения, какие данные разделяются между задачами. И, конечно, тесты. Много тестов. Очень много тестов.
А ещё — здоровая паранойя. Потому что в мире конкурентного программирования то, что может пойти не так, непременно пойдёт не так. Обычно в пятницу вечером.