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.
Goroutine blocking on IO
Что это и как работает
В Go горутины — это легковесные потоки, управляемые рантаймом, а не операционной системой. Когда горутина выполняет блокирующую IO-операцию (например, чтение из сокета), она НЕ блокирует OS-thread (в отличие от классической модели Java). Вместо этого Go runtime "паркует" горутину, сохраняет её состояние и освобождает поток для выполнения других горутин.
Под капотом происходит следующее: при попытке IO-операции Go использует неблокирующие системные вызовы. Если данные не готовы, горутина регистрируется в netpoller и переводится в состояние ожидания. OS-thread (M) освобождается и может выполнять другие goroutine (G). Когда данные готовы, netpoller уведомляет scheduler, и горутина возвращается в очередь выполнения.
В Java классическая модель (до Loom) — это блокирующие потоки: если поток делает socket.read(), он блокирует OS-thread. Это дорого, так как поток — тяжёлая структура. Поэтому используются NIO (Selector), CompletableFuture или реактивные фреймворки.
Пример кода (Go)
package main
import (
"fmt"
"net"
)
func main() {
// создаем TCP соединение
conn, _ := net.Dial("tcp", "example.com:80")
// эта операция может "заблокировать" горутину,
// но НЕ поток ОС
buffer := make([]byte, 1024)
n, _ := conn.Read(buffer)
fmt.Println("Read bytes:", n)
}
Пример кода (Java)
import java.io.InputStream;
import java.net.Socket;
public class Main {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("example.com", 80);
InputStream in = socket.getInputStream();
byte[] buffer = new byte[1024];
// блокирует поток ОС
int n = in.read(buffer);
System.out.println("Read bytes: " + n);
}
}
// Схема (Go):
G (goroutine)
|
v
IO call ---> нет данных
|
v
park goroutine ----> netpoller
|
v
OS thread свободен
// Схема (Java):
Thread
|
v
IO call ---> блокировка
|
v
Thread занят (ждёт)
Go автоматически делает IO неблокирующим на уровне рантайма, поэтому разработчику не нужно думать о Selector/epoll. Но важно понимать: если вы используете C-библиотеки или syscall напрямую, вы можете случайно заблокировать поток. В Java же наоборот — по умолчанию всё блокирующее, и нужно осознанно переходить на NIO/async. Причина в архитектуре: Go runtime контролирует выполнение, JVM — нет, она делегирует OS.
Этот механизм активно используется в high-load сервисах: HTTP-серверы, прокси, стриминг. В Go можно обрабатывать десятки тысяч соединений с минимальным числом потоков. В Java для этого обычно применяют Netty или reactive stack (Spring WebFlux). Плюс Go — простота кода (синхронный стиль). Минус — меньше контроля. Java даёт больше гибкости, но сложнее в реализации. Под капотом всё упирается в стоимость OS-thread vs goroutine.
Scheduler + IO interaction
Что это и как работает
Go использует модель GMP (Goroutine, Machine, Processor). Scheduler распределяет горутины (G) по логическим процессорам (P), которые выполняются на потоках (M). Когда горутина блокируется на IO, она снимается с выполнения, а P переключается на другую горутину.
Ключевая идея: scheduler тесно интегрирован с IO. Когда netpoller получает событие (данные готовы), он помещает горутину обратно в run queue. Это позволяет Go эффективно балансировать CPU-bound и IO-bound задачи.
В Java scheduler — это OS scheduler. JVM не управляет планированием потоков напрямую. Асинхронные IO-операции реализуются через Selector, ForkJoinPool, virtual threads (Loom).
Пример кода (Go)
package main
import (
"fmt"
"net/http"
)
func fetch(url string) {
// каждый вызов - отдельная горутина
resp, _ := http.Get(url)
fmt.Println("Fetched:", url, resp.Status)
}
func main() {
urls := []string{"http://example.com", "http://google.com"}
for _, url := range urls {
go fetch(url) // scheduler распределяет задачи
}
select {} // чтобы программа не завершилась
}
Пример кода (Java)
import java.net.http.*;
import java.net.URI;
import java.util.List;
public class Main {
public static void main(String[] args) {
HttpClient client = HttpClient.newHttpClient();
List<String> urls = List.of("http://example.com", "http://google.com");
for (String url : urls) {
client.sendAsync(
HttpRequest.newBuilder(URI.create(url)).build(),
HttpResponse.BodyHandlers.ofString()
).thenAccept(resp -> {
System.out.println("Fetched: " + url + " " + resp.statusCode());
});
}
}
}
// Схема GMP:
G1 G2 G3
| | |
v v v
P1 ----> M1 (OS thread)
P2 ----> M2
IO событие:
netpoller -> scheduler -> очередь G
Важно понимать, что scheduler в Go — кооперативный + частично вытесняющий. Если горутина долго не делает syscall или не уступает CPU, она может блокировать другие. Поэтому используйте функции runtime.Gosched() или разбивайте задачи. В Java это решается на уровне OS scheduler, но цена — больше overhead.
Этот механизм критичен в микросервисах и системах с большим количеством параллельных запросов. Go отлично подходит для API gateway, прокси и batch processing. Java — для сложных enterprise систем с контролем потоков. Плюс Go — автоматическое масштабирование задач. Минус — меньше контроля над планированием. Java даёт fine-grained control через thread pools.
Netpoller (epoll + kqueue)
Что это и как работает
Netpoller — это компонент Go runtime, который использует OS механизмы (epoll в Linux, kqueue в BSD/macOS) для отслеживания готовности IO. Он позволяет эффективно работать с тысячами соединений без блокировки потоков.
Когда горутина делает IO, дескриптор регистрируется в netpoller. Далее OS сообщает (через epoll/kqueue), когда сокет готов. Netpoller пробуждает соответствующую горутину через scheduler.
В Java аналог — Selector (NIO). Но разработчик должен сам писать цикл select() и управлять событиями. Go скрывает это за стандартной библиотекой.
Пример кода (Go)
package main
import (
"net"
)
func main() {
// net.Listener под капотом использует netpoller
ln, _ := net.Listen("tcp", ":8080")
for {
conn, _ := ln.Accept() // не блокирует поток
go handle(conn)
}
}
func handle(conn net.Conn) {
buffer := make([]byte, 1024)
conn.Read(buffer)
}
Пример кода (Java)
import java.nio.channels.*;
import java.net.InetSocketAddress;
import java.util.Iterator;
public class Main {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // блокируемся на событиях
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isAcceptable()) {
// обработка подключения
}
}
}
}
}
// Схема netpoller:
goroutine
|
v
register fd -> netpoller
|
v
epoll_wait()
|
v
event ready
|
v
scheduler -> goroutine runnable
Хотя Go скрывает netpoller, важно помнить: большое количество открытых соединений требует настройки ulimit и file descriptors. Также важно избегать долгих операций внутри goroutine после IO, иначе теряется преимущество неблокирующей модели. В Java вы контролируете Selector напрямую — это сложнее, но даёт больше контроля.
Netpoller используется во всех сетевых сервисах: HTTP servers, gRPC, WebSocket. Go даёт простой API и масштабируемость. Java (через Netty) достигает той же эффективности, но требует больше кода. Плюсы Go — скорость разработки и читаемость. Минусы — меньше прозрачности. Java — лучше для кастомных сетевых протоколов и fine-tuning.
Общая таблица сравнения
| Термин | 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 — про контроль и зрелость. И лучший результат получается, когда ты понимаешь оба мира.
Галерея
Полезные статьи:
Новые статьи: