async server#106
Conversation
There was a problem hiding this comment.
It would be cool to know if you could explain what you don't like about #96 that you propose a different implementation. Anything in particular?
But I'm fine to close #96, looks like there is no interest in taking it anyways :-)
But wrt this imp, I see a few issues:
a) apparently handlers cannot actually be async?
b) accept congestion
c) no back pressure support (we can as well drop it from the API then)
d) quite a lot of copying of buffers
e) no pipelining?
a, c & e are probably the worst parts. I guess b) is not cool but you could probably live with in a simple baseline framework, but d) at least needs to suspend parsing until the first request has been processed (otherwise you get interleaved response output?).
But as far as I see it, a) is completely unsupported aka this is not an async implementation of the API? Probably I'm missing something? How do you do this standard async server example with this implementation?: https://github.com/ZeeZide/http-testserver/blob/master/Sources/http-testserver/main.swift#L111
There seems to be no way to dispatch back to the response writer queue?
| } | ||
| let server = TCPServer(socket: socket, eventLoops: eventLoops) | ||
| print("Starting on http://localhost:\(options.port)/") | ||
| try server.start(hostname: "0.0.0.0", port: UInt16(options.port), backlog: 4096) { client in |
There was a problem hiding this comment.
I mean you use a (unreasonably?) huge backlog, but this seems to use a single accept queue. Since accept can take a moment (TCP handshake and all that crap, let alone a TLS driver ;-) ) and is a blocking call, I wonder whether this is viable.
There was a problem hiding this comment.
we use O_NONBLOCK so the accept should be nonblocking.
There was a problem hiding this comment.
This is of course completely right. 🤦♀️ :-)
| server.stop() | ||
| extension TCPClient: ParserConnecting { | ||
| func queueSocketWrite(_ from: Data, completion: @escaping (Result) -> Void) { | ||
| write(from) |
There was a problem hiding this comment.
Unless write is synchronous (contrary to the goal here), you cannot call completion until the call actually did complete. Otherwise you can't maintain back pressure and you are just overflowing the write buffer until no memory is left?
There was a problem hiding this comment.
shouldn't be too hard to add that :)
There was a problem hiding this comment.
Yeah, that probably isn't hard by itself (just queue them up and call them when the writes are done). The hard part (IMO) are the synchronization points for the closures in question as discussed on list. Hence the new queue argument to the handler (you can make it free threaded, but that would be pretty expensive).
Also, I'm not 100% but I think you may have the same issue on the reading side? (you need to pause reading until the bodyProcessor calls the done CB, e.g. to make this work reliably and at scale: https://github.com/ZeeZide/http-testserver/blob/master/Sources/http-testserver/main.swift#L61) Right?
There was a problem hiding this comment.
Yeah I think you're right. We should definitely be passing the DispatchQueue around w/ the request and response writer.
| try server.start(port: options.port, handler: handler) | ||
| let socket = try TCPSocket(isNonBlocking: true, shouldReuseAddress: true) | ||
| var eventLoops: [DispatchQueue] = [] | ||
| for i in 1...8 { |
There was a problem hiding this comment.
there are quite a few places i used magic numbers while porting this over. (like the accept queue, and read size as well) those can be easily fixed if people like this
| try server.start(hostname: "0.0.0.0", port: UInt16(options.port), backlog: 4096) { client in | ||
| self.connectionCount += 1 | ||
| client.onClose = { | ||
| self.connectionCount -= 1 |
There was a problem hiding this comment.
Isn't this running in a different thread and needs an atomic guard? 🤔
There was a problem hiding this comment.
Ah yes. That should be dispatched async to the server's queue. What is the connection count used for btw? Do we need that?
There was a problem hiding this comment.
Yeah, I think a count is pretty useless. A connection list could be useful for debugging.
|
So what about
Even ignoring the pipelining issue (aka multiple handlers running on the same connection), as far as I can see neither |
| let buffer = UnsafeBufferPointer<UInt8>(start: pointer, count: data.count) | ||
|
|
||
| do { | ||
| let length = try self.socket.write(from: buffer) |
There was a problem hiding this comment.
I think you have a low-hanging fruit here. Instead of doing individual writes, use writev w/ the array of buffers.
Each client/connection gets its own StreamingParser which in turn is only ever used on that client's queue. There would be problems here if the request handler starts dispatching writes on other queues, but we can remedy that by passing the queue into the handler and requiring that the response writer be called on it as you mentioned. Since the StreamingParser is dedicated to a connection (and thus a dispatch queue), there should be no problems there with the pipeline as far as I can tell. That seems like something that would have cropped up in the |
Yet that is the only way you can do anything asynchronous in your current setup, dispatch to a different queue? :-) But yes, either adding the queue to the handler (and requiring API calls on them) or guarding the API methods themselves is the solution to this.
The queue doesn't help you here because the handlers are asynchronous (their execution can interleave on the same queue). There are multiple solutions to this, but I think your current thing will just produce corrupted and interleaved responses. In #96 I'm 'corking' response-writers which are not ready yet, another (non-perfect, but okay'ish) option is to essentially disable pipelining by pausing the parser until an in-flight response is done. (in case this isn't obvious - in the original version this was a non-issue because it was purely synchronous, but that setup isn't suitable here)
You didn't show those, are they doing anything asynchronously like the sample I've send? If you are not running an actual async test, you are not going to run into async issues :-) Also, is wrk actually using pipelining here? Probably not, it is trying to measure the requests themselves, but maybe it has an option for that. But this is a good point, we need actual async tests to test the scalability. Like simulating a database call before returning the response (e.g. using a timer, or a background concurrent queue w/ actual sleeps()), or proxying large requests (would be nice to check out memory consumption). |
Oh wow you're right. TBH I am very surprised to learn that HTTP/1.1 supports pipelining. Looks like very few people actually support it, but obviously Swift Server should. I will take a look at your solution in #96 when I have some time! |
|
Note that I do not suggest that we have to actually support pipelining. As you say it is not usually enabled in web browsers, though it does have some real world relevance with HTTP APIs (and maybe proxies). The proper fix is HTTP/2, though this would still need a pretty similar decoupling to what I do in #96. But what we should not do is fall apart if a client does pipelining. That should at least suspend the read end of the socket until the active handler is done. I think that would be quite acceptable. |
|
Adding back pressure support here could potentially kill two birds with one stone. With back pressure, the streaming parser could wait to accept additional input until it has completed serializing the response. |
|
Closing as this project is historical, per the README. |
An alternative to #96. I ported over Vapor 3's async server.
Performance isn't terrible, but also not great. About the same as the blocking implementation, although that's expected. We've seen >100k req/s in Vapor 3 (even including router, middleware, etc). This could be due to some inefficiencies in the HTTP parsing/serialization or in how I hooked things up.
The keep-alive test wasn't passing (couldn't really figure out what was going on there). It also seems like the streaming parser has some locks in it which will probably need to be removed.
Ideally all of this will be done much better when the Transport module comes out, but perhaps this will at least be a better option than the current blocking server. (Also weighs in at slightly less code)