Swift-NIO thread-safety between channel handlers

I share a session object among multiple channel handlers. Are thread-safe operations required for the object?

1 Like

Yes, as all of those channels are operating on their own threads. You can see in my PacketHandler type (and probably some NIO examples too) that access to shared data needs to be behind locks. Luckily NIO has the NIOConcurrencyHelpers package to make that a bit easier.

Hi Jon, thank you for your reply and sample code. I'm a little confused about why we need a lock within the ChannelHandler in your example. clientIDsToChannels is a private property to PacketHandler right? I thought all the ChannelHandler 's events are called on the same EventLoop.

func handleConnect(_ connect: Connect, in context: ChannelHandlerContext) {
    // TODO: Store all relevant properties, not just clientID
    let channel = context.channel
    Logger.log("Writing to activeChannels for clientID: \(connect.clientID)")
    protector.withLock {
        self.clientIDsToChannels[connect.clientID] = channel
        self.channelsToClientIDs[ObjectIdentifier(channel)] = connect.clientID
        precondition(self.clientIDsToChannels.count == self.channelsToClientIDs.count)
    }
    let acknowledgement = ConnectAcknowledgement(response: .accepted, isSessionPresent: false)
    Logger.log("Sending ConnectAcknowledgement for clientID: \(connect.clientID)")
    context.writeAndFlush(wrapOutbound(.connectAcknowledgement(acknowledgement))).whenComplete { _ in
        Logger.log("Sent ConnectAcknowledgement for clientID: \(connect.clientID)")
    }
}

In this example from SwiftNIO-HTTP2, the HTTP2StreamMultiplexer doesn't use any lock. I assume that you shared the same PacketHandler in multiple ChannelPipelines? Are we supposed to use ChannelHandler in different Channels?

My PacketHandler is also where my session state lives. It keeps track of all ongoing connections, not just the state for one, so it's added to the channels somewhat differently, as you can see in my DragonflyServer class that sets up the channel handlers:

static func bootstrapServer(enableLogging: Bool) -> (group: MultiThreadedEventLoopGroup, bootstrap: ServerBootstrap) {
    let sharedPacketHandler = PacketHandler()
    let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
    let bootstrap = ServerBootstrap(group: group)
        .serverChannelOption(ChannelOptions.backlog, value: 256)
        .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
        .childChannelInitializer { channel in
            creatHandlers(for: channel, packetHandler: sharedPacketHandler, enableLogging: enableLogging)
        }
        .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
        .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
        .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16)
        .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator())
    
    return (group: group, bootstrap: bootstrap)
}

As you can see, I have one instance of PacketHandler that is shared amongst all connections. Since these connections are handled in parallel, anything shared between them must be thread-safe. (Note that some of this setup may be outdated, as I haven't updated it since June, and there have been some changes to NIO that make some of it unnecessary.)

Whether it's great design to have my session state inside an ChannelInboundHandler, I'm not sure, I'll let a NIO expert weight in here, but it is how the ChatServer example works as well.

Thank you so much for the detailed explanation! Also, I was wondering if there is a preference of using Atomic/Lock over Dispatch . I see the ChatServer example uses Dispatch .

1 Like

Lock is much more performant and I didn’t want to rely on Dispatch in general.

2 Likes

First of all, thanks for your question, please keep those coming. Exactly as @Jon_Shier said, every Channel is associated with exactly one EventLoop (which owns one thread). So you do not need any synchronisation to access state on a ChannelHandler (assuming you don’t add one handler instance to multiple Channels which almost always you should not).

If you need to access state from multiple ChannelHandlers in different Channels, you do need to synchronise (unless you make sure all those Channels are on the same EventLoop which you can not generally assume but you can arrange for that to happen). And just to be very explicit, all ChannelHandlers in the same Channel(Pipeline) are always called from the EventLoop the Channel is associated with. And all callbacks for all futures call back on the EventLoop they have been created from. And all Channel(Pipeline) operations always return futures from the EventLoop their Channel is associated with.

Those are the base rules. The benefits of this are: that unless you have state across multiple Channels, you never need to synchronise which makes it easy and fast.

Now, for HTTP2 there are additional guarantees: HTTP2 has multiple sorts of Channels: One root channel (the actual TCP) connection which can have many many child Channels which are used for the individual HTTP2 streams (one stream does exactly one HTTP request/response pair). The extra guarantee that NIOHTTP2 gives you is that these child Channels are on the same EventLoop as their root Channel. Therefore, the HTTP2 multiplexer does not need any thread synchronisation.

I hope this makes sense.

One other area that sometimes isn’t clear: All Channel API is fully thread safe, ie if you hold a Channel you may call all of its public API from wherever.

5 Likes