Scheduler internals in Go ↔ Java: how your code actually executes
When you write go func() or create a Thread in Java, it seems like you are managing concurrency. But in reality, you are delegating the task to the scheduler. And this is where the real show begins.
Go and Java use fundamentally different models: Go — M:N scheduler (many goroutines on fewer threads), Java — 1:1 (thread = OS thread). This affects everything: latency, scalability, behavior under locks.
Let's break down the key mechanisms: parking/unparking, work stealing, queues, netpoller, and preemption. This is not just theory — this is why some code "flies," while others suddenly freeze.
Goroutine parking / unparking
Goroutine parking / unparking - sleep and wake-up of tasks 😴⚡, the goroutine falls asleep and starts again
Parking is when a goroutine temporarily "falls asleep" and frees the thread (OS thread). Unparking is when it is returned to execution.
In Go this happens constantly: when waiting for a channel, mutex, syscalls. The scheduler removes the goroutine from execution and places it in wait structures. The thread (M) can then take another goroutine (G).
In Java, blocking a thread means blocking the OS thread. Yes, there is LockSupport.park(), but more often blocking is a real sleep at the OS level.
package main
import "time"
func main() {
ch := make(chan int)
go func() {
// this goroutine "parks" here
val := <-ch
println(val)
}()
time.Sleep(time.Second)
// will wake up the 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 {
// the thread is blocked (OS-level)
Integer val = queue.take();
System.out.println(val);
} catch (Exception e) {}
}).start();
Thread.sleep(1000);
queue.put(42); // unblocks the thread
}
}
Go parks lightweight goroutines, not heavy threads. This is key to scalability. Under the hood, the scheduler simply changes pointers and queues, not interacting with the OS. In Java, blocking often goes to kernel → more expensive. Therefore, in Go, you can hold tens of thousands of goroutines, while in Java, thousands of threads are already a problem.
Used everywhere: channels, mutex, IO. In Go, this is a cheap operation → you can write "naive" code. In Java, you need to think about thread pools and limiting threads. The advantage of Go is its lightweight nature. The disadvantage is a more complex debugging of the scheduler. Java, on the other hand: simpler model, but more expensive blocking.
Work stealing
Work stealing - stealing tasks for speed 🏴☠️⚡, threads take work from others
Work stealing is when one thread "steals" tasks from another.
In Go, each P (processor) has its own queue of goroutines. If the queue is empty, it steals tasks from other Ps.
In Java, ForkJoinPool uses a similar mechanism.
// ASCII scheme
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 reduces CPU idle time. Under the hood, it's load balancing. But it is not free: there are synchronization and contention. In Go, this is built into the runtime, in Java - it depends on the executor.
Ideal for CPU-bound tasks. Plus - even load. Minus - overhead. In Go, you get this "for free". In Java, you need to choose the right pool.
Syscall blocking behavior
Syscall blocking behavior - falling into kernel 🧱⛓️, system call blocks execution
When a goroutine makes a syscall (for example, reading a file), the thread may block. Go handles this by creating a new thread to avoid blocking the execution of other goroutines.
In Java, the thread is completely blocked.
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); // blocks the thread
} catch (Exception e) {}
}).start();
}
}
Syscall is the boundary between user space ↔ kernel. Go tries to hide this and not block the scheduler. Java leaves this to you. If you ignore this, you get thread starvation.
Go is great for IO-heavy applications. Java requires async (NIO). The plus of Go is its simplicity. The minus is more threads under the hood. Java offers control but the code is more complex.
Local run queue
Local run queue - personal task queue 🎯🧵, the thread executes its goroutines
Each P in Go has a local goroutine queue. This reduces contention.
// P1: [G1 G2]
// P2: [G3 G4]
// goroutine first goes into the local queue
go func() { println("hi") }()
// the analog — worker thread queue
Local queues are an optimization for cache locality. Under the hood, this reduces lock contention. In Java, the analog is thread-local queues in ForkJoinPool.
Used in high-concurrency systems. Plus — speed. Minus — balancing complexity.
Global run queue
Global run queue - global task storage 🏭⚡, queue for load balancing
Global queue — fallback. If local ones are empty — we take from there.
// Global: [G5 G6]
// runtime puts tasks in global queue under overload
// global queue executor
Global queue — contention point. Under the hood this is a shared structure. The less you use it — the better.
Used during burst load. Plus — balancing. Minus — locks.
Network poller (netpoller)
Network poller (netpoller) - network notification watchdog 🌐🔔, monitors the readiness of I/O events
Go uses epoll/kqueue through netpoller. This allows for non-blocking threads on IO.
Java does this through NIO.
// netpoller manages IO events
// Selector in NIO
Netpoller is the heart of IO in Go. It allows you to write synchronous code that is actually async. It’s a deception, but very useful.
Used in HTTP servers, gRPC. Plus — simplicity of the API. Minus — complexity of the runtime.
Cooperative vs async preemption
Cooperative vs async preemption - politeness vs coercion 🤝⚡, the scheduler either waits or interrupts
Previously, Go used cooperative preemption — a goroutine had to "yield" itself.
Now there is async preemption — the runtime can interrupt it.
Java has always had preemptive scheduling.
// long loop can be interrupted by the runtime
// thread can be interrupted by the OS scheduler
Async preemption makes Go safer. Without it, one could "get stuck" in an infinite loop. Under the hood, it's signals and safe points.
Important for fairness. Plus — stability. Minus — a small overhead.
Goroutine blocking on IO
Goroutine blocking on IO - blocking on input-output ⏳🌐, goroutine is waiting for data from the network or disk
In Go, goroutines are lightweight threads managed by the runtime rather than the operating system. When a goroutine performs a blocking IO operation (for example, reading from a socket), it does NOT block the OS-thread (unlike the classical Java model). Instead, the Go runtime "parks" the goroutine, saves its state, and frees the thread to execute other goroutines.
Under the hood, the following happens: when an IO operation is attempted, Go uses non-blocking system calls. If the data is not ready, the goroutine is registered in the netpoller and put into a waiting state. The OS-thread (M) is freed and can execute other goroutines (G). When the data is ready, the netpoller notifies the scheduler, and the goroutine returns to the execution queue.
In Java, the classical model (before Loom) consists of blocking threads: if a thread does socket.read(), it blocks the OS-thread. This is expensive, as a thread is a heavy structure. Therefore, NIO (Selector), CompletableFuture, or reactive frameworks are used.
Code example (Go)
package main
import (
"fmt"
"net"
)
func main() {
// create a TCP connection
conn, _ := net.Dial("tcp", "example.com:80")
// this operation may "block" the goroutine,
// but NOT the OS thread
buffer := make([]byte, 1024)
n, _ := conn.Read(buffer)
fmt.Println("Read bytes:", n)
}
Code example (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];
// blocks the OS thread
int n = in.read(buffer);
System.out.println("Read bytes: " + n);
}
}
// Diagram (Go):
G (goroutine)
|
v
IO call ---> no data
|
v
park goroutine ----> netpoller
|
v
OS thread free
// Diagram (Java):
Thread
|
v
IO call ---> blocking
|
v
Thread busy (waiting)
Go automatically makes IO non-blocking at the runtime level, so the developer does not need to think about Selector/epoll. But it is important to understand: if you use C libraries or syscall directly, you may inadvertently block the thread. In Java, on the contrary, everything is blocking by default, and one needs to consciously switch to NIO/async. The reason lies in the architecture: Go runtime controls execution, JVM does not; it delegates to the OS.
This mechanism is actively used in high-load services: HTTP servers, proxies, streaming. In Go, tens of thousands of connections can be handled with a minimal number of threads. In Java, Netty or a reactive stack (Spring WebFlux) is typically used for this purpose. The advantage of Go is the simplicity of the code (synchronous style). The downside is less control. Java offers more flexibility but is more complex to implement. Under the hood, it all comes down to the cost of OS-thread vs goroutine.
Scheduler + IO interaction
Scheduler + IO interaction - the dance of the scheduler and I/O 💃⚙️, management of threads and waiting for operations
Go uses the GMP model (Goroutine, Machine, Processor). The scheduler distributes goroutines (G) across logical processors (P), which are executed on threads (M). When a goroutine is blocked on IO, it is removed from execution, and P switches to another goroutine.
The key idea: the scheduler is tightly integrated with IO. When the netpoller receives an event (data is ready), it puts the goroutine back in the run queue. This allows Go to effectively balance CPU-bound and IO-bound tasks.
In Java, the scheduler is the OS scheduler. The JVM does not manage thread scheduling directly. Asynchronous IO operations are implemented through Selector, ForkJoinPool, virtual threads (Loom).
Code example (Go)
package main
import (
"fmt"
"net/http"
)
func fetch(url string) {
// each call - a separate goroutine
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 distributes tasks
}
select {} // to prevent the program from finishing
}
Code example (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 scheme:
G1 G2 G3
| | |
v v v
P1 ----> M1 (OS thread)
P2 ----> M2
IO event:
netpoller -> scheduler -> G queue
It is important to understand that the scheduler in Go is cooperative + partially preemptive. If a goroutine does not perform a syscall or yield the CPU for a long time, it can block others. Therefore, use the runtime.Gosched() function or break up tasks. In Java, this is resolved at the OS scheduler level, but the cost is greater overhead.
This mechanism is critical in microservices and systems with a large number of concurrent requests. Go is well-suited for API gateways, proxies, and batch processing. Java is for complex enterprise systems with thread control. The plus of Go is automatic scaling of tasks. The minus is less control over scheduling. Java provides fine-grained control through thread pools.
Netpoller (epoll + kqueue)
Netpoller (epoll + kqueue) - the kernel monitors sockets 👁️📡, epoll/kqueue notify about events
Netpoller is a component of the Go runtime that uses OS mechanisms (epoll in Linux, kqueue in BSD/macOS) to monitor IO readiness. It allows for efficient handling of thousands of connections without blocking threads.
When a goroutine performs IO, the descriptor is registered with netpoller. The OS then informs (via epoll/kqueue) when the socket is ready. Netpoller wakes up the corresponding goroutine through the scheduler.
In Java, the equivalent is Selector (NIO). But the developer has to write the select() loop and manage events themselves. Go hides this behind the standard library.
Code example (Go)
package main
import (
"net"
)
func main() {
// net.Listener under the hood uses netpoller
ln, _ := net.Listen("tcp", ":8080")
for {
conn, _ := ln.Accept() // does not block the thread
go handle(conn)
}
}
func handle(conn net.Conn) {
buffer := make([]byte, 1024)
conn.Read(buffer)
}
Code example (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(); // block on events
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isAcceptable()) {
// handle connection
}
}
}
}
}
// netpoller scheme:
goroutine
|
v
register fd -> netpoller
|
v
epoll_wait()
|
v
event ready
|
v
scheduler -> goroutine runnable
Although Go hides netpoller, it's important to remember: a large number of open connections requires tuning ulimit and file descriptors. It's also important to avoid long operations inside a goroutine after IO, otherwise, the advantage of the non-blocking model is lost. In Java, you control the Selector directly — this is more complex but gives more control.
Netpoller is used in all network services: HTTP servers, gRPC, WebSocket. Go provides a simple API and scalability. Java (through Netty) achieves the same efficiency but requires more code. Advantages of Go — development speed and readability. Disadvantages — less transparency. Java — better for custom network protocols and fine-tuning.
General comparison table
| Term | Go | Java | Comment |
|---|---|---|---|
| Parking | goroutine | thread | Go is easier and cheaper |
| Work stealing | built-in | ForkJoinPool | in Go by default |
| Syscall | does not block scheduler | blocks thread | important for IO |
| Local queue | exists | partially | optimization |
| Global queue | fallback | main | contention point |
| Netpoller | built-in | NIO | different levels of abstraction |
| Preemption | cooperative + async | preemptive | Go has caught up with Java |
Output / Result
Scheduler is the hidden conductor of your program. And in Go, it is a much more active participant than in Java.
Go builds an illusion: you write synchronous code, but under the hood, everything is asynchronous. The scheduler parks, steals tasks, balances the load, manages IO. It’s powerful, but it requires understanding.
Java is more honest: a thread is a thread. If it is blocked, it is blocked. Yes, there is ForkJoinPool, NIO, but they are not built in as deeply.
If you understand the scheduler, you start to see the invisible: why the code is lagging, where contention is, where the blocks are. This is the level where a developer stops being a user of the language and becomes its explorer.
Go is about massive parallelism. Java is about control and maturity. And the best result comes when you understand both worlds.
Оставить комментарий
Useful Articles:
New Articles: