Skip to content

First attempt at multithreading - #18

Open
glowredman wants to merge 25 commits into
masterfrom
multithreading
Open

First attempt at multithreading#18
glowredman wants to merge 25 commits into
masterfrom
multithreading

Conversation

@glowredman

Copy link
Copy Markdown
Member

Attempts to move certain tasks off of the main thread:

  • Fetching all versions
  • Indexing client and server jars
  • Getting assets from Mojang's servers or cached jars

Unlike before, the last task will be executed as soon as possible (versions have been fetched, jars are indexed and no other assets are currently being processed).

I have next to no experience with multithreading, please review carefully!

@glowredman
glowredman marked this pull request as ready for review February 27, 2026 20:27
@glowredman
glowredman requested a review from a team February 27, 2026 20:27
Comment thread src/main/java/glowredman/txloader/AssetQueue.java Outdated
Comment thread src/main/java/glowredman/txloader/TXLoaderModContainer.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/AssetBuilder.java Outdated
Comment thread src/main/java/glowredman/txloader/JarHandler.java
Comment thread src/main/java/glowredman/txloader/TXLoaderCore.java Outdated
Comment thread src/main/java/glowredman/txloader/TXLoaderCore.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Co-authored-by: Nikolay S. <nicksitnikov@gmail.com>
@Nikolay-Sitnikov Nikolay-Sitnikov self-assigned this Mar 6, 2026
Comment thread src/main/java/glowredman/txloader/ConfigHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/ConfigHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/JarHandler.java
Comment thread src/main/java/glowredman/txloader/TXLoaderCore.java Outdated
Comment thread src/main/java/glowredman/txloader/TXLoaderCore.java Outdated
@Nikolay-Sitnikov

Copy link
Copy Markdown

Also, are there any timing limitations on when the assets have to be loaded by? I could imagine a scenario where a user with a fast computer but slow Internet tries to use the assets before TX-Loader finishes downloading them.

Two options would be either using awaitShutdown on the thread pool or CompletableFuture.allOf() on the download tasks to block the main thread until all the assets are downloaded if they aren't downloaded by the time they should be.

@glowredman

Copy link
Copy Markdown
Member Author

Currently, the only timing restriction is that no assets can be loaded before TXLoaderCore.injectData() has been called (which is hardly a restriction). Mods using this (I don't know if there are any, but still) are expected to add their assets as early as possible. I'm a bit hesitant to put any further restrictions on it because the use-case could be arbitrary. A possible solution could be to add a new method to AssetBuilder like this:

public Future<Void> fetch() {
    return RemoteHandler.fetchAsset(this.asset);
}

and modify RemoteHandler.fetchAsset() to return the Future. This way, mods could handle any blocking themselves, if necessary.
This wouldn't solve the executor never being shut down though. How would this be?

Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/TXLoaderCore.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
Comment thread src/main/java/glowredman/txloader/RemoteHandler.java Outdated
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
static final Executor EXECUTOR = Executors.newCachedThreadPool();
static final Executor EXECUTOR_IO = new ThreadPoolExecutor(0, 512, 10, TimeUnit.SECONDS, new SynchronousQueue<>());
static final Executor EXECUTOR_NET = new ThreadPoolExecutor(0, 32, 10, TimeUnit.SECONDS, new SynchronousQueue<>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SynchronousQueue will block the main thread once the thread capacity on the executor is reached. If you want net requests to queue up, use a LinkedBlockingQueue


private static Path configFile;
// loaded from the config, Assets created via AssetBuilder are not stored
static final List<Asset> ASSETS = Collections.synchronizedList(new ArrayList<>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're going to synchronize on ASSETS everywhere, you don't have to wrap it in a synchronizedList.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I still had to do it, given its javadoc:

It is imperative that the user manually synchronize on the returned list when iterating over it:

 List list = Collections.synchronizedList(new ArrayList());
     ...
 synchronized (list) {
     Iterator i = list.iterator(); // Must be in synchronized block
     while (i.hasNext())
         foo(i.next());
 }

Failure to follow this advice may result in non-deterministic behavior.

https://docs.oracle.com/javase/8/docs/api/java/util/Collections.html#synchronizedList-java.util.List-

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"When iterating over it" is the key phrase. synchronizedList essentially just wraps every call to the list in a synchronized block. Therefore, the list's changes are guaranteed to be synchronized whenever anyone reads or writes it.

The one hole is that the iterator doesn't lock the list, since there is no way for synchronizedList to tell when your code is done using the iterator, and thus no way to know when it is safe to unlock the list. For this reason, .iterator() specifically is not synchronized, and can lead to non-deterministic behavior.

JVersionManifest manifest;

try {
manifest = downloadManifest();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we do if the player is offline? I think it would be reasonable to fall back to an on-disk cache for all downloads, and only report issues if the resource is both missing from the cache and cannot be downloaded. (See issue 1 of report.)

return VERSION_DETAILS_CACHE.computeIfAbsent(version, ver -> versionsStage.thenApplyAsync(versions -> {
try {
return TXLoaderCore.GSON.fromJson(
IOUtils.toString(new URL(versions.get(ver).url), StandardCharsets.UTF_8),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. We should cache not just the jars, but the version information too. (We could make a single method, say loadFromCacheOrWeb, which checks the caches first and downloads from the web otherwise.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's probably reasonable but I think we should make the current feature set work first

static final Map<String, JVersion> VERSIONS = new LinkedHashMap<>();
static volatile @Nullable String latestRelease;
static @Nullable CompletableFuture<Map<String, JVersion>> versionsStage;
static final Map<String, JVersion> VERSIONS = Collections.synchronizedMap(new LinkedHashMap<>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we make VERSIONS a global constant, even though it isn't usable until versionsStage is complete. If access to VERSIONS is not performance-critical, we should make it accessible only through versionsStage to make sure there can't be any code that accesses it before it is initialized.

@Nonnull
static CompletableFuture<Void> fetchAsset(@Nonnull Asset asset) {
Path path = asset.getPath();
if (versionsStage == null || Files.exists(path)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably throw an error if versionsStage == null. Silently ignoring the request to fetch the asset is entirely unacceptable.

(You could also instead initialize versionsStage to just a simple new CompletableFuture<>() in the static initializer, and then complete the future manually (using CompletableFuture#complete) after receiving the necessary info in the coremod initializer. This would ensure that versionsStage will never be null, and any requests, no matter how early, can get queued up.)

static volatile @Nullable String latestRelease;
static @Nullable CompletableFuture<Map<String, JVersion>> versionsStage;
static final Map<String, JVersion> VERSIONS = Collections.synchronizedMap(new LinkedHashMap<>());
private static final Map<CompletableFuture<JVersionDetails>, CompletableFuture<Map<String, JAsset>>> ASSETS = new ConcurrentHashMap<>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You cannot use a CompletableFuture as a key to a map! It is not the result of the request, it is just an object specifying a request. You cannot hash it, since when you create it, it doesn't even have a value yet!

@glowredman

Copy link
Copy Markdown
Member Author

A fundamental problem I haven't been able to solve yet (and I don't even know if it's possible without major restructuring) is that when when scheduling a task which needs to extract from a jar, we need to immediately decide what future to chain it to, but we don't know yet if the JAR is present or if it needs to be downloaded.

One way to solve this would to have the indexing be done synchronously so the question what to chain on would not be ambiguous (is the jar present? -> extract, otherwise download first). But this would be a step away from full concurrency.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

#26

@Eldrinn-Elantey Eldrinn-Elantey left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review focused on concurrency correctness per the PR author's request. Findings posted inline.

32,
10,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on the earlier SynchronousQueue vs LinkedBlockingQueue discussion: switching to LinkedBlockingQueue avoids the main-thread-blocking problem, but it introduces a new one. ThreadPoolExecutor only spawns threads beyond corePoolSize when the queue rejects a task, and an unbounded LinkedBlockingQueue never rejects. With corePoolSize=0, both EXECUTOR_IO and EXECUTOR_NET will settle at exactly one worker thread; maximumPoolSize (512 / 32) never comes into play. Tasks queue up and run one at a time instead of concurrently.

To actually get concurrency with a bounded thread count, set corePoolSize == maximumPoolSize and call allowCoreThreadTimeOut(true) so idle threads still die after the keep-alive.


@Override
public Set<String> getResourceDomains() {
if (TXLoaderCore.isRemoteReachable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This blocking call is removed with nothing replacing it. Previously getResourceDomains() waited for RemoteHandler.getAssets() before scanning this.dir, guaranteeing downloaded assets existed before Minecraft enumerated resource domains. Now fetchAsset() calls are fire-and-forget: MinecraftHook.insertPacks() and this class never join()/get() on the futures it returns. If the game asks for a domain before its background download finishes, the domain simply won't be present, and nothing re-triggers a rescan once the download completes.

This needs an explicit decision: either block here on the futures scoped to this.dir's assets, or fire a resource-reload once pending fetches for this pack complete.

static final Map<String, Path> CACHED_CLIENT_JARS = new HashMap<>();
static final Map<String, Path> CACHED_SERVER_JARS = new HashMap<>();
static final Map<String, CompletableFuture<Path>> CACHED_CLIENT_JARS = new ConcurrentHashMap<>();
static final Map<String, CompletableFuture<Path>> CACHED_SERVER_JARS = new ConcurrentHashMap<>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CACHED_CLIENT_JARS/CACHED_SERVER_JARS memoize the future per version via computeIfAbsent in RemoteHandler.fetchAsset(). If a fetch fails or resolves to null (e.g. transient network error while downloading client.jar), that failed future stays cached for the rest of the session; there's no retry path for that version. The previous implementation only populated the cache on success, so a transient failure would simply retry on the next lookup (e.g. F3+T). Worth checking for a failed/null result and evicting the entry so a later attempt can retry.

}
}
private static CompletableFuture<Path> downloadJar(Asset asset, String version, Source source) {
return JarHandler.cacheStage.thenCombineAsync(getDetails(version), (void_, versionDetails) -> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thenCombineAsync requires both input stages to complete before the combiner runs, including getDetails(version), which performs an HTTP GET for the version manifest. That request always fires, even though its result (versionDetails) is discarded a few lines down whenever JarHandler.index(client, version) already found a locally cached jar. This means every jar lookup now requires network connectivity, even for versions fully available on disk, breaking offline-from-cache operation and wasting a request per lookup. Checking JarHandler.index() first and short-circuiting before composing with getDetails() would restore the old cache-first behavior.

CompletableFuture.runAsync(RemoteHandler::fetchVersions, EXECUTOR_NET);
JarHandler.cacheStage = RemoteHandler.versionsStage.thenRunAsync(JarHandler::initCache, EXECUTOR_IO)
.thenRunAsync(ConfigHandler::moveRLAssets, EXECUTOR_IO);
JarHandler.cacheStage.thenRunAsync(ConfigHandler::load, EXECUTOR_IO);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getModContainerClass()/injectData() no longer special-case FMLLaunchHandler.side().isServer(). Previously dedicated servers returned early after ServerLangHelper.load() and skipped RemoteHandler.getVersions()/JarHandler.indexJars()/ConfigHandler.load()/moveRLAssets() entirely. Now that whole pipeline runs unconditionally, so headless servers reach out to Mojang's version manifest and download/index client+server jars, which they never did before. ServerLangHelper.load() still runs (now via TXLoaderModContainer.serverStarting), so the lang-loading behavior is preserved, but the added network/jar work on dedicated servers isn't mentioned in the PR description. Worth confirming this is intentional.

} catch (Exception e) {
TXLoaderCore.LOGGER.error("Failed to get Minecraft versions!", e);
return false;
versionsStage.completeExceptionally(e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CompletableFuture.runAsync(RemoteHandler::fetchVersions, EXECUTOR_NET) in TXLoaderCore.injectData() discards the returned future. Inside fetchVersions(), only downloadManifest() is wrapped in try/catch (this line); if anything after it throws (e.g. manifest.versions.forEach hitting unexpected null data), the exception completes the discarded future exceptionally with no log line, and versionsStage (plus everything chained off it: cacheStage, all asset fetches) is left permanently incomplete with no diagnostic trail. Worth adding .exceptionally(...) at the injectData() call site for logging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants