From 5d8f8df4e62df3ae2728c87dad5c92b81039808a Mon Sep 17 00:00:00 2001 From: shynixn Date: Sat, 4 May 2024 23:52:49 +0200 Subject: [PATCH] #113 Implemented main dispatcher. --- docs/wiki/docs/commandexecutor.md | 17 +++-- docs/wiki/docs/coroutine.md | 76 ++++++++++++------- docs/wiki/docs/listener.md | 19 +++-- .../mccoroutine/folia/CoroutineSession.kt | 5 ++ .../shynixn/mccoroutine/folia/MCCoroutine.kt | 18 +++-- mccoroutine-folia-core/build.gradle.kts | 2 +- .../folia/dispatcher/MainDispatcher.kt | 49 ++++++++++++ .../folia/impl/CoroutineSessionImpl.kt | 8 ++ .../commandexecutor/AdminCommandExecutor.kt | 11 ++- .../sample/listener/PlayerConnectListener.kt | 7 +- 10 files changed, 159 insertions(+), 53 deletions(-) create mode 100644 mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/dispatcher/MainDispatcher.kt diff --git a/docs/wiki/docs/commandexecutor.md b/docs/wiki/docs/commandexecutor.md index c18a6a5..9bbf844 100644 --- a/docs/wiki/docs/commandexecutor.md +++ b/docs/wiki/docs/commandexecutor.md @@ -96,15 +96,22 @@ plugins. class PlayerDataCommandExecutor(private val database: Database) : SuspendingCommandExecutor { override suspend fun onCommand(sender: CommandSender, command: Command, label: String, args: Array): Boolean { + // In Folia, this will be the global region thread, or entity execution thread. + // In Bukkit, this will be the main thread. + if (sender !is Player) { return false } if (args.size == 2 && args[0].equals("rename", true)) { val name = args[1] - val playerData = database.getDataFromPlayer(sender) - playerData.name = name - database.saveData(sender, playerData) + withContext(plugin.mainDispatcher) { + // Make sure you switch to your plugin main thread before you do anything in your plugin. + val playerData = database.getDataFromPlayer(sender) + playerData.name = name + database.saveData(sender, playerData) + } + return true } @@ -312,14 +319,14 @@ plugins. private val database = Database() override suspend fun onEnableAsync() { - // Minecraft Main Thread + // Global Region Thread. database.createDbIfNotExist() server.pluginManager.registerSuspendingEvents(PlayerDataListener(database), this) getCommand("playerdata")!!.setSuspendingExecutor(PlayerDataCommandExecutor(database)) } override suspend fun onDisableAsync() { - // Minecraft Main Thread + // Global Region Thread. } } ```` diff --git a/docs/wiki/docs/coroutine.md b/docs/wiki/docs/coroutine.md index e4aa898..79331c3 100644 --- a/docs/wiki/docs/coroutine.md +++ b/docs/wiki/docs/coroutine.md @@ -1,17 +1,16 @@ # Kotlin Coroutines and Minecraft Plugins -When starting with [Coroutines in Kotlin](https://kotlinlang.org/docs/coroutines-basics.html), it is interesting -how this can be translated to the world of minecraft plugins. It is recommended to learn how Kotlin Coroutines work -before you continue here. +When starting with [Coroutines in Kotlin](https://kotlinlang.org/docs/coroutines-basics.html), you may wonder how +you can use them for minecraft plugins and mods. This guide introduces concepts and a production ready API you can use, to start +adding coroutines to your project. !!! note "Important" Make sure you have already installed MCCoroutine. See [Installation](/gettingstarted) for details. ### Starting a coroutine -In order to start coroutine You may also encounter the function -``runBlocking`` because it makes sense for certain scenarios such as unittest. -However, keep in mind to **avoid** using ``runblocking`` in any of your plugins. +In order to start a coroutine, you can use the provided ``plugin.launch {}`` extension method. This is safe to be called +anywhere in your plugin except in onDisable where you need to use ``runBlocking``. However, keep in mind to **avoid** using ``runblocking`` anywhere else in any of your plugins. * To enter a coroutine **anywhere** in your code at any time: @@ -62,29 +61,45 @@ However, keep in mind to **avoid** using ``runblocking`` in any of your plugins. === "Folia" - As Folia brings multithreading to Paper based servers, threading becomes more complicated and MCCoroutine requires you to think - everytime you call plugin.launch. In Bukkit based servers, MCCoroutine can assume the correct thread automatically and optimise ticking. (e.g. - not sending a task to the scheduler if you are already on the main thread). - - In Folia, there are many threadpools (explained below) and we do not have a main thread. + As Folia brings multithreading to Paper based servers, threading becomes a lore more complicated for plugin developers. !!! note "Important" You can run mccoroutine-folia in standard Bukkit servers as well. MCCoroutine automatically falls back to the standard Bukkit scheduler if the Folia schedulers are not found and the rules for mccoroutine-bukkit start to apply. + + !!! note "Important" + If you have been using mccoroutine for Bukkit before, you have to perform some restructuring in your plugin. **Simply changing the imports is not enough.** + ``plugin.launch {}`` works differently in Folia compared to Bukkit. + + First, it is important to understand that Folia does not have a server main thread. In order to access minecraft resources you need to use the correct thread for + a given resource. For an entity, you need to use the currently assigned thread for that entity. MCCoroutine provides dispatchers for each of these usecases and + automatically falls back to the matching dispatchers if you are on a Bukkit server instead of a Folia server. + + However, this does not solve the problem of accessing our own data in our plugins. We do not have a main thread, so we could try accessing our data on the incoming + thread. However, sometimes you have to make sure only 1 thread is accessing a resource at a time. This is important for ordering events and avoiding concurrency exceptions. + Concurrent collections can help with that but you may still need synchronize access in other places. + + As a solution, MCCoroutine proposes that each plugin gets their own "main thread" and corresponding "mainDispatcher". It is intended to execute all the stuff the plugin is going to do. + For minecraft actions, like teleporting a player or manipulating an entity. You simply excute them in a sub context and return back to your personal main thread. This + concepts result into the following code. ```kotlin import com.github.shynixn.mccoroutine.folia.launch import org.bukkit.plugin.Plugin fun foo(entity : Entity) { - // The plugin.entityDispatcher(entity) parameter ensures, that we end up on the scheduler for the entity in the specific region if we suspend - // inside the plugin.launch scope. (e.g. using delay) - // The CoroutineStart.UNDISPATCHED ensures, that we enter plugin.launch scope without any delay on the current thread. - // You are responsible to ensure that you are on the correct thread pool (in this case the thread pool for the entity), if you pass CoroutineStart.UNDISPATCHED. - // This is automatically the case if you use plugin.launch{} in events or commands. You can simply use CoroutineStart.UNDISPATCHED here. - // If you use CoroutineStart.DEFAULT, the plugin.launch scope is entered in the next scheduler tick. - plugin.launch(plugin.entityDispatcher(entity), CoroutineStart.UNDISPATCHED) { - // In this case this will be the correct thread for the given entity, if the thread was correct before calling plugin.launch. + plugin.launch { // or plugin.launch(plugin.mainDispatcher) {} + // Your plugin main thread. If you have already been on your plugin main thread, this scope is entered immidiatly. + // Regardless if your are on bukkit or on folia, this is your personal thread and you must not call bukkit methods on it. + // Now perform some data access on your plugin data like accessing a repository. + val storedEntityDataInDatabase = database.get() + + // Apply the data on the entity thread using the entityDispatcher. + // The plugin.entityDispatcher(entity) parameter ensures, that we end up on the scheduler for the entity in the specific region. + withContext(plugin.entityDispatcher(entity)) { + // In Folia, this will be the correct thread for the given entity. + // In Bukkit, this will be the main thread. + } } } ``` @@ -306,7 +321,8 @@ A dispatcher determines what thread or threads the corresponding coroutine uses In Folia, MCCoroutine offers 4 custom dispatchers. - * globalRegion (Allows to execute coroutines on the global region. e.g. Global Game Rules) + * mainDispatcher (Your personal plugin main thread, allows to execute coroutines on it) + * globalRegionDispatcher (Allows to execute coroutines on the global region. e.g. Global Game Rules) * regionDispatcher (Allows to execute coroutines on a specific location in a world) * entityDispatcher (Allows to execute coroutines on a specific entity) * asyncDispatcher (Allows to execute coroutines on the async thread pool) @@ -315,16 +331,20 @@ A dispatcher determines what thread or threads the corresponding coroutine uses ```kotlin fun foo(location: Location)) { - plugin.launch(plugin.regionDispatcher(location), CoroutineStart.UNDISPATCHED) { - // The correct thread for the given location without delay, if the thread was correct before calling plugin.launch. + plugin.launch { + // Always make your you are on your personal plugin main thread. - val result = withContext(Dispatchers.IO) { - // Perform operations asynchronously. - "Playxer is Max" + val resultBlockType = withContext(plugin.regionDispatcher(location)) { + // In Folia, this will be the correct thread for the given location + // In Bukkit, this will be the main thread. + getTypeOfBlock() + } + + myBlockTypeList.add(resultBlockType) + + withContext(plugin.asyncDispatcher) { + // save myBlockTypeList to file. } - - // The correct thread for the given location. - println(result) // Prints 'Player is Max' } } ``` diff --git a/docs/wiki/docs/listener.md b/docs/wiki/docs/listener.md index af30f8e..343dee4 100644 --- a/docs/wiki/docs/listener.md +++ b/docs/wiki/docs/listener.md @@ -110,17 +110,22 @@ suspendable functions). You can mix suspendable and non suspendable functions in class PlayerDataListener(private val database: Database) : Listener { @EventHandler suspend fun onPlayerJoinEvent(event: PlayerJoinEvent) { - val player = event.player - val playerData = database.getDataFromPlayer(player) - playerData.name = player.name - playerData.lastJoinDate = Date() - database.saveData(player, playerData) + // In Folia, this will be entity thread of the player. + // In Bukkit, this will be the main thread. + withContext(plugin.mainDispatcher) { + // Make sure you switch to your plugin main thread before you do anything in your plugin. + val player = event.player + val playerData = database.getDataFromPlayer(player) + playerData.name = player.name + playerData.lastJoinDate = Date() + database.saveData(player, playerData) + } } @EventHandler fun onPlayerQuitEvent(event: PlayerQuitEvent) { - // Alternative way to achieve the same thing - plugin.launch(plugin.entityDispatcher(event.player)), CoroutineStart.UNDISPATCHED) { + plugin.launch { + // Make sure you switch to your plugin main thread before you do anything in your plugin. val player = event.player val playerData = database.getDataFromPlayer(player) playerData.name = player.name diff --git a/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/CoroutineSession.kt b/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/CoroutineSession.kt index c94c270..ece273a 100644 --- a/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/CoroutineSession.kt +++ b/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/CoroutineSession.kt @@ -28,6 +28,11 @@ interface CoroutineSession { */ val dispatcherAsync: CoroutineContext + /** + * The main dispatcher represents the main thread of a plugin. + */ + val dispatcherMain : CoroutineContext + /** * Manipulates the bukkit server heart beat on startup. */ diff --git a/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/MCCoroutine.kt b/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/MCCoroutine.kt index 51eef32..870f656 100644 --- a/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/MCCoroutine.kt +++ b/mccoroutine-folia-api/src/main/java/com/github/shynixn/mccoroutine/folia/MCCoroutine.kt @@ -52,6 +52,14 @@ val Plugin.asyncDispatcher: CoroutineContext return mcCoroutine.getCoroutineSession(this).dispatcherAsync } +/** + * Gets the plugin main dispatcher. The main dispatcher consists of a single thread dedicated for your plugin + */ +val Plugin.mainDispatcher : CoroutineContext + get() { + return mcCoroutine.getCoroutineSession(this).dispatcherMain + } + /** * Gets the dispatcher to schedule tasks on the region that owns the entity. * If Folia is not loaded, this falls back to the bukkit minecraftDispatcher. @@ -87,7 +95,7 @@ val Plugin.scope: CoroutineScope } /** - * Launches a new coroutine on the current thread without blocking the current thread and returns a reference to the coroutine as a [Job]. + * Launches a new coroutine on the main plugin thread without blocking the current thread and returns a reference to the coroutine as a [Job]. * The coroutine is cancelled when the resulting job is [cancelled][Job.cancel]. * * The coroutine context is inherited from a [Plugin.scope]. Additional context elements can be specified with [context] argument. @@ -95,8 +103,8 @@ val Plugin.scope: CoroutineScope * The parent job is inherited from a [Plugin.scope] as well, but it can also be overridden * with a corresponding [context] element. * - * By default, the coroutine is immediately scheduled on the current calling thread. However, manipulating global data, entities or locations - * is not safe in this context. Use subsequent operations for this case e.g. withContext(plugin.entityDispatcher(entity)) {} or withContext(plugin.regionDispatcher(location)) {} + * By default, the coroutine is scheduled on the plugin main thread. This is not the minecraft main thread even if you are using MCCoroutine for Folia in Bukkit mode. + * Manipulating global data, entities or locations is not safe in this context. Use subsequent operations for this case e.g. withContext(plugin.entityDispatcher(entity)) {} or withContext(plugin.regionDispatcher(location)) {} * Other start options can be specified via `start` parameter. See [CoroutineStart] for details. * An optional [start] parameter can be set to [CoroutineStart.LAZY] to start coroutine _lazily_. In this case, * the coroutine [Job] is created in _new_ state. It can be explicitly started with [start][Job.start] function @@ -111,8 +119,8 @@ val Plugin.scope: CoroutineScope * @param block the coroutine code which will be invoked in the context of the provided scope. **/ fun Plugin.launch( - context: CoroutineContext, - start: CoroutineStart, + context: CoroutineContext = mainDispatcher, + start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit ): Job { if (!scope.isActive) { diff --git a/mccoroutine-folia-core/build.gradle.kts b/mccoroutine-folia-core/build.gradle.kts index ef95f61..78695d7 100644 --- a/mccoroutine-folia-core/build.gradle.kts +++ b/mccoroutine-folia-core/build.gradle.kts @@ -1,6 +1,6 @@ repositories { maven { - url = uri("https://papermc.io/repo/repository/maven-public/") + url = uri("https://repo.papermc.io/repository/maven-public/") } } diff --git a/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/dispatcher/MainDispatcher.kt b/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/dispatcher/MainDispatcher.kt new file mode 100644 index 0000000..20e0ac9 --- /dev/null +++ b/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/dispatcher/MainDispatcher.kt @@ -0,0 +1,49 @@ +package com.github.shynixn.mccoroutine.folia.dispatcher + +import kotlinx.coroutines.CoroutineDispatcher +import org.bukkit.plugin.Plugin +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext + +class MainDispatcher( + private val plugin: Plugin, +) : CoroutineDispatcher(), AutoCloseable { + private val executor = Executors.newFixedThreadPool(1) { r -> + Thread(r, "MCCoroutine-${plugin.name}-MainThread") + } + private var threadId = -1L + + init { + executor.submit { + threadId = Thread.currentThread().id + } + } + + /** + * Handles dispatching the coroutine on the correct thread. + */ + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (!plugin.isEnabled) { + return + } + + if (Thread.currentThread().id != threadId) { + executor.submit(block) + } else { + block.run() + } + } + + /** + * Closes this resource, relinquishing any underlying resources. + * This method is invoked automatically on objects managed by the + * `try`-with-resources statement. + * However, implementers of this interface are strongly encouraged + * to make their `close` methods idempotent. + * + * @throws Exception if this resource cannot be closed + */ + override fun close() { + executor.shutdown() + } +} diff --git a/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/impl/CoroutineSessionImpl.kt b/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/impl/CoroutineSessionImpl.kt index 2820836..64c9946 100644 --- a/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/impl/CoroutineSessionImpl.kt +++ b/mccoroutine-folia-core/src/main/java/com/github/shynixn/mccoroutine/folia/impl/CoroutineSessionImpl.kt @@ -85,6 +85,13 @@ internal class CoroutineSessionImpl( } } + /** + * The main dispatcher represents the main thread of a plugin. + */ + override val dispatcherMain: MainDispatcher by lazy { + MainDispatcher(plugin) + } + /** * The RegionizedTaskQueue allows tasks to be scheduled to be executed on the next tick of a region that owns a specific location, or creating such region if it does not exist. */ @@ -194,6 +201,7 @@ internal class CoroutineSessionImpl( fun dispose() { scope.coroutineContext.cancelChildren() scope.cancel() + dispatcherMain.close() wakeUpBlockService.dispose() } } diff --git a/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/commandexecutor/AdminCommandExecutor.kt b/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/commandexecutor/AdminCommandExecutor.kt index a5a2891..a50bec9 100644 --- a/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/commandexecutor/AdminCommandExecutor.kt +++ b/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/commandexecutor/AdminCommandExecutor.kt @@ -3,8 +3,10 @@ package com.github.shynixn.mccoroutine.folia.sample.commandexecutor import com.github.shynixn.mccoroutine.folia.SuspendingCommandExecutor import com.github.shynixn.mccoroutine.folia.SuspendingTabCompleter import com.github.shynixn.mccoroutine.folia.callSuspendingEvent +import com.github.shynixn.mccoroutine.folia.mainDispatcher import com.github.shynixn.mccoroutine.folia.sample.impl.UserDataCache import kotlinx.coroutines.joinAll +import kotlinx.coroutines.withContext import org.bukkit.Bukkit import org.bukkit.command.Command import org.bukkit.command.CommandSender @@ -29,11 +31,12 @@ class AdminCommandExecutor(private val userDataCache: UserDataCache, private val val playerName = args[1] val playerKills = args[2].toInt() val otherPlayer = Bukkit.getPlayer(playerName)!! - println("[AdmingCommandExecutor/onCommand] Is starting on Thread:${Thread.currentThread().name}/${Thread.currentThread().id}") - val userData = userDataCache.getUserDataFromPlayerAsync(otherPlayer).await() - userData.amountOfPlayerKills = playerKills - userDataCache.saveUserData(otherPlayer) + withContext(plugin.mainDispatcher) { + val userData = userDataCache.getUserDataFromPlayerAsync(otherPlayer).await() + userData.amountOfPlayerKills = playerKills + userDataCache.saveUserData(otherPlayer) + } println("[AdmingCommandExecutor/onCommand] Is ending on Thread:${Thread.currentThread().name}/${Thread.currentThread().id}") return true } diff --git a/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/listener/PlayerConnectListener.kt b/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/listener/PlayerConnectListener.kt index 8538b8b..5707453 100644 --- a/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/listener/PlayerConnectListener.kt +++ b/mccoroutine-folia-sample/src/main/java/com/github/shynixn/mccoroutine/folia/sample/listener/PlayerConnectListener.kt @@ -2,7 +2,6 @@ package com.github.shynixn.mccoroutine.folia.sample.listener import com.github.shynixn.mccoroutine.folia.* import com.github.shynixn.mccoroutine.folia.sample.impl.UserDataCache -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.bukkit.Material @@ -38,14 +37,16 @@ class PlayerConnectListener(private val plugin: Plugin, private val userDataCach ItemStack(Material.APPLE) } - userDataCache.clearCache(playerQuitEvent.player) + withContext(plugin.mainDispatcher) { + userDataCache.clearCache(playerQuitEvent.player) + } println("[PlayerConnectListener/onPlayerQuitEvent] Is ending on Thread:${Thread.currentThread().name}/${Thread.currentThread().id}") } @EventHandler fun onEntitySpawnEvent(event: EntitySpawnEvent) { println("[PlayerConnectListener/onEntitySpawnEvent] Is starting on Thread:${Thread.currentThread().name}/${Thread.currentThread().id}") - plugin.launch(plugin.entityDispatcher(event.entity), CoroutineStart.UNDISPATCHED) { + plugin.launch { println("[PlayerConnectListener/onEntitySpawnEvent] Entering coroutine on Thread:${Thread.currentThread().name}/${Thread.currentThread().id}") delay(2000)