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


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

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

Compiler, Build и Tooling в Go и Java: как устроены сборка, инициализация, анализ и диагностика в двух экосистемах
Эта статья посвящена общему обзору того, как в Go устроены compiler, build и tooling-практики, и как их удобнее понимать через сравнение с Java. Мы не будем уходить в узкоспециализированные детали каж...
Условные операторы в Java
Java — Условные операторы Наглядная статья с примерами: if / else / логика / тернарный оператор / switch Кратко — условные операторы позволяют программе принимать решения: выполнить один кусок кода ...
Разбираем: array, slice, map, zero value - в Go vs Java | Types - Language
Серия: Go для Java-разработчиков Эта статья открывает серию материалов о языке Go для разработчиков, которые уже хорошо знакомы с Java. Мы будем сравнивать подходы двух языков, чтобы быстрее понять, ...

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

Compiler, Build и Tooling в Go и Java: как устроены сборка, инициализация, анализ и диагностика в двух экосистемах
Эта статья посвящена общему обзору того, как в Go устроены compiler, build и tooling-практики, и как их удобнее понимать через сравнение с Java. Мы не будем уходить в узкоспециализированные детали каж...
Низкоуровневые механизмы - часть 2 | Go ↔ Java
В этой статье мы собрали ключевые low-level механизмы Go, которые чаще всего вызывают вопросы у разработчиков, приходящих из Java. Мы рассмотрим: unsafe.Pointer, выравнивание структур, арифметику указ...
Go ↔ Java: Полное руководство по Runtime, памяти и аллокатору - часть 3
Эта статья — комплексное руководство по ключевым аспектам работы памяти и рантайма в Go и Java. Мы разберем фундаментальные концепции: планировщик выполнения, memory barriers, выравнивание памяти, рос...
Fullscreen image