godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.1k stars 69 forks source link

Add some way to replace GDScriptFunctionState in await coroutines #5673

Open yikescloud opened 1 year ago

yikescloud commented 1 year ago

Describe the project you are working on

A event driven behavior tree

Describe the problem or limitation you are having in your project

When use yield, we can emit a signal to let the yield continue, or use resume() to ignore the signal can continue the function run. For example when let a task node run a custom function, we can wait the custom function finished by

func tick():
  var p = call("custom_func")
  if p is GDScriptFunctionState:
    return RUNNING
func abort():
  p.resume()
  return FAILED

or use resume() to abort the function anytime. But in godot 4, We cannot let a function resume flexible. cause await cannot bind two signal, we only can resume it by emit certain signal manually, but there's no way to know what signal this await waiting for, so if we want it resume, we must hard code this part. We cannot know whether a function is yield/await after call it, cause it doesn't return anything. *If we use call(function_name) to call a function with await in it, the call still return a GDScriptFunctionState, but we cannot use it, that;s a bit inconsistent. We cannot pass data between main function and coroutine function by use yield and return. That's a very useful and powerful function in some use cases. Which is mean we can "hang" a function and run it in different place and different way. Kind of a polymorphism for function

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Not pretty sure what to code, or anyway to achieve this

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

Let await function return something so we know the function is now "Awaiting" , and we can resume this later

If this enhancement will not be used often, can it be worked around with a few lines of script?

No, it should be GDscript internal feature. And it stop convert godot 3 project to 4 cause the lack of GDScriptFunctionState class and resume function breaks the compatible. And it has no replacement yet.

Is there a reason why this should be core and not an add-on in the asset library?

Addon won't fix this

BimDav commented 1 year ago

I was very surprised to discover that we could no longer do this in Godot 4! To me, the ability to resume() is paramount for something to be called a "coroutine".

Note that you can emulate a resume() with this: in the resumed script:

signal resume()
var waiting_for_resume: = false
func corout() -> void:
   # do 1...
   waiting_for_resume = true
   await resume
   waiting_for_resume = true
   # do 2...

in the calling script:

while n.waiting_for_resume:
   n.resume.emit()

This is rather annoying, and the resume signal has to be defined for every context that can be resumed...

An OK solution would be to be able to declare Signals using code, so that we could do: in the resumed script:

var resume: Signal
func corout() -> void:
   # do 1...
   resume = Signal()
   await resume
   # do 2...

in the calling script:

while n.resume:
   n.resume.emit()

Edit: the above code for now returns "error connecting to signal: during await"

vnen commented 1 year ago

I would like to see some concrete usages of this, not mock up examples. What do you want to do that needs the feature and can't be done another way.

The removal was done deliberately because dealing with a GDScriptFunctionState was more confusing than helpful in the vast majority of cases.

I do understand it reduces the power of the language, but I could not find concrete need of this by the time I was working on await and asked around (or after it for that matter). So maybe we do need to bring it back in some capacity, maybe we can solve the actual problem in a different way.

Another issue is static typing. If a function says it returns int it should always return an int. If sometimes it returns a GDScriptFunctionState, it breaks all the optimizations we can do with the static type.

I was very surprised to discover that we could no longer do this in Godot 4! To me, the ability to resume() is paramount for something to be called a "coroutine".

TBH I don't like calling those "coroutines", but the name kind of stuck now.

BimDav commented 1 year ago

A basic use case for resume is to have a loop running on process(), that resumes as long as some time has not yet passed. With yield, you were able to let the logic go back to the loop, that could decide whether to resume or not.

Another basic use case that is harder to implement now is awaiting multiple signals: you want to begin the process of n elements and then wait for them all to be over.

Those are example that can be found in other language’s tutorials on coroutines and await, they are not really hard to find, I think. Awesome job you did, though, I’m sure there is a way to have satisfying interfaces for more complex cases

BimDav commented 1 year ago

I have played with await a little more, and coupled with Callable it's still very powerful, I was able to implement join() and resume() in a way that I actually find great to use. My code is public here: https://github.com/BimDav/Godot4-Coroutines.

vnen commented 1 year ago

@BimDav thanks for the info.

I have a feeling we might need something other than await for this, to have proper coroutines. Potentially the coroutines need to be marked as such so the compiler and runtime can expect the function state to be an actual possibility.

Or maybe we can have helper objects, like your addon, that allows this usage with await, albeit with some extra steps.


This proposal is tricky because GDScript is supposed to be simple and this is advanced usage. For instance, I know coroutines can be used for implementing state machines, which are useful in gamedev, but there also other ways of implementing state machines. That's why we ask for concrete use cases, so we can tailor the solution for the problem.

Now, given GDScript technically had this before, I guess we can re-add it for the sake of portability. But it's also a chance to make the feature work properly instead of trying to hack something into the current implementation. We don't have to bring it back exactly like it is in 3.x.

LuisCarli commented 1 year ago

@vnen I think this article (https://bvisness.me/coroutines/) show a very good example of how coroutines help with coding the behavior of agents, and how they’re easier to teach and code than other ways to handle state machines. This was possible with yield but not straightforward todo anymore with the await implementation.

The example in the article is something I keep bumping again and again when coding games.

tcoxon commented 1 year ago

I would also like to see GDScriptFunctionState come back in Godot 4. We made extensive use of it in Cassette Beasts for behavior trees, scripted animations / UI transitions, and awaiting network processes that can be aborted/canceled or timed out, for example. While our project didn't ever make use of resume specifically, the ability to call a function and not immediately await on it to finish is powerful in the same way that lambdas and first-class functions are: it lets you factor out a lot of common patterns and boilerplate into a few lines of code.

Some concrete examples:

# Implementation of 'join'. This lets you start multiple 'coroutines' at once and await until they're
# all finished, regardless of which one finishes first.

class _Join extends Reference:
    signal completed

    var _co_list: Array
    var _results: Array
    var _incomplete: int

    func _init(co_list: Array):
        assert(co_list.size() > 0)
        assert(co_list.size() < 64)
        _co_list = co_list
        _results = []
        _incomplete = (1 << _co_list.size()) - 1
        for i in range(_co_list.size()):
            assert(_co_list[i].has_signal("completed"))
            _co_list[i].connect("completed", self, "_co_completed", [i])

    func _co_completed(result, i: int):
        var bit = 1 << i
        assert(_incomplete & bit)
        _results[i] = result
        _incomplete &= ~bit
        if _incomplete == 0:
            emit_signal("completed", _results)

func join(co_list: Array):
    return _Join.new(co_list)

# Use it like this to yield until the message dialog has been dismissed and the death animation has
# finished playing (either could finish first):
yield(Co.join([
    battle.show_message("X died!"),
    fighter.play_animation("death")
]), "completed")

'Select' is another kind of operation you can do with function state objects, where instead of awaiting all coroutines, you wait for only the first one to finish. This example pauses the flow of a scripted sequence of battle animations until either the animation finishes, or a maximum allowed delay has passed:

yield(Co.select([
    fighter.play_animation("attack"),
    Co.wait(max_attack_duration) # essentially: yield(get_tree().create_timer(...), "timeout")
]), "completed")

The most powerful use of 'select' that I've found is actually in network code, where you can await on a response simultaneously with a signal indicating that the operation has been canceled (or the network disconnected). But it's a bit harder to provide a concise and readable example for that here without a lot more context.

Another issue is static typing. If a function says it returns int it should always return an int. If sometimes it returns a GDScriptFunctionState, it breaks all the optimizations we can do with the static type.

Other languages that provide a similar feature usually change the static return type of an async function to something like Async[T] (or Promise[T] #5510), which would allow you to maintain type safety. But I can see how that would be difficult to introduce into Godot 4 now that it's reached stable.

For now I've found a workaround (Co.async) that mimics enough of what Godot 3's GDScriptFunctionStates did that I can continue porting code, but it's not optimal. It involves creating a bunch of extra objects just for something that Godot appears to still have under the hood, and lacks the potential type safety of an Async[T]-style static return type.

vnen commented 1 year ago

I don't mind re-exposing GDScriptFunctionState. I just needs to be done in a way that is explicit intended.

Other languages that provide a similar feature usually change the static return type of an async function to something like Async[T] (or Promise[T] #5510), which would allow you to maintain type safety. But I can see how that would be difficult to introduce into Godot 4 now that it's reached stable.

The difficult part is not being stable, is just the complete absence of generics in GDScript. Even the recent introduction of typed arrays is still messy.

Not that it can't be done, but it won't be easy and carries a lot of implications. I'm not even sure how much it would help in terms of optimization also.

The easier route would be providing a wrapper (like Co.async, except natively in C++). This way you can construct a function state when you need it and use it explicitly. The main issue is that it would lose a bit of static typing as the state wouldn't carry info about its internal method, just like Callable does. However, it wouldn't get in the way of type optimizations, only reduce those in a few controlled places.

tcoxon commented 1 year ago

The easier route would be providing a wrapper (like Co.async, except natively in C++). This way you can construct a function state when you need it and use it explicitly.

Having worked on compilers before, I've always been curious how the GDScript parser/compiler works. So I took this opportunity to learn a bit more while prototyping exactly this: https://github.com/tcoxon/godot/commits/async_keyword

It adds an async keyword that you can use in place of await to start coroutines without immediately awaiting:

func _ready():
    var co = async test_func()
    if co is GDScriptFunctionState:
        co = await co
    print(co)

func test_func():
    await get_tree().create_timer(1.0).timeout
    return "Hello, world!"

And it works nicely with select/join-like functions:

func run_race1() -> String:
    var winner = await Co.select([
        async race_horse("foo"),
        async race_horse("bar"),
        async race_horse("baz")
    ]).completed
    print("The winner of race1 is ", winner, "!")
    return winner

If you approve of the approach, I'll clean it up, add documentation and tests, and submit a PR to the Godot repo.

The main issue is that it would lose a bit of static typing as the state wouldn't carry info about its internal method, just like Callable does. However, it wouldn't get in the way of type optimizations, only reduce those in a few controlled places.

Yep, but I don't think coroutines will tend to be used on performance critical paths. The main loss IMO is static type checking. Personally, I could live without static type checking on async, since not even Godot 3 had it.

BimDav commented 1 year ago

To me, it sounds a little too much to add another keyword. Since we both came up with similar implementations (mine is here, as published above https://github.com/BimDav/Godot4-Coroutines) for join, maybe a first party implementation could come from a type that is called AsyncArray or even better, Array[Awaitable]. That being said, resume() is also very useful in some cases, so we would still have to rely on implementation such as the one in my addon, or expose the underlying GDScriptFunctionState. Since it is more rare though, I would be ok with having 1st party implementation for join, and some more convoluted code for resume()

tcoxon commented 1 year ago

@BimDav So you propose instead of a keyword, allowing asynchronous calls only in calls to the constructor of a special array type? Is that correct? I feel like a keyword you can use in place of await is much cleaner than giving special language-level features to a particular type.

Join and select (or join_either) aren't the only things you can do with function states. You might for example want a variant of select which completes when the first non-null result is returned by a co-routine. If join/select are baked into the language, and GDScriptFunctionState isn't exposed, then you can't implement it. Or maybe you have only one coroutine, and just want a couple lines of code to execute between starting it and awaiting it. In which case, there's no need for an array at all.

I think that join/select are best left to developers to implement. However I would like GDFS, which is a necessary building block for implementing them, to be exposed by the language.

BimDav commented 1 year ago

@tcoxon I don't really mind either way but I think that we have shown in both our addons that these functionalities you describe are still doable and not over complex to implement with the current await feature. On the other hand both join and select seem such basic functionalities that they should in my opinion be very accessible to all users, so a 1st party implementation makes sense.

gamedevsenad commented 2 months ago

So in Godot 3 I had code that depended on seing if a command returns GDScriptFunctionState so I can now if I should wait for it being executed or could continue right away.

After porting the game to Godot 4 that syntax was no longer available, so I wrote a very simple Promise class. The command function will now return a promise. It can chose to finish the promise right away or later. In any the class processing the command can wait for the promise to be finished either way and than continue its business.

This could also easily work with waiting for multiple promises to finish I guess.

Using the promise class is great for me, because the code ends up being so much more readable!

Here is the code for the Promise class:

extends Object

class_name Promise

signal finished(promise_result: PromiseResult)

var _is_finished = false
var is_finished: bool:
    get:
        return _is_finished

var value = null

var _has_error = false
var has_error: bool:
    get:
        return _has_error

var error_message : String = ""

func finish(value = null):
    var promise_result = PromiseResult.new()

    promise_result.is_finished = true
    promise_result.has_error = false
    promise_result.value = value
    promise_result.error_message = ""

    finished.emit(promise_result)

func finish_with_error(error_message: String):
    var promise_result = PromiseResult.new()

    promise_result.is_finished = true
    promise_result.has_error = true
    promise_result.value = null
    promise_result.error_message = error_message

    finished.emit(promise_result)

class PromiseResult:
    var is_finished = false
    var value = null

    var has_error = false
    var error_message : String = ""