-
Notifications
You must be signed in to change notification settings - Fork 650
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
NIOAsyncChannel.executeThenClose
can lead to lost writes
#2795
Comments
This is clearly a bug. You have to wait for the write to complete or else you'll violate Structured Concurrency as well the possibility of losing writes. There's no way out of this. Note, you could use an empty There's no fire & forget in Swift Concurrency and we shouldn't invent one. |
I agree with that. I am wondering if we should have two methods |
There's not really room for that. The method can't be synchronous (may need to hop threads to enqueue) and |
Is there any reason this shouldn't use the existing close ratchet functionality we have to default to half-closure? We already know the closure state of the system, and half-closure is supposed to be well-ordered with writes (though it isn't always, that's a bug IMO). |
That should also be the case. But regardless, we need to get the correct error at the correct time. So if you This also hits an interesting difference between Netty & NIO: Netty will not send outbound I/O errors through the (inbound) |
So the downside with this proposal is that naive use of the async/await APIs will be very slow: essentially all writes count as a full I'm not confident that the cure isn't worse than the disease here. |
The problem with that is that it only works in half closure cases. If you want to do a
The above has the same problem that any intermediate handler could buffer the write and the flush and then we just lose the write because we did a hard close at the end.
I agree with this though. Making every single write require a promise and a flush is going to make the async interfaces incredibly costly. The only thing that I could come up with so far that addresses both the performance + the guarantee that the sys call to write happened is making it async all the way down i.e. #2648 |
I don't think that's accurate either. If you do If, however, you do |
While we're here, merely guaranteeing that the write syscall happened is still not enough. If If you write, and then want to wait for the bytes to go out, the only supported mode is |
Right, different proposal then. What if we make |
Not really enough either. In the case where the local entity begins the shutdown, merely having We can also rely on cancellation to override theabove "don't lose data" pattern to upgrade it to "shut up and drop the damn thing", though [robably we don't want to wait forever, some amount of "graceful shutdown timeout" should happen, and will need to be configurable. Ultimately, the original design of NIOAsyncChannel aimed to make half-closure the default behaviour, and we should continue to aim for it as it's the only thing that prevents this kind of issue. |
Sure, I understand. But we would still communicate everything the kernel tells us. Yes, that doesn't mean the other side has received it but as least we didn't eat up a return value we could've provided.
Yes, half-closure is great but that doesn't imply full fire and forget mode |
Right, but that's my argument. Back when NIOAsyncChannel used deinit-based cleanup, it would do half-closure more-or-less by default. |
I think we have two orthogonal but related points here.
For 1. we have a solution in mind but 2. is difficult without creating a promise for every write. However, even in pure NIO we rarely create promises for writes and instead rely on the error coming through |
I don't think there can be a correct solution without a promise for every write. Why don't we want a promise for every write? Swift Concurrency's model forces us into doing this and one extra allocation isn't killing anybody here. We'll be doing a syscall anyway which is orders of magnitude more expensive. For multi-write things where the allocation would indeed suck we can find different options. |
How do we handle the inefficient write pattern caused by promise on every write? |
A syscall will come in at ~10us, an allocation will come in at maybe 50ns in the p50 case (so almost 3 orders of mag difference). Given that almost every write will cause a syscall anyway, I think that's okay. If you have multiple things to send I assume we do or will provide a batch-writing API anyway, right? And if one thing to write gets split into multiple writes by lower-level handlers like the HTTP encoder you won't suffer. So yes, a HTTP response will be +1 allocations in total but I really think that's okay. The model forces us into that. |
Yes, we do.
Sure, but a HTTP response shouldn't be just one write. The body has to be streamed, and so it's n writes. Yes, for users who write their programs carefully the cost of this can be mitigated, but for those who don't this will get pretty gnarly pretty fast. |
I am specifically thinking about proxy use-cases. They would consume an inbound async sequence containing the individual body parts and then write them out one by one. Since async sequences are currently not capable of batching elements it will lead to single writes. |
I mean sure, but I don't think that's an argument why we should do something incorrect. If you need the highest possible performance, you'll drop down anyway. Currently, the proxy use case will anyway be slow because of the thread hops which also cost ~3 orders of magnitude more than that single promise. I really don't think this argument holds. |
The thread hops can be completely avoided with Swift 6 and |
It's getting better, yes but I've yet to see an example where this fully works.
Again, allocations are almost 3 orders of magnitude cheaper than hops. In other words, you can probably allocate 1,000 times to make up a thread hop. Bottom line: I don't see why we're arguing that saving ~50ns is worth doing the wrong thing. Especially given that 99% of the time we're doing a syscall after. |
I think it does. I believe all three of us in this conversation believe the thread hops are a resolvable issue, either with task executors or by taking over the global executor. Our stated goal is to get to a place where As for "doing something incorrect", I don't think it's easy to see how this is any more incorrect than what NIO programs tend to do by default. The overwhelming majority of NIO programs don't attach promises to their writes, instead allowing the general error handling pattern to pick them up and terminate. Certainly the ones that care about maximum performance do. A more useful framing of this conversation might be the flipping of a default. The original API of NIO defaults to vector writes: So maybe a more radical redesign is worth considering. How about we remove |
IMO the fact that currently the Assuming we promote the batch writes API more. Are we in agreement that this API should be backed by a promise for the last write for correctness? If we are then that also means scalar writes should be backed by a promise. In the end, getting correctness is the most important piece here. Currently, it is too easy to drop writes on the ground even though the code looks like you did the right thing. |
Do we? We already have intermediate storage in the async writer. I think it is very possible to produce an implementation of this API that does not require temporary storage, though we may choose to use some anyway.
Yes.
I don't dispute this, but I do think having scalar writes be easier and more natural than vector writes makes them an attractive nuisance. |
Yes, but NIO isn't structured concurrency. It has fire&forget and it has do&(a)wait. Swift Concurrency doesn't have fire & forget so this is incorrect in my view.
I see an argument for adding a
Also an idea yeah |
I think I see what you mean. It would require us to take the lock for each
Probably a minor point but how are we going to keep scalar writes from not being as attractive. In the end doing |
I would be happy with a Or maybe outbound.enqueue(.head(response.head))
for try await buffer in response.body {
outbound.enqueue(.body(buffer))
}
try await outbound.write(.end(nil)) Where the promise generated for the write at the end is enough for me to know the rest of the request has been written. |
I mean...that's the point 😉. Sounds like it's working as designed! You're right though: making a nice scalar API will almost always make it nicer than a batched API. I'm suggesting, though not necessarily very strongly, that we should at least consider simply not doing that. To @adam-fowler's point, I'm a touch nervous about |
I'd be happy with batched or batchedWrite, it aligns with similar APIs elsewhere. |
I just want to point out that the following code that one might come up with is super prone to the So the batched APIs do push developers to think about batches but they also might provide a foot gun when they start to stream inside a single batch.
|
Indeed! To me, the nicest property about Swift Structured Concurrency is that it's one of the very few concurrency systems that makes it hard to build up unbounded queues by accident. Non-reentrant actors for example make it nice & easy to program a complex state flow without having to use an explicit state machine. But their mailboxes are essentially unbounded queues as the This however hinges on one important requirement (which I don't think is as widely known as it should be) that you must never have a loop around If you follow this principle and your code uses Structured Concurrency, then you profit:
|
Well, Structured Concurrency doesn't mean "your code is bug free" or "your code is free of unbounded queues". It means that your resources follow the structure of your code. So yes, the code you wrote is not ideal but under Structured Concurrency you could at least use the standard tools ( If you forget a |
Well is it in the above? This surely includes an unbounded queue in the
|
What I wrote was
Note the doesn't. But it does allow you to find the culprit because you have a "guarantee" (if everybody plays by the Structured Concurrency rules which this code does) that the offending piece of code is still "on (async) stack". |
When using the
NIOAsyncChannel.executeThenClose
it is easy to lose writes. The following code is prone to thisThe problem here is that while
write
isasync
it doesn't wait for the write to actually hit the socket. We did this to avoid allocating a promise for every single write. However, this also means that the write might be buffered in the pipeline or the channel itself. Once we return from the closure ofexecuteThenClose
we are callingchannel.close()
which leads to a forceful closure of the channel. This means that any potentially buffered write might get dropped.Even when using outbound half closure this can happen since
outbound.finish
is not async and not waiting for the half closure to be written out.A few options that I thought about so far:
The text was updated successfully, but these errors were encountered: