C
C#•12mo ago
MechWarrior99

How to safely access parameters from Task (Porting from Unity IJob)

I am making a program for generating a 3D mesh from parameters. The generation is done in some Tasks run with Task.Run. I'm struggling to grasp how to properly and safely access the parameters when generating. I am porting the system over from Unity's IJob system, which I think is sort of 'boxing' my thinking in making it hard to get my head around what I need to do. Do I make a class/struct that has all the parameters in it that I populate beforehand and use those values during generation? Sort of like Unity's Job system. Thanks 🙂
27 Replies
Burrito
Burrito•12mo ago
Not necessarily, the way Unity Job system does it like that is for dependency tracking and memory safety, so if that's not a concern you are free to not follow the same design.
MechWarrior99
MechWarrior99OP•12mo ago
Yeah I figured that is the case. But I am not sure what is the 'right' way then since the values can change from the UI during generation. Accessing them directly would be a no go, right?
Burrito
Burrito•12mo ago
This for example is fine:
var result = 0;

await Task.Run(() =>
{
for (var i = 0; i < 10; i++)
{
result += i;
}
});

Console.WriteLine(result);
var result = 0;

await Task.Run(() =>
{
for (var i = 0; i < 10; i++)
{
result += i;
}
});

Console.WriteLine(result);
And under the hood, the lambda compiles to a class that captures the result variable, so essentially it does what you said about "wrapping data in a class" for you.
MechWarrior99
MechWarrior99OP•12mo ago
Well lets say I have something like this
class Generator
{
public SomeClass Foo {get; set;}
public int Bar {get; set;}

public async void Generate()
{
await Task.Run() =>
{
for (int i = 0; i < Bar; i++)
{
Foo.DoThing(i);
}
}
}
}
class Generator
{
public SomeClass Foo {get; set;}
public int Bar {get; set;}

public async void Generate()
{
await Task.Run() =>
{
for (int i = 0; i < Bar; i++)
{
Foo.DoThing(i);
}
}
}
}
The values in Foo could be changed while the task is running which would be not great, right? Or am I misunderstanding something?
Burrito
Burrito•12mo ago
Yep that is indeed possible and is something you have to deal with yourself. Unity's Job system prevents you from doing this with a lot of safety checks.
MechWarrior99
MechWarrior99OP•12mo ago
Right, that is the question I was asking. How do I do that safely Jobs gets around it by only allowing structs, and putting it all in the struct doing the executing. Which is what I was thinking of doing (or making a clone of SomeClass and passing it)
Burrito
Burrito•12mo ago
One potential way is to rewrite it into:
async void Generate()
{
var foo = Foo;
var bar = Bar;

await Task.Run(() =>
{
for (int i = 0; i < bar; i++)
{
foo.DoThing(i);
}
});
}
async void Generate()
{
var foo = Foo;
var bar = Bar;

await Task.Run(() =>
{
for (int i = 0; i < bar; i++)
{
foo.DoThing(i);
}
});
}
This will guarantee that even if Foo and Bar changes during the execution, it will not affect the running task because it's operating on a copy of it. However, do keep in mind that foo and Foo are still possible to be pointing to the same instance and thus can still be externally modified.
MechWarrior99
MechWarrior99OP•12mo ago
That feels weird to do to me. I mean, makes sense, but feels weird. And I guess Foo would need to be cloned or something to be safe.
Burrito
Burrito•12mo ago
Yeah Unity Job system gets around it because it only allow structs and disallows reference types, so structs are copied by value and no reference type = no possibility of external modification. Native containers are still pointers and basically share the same problems as reference types, so Unity has a bunch of safety checks in place. If you are simply worrying about UI changing causing ongoing tasks to get into race conditions, one simple solution could just be disable UI until the task is done. It depends on if you think it's worth it to care about user changing UI while the task is already ongoing.
MechWarrior99
MechWarrior99OP•12mo ago
I guess that is possible, but feels like worse UX. Will probably just go the Unity Jobs approach and limit it to structs, or clones in some cases. I guess I could forgo the 'IJob' structs and just capture local variables as you showed before. Would you say that capturing the variables is the more 'C# standard' way?
Burrito
Burrito•12mo ago
Sure, making a struct and constructing it with all the values you need, is basically just a more verbose capture. Another way you can do it is with records, because they are immutable by default you don't need to worry those things, and C# has syntax sugars to help working with records.
MechWarrior99
MechWarrior99OP•12mo ago
Oh it does?
Burrito
Burrito•12mo ago
Yep, record member have init only setters, so immutable after construction. If all your members are also records, then they don't have reference type mutation issues either.
MechWarrior99
MechWarrior99OP•12mo ago
Huh, didn't realize they were immutable. Interesting!
Burrito
Burrito•12mo ago
Yep, helps make code very simple too.
public record User(string FirstName, string LastName);

public record ResultJob(User User, int Score)
{
public async Task<string> Generate()
{
return await Task.Run(() =>
{
return $"{User.FirstName} {User.LastName} got a score of {Score}!";
});
}
}
public record User(string FirstName, string LastName);

public record ResultJob(User User, int Score)
{
public async Task<string> Generate()
{
return await Task.Run(() =>
{
return $"{User.FirstName} {User.LastName} got a score of {Score}!";
});
}
}
MechWarrior99
MechWarrior99OP•12mo ago
Ooh yeah I totally forgot you could define records like that! What would be the way you would recommend doing it? Variable capture? Record? Struct?
Burrito
Burrito•12mo ago
Not sure tbh, but if you are worrying about potential race conditions then records is the simplest way to ensure they can't happen (you would still need to deal with cancelling ongoing tasks though, using cancellation token), and the code probably looks pretty similar to how you write jobs but cleaner.
MechWarrior99
MechWarrior99OP•12mo ago
Alrighty, well this definitely gives me clarity on it all. Thank you for the help and insight as always! 😄
cap5lut
cap5lut•12mo ago
im not really sure if this sufficient like this. first of all, unity brings its own synchronization context, which executes task on the game loop. unless u slap a .ConfigureAwait(false) onto the task returned by Task.Run() u will might run into fps drops if this still runs in unity (basically same issue for GUI frameworks WinForms, WPF, AvaloniaUI and maybe also MAUI - its not clear to me if this is in still Unity or not) Generate() sounds to me like it does some quite CPU heavy stuff, which might take some seconds, then these should created with TaskCreationOptions.LongRunning. im not sure what unity's synchronization context does here, but the default/none will be handled by running this on a dedicated thread. that would mean instead of Task.Run(´lambda) u would call Task.Factory.StartNew(lambda, TaskCreationOptions.LongRunning); a second is already long running. and that is about the synchronous code (basically each code section between awaits) in ur task. here is also some general info: https://devblogs.microsoft.com/pfxteam/task-run-vs-task-factory-startnew/
Stephen Toub - MSFT
.NET Parallel Programming
Task.Run vs Task.Factory.StartNew
In .NET 4, Task.Factory.StartNew was the primary method for scheduling a new task.  Many overloads provided for a highly configurable mechanism, enabling setting options, passing in arbitrary state, enabling cancellation, and even controlling scheduling behaviors.  The flip side of all of this power is complexity.
Burrito
Burrito•12mo ago
You might be missing a bit of the context, OP's question is "I'm familiar with Unity's Job system, what's the idiomatic way to do the same in a plain C# app?"
MechWarrior99
MechWarrior99OP•12mo ago
Appreciate the info! And yeah, as Burrito said, this is not in Unity, just familiar with the Unity Jobs system and way of doing things. And trying to understand how you would do the same in a plain C# app.
cap5lut
cap5lut•12mo ago
thats why i mentioned the GUI frameworks as well, and that would generally only be relevant for the .ConfigureAwait(), the rest still applies even for the default/no synchronization context which would then use the thread pool
Burrito
Burrito•12mo ago
TIL about long running. I've been spoiled by Unity's Job system and UniTask.RunOnThreadPool that I didn't know about this.
Petris
Petris•12mo ago
Pretty sure that ConfigureAwait is ignored in Unity And that continuations are always run on the main thread
cap5lut
cap5lut•12mo ago
didnt know that, thx for info
Petris
Petris•12mo ago
It also causes .GetAwaiter().GetResult() to always deadlock when called from the main thread kekw
MechWarrior99
MechWarrior99OP•12mo ago
Ahh I see, thank you. I will keep .ConfigureAwait() in mind, wasn't aware of it! I was aware of LongRunning, but I don't think it applies in this case as I want the the generation to happen in 'real time' (less than ~100ms) as the user changes properties in the UI. So if it is taking longer than that, I need to do some more optimization haha.

Did you find this page helpful?