Skip to content
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

Cross Thread Access #4

Open
Aidan63 opened this issue Apr 4, 2024 · 3 comments
Open

Cross Thread Access #4

Aidan63 opened this issue Apr 4, 2024 · 3 comments

Comments

@Aidan63
Copy link
Owner

Aidan63 commented Apr 4, 2024

Take the following contrived example, the user opens a file and then spins up a new thread (for some reason) to perform a write on it.

FileSystem.openFile("foo.txt", Append, (file, error) -> {
	Thread.createWithEventLoop(() -> {
		final text = "lorem ipsum";
		final data = Bytes.ofString(text);

		file.write(data, 0, data.length, (_, _) -> {});
	});
});

Might seem a bit odd given that async functions are supposed to avoid dealing with thread manually, but nothing seems wrong with this, with the exception that libuv doesn't allow it.
Libuv is not a thread safe API, you can only access handles on the thread they were created on which means the above sample won't work. I think there are probably two main things we could do here.

  • Store the thread the asys object was created on, any calls made from different threads queue up the work to be performed on that original thread and then shuttle the callback onto the calling thread event loop with a promise function. The one edge case with this is where the original thread has exited, In this situation queuing up work on the exited thread will never be executed and you wouldn't even get an error callback (unless we did a bunch of book keeping to know which threads have exited).
  • The alternative is to again store the original thread but return some sort of InvalidThreadAccessException to the callback if called from a different thread. This is much easier to implement but would probably seem a bit odd to most users. This limitation is also imposed by libuv and may not be true if other implementations are not libuv based, but they would still have to implement this behaviour as it seems too big to be left as target defined.
@Aidan63
Copy link
Owner Author

Aidan63 commented Apr 4, 2024

A decision on this might also be influenced by how haxe coroutines are going to handle suspendable functions potentially changing which thread a coroutine would continue on.

@Aidan63
Copy link
Owner Author

Aidan63 commented Sep 14, 2024

Had an idea the other day which might solve several questions around threading, lifetimes, and some coroutine stuff.

I've been operating on a 1 haxe thread, 1 libuv loop principle. But what if there was a dedicated background thread which just ran the libuv loop and which all asys api calls were serialised onto. I think this approach solves several problems.

Cross thread usage. You no longer have the limitation of asys objects only being usable on the thread they were created on. Passing asys objects around threads might seem odd but given that there as been a fair bit of discussion about coroutine scheduling, it might be possible for coroutines to resume on a threadpool which means asys objects being usable across threads is important and this approach would allow that.

Resouce management. A single libuv thread owned by the runtime makes object lifetimes much easier. If close hasn't been called by the time the asys object get finalised, it's easy to schedule the close on dedicated the libuv thread. You no longer have to worry about tracking haxe threads, which objects were created on them, is that thread still alive, etc, etc.

Not tied to the thread event loop. This would also free asys from the thread event loop and would allow a much easier blocking start implementation in coroutines. Basing this off my recent coroutine experiments and assuming the coroutine asys wrappers shuttles the callbacks through the coroutine scheduler, start could create its own EventLoop and pump that between checking for results. This way its true blocking, instead of the thread event loop pumping we were thinking of before which could cause other code to be executed.

There are potentially some downsides though. This approach would probably work fine for hxcpp and hl where they have their own runtime, but if another target which doesn't really implement its own runtime and wants to use libuv for its implementation, that might make things a bit more difficult.

This single thread could become a bottle neck as work will have to be placed in some sort of thread safe collection which can be picked up by the libuv thread to process. This might not end up being a problem under normal conditions though.

@Aidan63
Copy link
Owner Author

Aidan63 commented Sep 17, 2024

This is probably more coroutine related than asys but it's a follow on from the above comment so I'm putting it here.

Played around with the global libuv loop idea and it seems to work well, converted a very small subset of my asys stuff to use it (file open, write, and close). A coroutine implementation of openFile based on my coroutine branch would look like this.

@:coroutine public static function openFile<T>(path:FilePath, flag:FileOpenFlag<T>):T {
	if (path == null) {
		throw new ArgumentException('path', 'path was null');
	}

	return Coroutine.suspend(cont -> {
		cpp.asys.File.open(
			path,
			cast flag,
			file -> cont.resume(@:privateAccess new File(file), null),
			error -> cont.resumt(null, new FsException(error, path)))
	});
}

The cpp.asys.File.open call queues up work on the libuv thread and the callbacks are also performed on that thread. The call to the continuations resume which goes through the current scheduler will move execution back onto whatever the scheduler is implemented to do, that could be an event loop, thread pool, or something else.

This makes implementing a blocking coroutine start much more fesible. previously we were thinking we'd need to pump the threads main event loop, but with this setup we wouldn't need to. We can create a new sys.thread.EventLoop, setup a scheduler to execute functions on that event loop, and then pump just that event loop. Only work from the coroutine will be executed, not anything else which happens to also use the threads event loop.

// possible `start` implementation.
final loop    = new EventLoop();
final blocker = new WaitingCompletion(loop, new EventLoopScheduler(loop));
final result  = switch myCoroutine(blocker) {
	case Suspended:
		// wait will pump the provided event loop until its `resume` is called indicating the coroutine has completed.
		blocker.wait();
	case Success(v):
		v;
	case Error(exn):
		throw exn;
}

An enhancement / slight alternative to this global loop which might help libuv integration with other targets could be to have some sort of AsysContext type which is created by the initial coroutine and stored in the context. Suspending functions can then access that context which would manage a libuv loop / do whatever target specific stuff is needed without requiring a global thread.
You would start to reintroduce some unsupported operations though, e.g. not being able to use opened files across two different coroutine start calls as they would each have a separate context and therefore libuv loop. Although this might be an acceptable limitation.

I've created a new branch in both my hxcpp fork and hxcpp_asys repo with this small global loop test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant