There is no way for your operating system to know exactly how much stack space a thread will need so it allocates an amount on the order of around a kilobyte initially and then around a megabyte once the thread starts to be used. You only have around a bakers dozen gigabytes of RAM, so you can only have give or take 10,000 active threads.
The way around this is to implement a some mechanism that takes a limited number of operating system threads and juggles a much larger number of “logical threads” on top of them.
For most languages, this means adding some form of async/await syntax. Where you put an await the language knows it can switch to handling another task. You can only put awaits inside of code marked async. This has problems.
The Go programming language is different than most in that it implemented this juggling “non-cooperatively”. You don’t explicitly mark your code with async and await, the runtime slices it up for you. They call these cheap threads “goroutines.”
The Java Virtual Machine is going to get an analogous feature called “Virtual Threads.”
This won’t just benefit Java, but every language on the JVM including Clojure, Groovy, Kotlin, and Scala.
Virtual Threads are slated to appear as a “Preview” feature in Java 19 on September 20, 2022. This means that the implementation of the underlying feature is complete and tested, but the public API is subject to breaking changes and must be opted into explicitly.
Many of Go’s patterns around concurrency arise from the conceit that you can create threads with abandon.
Since Java is about to join that club, it seems a good time to go through some of the Go concurrency examples and see what they might look like translated over.
If you want to follow along, you can get an early access build here. Unzip the files and add the bin/ directory to your path.
All the examples can be followed in sequence by using jshell.
This is a pretty classic example, and frankly can be done with operating system threads just as well.
A few key things to notice.
There is some noise in the say method around handling what will happen if the thread is interrupted.
In this case we just choose to throw a RuntimeException to indicate we just want to crash.
In Go there is less noise, but also there no way to interrupt Go’s time.Sleep.
It is also an option to propagate the InterruptedException up if we add a return null; to target the Callable overload.
You need more than go say("world")
Executors.newVirtualThreadPerTaskExecutor() creates an ExecutorService. This is a thing which you can submit tasks to and it will run them “somehow”.
Today most ExecutorServices are backed by some pool of threads. The purpose of the interface is to be able to write code without needing to know about the underlying strategy for maintaining that pool.
Virtual Threads are cheap, so you don’t need to pool them. The interface still serves a use though. ExecutorServices will extend AutoClosable, so when used with the “try-with-resources” syntax you can make a block of code where you wait until all tasks have completed before moving on.
If you wanted to do the same creating threads directly it would look like this.
A somewhat close analogue is a BlockingQueue, so that is what I am going to use for the purposes of these examples.
Instead of Go’s syntax for making slices of arrays, I opted to instead pass the indexes that each sum call was expected to work on.
It is only safe to share the memory for the array like this because no other threads are changing its contents. If there was we would have summoned Gorslax. This would be true in both Go and Java.
In both cases the way this works is each worker sends the results of its computation to a logical queue. Once we have read two values off the shared queue we implicitly know that the two tasks we started have finished.
For “one shot” use cases such as this, you could also use Java’s CompletableFuture for the same purpose.
This adds ExecutionException to the explicit list of things that can go wrong, but is a more direct api for a task that will run and produce one value as a result.
In fact, if we were to change sum to return its result directly then we could eliminate its awareness that it is being run asynchronously.
And if we don’t need any of the fancier capabilities of CompletableFuture, then the plain Future objects returned by submitting directly to the ExecutorService are also an option.
There isn’t much to this one. Go’s channels can be “buffered”, meaning they can accept multiple values before they will be “full”. If a channel is full then any thread that wants to put a value onto that channel will have to wait until another thread takes a value off.
Here is where the differences between a Java BlockingQueue and a Go chan start to manifest themselves.
There is no ability to “close” a BlockingQueue. One way around this is to send a special “sentinel” value over the queue to indicate that a reader should stop reading. This only works cleanly when we have a single reader though.
There is also no equivalent to the range operator. We need to write a normal while loop.
This snippet makes use of sealed interfaces, a relatively recent addition to Java, for modeling getting either a legitimate value over the queue or a signal to stop consuming.
The other options for the same result would be to drop the generic types from the BlockingQueue and use a special sentinel instance of Object or disallow null values for normal use and have that indicate that the queue is closed.
There is also no equivalent to select for BlockingQueues. We have to implement that logic in a hand rolled loop.
I’m unsure for what purpose the Go version uses a channel of integers as its quit mechanism. In Java it is more natural to use something like a shared AtomicBoolean as a signal for shutdowwn.
If it were a situation with multiple “one shot” queues then CompletableFuture#anyOf and similar methods might suffice.
There isn’t a novel transformation of this default case syntax, but it is worth noting how Go’s time library directly returns its channels as the mechanism for handling delays.
Here we rely on the behavior of ExecutorService#shutdownNow to interrupt the task pushing to the tick queue. Unlike with the built in Go time.Tick where the underlying goroutine is never cancelled and is a “leak.”
So before touching the concurrency bits we need to translate this Tree type.
A 1-1 translation of the Go wouldn’t be fun Java, so I opted to translate it instead to an immutable sum type. This won’t affect the concurrent part other than a stronger conceptual guarentee that we can safely share the tree across multiple threads.
With this version the Go maps pretty straight forwardly to this.
We use the same tricks as before to emulate a closable queue with TakeResult. Then we translate the select statement to a loop calling offer and poll.
The example Go solution had a recursive local closure for walk. While technically possible via some wizardry, its more straight forward in Java to make a helper method.
There is also a reliance on the walk tasks responding correctly to shutdownNow. If they did not, executor.close() would hang and the scope wouldn’t exit.
Java has a direct analogue to sync.Mutex in ReentrantLock. We can make this same program without much issue.
The only thing of note is that unlike Go where you can defer some arbitrary action like releasing your hold on a lock, in Java the general mechanism for “cleanup that must happen” is using the finally clause of a try block.
For Java ReentrantLock is special in that its locks can “escape” a lexical scope. You can lock before entering a method and unlock in a totally unrelated one.
If you don’t need this ability then you can use the fact that every “identity” having object in Java can be used as a lock with synchronized.
If the thing being synchronized on is just this and that synchronization lasts the entire scope of the method, we can just mark the method as synchronized for the same effect.
Another challenge problem. Before going to a reference solution, I am going to translate the synchronous example.
Go has a pattern of returning an error as a conditionally filled in extra return value. This isn’t super idiomatic in Java, so instead I am going to model the error case as an Exception.
The Go version also returns both a body and an array of urls from a single call. For Java we can accomplish this by making an aggregate containing both values.
We can make a “fake” implementation of fetcher using the same technique as Go by backing it with an in-memory map.
Then the synchronous fetcher is just a regular recursive function as it was in Go.
We could stop there, but there is one api that is in an incubator module in the early access builds that can replace our home rolled WaitGroup.
This is why I had --add-modules=jdk.incubator.concurrent in the jshell command up top.
A StructuredTaskScope.ShutdownOnFailure lets us submit an arbitrary number of tasks to the scope recursively and will only close after either all those tasks are complete. There is another implementation StructuredTaskScope.ShutdownOnSuccess that will finish after a single task succeeds.
This obviates the need to manually count up and down with a WaitGroup.
This combines the role of the ExecutorService and the WorkGroup into one object which at the least makes for one less point of coordination and slightly cleaner code.
The exact shape the StructuredTaskScope api will take is very much in flux so if you are reading this a year or so in the future this snippet might not work.