-
Notifications
You must be signed in to change notification settings - Fork 81
async: thread-safe schedule() #218
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
base: main
Are you sure you want to change the base?
Conversation
e327e07 to
e1c9191
Compare
53f60c3 to
4a95650
Compare
|
I see it now.
If it's guaranteed that all tasks run on the main thread, I don't think it's dangerous. This change only allows scheduling from other threads. It's not uncommon that libraries start their own helper threads, for instance. async-compat starts a transparent tokio runtime in a thread for IO completion handlers, while still using our executor for the tasks. I also can image situations where you'd want to start non-IO compute in a thread pool to not block nginx - in our case, for example, crypto. You'd want to be able to notify the request handler async task of completion by writing to a channel or a similar mechanism. This, in turn, would call the waker from that thread (AFAIK), which calls schedule for the task from that thread, but the woken task would be scheduled to run on the main thread via ngx_notify.
We need to work with the request heavily (mutate headers_in and headers_out, read client bodies, produce response bodies) in response to I/O (external requests, database queries, custom crypto/tunneling), which can only be done on the main thread safely. If all our code is running in a completely separate engine, it all becomes extremely hard. In addition, we need a way to interrupt nginx' epoll reacting I/O events, which aren't all bound to a request (OpenID shared signals, e.g.).
I don't think it would do that. If the waker is invoked from the main thread, schedule in my branch would simply .run() the runnable, and everything stays on the main thread. ngx_notify would not be called (except once during the lifetime of a worker process because it's not known which tid is main). I have to admit I didn't test with nginx-acme yet though. To recap, I'd still like the following:
Given ngx_epoll_module.c:769, ngx_notify from other threads is indeed inherently unsafe. However, what if we do this:
Would this work for you? |
7a736f8 to
35f97ff
Compare
|
@bavshin-f5 I've rewritten the code to not rely on ngx_notify. Instead, I'm using ngx_post_event, followed by pthread_kill(main_thread, SIGIO) as I had a hard time getting the notify_fd from within ngx-rust. Does that address your concern? |
Ah. I got why you assume that this is safe. I don't believe it is, and I expect that some of your code is quietly being scheduled on a tokio executor in another thread. The only approach I would consider safe is where nothing owned by a request or a cycle pool is allowed to move to another runtime, either accidentally or intentionally. Many things we do are lacking such protection because we assume single-threaded environment. |
I don't claim to fully understand it, but they state: "Otherwise, a new single-threaded runtime will be created on demand. That does not mean the future is polled by the tokio runtime ." The tokio runtime could spawn their own tasks into that runtime, sure. e.g some kind of helper task. But I don't see how my task could end up there. If my tasks Runnable.schedule() arrange it to be scheduled on the event thread, which is precisely what my PR does, it will run just there. I'm not an expert, but I think what happens is this:
This is what I see right now, using the code from the PR. This is also what I'd expect to happen with a "sidecar"-tokio-runtime that I started myself (no async-compat). |
|
I just pushed an experiment with a sidecar tokio runtime and added tid debug logging here: https://github.com/pschyska/ngx-rust/blob/a5ff1bb0cc3e6d5bb15f46e24348a1d2fa694f18/examples/async.rs#L115 This supports my theory: my task is never moved to the tokio runtime. It calls schedule from its own threads though - when using tokio::spawn from the thread of the runtime (494047), when awaiting I've also pushed a change to main to switch to kill and nginx_thread_tid. It works fine also. |
I just had another idea that helped me visualize this: If Futures !Send could move executors at will, it would be able for them to end up in an executor that requires Send (and/or Sync). E.g.: if the "part-2" future of my task, after awaiting a future from a tokio runtime, would magically run in a tokio executor using threads somehow, it would have to be Send. But If I used e.g. async_task::spawn_local, it could be just 'static. The compiler would not compile that code. (of course, crucial parts of an executor are unsafe, but this would still make this behaviour wildly illegal in Rust). I don't know of any method of making a task move executors. If wanted to connect futures of different executors beyond their output for some reason (e.g. to be able to cancel the other task), I would use a remote_handle. But AFAIK this doesn't change the Context (which ties back to schedule() and task), but establishes an oneshot between the tasks. We could use spawn_local instead of spawn_unchecked (which would store Rust's thread id and check that it is the same on .run()), but this is unnecessary overhead in this case, it simply can't happen. The example code I wrote which leads to waking from other threads all the time still runs fine with spawn_unchecked. Another angle on this - the spawn_unchecked docs state: Safety
I think I have now fully convinced myself, let me know if this helps to convince you as well 🙂 |
|
@bavshin-f5 Did you have a chance to take a look? |
f7fdbeb to
ce8ba80
Compare
schedule() can now be called from any thread, but will move tasks to the event loop thread. pthread_kill(main_thread, SIGIO) is used to ensure prompt reponse if needed. This enables receiving I/O notification from "sidecar runtimes" like async-compat, for instance. The async example has been rewritten to use async_::spawn, demonstrating usage of reqwest and hyper clients wrapped in Compat to provide a tokio runtime environment while using the async_ Scheduler as executor.
…ionally, e.g. in detached tasks)
1f53296 to
c8b35bc
Compare
|
@bavshin-f5 I've addressed your concerns in the updated summary. I also added an eventfd-based approach as an alternative to the SIGIO method, behind optional feature async_eventfd, requiring ngx_feature = "have_eventfd". Both approaches fix the UB, and work well with the updated async example, and in our project. Cheers, |
|
@pschyska I'm still very much not interested in allowing use of threads in the library code. I'm convinced that it is not possible to make such interface safe for use within nginx and general enough, so I would prioritize single-threaded scenarios. No threads — no problems. I would accept certain independent building blocks that allow making thread-aware scheduler in your module easier, such as a good notify implementation. The one in this PR is unfortunately not good enough: sigio implementation is dangerously unsafe (note that Given the list of platforms we care about, the prospective notify implementation must support I don't have enough time to cover everything, but I still want to address some points:
To reiterate: any library that assumes tokio environment is able to spawn tasks in another thread and move anything |
What makes ngx_post_event not atomic? It looks like it does this: Is the ngx_queue_insert_tail call alone atomic? We could allocate a fresh ngx_event_t each time instead of using a static one to post, if the issue is the posted=1. I had this variant in one of my earlier commits (force-pushed over by now...) and it worked without issues for me also.
In my mind Futures poll ready, until they can't because something external needs to happen, i.e. a series of run()s, at which point they install the waker and suspend. Then the external thing happens, calls .schedule() once and the state machine keeps moving again. A future must deliberately suspend in a state its not Pending for it to immediately run again. I actually test this in the updated example: yield_now() produces such a state. The example does this 1000 times, and the async code logs that it had processed 1001 items afterwards (because the channel batches this). Can you share the snippet of your future that hits this case? What makes you think this is expensive memory-wise? The Runnable should be a light object, containing just a pointer to the Future, to serve as a token to schedule to poll it again. But anyways, this is besides the point, as it is not a difference between the old and new behavior when scheduled on the main thread (e.g. PeerConnection future): Old; not woken while running
Old, woken while running:
New; not woken while running
New; woken while running
The only difference is self.queue.push_back vs self.tx.send(runnable), which are roughly equivalent, and an additional ngx_thread_tid() == MAIN_TID.load(Ordering::Relaxed), which shouldn't break the bank.
I think this is a misunderstanding: I does not spawn Tasks using tokio. The only Tasks spawned are in the ngx-rust executor. What it does is that it starts background helper threads for I/O, that might schedule() Tasks on our executor, hence the importance of schedule() being thread-safe. To get a Task on the tokio runtime, you have to call tokio::spawn(). We actually do this in our product, and Send is precisely the stop the language uses to prevent you from referring to e.g. &mut Request or ngx_http_request_t, because this makes the Future !Send. You could use tokio::spawn_local, which uses a thread-local scheduler, as this does not schedule to other threads. It would still be safe to use main-thread owned data, as we stay on the main thread. spawn_local doesn't require Send. We don't have to force Send for ngx_http_request_t ever. What we have to unsafely assert is that it is 'static, but this is an orthogonal concern entirely. The language just works here, but the current Scheduler breaks this by allowing run() to move threads. In our product, there is a way to establish an encrypted channel on top of tls using custom cryptography to implement a healthcare system requirement. After a 2 roundtrip handshake, you can encrypt a raw HTTP request and send it through the tunnel. Our nginx module decrypts it and unwraps it, and sends the inner request to itself. It then awaits the response, encrypts it and sends it back.
As you can see, there are dozens of async calls of different types for one "round-trip", and I don't believe it could have been done without a thread-safe scheduler. This holds for both the SIGIO and the eventfd based notification method, so while the SIGIO approach might be technically unsafe as you say, the problems doesn't appear in this test. Interestingly, the eventfd-based approach doesn't seem to be faster. Both notification methods work flawlessly and the bottleneck is somewhere else (almost certainly the cryptography). So I'm pretty confident that this can be made to work, and I experienced many surprises, ie. segfaults, along the way. Hence the rapid iterations on this PR 🙂 , but this is at least very close, in my estimation. I can't share the full code we are working on yet, unfortunately, but it will be published to https://github.com/gematik/zeta-guard-ngx-pep soon. Right now the repo only contains an early version of the access phase stuff, but this already broke with the current Scheduler, and was lacking a notify method leading requests to hang indefinitely until nginx gets woken up by something else happening. The code doesn't have to live in ngx-rust though, it could be an add-on crate. I just thought it would be a good addition to the core, because in my opinion, in the current state, spawn() is extremely dangerous to use for newcomers. We tried to use it as soon as we hit the first use-case where we didn't want to block the main thread, and ran into hard to debug segfaults because our task was moved to some helper thread by the current Scheduler. In our case it was via async-compat, but it will happen any time any async code, e.g. from libraries, relies on the fact that Schedulers must be thread-safe and calls schedule() from some helper thread. As Scheduler must be Send (and invalidly asserted to be by your Scheduler right now), this is a problem. And of course it would be better if any I/O primitive could be natively implemented with nginx primitives, but we don't have that, so I don't share your opinion that threads are bad per-se. Even nginx uses them sometimes to unblock things, and the native Future approach is only feasible if the kind of I/O can be represented with nginx primitives and callbacks in the first place, and helper threads aren't as scary in Rust as in C as it is a safe language. So,
|
... No, of course it is not. Modification of a doubly linked list cannot be atomic. It's two pointers and two memory write operations.
I'm afraid I'm not allowed to. But Rust's async is essentially a cooperative multitasking implementation. If you let a single task to run for a while without interruptions, it will start affecting other tasks. In a very degraded case, it may lead to accept queue overflow and loss of incoming connections. That is why certain tasks, such as script or bytecode interpreters, must limit the uninterrupted execution time and yield control periodically. And the time quantum allocated here directly affects the responsiveness of the server.
We want to have zero allocations on this path. Or one, but fallible with correct handling. The current implementation of task queuing is suboptimal and temporary.
Even if you don't,
I need to remind you that
Hm... This step actually looks suspicious. If you had to do this, you seem to have an issue with lock scope and contention. Offloading the locking to a thread only adds increased latency to the list of issues. Our SSL session cache is shared between worker processes and protected with a spinlock, and it's not a bottleneck.
A good addition would be to ensure that the existing Sometimes I wish we could just mark this project as explicitly incompatible with
And that's exactly how we know that threads are bad, not compatible with nginx architecture and should be avoided if possible :( We are (slowly) working on the native async wrappers for sockets, subrequests, request bodies etc, which hopefully should address some of the usability concerns you raise. |
Proposed changes
As mentioned in #110, my work on making Scheduler.schedule() thread-safe.
1. Problem statement
In
ngx-rust, the async executor is built on async-task.async-taskhas strict safety requirements:The
Schedulermust either:Send + Sync, orRunnable::run()calls,Wakerinvocations,Wakerdropshappen on the same thread.
The previous implementation violated this.
Previous behaviour
The Scheduler was declared:
schedule()could be called from any thread:Waker.And critically,
schedule()would sometimes callrunnable.run()directly on that foreign thread.That breaks both async-task’s and nginx’s assumptions:
ngx_http_request_tare explicitly non-Send and must stay on the event loop thread,This is undefined behaviour. In practice we observed:
This is not a theoretical concern; it’s a real safety bug.
2. What this PR changes
The fix is intentionally minimal and local to the executor, and keeps existing APIs intact.
The performance impact on Futures that are implemented in terms of native nginx I/O, like
nginx-acme's PeerConnection
is low: as the Wakers are called from the event loop thread, we are still in the
fast-path and simply .run() them, as before. The only visible change is checking the ngx_thread_tid,
which should be cheap.
2.1. Scheduler design
The new design introduces:
a global
Schedulerwith acrossbeam_channelqueue:a
MAIN_TID: AtomicI64which stores the nginx event thread id the first time the event handler runs:a
schedule()function that chooses between:Key properties:
Only the nginx event thread ever calls
Runnable::run():schedule()is called on the event thread and this is not awoken_while_runningre-entrant wakeup, we run inline.NOTE: the reentrant case is mostly pathological (e.g. a future that call yield_now() repeatedly,
and this is just to prevent a stack overflow — a future schedule()'ing itself on the main thread will promptly .run() itself in a new stack frame otherwise.
async_handler(on the event thread) drain the queue and run tasks.When
schedule()is called from foreign threads, they only:notify()to wake the event loop.This PR contains two implementation of notify(): a kill(pid, SIGIO) approach for
compatibility, and an epoll-with-eventfd specific approach in the spirit of ngx_notify
without actually using that — as
ngx_notify clobbers the static notify_event.data
and ngx_threads call that outside of any lock,
any other usage — "nginx internal" or not — would break that.
The eventfd-based mode is behind the optional feature "async_eventfd", which requires
cfg
ngx_feature = "have_eventfd", and will be more efficient by integrating moretightly with nginx' epoll loop, but the safety invariant is the same in both modes.
This satisfies async-task’s contract:
Blanket
unsafe impl {Send,Sync} … {}are avoided, because this led to the previousScheduler UB (unintentionally moving threads) to be unnoticed.
3. About
async_compatand “moving runtimes”The concern was that using
async_compatmight cause tasks to “run on tokio without us noticing”.This design makes that impossible.
ngx_rust::async_. It owns and polls all tasks.async_compatis only used so that tokio-based I/O types can be awaited.Waker,Wakercalls ourschedule()function,schedule()either:notify()(foreign thread / re-entrant case).Tokio never:
ngx_http_request_tto its own threads.The only cross-thread boundary is the wakeup itself, which this PR makes strictly enqueue-and-notify, never run.
4. Why this matters
Without this PR:
async-task’s safety guarantees are violated.With this PR:
schedule()is thread-safe and main-thread-affine.async-task’s invariants are respected.TL;DR
notify()to hop from foreign threads to nginx’s event loop,eventfdfor wakeups instead of SIGIO,Runnable::run()on the nginx event thread (with an inline fast path when already there).Checklist
Before creating a PR, run through this checklist and mark each as complete.