How do free runner threads work (custom signals)

I have been looking at the code in a few custom RBXscriptsignal modules, and I always see this but don’t understand how it works:

local freeRunnerThread = nil

local function acquireRunnerThread(function, ...)
	local acquiredRunnerThread = freeRunnerThread
	freeRunnerThread = nil
	function(...)
	freeRunnerThread = acquiredRunnerThread
end

local function runEventHandlerInFreeThread()
	while true do
		acquireRunnerThread(coroutine.yield())
	end
end

I mainly don’t get how the freerunnerthread works and how the function acquires it, but I am also unsure of why the other function is constantly calling it with a coroutine.yield().

1 Like

Understanding freeRunnerThread

The freeRunnerThread variable is used to store a “free” or “idle” thread (coroutine) that can be reused for executing new tasks. This approach is known as thread pooling or coroutine pooling, where instead of creating a new coroutine every time you need to perform an asynchronous operation, you reuse an existing one from a pool (in this case, a single-thread pool represented by freeRunnerThread).

How acquireRunnerThread Works

The acquireRunnerThread function is designed to handle the execution of a given function in the context of the freeRunnerThread if it’s available, or a new thread if it’s not. Here’s a step-by-step explanation:

  1. Acquire the Runner Thread: It first checks if there is a “free” runner thread available. If there is, it stores this thread in a local variable acquiredRunnerThread and sets freeRunnerThread to nil to indicate that the pool is now empty.

  2. Execute the Function: It then immediately executes the provided function with the given arguments (...). This execution happens synchronously and in the current thread context, not in the runner thread. The runner thread is not actively used to run the function; instead, it’s reserved for handling the next event or function execution asynchronously.

  3. Release the Runner Thread: After the function execution is complete, it restores freeRunnerThread with the previously acquired thread, making it available for the next operation.

The Role of coroutine.yield() in runEventHandlerInFreeThread

The runEventHandlerInFreeThread function is designed to operate within a coroutine and continuously handle events or functions queued for execution:

  1. Infinite Loop: It runs an infinite loop to always be ready for new tasks.

  2. Yielding for Tasks: Inside the loop, it uses coroutine.yield() to suspend execution of the coroutine until a new task is received. The coroutine.yield() function essentially pauses the coroutine and waits for it to be resumed with new arguments, which are expected to be a function and its arguments.

  3. Resuming with a Task: When runEventHandlerInFreeThread’s coroutine is resumed, it immediately calls acquireRunnerThread with the arguments provided upon resumption. This means that some external mechanism is responsible for resuming the coroutine (using coroutine.resume) and passing in the function to execute along with its arguments.

So I found the :Fire event in the module I’m currently looking at:

function Signal:Fire(...)
	local item = self._handlerListHead
	while item do
		if item._connected then
			if not freeRunnerThread then
				freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
				-- Get the freeRunnerThread to the first yield
				coroutine.resume(freeRunnerThread)
			end
			task.spawn(freeRunnerThread, item._fn, ...)
		end
		item = item._next
	end
end

If I am understanding correctly, which I probably am not, it loops through every function connected, and for each one, if the freerunnerthread doesn’t exist (or is nil / taken as you said before) then it creates a new one. I am still confused on a few things though

  1. How does it only use one coroutine if it is constantly creating new ones when there is no available free thread
  2. what does the coroutine.resume() call do
  3. how are the connected functions even called

Sure, let’s simplify that explanation:

  1. Single Coroutine Usage: The code checks if a coroutine (freeRunnerThread) exists. If it doesn’t, it creates one. This coroutine is reused for all event handlers, meaning it doesn’t create a new coroutine each time an event is fired but uses the same one over and over, which is more efficient.

  2. What coroutine.resume() Does: This function starts or continues the execution of the coroutine. Initially, it’s called to get the coroutine ready. After that, the coroutine waits to be given tasks (event handlers to run).

  3. Calling Connected Functions: The event handlers are called using task.spawn with the coroutine. This means that when an event is fired, the coroutine runs the event handler functions one after another without creating new coroutines for each, making the process efficient and fast.

I feel like I should be understanding this now, but I still don’t really get whats happening, so correct me if this is wrong.

  1. When fire is called the first time, the coroutine is created with the runEventHandlerInFreeThread function

  2. coroutine.resume runs the function inside, which somehow yeilds the coroutine stored inside the freerunnerthread

  3. you run the runEventHandlerInFreeThread function inside the task.spawn(), but I don’t know how it ends up in the acquireRunnerThead function()

The acquireRunnerThead function acts like this I think:

  1. It gets the coroutine from the freeRunnerThread, and then sets it to nil (would this mean another coroutine would be created?)

  2. it runs the function passed in (coroutine.yeild is passed in? how does this work?)

  3. sets the freerunnerthead back to the coroutine (what happens if a new coroutine was in the freerunnerthead?)

Sorry for so many questions, but I am just really confused about how this works

Key points of those and you’re fine don’t worry lol as you’re curious and can help about the process and your inquiries are good

  • Creating a Coroutine with fire:

    • Calling fire initializes a coroutine using runEventHandlerInFreeThread.
    • This coroutine is prepared to run tasks asynchronously.
  • Using coroutine.resume:

    • coroutine.resume starts or resumes the coroutine’s execution.
    • If the coroutine yields, coroutine.resume can reactivate it.
  • Running in task.spawn():

    • task.spawn(runEventHandlerInFreeThread) schedules the function to run asynchronously.
    • This allows the function to execute without blocking the main thread.
  • How acquireRunnerThread Works:

    • Retrieves a coroutine from freeRunnerThread. If none is available, a new one is created.
    • Temporarily sets freeRunnerThread to nil to indicate the coroutine is in use.
    • Executes the passed-in function (often involves coroutine yielding).
    • Once the task is done, the coroutine is set back to freeRunnerThread for reuse.
  • Handling New Coroutines in freeRunnerThread:

    • The design should ensure coroutines are fully completed and yielded before another takes its place.
    • If a new coroutine is prematurely assigned, it indicates a potential synchronization issue.
    • Proper management ensures no conflicts between coroutines, maintaining efficient reuse.

This structure aims to manage and reuse coroutines efficiently in an environment that requires asynchronous task handling without blocking the main program flow.

Alright, I think I get basically all of it, my last question involves the task.spawn(). If you are putting the freeRunnerThread as the function, wouldn’t that just call the runEventHandlerInFreeThread function? If so, then how does it execute the function from there, and if it doesn’t how does the task.spawn() lead to the acquireRunnerThread function.

  1. Starting Point with task.spawn():

    • When you use task.spawn(), the idea is to execute a function asynchronously.
    • If task.spawn() is mentioned in the context of freeRunnerThread, it implies that task.spawn() is being used to initiate the process that involves runEventHandlerInFreeThread.
  2. Role of runEventHandlerInFreeThread:

    • runEventHandlerInFreeThread is not the coroutine itself but a function that manages or uses coroutines for asynchronous execution.
    • This function might be where acquireRunnerThread is called to get a coroutine for running tasks asynchronously.
  3. How acquireRunnerThread Fits In:

    • acquireRunnerThread is designed to manage coroutine usage efficiently, either by reusing an existing coroutine or creating a new one if necessary.
    • When runEventHandlerInFreeThread is executed (directly or indirectly through task.spawn()), it would likely call acquireRunnerThread to handle the coroutine logistics.
  4. The Flow Explained:

    • Step 1: task.spawn() is called with runEventHandlerInFreeThread to start asynchronous execution.
    • Step 2: Inside runEventHandlerInFreeThread, acquireRunnerThread is called to secure a coroutine for the task. This could mean reusing a coroutine stored in freeRunnerThread or creating a new one if freeRunnerThread is nil.
    • Step 3: The function or task you want to run asynchronously is executed within the coroutine obtained from acquireRunnerThread.
    • Step 4: Upon completion or at a yielding point, the coroutine can be returned to freeRunnerThread for future reuse.
  5. Clarification on Execution:

    • task.spawn() itself does not directly call acquireRunnerThread. Instead, it starts the process (runEventHandlerInFreeThread) that will eventually use acquireRunnerThread to manage coroutines.
    • The actual “function” you want to run asynchronously is passed into this system at some point in the runEventHandlerInFreeThread process, which then utilizes the coroutine obtained from acquireRunnerThread for execution.

so task.spawn() creates a new coroutine? In the function it sets freerunnerthread to coroutine.create(runEventHandlerInFreeThread). Does this mean the task.spawn() just creates a new coroutine with runEventHandlerInFreeThread? I am now more confused and I still don’t understand how the item.fn is executed.

Let’s break it down into super simple terms ok

Imagine you’re at an amusement park, and task.spawn() is like deciding to ride a roller coaster (starting an adventure).

  • When you decide to go on the ride (task.spawn()), you’re actually getting on a special car (starting a new coroutine) that’s designed to take you on a specific path (executing a function asynchronously).

  • The function runEventHandlerInFreeThread is like the specific path your roller coaster (coroutine) is going to follow. So, when you see coroutine.create(runEventHandlerInFreeThread), it’s like saying, “Let’s build this roller coaster track to go through this exciting path.”

  • Now, about freeRunnerThread being set to coroutine.create(runEventHandlerInFreeThread)—imagine every time someone wants to start a new ride (task), the park checks if there’s a pre-built track ready. If not, they quickly build a new one (start a new coroutine) for this specific ride (task).

  • The big question: How does your chosen ride (the function you want to execute, like item.fn) actually happen? Think of runEventHandlerInFreeThread as the ride operator who knows you’ve come for the roller coaster (you’ve started a coroutine). Inside this operation, there’s a step where your specific ride request (item.fn) is understood and then started. So once your roller coaster is moving (coroutine is running), the operator makes sure it goes through the item.fn experience you’re looking forward to.

In super simple terms, task.spawn() is like deciding to go on an amusement park ride. You get a roller coaster (coroutine) prepped to follow a fun path (runEventHandlerInFreeThread). If there isn’t a coaster ready, they make one for you. And item.fn? That’s the specific excitement you’re there for, which gets included in your ride once everything is moving.

Ok, just very simple, does the task.spawn() run two functions? first the runEventHandlerInFreeThread and then the function? because task.spawn() usually takes two arguements and its taking three here

Think of task.spawn() like starting a task in the background. When you see task.spawn(functionName, arg1, arg2), it’s not doing two tasks. It’s just starting functionName and giving it arg1 and arg2 as extra info it might need. So, it’s all about doing one thing, but with the option to pass along some details to help it out. If you wanted to do two things one after the other, you’d wrap them up in one big task and let task.spawn() kick it off.

So does the task.spawn start a new coroutine with the function?

Yes with the function you specify

So just to clarify, it doesn’t start a coroutine with the runEventHandlerInFreeThread function, it starts a new coroutine with the second arguement passing the third as parameters?

That’s correct! When you use task.spawn in Lua, the second argument is the function that will be executed in a new coroutine, and any arguments that follow are passed to this function as parameters. So, it’s not about starting a coroutine with runEventHandlerInFreeThread or any specific function but rather with the function you provide as the second argument to task.spawn.

1 Like

If anybody sees this, for clarification, coroutine.yield() has a funny little feature where any arguments passed to the next coroutine.resume() call for the thread are returned by yeild(). So basically taks.spawn() (which resumes the coroutine) has the parameters of the function and arguements, meaning they are returned by the yield and therefore passed on to the other function. Here is a page outlining task.spawn() if I didn’t explain it well: Lua | Coroutines | yield() | Codecademy

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.