task.yield<T>(value: T) -> T

It’s a hassle to deal with race conditions when working with synchronous code processes or code processes that read values/properties/variables outside of a function’s environment

The only real workaround developers have is to either write a custom yield function or tack on a repeat loop, both of which contain something akin to:

repeat task.wait() until x end

example:

---pretend that data is loaded/handled in modulescript "DataModule"
---this is a weak example but it should help get the point across
local function LoadItems(Player: Player)
	if not DataModule.Cache[Player] then
		repeat task.wait() until DataModule.Cache[Player]
	end

	local SavedData = DataModule.Cache[Player]
	--... do things with saved items in SavedData
end

…it’s reminiscent of how old-school scripts had a wait(2) on line 1 to prevent dependencies from breaking


a cleaner, less-tacky solution to this problem would be:

task.yield<T>(condition: () -> T) -> T
task.yield<T>(value: T) -> T

it behaves similarly to assert(), except condition/value is “tried” until it returns/becomes something truthy, then yield will return that truthy value

I’m unsure if the 2nd overload is possible because it’s unknown how variables are handled under-the-hood, but value would be treated like a pointer to the variable being “tried”

---pretend that data is loaded/handled in modulescript "DataModule"
local function LoadItems(Player: Player)
	local SavedData = task.yield(DataModule.Cache[Player]) --uses the 2nd overload in the proposed request
	--... do things with saved items in SavedData
end
10 Likes

This doesn’t functionally work without significant extensions to Luau’s semantics supporting some kind of macro system: task.yield(DataModule.Cache[Player]) is the same as task.yield(nil) because the arguments you’re passing to a call get evaluated before that call gets a chance to do anything.

I would recommend looking into promise libraries, they can get you the semantics you want here.

5 Likes

task.yield(function() return DataModule.Cache[Player] end) would still work, right?

At that point it would just be a repeat task.wait() loop, so no.

What if some sort of lambda system was made?

The value ... in arguments is like a variable that is called like a function when it’s value is asked for. What if lambdas did the same thing?

local value: Lambda<number> = lambda {math.random()}
print(value) --> 0.1844987442
print(value) --> 0.8471027478
print(value) --> 0.2984728347

This would make it extremely easy to write very confusing code and code that is harder to statically analyze.

I think the right solution to your problem is having your systems expose signals that you can wait on, and going to task.wait() loops if the implementation cost of that would be too high.

In the OP’s case, something akin to…

local function loadSavedData(player: Player)
     if DataModule.Cache[player] == nil then
         repeat
             DataModule.CacheLoaded:Wait()
         until DataModule.Cache[player] ~= nil
    end

    return DataModule.Cache[player]
end

…or promises as tnavarts referred to.

2 Likes

Encountering race conditions is a sign that you need to look into defensively architecturing your codebase and rely less on timing. Some libraries that can help here are Promise, Future and Rx (via Observables).

Waiting at the top of a script to avoid race conditions is a code smell. Race conditions indicate an architecturing problem. Defensive architecture (e.g. via the aforementioned libraries, events, etc) will get you where you need to be without introducing bloat and noob traps in the API.

4 Likes

This most definitely would need a different name, because coroutine.yield exists but means something completely different, so reusing the name like this would not be nice. task.await could make sense, but yeah it’s very close to writing a loop manually.

3 Likes

As aforementioned, typically you’ll never experience race conditions in a better codebase. You should instead implement a callback function into “DataModule”, that is called whenever the data cache was loaded.

(post deleted by author) ᅟ ᅟ ᅟ

1 Like

(post deleted by author) ᅟ ᅟ ᅟ

1 Like

The better answer here is making it easier to distribute and consume 3rd party libraries for common patterns like promises and maids. That way it’s almost as convenient but you still have the flexibility to tweak those libraries if necessary rather being stuck with whatever behavior we chose for a builtin.

That’s something that we’re working towards solving.

4 Likes

(post deleted by author) ᅟ ᅟ ᅟ

I know this isn’t what you’re looking for, but let me provide you with a no-api solution a few years later!

btw, using Rx is a good idea here, but this is library free!

local function waitForData(player: Player): any
    if dataModule.Cache[player] and dataModule.Cache[player].data then
        -- Already loaded, no need to yield
        return dataModule.Cache[player].data
    end

    dataModule.Cache[player] = dataModule.Cache[player] or {
        waitingThreads = {}
    }

    -- Store our current thread
    table.insert(dataModule.Cache[player].waitingThreads, coroutine.running())
    
    coroutine.yield()
    
    return dataModule.Cache[player].data
end

-- On load
local function assignData(player: Player, data: any)
    local threads = dataModule.Cache[player].waitingThreads
    dataModule.Cache[player].waitingThreads = {}
    dataModule.Cache[player].data = data

    for _, thread in threads do
        coroutine.resume(thread)
    end
end
3 Likes