-
-
Couldn't load subscription status.
- Fork 689
[work in progress] Coroutines for Haxe #11554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
# Conflicts: # src/typing/typer.ml
|
I'm making this the main coroutine PR with the following changes:
Still a million things to figure out, but I'm slowly starting to understand how all this is supposed to work! |
|
Some notes on the transformation: If we have this: @:coroutine
@:coroutine.debug
private function mapCalls<TArg, TRet>(args:Array<TArg>, f:Coroutine<TArg->TRet>):Array<TRet> {
return [for (arg in args) f(arg)];
}The transformer looks at this: function(args:Array<mapCalls.TArg>, f:Coroutine<mapCalls.TArg -> mapCalls.TRet>) {
return {
var ` = [];
{
var ` = 0;
while (` < args.length) {
var arg = args[`];
++ `;
`.push(f(arg));
};
};
`;
};
}And generates this: function(args:Array<mapCalls.TArg>, f:Coroutine<mapCalls.TArg -> mapCalls.TRet>, _hx_continuation:(Array<mapCalls.TRet>, Dynamic) -> Void) {
var ` = null;
var ` = 0;
var _hx_state = 0;
var _hx_exceptionState = 10;
var _hx_stateMachine = function(_hx_result:Dynamic, _hx_error:Dynamic) {
if (_hx_error != null) _hx_state = _hx_exceptionState;
do (try switch (_hx_state) {
case 0: {
_hx_state = 1;
};
case 1: {
`;
` = [];
_hx_state = 3;
};
case 2: {
_hx_state = -1;
_hx_continuation(null, null);
return;
};
case 3: {
`;
` = 0;
_hx_state = 5;
};
case 4: {
_hx_state = -1;
_hx_continuation(`, null);
return;
};
case 5: {
if (! (` < args.length)) _hx_state = 7 else _hx_state = 8;
};
case 6: {
_hx_state = 4;
};
case 7: {
_hx_state = 6;
};
case 8: {
var arg;
arg = args[`];
++ `;
_hx_state = 9;
f(arg, _hx_stateMachine)(null, null);
return;
};
case 9: {
`.push(cast _hx_result);
_hx_state = 5;
};
case 10: throw _hx_error;
default: {
_hx_state = 10;
throw "Invalid coroutine state";
}
} catch (e:Dynamic) if (_hx_state == 10) {
_hx_exceptionState = 10;
_hx_continuation(null, e);
return;
} else {
_hx_state = _hx_exceptionState;
_hx_error = e;
}) while(true);
};
return _hx_stateMachine;
}For one, it would be nice to eliminate the forwarding states 0, 6 and 7. Ideally in a way that would still give us the nice state numbering without any holes. The other thing that looks suspicious to me is that we have Perhaps this could even be optimized for when there's only a single exception state (which I think is the vast majority of coros) because then we can just call the continuation, which we would otherwise do after catching the Yes I know, premature optimization and all that, but I find this stuff easier to debug if the code looks cleaner! |
|
I got the basics working on the JVM target. This increasingly feels like I have no idea what I'm doing, but that may or may not be normal when implementing something like coroutines. The generator tests don't work yet because I still don't know how to actually implement For now, I'm dealing with the Coroutine special cases in genjvm directly. I don't know if we should map this away in a distinct pass instead - seems a bit annoying because the Coroutine type could "leak" to various places. Maybe simply dealing with this in the generators is acceptable. |
|
Got the suspend thing working as well. This feels pretty hacky to do it like that, but I suppose it's not unexpected with these two different signatures going on. @Aidan63 How did you do this for C++? I managed to include your Coroutine implementation but I can't seem to get the function right. |
|
Just gave this a try and it seems to work as it did before for my quick asys bodge, but without needing any of my additions. I added I agree that I'm still seeing C++ errors with non static coroutines, stuff about I'll have to run the coroutine tests instead of just my quick asys bodge to see what else currently breaks. |
Hmm, that's what I tried as well but it generates this which the C++ compiler doesn't appreciate: ::Dynamic Coroutine_Impl__obj::_hx_f0xd5f8b494( ::Dynamic f, ::Dynamic cont) {
return ::::hx::Coroutine::suspend(f,cont);
} |
|
That quad colon is suspicious, if I just tried compiling Looking at that header file it does not have a foward declaration of the exception type for some reason. |
|
I can reproduce the Exception problem as well. Don't know yet what that is about, #11574 doesn't help with that either. Another problem afterwards is this: @:coroutine function error() {
throw "nope";
}
function main() {
error.start((result, error) -> {
trace(error);
});
}This fails with |
|
That would work and would be very similar to the current function approach (just in resume instead of a function). The downside to that is that when a suspend function call returns you don't know if it has ran to completion, error, or has been suspended and needs to be resumed at some point. Which is useful to know in many situations. Having looked at the problem again I think I've made it far more complex than it needs to be. My thinking behind the extra factory classes was that to create a coroutine which is initially suspended needed special support, but thats not the case. You can easily implement it with an extra continuation. function test_defered() {
final cont = new BlockingContinuation(new EventLoopScheduler(Thread.current().events));
final defer = new Defer(getNumber, cont);
Assert.equals(0, nextNumber);
defer.resume(null, null);
Assert.equals(1, cont.wait());
}with class Defer implements IContinuation<Any> {
final lambda : IContinuation<Any>->Any;
final continuation : IContinuation<Any>;
public final _hx_context:CoroutineContext;
public function new(lambda, continuation) {
this.lambda = lambda;
this.continuation = continuation;
this._hx_context = continuation._hx_context;
}
public function resume(_:Any, _:Exception) {
try {
final result = lambda(continuation);
if (result is Primitive) {
return;
}
continuation.resume(result, null);
} catch (exn) {
continuation.resume(null, exn);
}
}
}Calling resume on the defer continuation will start execution of the held coroutine function, so its initially suspended until manually started. So I think my previous comment can be disregarded as a load of rubbish. |
But the function call would merely "construct" a coroutine and not start it yet. |
|
Hi, I see that there haven't been any new comments for almost a year. Since coroutines would be quiet a big new standard fundamental in the language, I'm a bit worried and wondering how to better handle coroutines/futures/promises in a lib that'd want to be future proof. Is there any update on this topic? |
|
It seems the feature already exists in Ocaml - effect handlers there other libraries created around it effect-handlers-bench and see Effect handlers - wiki , the behavior is the same. The problem is getting fixed on terminology currently used in this discussion having real differences, and getting in the way. Doing a quick search, I find Haxe has no direct language support for Implementing |
|
One year later I've taken another look at all this and while the finer details have long since leaked out of my head I was able to port my kotlin style macros into the 2025 coroutines branch. It can be found here. https://github.com/Aidan63/haxe/tree/kt_coro I was hoping there would be binaries available, but the CI has other ideas by the looks of it... Here is a nadako-chat style sample, with io done by asys. https://gist.github.com/Aidan63/db02fe175cd446f3030f286f5aaa1024 Below are some notes. Coroutine Starting Starting a coroutine right now is a bit manual since I haven't updated final cont = new BlockingContinuation(new EventLoopScheduler(Thread.current().events));
foo(cont); // foo is a coroutine function
cont.wait();In this case the Hoisted Variables Variables and arguments which are used between states are hoisted into the generated class, and any access is remapped to those class fields. Initially I was using the original variable name as the classes field name, but this seemed to cause issue with generated variables. I was seeing variables with names which were just backticks, I'm guessing these get renamed at some later stage into the Clear as mud... CoroutineIntrinsic This class has a magic Cancellation I've not yet pulled in my cancellation token stuff. It's really easy to do so since it's all in haxe, I just need to double check that above intrinsic thing first. Targets This should all be target independent (although I do need to add an implementation of Coroutine.suspend which doesn't use a mutex for js), having said that I've only been testing with C++. I tried to compile with the jvm target but get a runtime error with the resulting jar. I don't think there's anything fundamentally wrong here, I'm guessing I've just made a mistake somewhere in all the manual typed expressions I'm building and that the cpp target is a bit more lax on these things. I just haven't been bothered to trawl through a record dump yet looking for type mismatches. |
|
I've started trying to merge current development and kt_coro here: https://github.com/HaxeFoundation/haxe/tree/kt_coro_rebase Not very easy to merge, but should at least be a starting point. I'd send you a PR but I can't figure out how to send a PR to a fork. I reset all gencpp stuff to development because you'll have to reapply changes here after the refactoring anyway. |
|
Great, thanks! The first thing I did when I checked out the 2025 branch was to try and merge dev in, and quickly gave up... While that rebase branch compiles it doesn't seem to work correctly. From what I can tell the types passed into I modified let follow_with_coro t =
let rec s_type_kind t =
let map tl = String.concat ", " (List.map s_type_kind tl) in
match t with
| TMono r ->
begin match r.tm_type with
| None -> Printf.sprintf "TMono (None)"
| Some t -> "TMono (Some (" ^ (s_type_kind t) ^ "))"
end
| TEnum(en,tl) -> Printf.sprintf "TEnum(%s, [%s])" (s_type_path en.e_path) (map tl)
| TInst(c,tl) -> Printf.sprintf "TInst(%s, [%s])" (s_type_path c.cl_path) (map tl)
| TType(t,tl) -> Printf.sprintf "TType(%s, [%s])" (s_type_path t.t_path) (map tl)
| TAbstract(a,tl) -> Printf.sprintf "TAbstract(%s, [%s])" (s_type_path a.a_path) (map tl)
| TFun(tl,r) -> Printf.sprintf "TFun([%s], %s)" (String.concat ", " (List.map (fun (n,b,t) -> Printf.sprintf "%s%s:%s" (if b then "?" else "") n (s_type_kind t)) tl)) (s_type_kind r)
| TAnon an -> "TAnon"
| TDynamic t2 -> "TDynamic"
| TLazy _ -> "TLazy"
in
match follow t with
| TAbstract({a_path = (["haxe";"coro"],"Coroutine")},[t]) ->
begin match follow t with
| TFun(args,ret) ->
Printf.printf "%s is a coro!\n" (s_type_kind t);
Coro (args,ret)
| t ->
Printf.printf "%s, is a coro, but not a tfun...\n" (s_type_kind t);
NotCoro t
end
| t ->
Printf.printf "%s, not a coro...\n" (s_type_kind t);
NotCoro tand on my ky_style branch I see stuff like, but on that rebase branch it never finds coroutines. It shows those same I've not dug any deeper that that so far. |
|
Looks like I botched the most important line... I pushed an update, see if that helped. Also, do you have a coro hello world so I can try compiling stuff myself? I don't remember how to set all this up. |
|
Thanks, that seems to have fixed it and a quick hello world now compiles. import haxe.coro.*;
import haxe.Exception;
import sys.thread.EventLoop;
import sys.thread.Thread;
private class EventLoopScheduler implements IScheduler {
final loop : EventLoop;
public function new(loop) {
this.loop = loop;
}
public function schedule(func : ()->Void) {
loop.run(func);
}
}
private class BlockingContinuation implements IContinuation<Any> {
public final _hx_context:CoroutineContext;
var running : Bool;
var result : Int;
var error : Exception;
public function new(scheduler) {
_hx_context = new CoroutineContext(scheduler);
running = true;
result = 0;
error = null;
}
public function resume(result:Any, error:Exception) {
running = false;
this.result = result;
this.error = error;
}
public function wait():Any {
while (running) {
Thread.current().events.progress();
}
if (error != null) {
throw error;
} else {
return cast result;
}
}
}
class Main {
@:coroutine static function test() {
trace('hello');
delay(1000);
trace('world');
}
@:coroutine public static function delay(ms:Int):Void {
return Coroutine.suspend(cont -> {
haxe.Timer.delay(() -> cont.resume(null, null), ms);
});
}
static function main() {
final cont = new BlockingContinuation(new EventLoopScheduler(Thread.current().events));
test(cont);
cont.wait();
}
}This compiles fine with git hxcpp, it should work fine on any sys target but as mentioned in my other comment there's probably some dodgy typed expression building stopping jvm from running atleast. Before I forget this is a very good article on how kotlins coroutines work, less about the state machine transformation and more about how it all comes together with suspension and continuation. https://kt.academy/article/cc-under-the-hood I've trawled through decompiled kotlin and it was very hard to understand. Kotlin's coroutines have many features and optimisations applied which obscures the root of what's going on. That article strips back all of that, I pretty much copied what they described for my original macro attempt and what I've ported to the compiler. For anyone who does feel brave enough to dive into decompiled kotlin I found the following article quite helpful. https://www.droidcon.com/2022/09/22/design-of-kotlin-coroutines/ It goes through all the core functions which create a coroutine and explains what they're there for. Very useful when trying to figure out if what you're looking at is important to what you want to know. |
|
Thanks! I had to add some imports but with that I can compile it. JVM is offended by this decompiled piece of code: if (Std.isOfType(_hx_tmp, Primitive.class)) {
return Primitive.suspended;
}The I'm not sure which runtime return type this function is supposed to have. |
|
All coroutine functions should be transformed to return @:coroutine function foo(s:String):Int;should become function foo(s:String, c:IContinuation<Any>):Any;sounds like I've missed updating the cf_type of the field. |
|
I have fixed this in b2fb3bc, genjvm still had the previous coroutine signature. With that, hello world works there now. |
Because of potential optional and rest arguments to functions in Haxe, should the |
Interesting question... My first thought is that this is indeed the case for rest arguments at least. Optional arguments are "probably" properly handled via null-padding, but I'm not sure. |
|
Probably makes sense to move the continuation object to the front of the argument list. I only put it at the end because "that's what kotlin do". For reference a kotlin suspending function containing an argument with a default value, such as the following. suspend fun foo(i:Int = 7) {}Has a extra compiler generated function to deal with it. // $FF: synthetic method
public static Object foo$default(int var0, Continuation var1, int var2) {
if ((var2 & 1) != 0) {
var0 = 7;
}
return foo(var0, var1);
}The extra parameter at the end it used for testing if the actual argument is using the default value or not. Kotlin appears to have no way to express optional / default types in function signatures, so completly avoids the optional / default argument mess we have. |
|
I have concerns about the visibility and use of the "continuation object", which I expect the authors plan to resolve as part of the tidying-up phase towards the end of this PR's lifecycle. In the really helpful "Hello World" example above the "continuation object" called
@:coroutine static function test() {
trace('hello');
delay(1000);
...
@:coroutine public static function delay(ms:Int):Void {
...
static function main() {
final cont = new BlockingContinuation(new EventLoopScheduler(Thread.current().events));
test(cont);
For ease of understanding, when initially starting a coroutine, maybe using some formula like
@:coroutine public static function delay(ms:Int):Void {
return Coroutine.suspend(cont -> {
haxe.Timer.delay(() -> cont.resume(null, null), ms);
});
}Clearly, access to the "continuation object" is required from within the coroutine code, so some standard formula to enable that is also required, maybe |
|
I think 1. is just because of this:
This will ultimately be handled by the compiler itself when it comes across a coroutine call from a not-coroutine context. I agree regarding 2., the appearance of |
|
Thank you for your prompt reply @Simn. Having calls to coroutines from non-coroutine contexts handled by the compiler would be most understandable, provided that the non-coroutine context waits for the coroutine to complete, as if it were a normal subroutine. If the coroutine is to execute independently, some annotation in the code that this is to occur would be easiest to understand. Obviously the Go language uses On a related note, from the go2hx project's point-of-view, it may be helpful to be able to extend the underlying coroutine scheduler, as done in the "Hello World" example above. Do you plan for this be possible? |
|
A coroutine call in non-coroutine context should simply return some kind of coroutine object and the standard library can then provide apis to run/schedule it. Implicitly making coroutine calls in non-coro contexts block is a very bad idea in my opinion. |
|
Yes I didn't mean an implicit coroutine call, I meant the actual |
|
I added an initial basic public static function run<T>(f:Coroutine<()->T>) {
final loop = new EventLoop();
final cont = new BlockingContinuation(loop, new EventLoopScheduler(loop));
f(cont);
return cast cont.wait();
}There's no special "compiler magic" going on here right now, it's just sweeping the existing stuff under the carpet. This function now also creates it's own dedicated event loop to pump instead of the main threads. So in the below example "Hello!" now won't be printed until the coroutine completes. import sys.thread.Thread;
import haxe.coro.Coroutine;
import haxe.coro.Coroutine.delay;
@:coroutine function foo() {
delay(1000);
}
function main() {
Thread.current.events.run(() -> trace("Hello!"));
Coroutine.run(foo);
}I also added You can get access to the continuation of the current coroutine using the magic @:coroutine public static function suspend<T>(func:(IContinuation<Any>)->Void):T {
final cont = haxe.coro.Intrinsics.currentContinuation();
final safe = new RacingContinuation(cont);
func(safe);
return cast safe.getOrThrow();
}I want to find a better / safer way of accessing it since the On scheduling. |
|
Closing in favor of #12168. |




Here's a different approach to handling the internal state for coroutines. This is pretty much #10128 but without the additional argument to
TFun. The idea is this:"Coroutine"path, we let it load the abstract. The function type is carried in the type parameter.com.basic.tcorowhich works liketnulland createsCoroutine<Arguments -> Return>. This is used when typing@:coroutinefunctions.follow_with_coro, which gives either aCoro(args, ret)or aNotCoro(t). This allows all code paths who are interested in coroutines (which in the entire compiler is only like 10) to check for them, but still deal with the non-coro behavior easily.This gives us a pretty light diff here, which is always good. There's a chance that I'm missing something important because at the moment we only have the few tests in misc/coroutines.