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 — про контроль и зрелость. И лучший результат получается, когда ты понимаешь оба мира.


Всего лайков:0
Мой канал в социальных сетях
Отправляя email, вы принимаете условия политики конфиденциальности

Полезные статьи:

Современный подход к параллелизму в Java - Fork/Join Framework, CompletableFuture и виртуальные потоки (Project Loom)
Предисловие Мир программного обеспечения уже давно перестал быть спокойным океаном: сегодня это бурная экосистема, где каждая миллисекунда отклика приложения может стоить компании клиентов, репутации ...
Slice internals в Go ↔ Java: от заголовка до скрытых аллокаций
Slice в Go — это одна из тех структур, которая выглядит простой, но под капотом ведёт себя как маленький хитрый зверь. Если ты Java-разработчик, ты можешь думать: «ну это же просто ArrayList». И вот з...
Понимаем многопоточность в Java через коллекции и атомики
1️⃣ HashMap / TreeMap / TreeSet (не потокобезопасные) HashMap: Структура: массив бакетов + связные списки / деревья (для коллизий). Под капотом: при put/remove происходит модификация массива бакетов ...

Новые статьи:

Go ↔ Java: Полное руководство по Runtime, памяти и аллокатору - часть 3
Эта статья — комплексное руководство по ключевым аспектам работы памяти и рантайма в Go и Java. Мы разберем фундаментальные концепции: планировщик выполнения, memory barriers, выравнивание памяти, рос...
Низкоуровневые механизмы | Go ↔ Java
В этой статье мы разберем ключевые низкоуровневые механизмы Go, сравнивая их с аналогичными инструментами в Java. Статья предназначена для Java-разработчиков, которые хотят глубже понять Go, а также д...
Scheduler internals в Go ↔ Java: как на самом деле исполняется твой код
Когда ты пишешь go func() или создаёшь Thread в Java, кажется, что ты управляешь параллельностью. Но на самом деле ты даёшь задачу планировщику — scheduler у. И вот тут начинается настоящее шоу. Go ...
Fullscreen image