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
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.
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
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.
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.
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.
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.
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