Scheduler internals в Go ↔ Java: как на самом деле исполняется твой код
Когда ты пишешь go func() или создаёшь Thread в Java, кажется, что ты управляешь параллельностью. Но на самом деле ты даёшь задачу планировщику — scheduler'у. И вот тут начинается настоящее шоу.
Go и Java используют принципиально разные модели: Go — M:N scheduler (много goroutine на меньшее число потоков), Java — 1:1 (поток = OS thread). Это влияет на всё: latency, масштабируемость, поведение при блокировках.
Разберём ключевые механизмы: parking/unparking, work stealing, очереди, netpoller и preemption. Это не просто теория — это то, почему один код «летает», а другой внезапно замирает.
Goroutine parking / unparking
Что это такое и что происходит под капотом
Parking — это когда goroutine временно «засыпает» и освобождает поток (OS thread). Unparking — когда её возвращают к выполнению.
В Go это происходит постоянно: при ожидании канала, mutex, syscalls. Scheduler снимает goroutine с выполнения и помещает её в wait-структуры. Поток (M) при этом может взять другую goroutine (G).
В Java блокировка потока означает блокировку OS thread. Да, есть LockSupport.park(), но чаще блокировка — это реальный sleep на уровне ОС.
package main
import "time"
func main() {
ch := make(chan int)
go func() {
// эта goroutine "паркуется" здесь
val := <-ch
println(val)
}()
time.Sleep(time.Second)
// разбудит goroutine
ch <- 42
}
import java.util.concurrent.ArrayBlockingQueue;
public class Main {
public static void main(String[] args) throws Exception {
ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1);
new Thread(() -> {
try {
// поток блокируется (OS-level)
Integer val = queue.take();
System.out.println(val);
} catch (Exception e) {}
}).start();
Thread.sleep(1000);
queue.put(42); // разблокирует поток
}
}
Go паркует лёгкие goroutine, а не тяжёлые потоки. Это ключ к масштабируемости. Под капотом scheduler просто меняет указатели и очереди, а не взаимодействует с ОС. В Java блокировка часто уходит в kernel → дороже. Поэтому в Go можно держать десятки тысяч goroutine, а в Java — тысячи потоков уже проблема.
Используется везде: каналы, mutex, IO. В Go это дешёвая операция → можно писать «наивный» код. В Java нужно думать о thread pools и ограничении потоков. Плюс Go — лёгкость. Минус — сложнее отладка scheduler. Java наоборот: проще модель, но дороже блокировки.
Work stealing
Что это такое и что происходит под капотом
Work stealing — это когда один поток «крадёт» задачи у другого.
В Go каждый P (processor) имеет свою очередь goroutine. Если очередь пустая — он ворует задачи у других P.
В Java ForkJoinPool использует аналогичный механизм.
// ASCII схема
P1: [G1 G2 G3]
P2: []
P2 -> steal -> G2
for i := 0; i < 100; i++ {
go func(i int) {
println(i)
}(i)
}
import java.util.concurrent.ForkJoinPool;
public class Main {
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
for (int i = 0; i < 100; i++) {
int x = i;
pool.submit(() -> System.out.println(x));
}
}
}
Work stealing снижает простой CPU. Под капотом это балансировка нагрузки. Но это не бесплатно: есть синхронизация и contention. В Go это встроено в runtime, в Java — зависит от executor.
Идеально для CPU-bound задач. Плюс — равномерная загрузка. Минус — overhead. В Go ты получаешь это «бесплатно». В Java нужно выбрать правильный pool.
Syscall blocking behavior
Что это такое и что происходит под капотом
Когда goroutine делает syscall (например, чтение файла), поток может заблокироваться. Go решает это так: создаёт новый поток, чтобы не блокировать выполнение других goroutine.
В Java поток блокируется полностью.
package main
import "os"
func main() {
go func() {
f, _ := os.Open("file.txt")
buf := make([]byte, 100)
f.Read(buf) // syscall
}()
}
import java.io.FileInputStream;
public class Main {
public static void main(String[] args) throws Exception {
new Thread(() -> {
try {
FileInputStream f = new FileInputStream("file.txt");
byte[] buf = new byte[100];
f.read(buf); // блокирует поток
} catch (Exception e) {}
}).start();
}
}
Syscall — это граница user space ↔ kernel. Go старается скрыть это и не блокировать scheduler. Java оставляет это тебе. Если ты игнорируешь это — получаешь thread starvation.
Go отлично подходит для IO-heavy приложений. Java требует async (NIO). Плюс Go — простота. Минус — больше потоков под капотом. Java — контроль, но сложнее код.
Local run queue
Что это такое и что происходит под капотом
Каждый P в Go имеет локальную очередь goroutine. Это уменьшает contention.
// P1: [G1 G2]
// P2: [G3 G4]
// goroutine сначала попадает в локальную очередь
go func() { println("hi") }()
// аналог — worker thread queue
Локальные очереди — это оптимизация под cache locality. Под капотом это уменьшает lock contention. В Java аналог — thread-local queues в ForkJoinPool.
Используется в high-concurrency системах. Плюс — скорость. Минус — сложность балансировки.
Global run queue
Что это такое и что происходит под капотом
Глобальная очередь — fallback. Если локальные пусты — берём оттуда.
// Global: [G5 G6]
// runtime кладёт задачи в global queue при перегрузке
// глобальная очередь executor
Глобальная очередь — точка contention. Под капотом это shared структура. Чем меньше ты её используешь — тем лучше.
Используется при burst нагрузке. Плюс — балансировка. Минус — блокировки.
Network poller (netpoller)
Что это такое и что происходит под капотом
Go использует epoll/kqueue через netpoller. Это позволяет не блокировать потоки на IO.
Java делает это через NIO.
// netpoller управляет IO событиями
// Selector в NIO
Netpoller — это сердце IO в Go. Он позволяет писать синхронный код, который на самом деле async. Это обман, но очень полезный.
Используется в HTTP серверах, gRPC. Плюс — простота API. Минус — сложность runtime.
Cooperative vs async preemption
Что это такое и что происходит под капотом
Раньше Go использовал cooperative preemption — goroutine должна «уступить» сама.
Теперь есть async preemption — runtime может прервать её.
Java всегда имел preemptive scheduling.
// long loop может быть прерван runtime
// thread может быть прерван планировщиком ОС
Async preemption делает Go более безопасным. Без него можно «зависнуть» в бесконечном цикле. Под капотом это сигналы и safe points.
Важно для fairness. Плюс — стабильность. Минус — небольшой overhead.
Общая таблица сравнения
| Термин | Go | Java | Комментарий |
|---|---|---|---|
| Parking | goroutine | thread | Go легче и дешевле |
| Work stealing | встроено | ForkJoinPool | в Go по умолчанию |
| Syscall | не блокирует scheduler | блокирует поток | важно для IO |
| Local queue | есть | частично | оптимизация |
| Global queue | fallback | основная | точка contention |
| Netpoller | встроен | NIO | разные уровни абстракции |
| Preemption | cooperative + async | preemptive | Go догнал Java |
Вывод / Итог
Scheduler — это скрытый дирижёр твоей программы. И в Go он гораздо более активный участник, чем в Java.
Go строит illusion: ты пишешь синхронный код, но под капотом всё асинхронно. Scheduler паркует, ворует задачи, балансирует нагрузку, управляет IO. Это мощно, но требует понимания.
Java же более честен: поток — это поток. Если он заблокирован — он заблокирован. Да, есть ForkJoinPool, NIO, но они не встроены так глубоко.
Если ты понимаешь scheduler, ты начинаешь видеть невидимое: почему код тормозит, где contention, где блокировки. Это уровень, где разработчик перестаёт быть пользователем языка и становится его исследователем.
Go — это про массовую параллельность. Java — про контроль и зрелость. И лучший результат получается, когда ты понимаешь оба мира.
Галерея
Полезные статьи:
Новые статьи: