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

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 - воруют задачи ради скорости 🏴‍☠️⚡, потоки забирают работу у других

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

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

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

Global run queue - общий склад задач 🏭⚡, очередь для балансировки нагрузки

Глобальная очередь — fallback. Если локальные пусты — берём оттуда.


// Global: [G5 G6]

// runtime кладёт задачи в global queue при перегрузке

// глобальная очередь executor
Глобальная очередь — точка contention. Под капотом это shared структура. Чем меньше ты её используешь — тем лучше.
Используется при burst нагрузке. Плюс — балансировка. Минус — блокировки.

Network poller (netpoller)

Network poller (netpoller) - сетевой сторож уведомлений 🌐🔔, следит за готовностью I/O событий

Go использует epoll/kqueue через netpoller. Это позволяет не блокировать потоки на IO.

Java делает это через NIO.


// netpoller управляет IO событиями

// Selector в NIO
Netpoller — это сердце IO в Go. Он позволяет писать синхронный код, который на самом деле async. Это обман, но очень полезный.
Используется в HTTP серверах, gRPC. Плюс — простота API. Минус — сложность runtime.

Cooperative vs async preemption

Cooperative vs async preemption - вежливость vs принуждение 🤝⚡, планировщик либо ждёт либо прерывает

Раньше 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

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

Scheduler + IO interaction - танец планировщика и I/O 💃⚙️, управление потоками и ожиданием операций

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


🌐 in English
Всего лайков:0

Оставить комментарий

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

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

Типы данных в Java
Типы данных в Java Привет! С вами Виталий Лесных. В этом уроке курса «Основы Java для начинающих» разберем, что такое типы данных. Типы данных — это фундамент любого языка программирования. С их помо...
Понимаем многопоточность в Java через коллекции и атомики
1️⃣ HashMap / TreeMap / TreeSet (не потокобезопасные) HashMap: Структура: массив бакетов + связные списки / деревья (для коллизий). Под капотом: при put/remove происходит модификация массива бакетов ...
Основы параллельности в Go для Java-разработчиков | Сoncurrency часть 1
Если вы Java-разработчик, привыкший к потокам и ExecutorService, Go предлагает более лёгкий и удобный подход к параллельной обработке — goroutine и каналы. В этой статье мы разберём ключевые концепции...

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

Конкурентность — это не про «запустить много потоков». Это про договорённости между ними. Представь кухню ресторана: — повара (потоки / горутины) — заказы (задачи) — и главный вопрос: как они коорди...
История начинается не с академической теории, а с типичной production-проблемы. Представьте сервис: 48 CPU 300+ потоков нагрузка 200k операций в секунду много shared state Команда использует обы...
Когда HashMap начинает убивать продакшн: инженерная история ConcurrentHashMap
Представьте обычный продакшн-сервис. 32 CPU сотни потоков кэш конфигурации / сессий / rate limits десятки тысяч операций в секунду И где-то внутри — обычный Map. Сначала всё выглядит безобидно. Map&...
Fullscreen image