-
-
Notifications
You must be signed in to change notification settings - Fork 665
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
Coroutine Experiments #10128
Coroutine Experiments #10128
Conversation
src/typing/typer.ml
Outdated
@@ -1597,6 +1623,19 @@ and type_call ?(mode=MGet) ctx e el (with_type:WithType.t) inline p = | |||
(match follow e.etype with | |||
| TFun signature -> type_bind ctx e signature args p | |||
| _ -> def ()) | |||
| (EField (e,"start"),_), args -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are your thoughts on expressing this in the type-system instead? This reminds me of the trace
problem where we have a variable amount of arguments followed by PosInfos
, except here we have the _hx_continuation
argument instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't thought about this. I wonder if TypeScript "mapped types" feature has something that could allow this, but I don't know to what extent that makes sense in Haxe. Also @Aurel300 is our type system expert, maybe he has some ideas?
We could also have compiler-supported callable abstracts for these specific cases, like CoroutineStart<(CoroArg1,CoroArg2)->CoroReturn>
(and similar for .bind
and trace
), but I'm not sure if that'a good idea either...
How does this relate to https://github.com/nadako/haxe-coroutines/ ? It seems you've settled for a quite different approach after all. How come? |
No, it's pretty much the same actually. The current difference is in the implementation - instead of generating classes, I use inner functions and the continuation itself is also a function type, simply because it's easier to implement and the generators already handle functions well. This might change when we'll get to optimizing and making it work on all the targets, but that's more or less an implementation detail. |
What do you mean? If you compare https://github.com/nadako/haxe-coroutines/#generators and https://github.com/nadako/haxe/blob/coroutines/tests/misc/coroutines/src/TestGenerator.hx you will find that it is pretty much the same except for some names. Edit: Was replying to @back2dos when nadako happened. |
Ah, ok. The substitution of suspend function getAnswer(arg:Bool):Int {
if (arg)
return 42;
else
throw "oh no";
} The current API doesn't seem to allow the caller to retrieve that error in any way, so I was wondering if something changed, but I suppose it's just not there yet. |
Yeah, I decided not to care about exceptions for now. With the callback continuations it could be just a nodejs-style argument, but I don't know if it's good or bad. |
I'll experiment with some analyzer-specific data design changes on this branch so that we don't end up with silly conflicts. If this works we can factor out these changes into a separate PR. |
FYI I was testing and playing a bit with coroutines outside of await/yield scenarios and come up with this nice example: UhW3fjmbVt.mp4 |
It's really nice that we can do this with like 50 lines of code! And the writing is better than most stuff on Netflix too. |
I have merged development into this. Here are some observations:
function main() {
var a:Coroutine<() -> Void> = null;
var b:() -> Void = null;
a = b; // () -> Void should be Coroutine<() -> Void>
} This indeed makes it necessary to have the flag on
(* TODO: this is wrong *)
let coroutine_type ctx args ret =
let args = args @ [("_hx_continuation",false,(tfun [ret; t_dynamic] ctx.com.basic.tvoid))] in
let ret = ctx.com.basic.tvoid in
TFun(args,ret,true) I'm pretty sure I asked nadako about this before, but I forget what this is about and why it's wrong.
|
Chiming in with my regular "this is how it works in Rust" update: closures and coroutines in Rust generate a unique class per declaration. Each source code closure is a different instance, of a type that you cannot refer to from user syntax. However, the type implements a trait (~interface for us I guess), e.g. |
Isn't that more about how |
# Conflicts: # src/optimization/optimizer.ml # src/typing/generic.ml # src/typing/typeloadFields.ml # src/typing/typer.ml
# Conflicts: # src/codegen/gencommon/closuresToClass.ml
# Conflicts: # src/typing/typeload.ml
# Conflicts: # src/codegen/gencommon/realTypeParams.ml # src/context/typecore.ml # src/filters/filters.ml # src/generators/genjvm.ml # src/typing/callUnification.ml # src/typing/matcher/exprToPattern.ml # src/typing/typeload.ml # src/typing/typeloadFields.ml # src/typing/typer.ml
# Conflicts: # src/codegen/gencommon/castDetect.ml # src/codegen/gencommon/closuresToClass.ml # src/codegen/gencommon/enumToClass.ml # src/codegen/gencommon/enumToClass2.ml # src/codegen/gencommon/fixOverrides.ml # src/codegen/gencommon/gencommon.ml # src/codegen/gencommon/initFunction.ml # src/codegen/gencommon/interfaceVarsDeleteModf.ml # src/codegen/gencommon/normalize.ml # src/codegen/gencommon/overloadingConstructor.ml # src/codegen/gencommon/realTypeParams.ml # src/codegen/gencommon/reflectionCFs.ml # src/compiler/retyper.ml # src/context/display/displayFields.ml # src/context/display/displayToplevel.ml # src/context/typecore.ml # src/core/tFunctions.ml # src/generators/gencs.ml # src/generators/genjava.ml # src/typing/macroContext.ml # src/typing/tanon_identification.ml # src/typing/typeloadFields.ml # src/typing/typeloadFunction.ml # src/typing/typeloadModule.ml # src/typing/typer.ml # src/typing/typerEntry.ml
I have updated the branch again, which was a bit tricky with the recent refactoring. I hope I didn't break anything! The tests in misc/coroutines still work though. I'll see if I can assemble a coroutine working group for Haxe 5 because I really want this as the next big feature. Will see what needs to be done here and how to make progress. On that note, pinging @Aidan63 because this is at least adjacent to what he's been working on, and targets like hxcpp are likely going to be the most difficult to get right. I'd like to make sure that we're not designing something that's difficult to support on our "own" targets. The design we're going with is still this: https://github.com/nadako/haxe-coroutines, so if you could take a look at the overall situation here some time, that'd be appreciated! |
Had a quick gander at the proposal as well as some code dumps generated by this branches ci builds and made some notes. Having generators be able to know if a class implementing |
Spent some time playing around with this some more this evening. I quickly threw together an implementation of
I quickly bodged together a coroutine for my hxcpp asys implementation and it seems to work fine, the file gets created and the file path is printed out when the coroutine ends. import asys.native.filesystem.File;
import asys.native.filesystem.FilePath;
import asys.native.filesystem.FileSystem;
import asys.native.filesystem.FileOpenFlag;
class CoroFileSystem {
@:coroutine static public function openFile<T>(path:FilePath, flag:FileOpenFlag<T>) : CoroFile {
return Coroutine.suspend(cont -> {
FileSystem.openFile(path, flag, (file, error) -> {
switch error {
case null:
cont(new CoroFile(cast file), null);
case exn:
cont(null, exn);
}
});
});
}
}
class CoroFile {
public final file : File;
public function new(file) {
this.file = file;
}
}
@:coroutine function listen() : String {
final file = CoroFileSystem.openFile("test.txt", FileOpenFlag.Write);
return file.file.path;
}
function main() {
listen.start((name, error) -> {
trace(name);
});
} Things got more broken when I tried to build on it a bit more by exposing the import haxe.NoData;
import haxe.io.Bytes;
import asys.native.filesystem.File;
import asys.native.filesystem.FilePath;
import asys.native.filesystem.FileSystem;
import asys.native.filesystem.FileOpenFlag;
class CoroFileSystem {
@:coroutine static public function openFile<T>(path:FilePath) : CoroFile {
return Coroutine.suspend(cont -> {
FileSystem.openFile(path, FileOpenFlag.Append, (file, error) -> {
switch error {
case null:
cont(new CoroFile(file), null);
case exn:
cont(null, exn);
}
});
});
}
}
class CoroFile {
public final file : FileAppend;
public function new(file) {
this.file = file;
}
@:coroutine public function write(text:String) : Int {
return Coroutine.suspend(cont -> {
file.write(Bytes.ofString(text), 0, text.length, (count, error) -> {
switch error {
case null:
cont(count, null);
case exn:
cont(0, exn);
}
});
});
}
@:coroutine public function close() : NoData {
return Coroutine.suspend(cont -> {
file.close((_, error) -> {
switch error {
case null:
cont(_, null);
case exn:
cont(0, exn);
}
});
});
}
}
@:coroutine function listen() : String {
final file = CoroFileSystem.openFile("test.txt");
var count = 0;
count += file.write("Hello, ");
count += file.write("World!");
file.close();
return 'We wrote $count bytes to the file';
}
function main() {
listen.start((name, error) -> {
trace(name);
});
} The above sample gives C++ errors and looking at the generated code Interestingly the pretty print dump of this looks fine, so appears to be a C++ generator specific issue? Another issue which has cropped up is that the This was all using that "coroutines without the tfun change" branch from the other day. |
Thanks for looking into this! It's good to know that some things work, and we'll figure out the rest. Really curious about the C++ code generation issue there. I don't know how it's possible for gencpp to generate "nothing" in cases like that. Generally, any coroutine transformation we make should also be writable by hand, unless there's some bad type somewhere. I'd look at it with Actually it wouldn't be very surprising if there were some type issues because the only target this has been tested on is JavaScript, which doesn't care much about types. |
# Conflicts: # src/typing/typeload.ml # src/typing/typeloadModule.ml # src/typing/typer.ml
I have updated the branch again. It's a pretty big conflict magnet due to the TFun changes, so I'd like to make the decision between this PR and #11554 soon. If nobody has any concerns there, I'd actually like to merge the changes into this branch so that we have all the discussion/code in one place. |
The function main() {
// Lambdas can't refer to themselves.
final lambda =() -> {
lambda();
};
// local functions can.
function local() {
local();
}
// they get put into an array to be allowed to refer to themselves.
final local = [ null ];
local[0] = () -> {
local[0]();
}
} Guessing the coroutine stuff gets done after that local function transform so its missed and javascript just so happens to support this? Regarding the missing assignments, the normal non pretty dump looks fine as well. The issue seems to be those variables go through the | CppExternRef(name, isGlobal) ->
(* Printf.printf "%s - %s - %s - %s\n" class_name func_name name (tcpp_to_string rvalue.cpptype); *)
if isGlobal then out " ::"; out (name ^ " = "); this originates from when a | TLocal tvar ->
let name = tvar.v_name in
if (Hashtbl.mem !declarations name) then begin
(*print_endline ("Using existing tvar " ^ tvar.v_name);*)
CppVar(VarLocal(tvar)), cpp_type_of tvar.v_type
end else begin
(*print_endline ("Missing tvar " ^ tvar.v_name);*)
Hashtbl.replace !undeclared name tvar;
match has_var_flag tvar VCaptured with
| true -> CppVar(VarClosure(tvar)), cpp_type_of tvar.v_type
| false ->
Printf.printf "1 - %s\n" name;
CppExtern(name,false), cpp_type_of tvar.v_type
end I have no looked into the |
Oh yes, and here's my hxcpp coroutine suspend code which just does what the existing js one does, but for hxcpp. Just have to change the suspend function in std types to call #pragma once
#include <hxcpp.h>
namespace hx
{
struct Coroutine
{
static Dynamic suspend(Dynamic f, Dynamic cont)
{
struct SuspendCaller final : public hx::Object
{
Dynamic f;
Dynamic cont;
SuspendCaller(Dynamic inF, Dynamic inCont) : f(inF), cont(inCont)
{
HX_OBJ_WB_NEW_MARKED_OBJECT(this);
}
Dynamic HX_LOCAL_RUN()
{
return f(cont);
}
Dynamic __run(const Dynamic&, const Dynamic&) override
{
return HX_LOCAL_RUN();
}
Dynamic __Run(const Array<Dynamic>& args) override
{
return HX_LOCAL_RUN();
}
void __Mark(hx::MarkContext* __inCtx) override
{
HX_MARK_MEMBER(__inCtx);
}
#ifdef HXCPP_VISIT_ALLOCS
void __Visit(hx::VisitContext* __inCtx) override
{
HX_VISIT_MEMBER(__inCtx);
}
#endif
};
return new SuspendCaller(f, cont);
}
};
}
|
Something I forgot to bring up the other day, possibly related to js not caring much about types. If a coroutine function doesn't actually suspend execution and returns void, that void will sneak through to the generators and gencpp will try and create a function where an argument has a type of void leading to cpp compiler errors. @:coroutine function print() : Void {
trace("Hello, World!");
}
function main() {
print.start((_, error) -> {
trace("done");
});
} Mousing over I'd imagine most typed targets will get upset at this. |
I'll see if I can port the coroutine transformation out of the analyzer. I originally liked the idea to do it there and the structure supports it nicely, but there are too many issues with having it in there. I'm thinking that the coroutine transformation should be one of the first post-processing filters to run. I think this should be doable in a way that we can still use most of the code in analyzerCoro.ml. |
I updated the analyser coro file to use the array trick over my lunch break, but my hopes of it magically solving all those gencpp issues was probably a bit far fetched. While it solves that |
Ah, just realised the cause of the missing assignments. The local variables captured by the state machine function are never given the |
Doing this without the analzyer seems... difficult. There's a lot of heavy lifting that we would have to implement again, and overall it feels like we would have to solve many problems we have already solved. But keeping it in the analyzer doesn't seem ideal either, so I don't really know how to proceed with this. |
Approaching this from the other direction, analyzerCoro doesn't actually need that much from the analyzer. By declaring these types and functions, the module will happily compile: type suspend_call = {
efun : texpr; (* coroutine function expression *)
args : texpr list; (* call arguments without the continuation *)
pos : pos; (* call position *)
}
type bb = {
bb_pos : pos;
bb_el : texpr DynArray.t;
bb_syntax_edge : syntax_edge;
bb_terminator : terminator_kind;
}
and syntax_switch = {
ss_cases : (texpr list * bb) list;
ss_default : bb option;
ss_next : bb;
}
and syntax_edge =
| SEIfThen of bb * bb * pos (* `if` with "then" and "next" *)
| SEIfThenElse of bb * bb * bb * Type.t * pos (* `if` with "then", "else" and "next" *)
| SESwitch of syntax_switch (* `switch` with cases, "default" and "next" *)
| SETry of bb * bb * (tvar * bb) list * bb * pos (* `try` with "exc", catches and "next" *)
| SEWhile of bb * bb * pos (* `while` with "body" and "next" *)
| SESubBlock of bb * bb (* "sub" with "next" *)
| SEMerge of bb (* Merge to same block *)
| SESuspend of (suspend_call * bb) (* Suspension point *)
| SENone
and terminator_kind =
| TermNone
| TermCondBranch of texpr
| TermReturn of pos
| TermReturnValue of texpr * pos
| TermBreak of pos
| TermContinue of pos
| TermThrow of texpr * pos
let declare_var g v bb =
() For this purpose, the syntax edges and terminators could be merged together, which would clean up the code a bit. The transformation from this data structure back to |
Another thing I've noticed, only static functions seem to work with the coroutine meta, member function don't work correctly (for cpp atleast). There appears to be a difference between the class field type and the inner CoroFile_obj - write The first two entries there are member functions and the void's on the right hand side of the "vs" is the TFunction tf_type, these are coming out |
Closing this in favor of #11554. Here's the transcript of nadako-chat which I just made to ensure that it still works: import haxe.coro.Coroutine;
import js.Node.process;
import js.node.Readline;
import js.node.readline.Interface;
final rl = Readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "> "
});
@:coroutine function printChar(c:String) {
return Coroutine.suspend(cont -> {
process.stdout.write(c, () -> cont(null, null));
});
}
@:coroutine function delay(ms:Int) {
Coroutine.suspend(cont -> haxe.Timer.delay(() -> cont(null, null), ms));
}
@:coroutine function fancyPrint(text:String) {
for (i in 0...text.length) {
printChar(text.charAt(i));
delay(100);
}
printChar("\n");
}
@:coroutine function promptLine() {
rl.prompt();
return Coroutine.suspend(cont -> {
rl.on(InterfaceEvent.Line, line -> cont(line, null));
});
}
function main() {
@:coroutine function script() {
fancyPrint("Hello");
delay(1500);
fancyPrint("Who are you?");
switch (promptLine()) {
case _.toLowerCase() => "world":
fancyPrint("Haha, very funny... not!");
return;
case name:
fancyPrint('Nice to meet you, $name!');
}
fancyPrint("Take care!");
}
script.start((res, err) -> rl.close());
} Thank you for your work! This PR is what ultimately got me started on the coroutine implementation, and hopefully I'll be able to come through with a polished implementation. |
I want to get this thing going, so I'm publishing my branch with some experiments.
I'm adding some tests and demos here (js-only for now): https://github.com/nadako/haxe/tree/coroutines/tests/misc/coroutines/src