preliminary design discussion of arsd.core event loop

Posted 2022-08-29

Core D Development Statistics

In the community

Community announcements

See more at the announce forum.

What Adam is working on

I had a week sketching out designs. Wrote down more stuff for my game, which I don't expect to get coded for a long time, and I started jotting down some arsd.core ideas.

The goals

arsd.core is an increasingly less hypothetical module I'd use to unify my event loops and other things. I've been vacillating between doing it and not because while it would be useful, it would break several of my promises of stand-alone files by adding one more to the build, and it risks raising one form of interoperability at the cost of breaking another.

Nevertheless, I'm leaning toward doing it and am thinking of ways to minimize the pain and maximize the utility. Among my goals:

  • Keep working in cases where the modules work now. Ideally, I wouldn't even require you to have the new module, but failing that, at least the api should remain fairly similar. For example, WebSocket.eventLoop should still run the websocket events and return when the websockets are all closed, even if it forwards to the other loop internally.
  • Be compatible with the structured concurrency library by Sebastiaan Koppe he described in dconf 2022. I'd like to both use similar concepts where appropriate, and also let code using his library interoperate with code from my library with relatively little hassle.
  • Be friendly to fiber-based tasks.
  • Look familiar to Javascript programmers who know the Web platform. When reasonable, I like to offer functions and classes with the same names that work close to the same way. While the web platform and class-based OOP is far from perfect, it minimizes the new things you have to learn by relating to common knowledge, and besides, it makes my ports to webassembly easier.

These are, at times, contradictory. For example, the structured concurrency library has a concept of strict ownership of tasks. If a parent task is cancelled, its child tasks are too. Javascript's apis don't do this - you can have a web fetch callback call setTimeout, cancel the fetch, and still have the timeout running. You need to set up the callbacks to clearTimeout yourself when needed. If I wanted to require setTimeout come from an owning task, it is now incompatible with Javascript, and some compromise will have to be made.

One option for task ownership is to use D's existing destructor rules. That is, when an owner goes out of scope, its destructor cancels itself and all its children. Much of this could be done automatically by the language, caveats about destructor and finalizer confusion notwithstanding, but this is incompatible with much of my existing paradigms where it is expected that an async task is going to outlive the lexical scope where destructors are called. But, it is arguably more fiber friendly and might work better with reusing stack buffers too.

Each design choice is likely at odds with one of these goals, to some degree.

Some designs

I've written in this blog before about how an async api would best copy the Windows API when in doubt. This lets you do callbacks and synchronization. See here: http://dpldocs.info/this-week-in-d/Blog.Posted_2022_04_18.html

I used a similar model in arsd.http2, you call a function which returns a request, you can attach callbacks, etc., then send it, then wait for the response object when you want it. I like this quite a bit, even without the full event loop, you can use it - it just one-steps the event loop opportunistically as needed.

I think I'd like to expand this to other functions. But there is an open question: who is responsible for creating those response and request objects? In the Windows API, you pass a pointer to the OVERLAPPED struct. The node.js foundation, libuv, also passes pointers to objects from the outside, which gives you control over how they are allocated.

This leads to a question: if you create the object anyway, why have a free function to read/write? Win32, libuv, and others do because they are used from C, where you don't have constructors and member functions. In D, we do, so we could put the methods right there in the object. arsd.http2 does both: client.request will create and return a request object, but you can also new HttpRequest outside yourself if you wanted to (though you do need to set a client for some features to work, like cookie storage).

We're so used to apis like:

ubyte[N] buffer;
auto ret = read(file, buffer.ptr, buffer.length);

or

ubyte[N] buffer;
Request* req = malloc(Request.sizeof);
auto error = read(file, req, buffer.ptr, buffer.length);
auto response = get_async_result(req);

But there's no reason I can think of why you might not do:

ubyte[N] buffer;
ReadRequest res = new ReadRequest(buffer[], new ResponseHolder());
auto response = res.get_result();

You might not need to pass in the ResponseHolder, since it arguably is best simply being part of the request object. But the buffer being separate I do think is good since then you can more easily reuse it, which is an important consideration for the fiber case to make it fit in the limited stack space.

Anyway, since you need to allocate - on the heap or stack or whatever - a request object anyway in all the async modes anyway, I think member functions make some good sense to use.

BTW, I used new in the examples, but I'd actually be inclined to @disable new(); these objects because having them owned by the GC has some potential pitfalls here. With the callback model, the object may be invisible to the garbage collector while the i/o operating is pending. You can solve this with things like GC.addRoot, but that's easy to forget. Of course, as of this writing, @disable new() also disables scope new, which makes things worse, but there probably does need to be packaged solutions. The exact allocation method isn't terribly important, but the lifetime being correct through the process is. Since this is written primarily as an internal support for my libs though, I can just do it myself by convention, but even by myself, it is nice to have the language helping. I'll come back to this later, but what I'm thinking is wrap it in a helper struct and keep an internal reference count. When it is registered with the OS system, I'll add to the count, when it comes out, I can subtract again. This fairly automatic, and I can document it for other user extensions.

So, the code might look something like this:

void mytask() {
	auto timeout = scoped!Timeout(500.msecs);
	auto buffer = new ubyte[](1024*32);
	auto readRequest = scoped!ReadOperation(file, buffer);

	auto response = waitForFirstToComplete(readRequest, timeout);
	if(response is timeout)
		throw new Exception("read timed out"); // this will cancel the pending read because readRequest's destructor will run
	assert(response is readRequest);
	if(!readRequest.wasSuccessful)
		throw new Exception("read failed");

	auto data = readRequest.filledBuffer();
	// process data here

	// the timeout will be canceled by its destructor upon function return
}

Following on the structured concurrency idea, you might have a task return an aggregate of other tasks, but this would only be possible if they're not strictly owned by the lexical scope. Either they are owned by something passed in or refcounted upon return or similar. That's all doable. You might return a GroupOfConcurrentRequests or something, which is really just an argument list suitable for waitForFirstToComplete. Then, sequential results are represented by the task function, which is run inside a thread or fiber, so you don't necessarily need things like .then or .catch from Javascript promises.

Speaking of the structured concurrency idea, my requests are pretty similar to those "senders". Both have a start method (though you didn't see it here because waitForFirstToComplete, etc., would call it implicitly) and ultimately pass back a value or error. I think making an adapter would be simple, a small, straightforward template ought to do the job.

Integration with other event loops

Another goal would be to let my libs be just as deeply integrated with other things too. I think the way to do that would be a source compatibility later... and idk. deep integration with arbitrary frameworks is a lot of work. But I do want to do something, so maybe I can figure it out, an interface is one option, but I also don't want to get the code too complicated.

What about using other things

I looked at vibe's eventcore and it actually isn't bad overall. But it repeated some of the same mistakes I've made in the past and learned pitfalls from... and I just don't like the code style either, I find it hard to follow. There's also the libuv which does the same concepts but that's a painful C library that makes some decisions I don't entirely agree with, like their sync thread pool... though that is the best way to do it on some platforms, it isn't on all. And I need to keep my other integrations going and idk how to do it here.

Really, writing the loop code isn't that hard. I've done it before. I just want to integrate my existing things better. So better to just take my code and refactor it rather than throw it all out.

Will you actually do any of this?

Well, I still not even sure I'll make the module at all, and the design is especially not solid. I think I like what I described here, but I don't know yet. I have a lot to do and this is going to be a breaking change according to my policy (though most users won't notice - the biggest thing is downloading an additional file, and the dub people won't even notice, though dub ironically will see a semver bump and ignore the new stuff by default. Alas.) so it will need to come with a breaking change cycle. If I do it, it probably won't be mainlined until next year.

In the mean time, helper threads sending messages between independent event loops works pretty well if you are ok with the awkwardness.