Skip to content

Commit

Permalink
#113 Implemented main dispatcher.
Browse files Browse the repository at this point in the history
  • Loading branch information
shynixn committed May 4, 2024
1 parent 04e9ce1 commit 5d8f8df
Show file tree
Hide file tree
Showing 10 changed files with 159 additions and 53 deletions.
17 changes: 12 additions & 5 deletions docs/wiki/docs/commandexecutor.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,22 @@ plugins.

class PlayerDataCommandExecutor(private val database: Database) : SuspendingCommandExecutor {
override suspend fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): 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
}

Expand Down Expand Up @@ -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.
}
}
````
Expand Down
76 changes: 48 additions & 28 deletions docs/wiki/docs/coroutine.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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.
}
}
}
```
Expand Down Expand Up @@ -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)
Expand All @@ -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'
}
}
```
Expand Down
19 changes: 12 additions & 7 deletions docs/wiki/docs/listener.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -87,16 +95,16 @@ 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.
* If the context does not have any dispatcher nor any other [ContinuationInterceptor], then Unconfined Dispatcher is used.
* 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
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion mccoroutine-folia-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repositories {
maven {
url = uri("https://papermc.io/repo/repository/maven-public/")
url = uri("https://repo.papermc.io/repository/maven-public/")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -194,6 +201,7 @@ internal class CoroutineSessionImpl(
fun dispose() {
scope.coroutineContext.cancelChildren()
scope.cancel()
dispatcherMain.close()
wakeUpBlockService.dispose()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down

0 comments on commit 5d8f8df

Please sign in to comment.