childChannelInitializer questions


(Georgios Moschovitis) #1

I am using the bootstrap code from the http example:

        let bootstrap = ServerBootstrap(group: loopGroup)
            .serverChannelOption(ChannelOptions.backlog, value: 256)
            .serverChannelOption(reuseAddrOption, value: 1)
            .childChannelInitializer { channel in
                return channel.pipeline.configureHTTPServerPipeline(withErrorHandling: true).then {
                    return channel.pipeline.add(handler: self.handler)
                }
            }
            .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
            .childChannelOption(reuseAddrOption, value: 1)
            .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1)

I noticed that the closure passed to childChannelInitializer is called on every request. Is the channel-pipeline recreated on every request?

On a related note. Are the handlers added to the pipeline guaranteed to run on a single thread? Can these handlers keep some state? Or should we create new instances of the handlers for each request?

-g.

PS: Apologies if the questions are trivial, I am a newbie here (and worked exclusively on single-threaded languages for too long...)


(Johannes Weiss) #2

Thanks, that's a great question!

The childChannelInitializer is called for every new child channel, ie. once for every new accepted connection. If you only ever have one request per connection (which is pretty inefficient) then sure that would make it one per request.

Regarding the the threading guarantees: Every Channel is bound to exactly one EventLoop which is bound to exactly one thread. In your case however you're reusing self.handler for all accepted Channel which means you will be reusing self.handler in different Channels. And two distinct Channels are definitely not guaranteed to run on one thread. In fact it's very unlikely they run on the same thread unless you use MultiThreadedEventLoopGroup(numberOfThreads: 1). To answer your question: Reusing one instance is only safe if the handler does not hold any state and generally reusing handlers across Channels is discouraged. The recommendation is to instantiate a new handler every time childChannelInitializer is called.

This might sound inefficient but protocols that care about efficiency will always allow multiple requests per connection which usually makes instantiating a new handler per connection a non-issue. With a new connection you'll see at least three roundtrips for a plain TCP connection (without TCP fast-open) and even more when using SSL/TLS which is pretty much a given these days. And those roundtrips are usually a lot slower than the instantiation of a new handler. There might be scenarios where the instantiation of a new handler is a problem and therefore we do allow reusing them today. If you're curious with SO_REUSEPORT (which is different to SO_REUSEADDR) and multiple EventLoopGroups you should be able to construct a NIO program that only allocates a constant number of handlers and yet uses multiple threads. But that's definitely a very power user feature and I'd be very intrigued to learn about the use-case if you think you need to do that.

PS: As usually: If the documentation is lacking information (which is likely) we'd appreciate a PR, a GitHub issue or at least a tweet about what we can improve. This kind of information is super valuable because we core devs live in our NIO bubble and a bunch of things seem natural but in reality they only are because that is literally our job :).


(Helge Heß) #3

NIO actually includes an example for a shared handler, in NIOChatServer.


(Johannes Weiss) #4

Indeed, see this comment on why:

// We need to share the same ChatHandler for all as it keeps track of all
// connected clients. For this ChatHandler MUST be thread-safe!

That ChatHandler is a bit special as it holds shared state, something that's not too common and one of those 'power user features'. It doesn't do it for performance but you're right I should've pointed out that there are situations where you just need to share some state and then a shared handler one easy way to achieve that. But(!) you'll be on your own regarding regarding synchronisation between the different threads accessing the state.


(Georgios Moschovitis) #5

Thank you for the very detailed answer, really appreciated.
I think I got the gist of it.


(Georgios Moschovitis) #6

You mean, I need to handle HTTP keep-alive?


(Johannes Weiss) #7

Yes, for HTTP/1 keep-alive needs to be used to have more than one request per connection.

further potentially irrelevant details following (especially if the client just doesn't do keep-alive in which case there's not much we can do and it'll be 1 connection per request)

But NIO kind of does HTTP keep-alive by default (assuming HTTP 1.1). If you use channel.pipeline.configureHTTPServerPipeline in default configuration you'll even get the HTTPServerPipelineHandler inserted into the pipeline automatically. That handler makes sure you'll get the requests one after another even if the client pipelines them (very unlikely). So if you just don't close the connection, you'll get another HTTPServerRequestPart.head(...), .body(...) ... and .end(...) after the previous connection's .end(...). Obviously that will only happen if the client actually keeps the connection alive after the first request has completed. Does that make sense?

Side note: NIO being fairly low-level I'm glancing over a few details here, as you can see in the NIOHTTP1Server example, the user of NIO is responsible for managing the keep-alive state depending on the Connection: header (and the HTTP version) that the client sent. So in many cases if you want to write an HTTP web app it's advisable to use a framework that sits on top of NIO and deals with the nitty-gritty details.