Scheduler internals in Go ↔ Java: how your code is actually executed
When you write go func() or create a Thread in Java, it seems like you are managing concurrency. But in reality, you are passing 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 — it's why one code "flies" while another suddenly freezes.
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 releases the thread (OS thread). Unparking is when it is brought back 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)
// wakes 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, rather than interacting with the OS. In Java, blocking often goes to the kernel → more expensive. Therefore, in Go, you can keep 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 plus of Go is simplicity. The downside is more complex scheduler debugging. Java is the opposite: simpler model, but more expensive blocking.
Work stealing
Work stealing - steal 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 P.
In Java, ForkJoinPool uses a similar mechanism.
// ASCII diagram
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's not free: there is 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 the kernel 🧱⛓️, the system call blocks execution
When a goroutine makes a syscall (for example, reading a file), the thread can be blocked. Go solves this by creating a new thread so as not to block 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 it — you get thread starvation.
Go is great for IO-heavy applications. Java requires async (NIO). The plus of Go — simplicity. The minus — more threads under the hood. Java — control, but more complex code.
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") }()
// analogous — worker thread queue
Local queues are an optimization for cache locality. Under the hood, this reduces lock contention. In Java, the analogue is thread-local queues in ForkJoinPool.
Used in high-concurrency systems. Plus — speed. Minus — complexity of balancing.
Global run queue
Global run queue - global task storage 🏭⚡, queue for load balancing
Global queue — fallback. If local queues are empty — take from there.
// Global: [G5 G6]
// runtime puts tasks in global queue during overload
// global executor queue
Global queue — point of contention. Under the hood, it 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 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 trick, but very useful.
Used in HTTP servers, gRPC. Plus — simplicity of the API. Minus — complexity of runtime.
Cooperative vs async preemption
Cooperative vs async preemption - politeness vs coercion 🤝⚡, the scheduler either waits or interrupts
Earlier, Go used cooperative preemption — 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, you could "hang" in an infinite loop. Under the hood, it's signals and safe points.
Important for fairness. Plus — stability. Minus — slight overhead.
Goroutine blocking on IO
Goroutine blocking on IO - hanging on input-output ⏳🌐, the goroutine is waiting for data from the network or disk
In Go, goroutines are lightweight threads managed by the runtime, not 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 trying to perform an IO operation, Go uses non-blocking system calls. If the data is not ready, the goroutine is registered in the netpoller and put in 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 because 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() {
// creating 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);
}
}
// Scheme (Go):
G (goroutine)
|
v
IO call ---> no data
|
v
park goroutine ----> netpoller
|
v
OS thread free
// Scheme (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 accidentally block the thread. In Java, on the other hand, everything is blocking by default, and you need 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, you can handle tens of thousands of connections with a minimal number of threads. In Java, this is usually done using Netty or a reactive stack (Spring WebFlux). The plus 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 💃⚙️, managing threads and waiting for operations
Go uses the GMP model (Goroutine, Machine, Processor). The scheduler distributes goroutines (G) across logical processors (P), which run 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 closely 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 efficiently 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 exiting
}
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 make a syscall for a long time or does not yield the CPU, it can block others. Therefore, use runtime.Gosched() functions or break tasks down. In Java, this is handled at the OS scheduler level, but the cost is more 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 advantage of Go is automatic task scaling. The downside is less control over scheduling. Java gives fine-grained control through thread pools.
Netpoller (epoll + kqueue)
Netpoller (epoll + kqueue) - the core 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 track IO readiness. It allows efficient handling of thousands of connections without blocking threads.
When a goroutine performs IO, the descriptor is registered in netpoller. Then the OS notifies (through epoll/kqueue) when the socket is ready. Netpoller wakes up the corresponding goroutine via the scheduler.
The Java equivalent is Selector (NIO). But the developer must 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(); // blocks on events
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isAcceptable()) {
// handling the connection
}
}
}
}
}
// Netpoller Scheme:
goroutine
|
v
register fd -> netpoller
|
v
epoll_wait()
|
v
event ready
|
v
scheduler -> goroutine runnable
Although Go hides netpoller, it is important to remember: a large number of open connections requires tuning ulimit and file descriptors. It is also important to avoid long operations inside goroutines after IO, otherwise the benefit of the non-blocking model is lost. In Java, you control the Selector directly — it is more complex but gives more control.
Netpoller is used in all networking services: HTTP servers, gRPC, WebSocket. Go provides a simple API and scalability. Java (through Netty) achieves the same efficiency but requires more code. The pros of Go are speed of development and readability. The cons are less transparency. Java is better for custom networking 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 | various 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. This is powerful, but 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 so deeply.
If you understand the scheduler, you start to see the invisible: why code is stuttering, where contention is, where locks 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: