New SwiftNIO async APIs

I am excited to share that we just cut new releases of some of our packages which include brand new async APIs.

We have been working on the new APIs for some time, letting them bake behind the AsyncChannel SPI. With the latest releases we promoted the SPIs to stable APIs.

The goal of the new async APIs is to allow developers to easily and safely bridge between NIO Channels and Swift Concurrency. An important part of the new APIs is that they carry type information about the ChannelPipeline whilst remaining flexible, made possible by utilizing the power of generics. You can find more documentation about the new APIs in our brand-new NIO and Swift Concurrency article. If you are interested in reading more about our thoughts behind the new APIs and seeing an overview of the API additions across the packages feel free to check out our developer documentation.

One important thing that I would like to call out, is that the new APIs provide a clear separation of concerns between business logic and networking. Network protocols live in the channel pipeline and business logic should be implemented directly in Swift Concurrency, outside of the channel pipeline. Lots of existing applications have both residing inside the channel pipeline and we strongly recommend to move the business logic out.

We can’t wait to see what the community builds with the new APIs and are looking forward to your feedback!

29 Likes

Congratulations on the new release! Excited to see the advancements with the async APIs :rocket:

1 Like

this is likely a very dumb question, but i am confused as to how to attach the new channel interfaces to the old ServerBootstrap childChannelInitializers.

this doesn’t compile:

.childChannelInitializer
{
    (channel:any Channel) -> EventLoopFuture<Void> in

    channel.pipeline.addHandler(NIOSSLServerHandler.init(context: authority.tls))
        .flatMap
    {
        channel.configureAsyncHTTPServerPipeline
        {
            (connection:any Channel) in

            connection.eventLoop.makeCompletedFuture
            {
                try NIOAsyncChannel<HTTPServerRequestPart, HTTPServerResponsePart>.init(
                    synchronouslyWrapping: connection,
                    configuration: .init())
            }
        }
            http2ConnectionInitializer:
        {
            (connection:any Channel) in

            connection.eventLoop.makeCompletedFuture
            {
                try NIOAsyncChannel<HTTP2Frame, HTTP2Frame>(
                    synchronouslyWrapping: connection,
                    configuration: .init())
            }
        }
            http2InboundStreamInitializer:
        {
            (stream:any Channel) in

            stream.eventLoop.makeCompletedFuture
            {
                try NIOAsyncChannel<
                    HTTP2Frame.FramePayload,
                    HTTP2Frame.FramePayload>.init(
                    synchronouslyWrapping: stream,
                    configuration: .init())
            }
        }
    }
}

because the configureAsyncHTTPServerPipeline returns a

EventLoopFuture<
    EventLoopFuture<
        NIONegotiatedHTTPVersion<
            NIOAsyncChannel<
                HTTPServerRequestPart, 
                HTTPServerResponsePart>, 
            (
                NIOAsyncChannel<HTTP2Frame, HTTP2Frame>, 
                NIOHTTP2Handler.AsyncStreamMultiplexer<
                    NIOAsyncChannel<
                        HTTP2Frame.FramePayload, 
                        HTTP2Frame.FramePayload>>
            )>>>

which is a magnificent type sculpture - truly a thing of beauty - but sadly incompatible with childChannelInitializer, which expects a humble EventLoopFuture<Void>.

your article suggests to instead add all the handlers, including the TLS handler, during the bind call. to which i must ask, what is the difference between the two childChannelInitializers?

does the one passed to bind override the one stored inside the ServerBootstrap? is there any reason to use childChannelInitializer on ServerBootstrap anymore?


another concurrent remark: i am getting Sendable warnings related to IOData:

conformance of 'IOData' to 'Sendable' is unavailable
conformance of 'IOData' to 'Sendable' has been explicitly marked unavailable here

this is because HTTPServerResponsePart is a union with IOData:

/// The components of a HTTP response from the view of a HTTP server.
public typealias HTTPServerResponsePart = HTTPPart<HTTPResponseHead, IOData>

Great questions! You are right that there are two childChannelInitializer now. One on the Server/ClientBootstrap itself and one passed to the new async bind/connect methods. The former is expected to return an EventLoopFuture<Void> and is the old API. The latter is part of the bind/connect method, is generic, and returns the generic value returned from the childChannelIntializer from the bind/connect method.
We had to do this way because we couldn't add a generic parameter to the various bootstraps without breaking API.

To your second question, we are calling both the old and new childChannelInitializer when you call the new bind/connect methods. However, we encourage to only use the childChannelInitializer passed to bind/connect.

3 Likes

This is a known issue and an annoyance with IOData. However, we recently merged a PR to swift-nio-extras that adds new handlers that bridge to the new swift-http-types. Those handlers also get rid of the Sendable warning with IOData. We just have to cut a new release for swift-nio-extras which I hope I get to tomorrow.

3 Likes

gotcha, thanks. btw regarding the example in the article:

try await withThrowingDiscardingTaskGroup { group in
    for try await connectionChannel in serverChannel.inboundStream {
        group.addTask {
            do {
                for try await inboundData in connectionChannel.inboundStream {
                    // Let's echo back all inbound data
                    try await connectionChannel.outboundWriter.write(inboundData)
                }
            } catch {
                // Handle errors
            }
        }
    }

it looks like inboundStream was renamed to just inbound. same with outboundWriteroutbound.

1 Like

Thanks for catching this. Just raised a PR.

any downsides to just using a minimal adapter like:

extension HTTP
{
    final
    class OutboundShimHandler
    {
        init()
        {
        }
    }
}
extension HTTP.OutboundShimHandler:ChannelOutboundHandler
{
    typealias OutboundIn = HTTPPart<HTTPResponseHead, ByteBuffer>
    typealias OutboundOut = HTTPPart<HTTPResponseHead, IOData>

    func write(context:ChannelHandlerContext, data:NIOAny, promise:EventLoopPromise<Void>?)
    {
        let part:OutboundOut = switch self.unwrapOutboundIn(data)
        {
        case .head(let head):   .head(head)
        case .body(let body):   .body(.byteBuffer(body))
        case .end(let tail):    .end(tail)
        }

        context.write(self.wrapOutboundOut(part), promise: promise)
    }
}

?

No downside at all. That is another totally valid approach to get rid of the IOData warning.

1 Like

On a different thread recently you mentioned that the bootstraps behave poorly in an async context, which is true, and this is another of their warts. We have plans to replace them with a purely value-typed solution that should make this issue go away. So watch this space: we know it's painful.

3 Likes

@taylorswift We just released a new version of swift-nio-extras 1.20.0 that include the new swift-http-types adapter handlers.

3 Likes

i had to add two @unchecked Sendable conformances to get the new APIs to compile without warnings:

  1. AsyncStreamMultiplexer
  2. NIONegotiatedHTTPVersion

as far as i can tell from reading the source code for NIONegotiatedHTTPVersion, this Sendable would be perfectly valid if declared inside NIO without @unchecked. i don’t know enough about AsyncStreamMultiplexer to say the same.

(trying out this new API)

My question is, where do I need to define my channel pipeline? Or is that no longer a thing. I.e. using the old API, I'd have something like:

channel.pipeline.addHandlers([
  BackPressureHandler(), 
  SessionHandler(), 
  VerbHandler(), 
  ParseHandler(), 
  ResponseHandler()
])

Where would this go in the new API?
(I have the echo server as indicated on the Concurrency documentation page running.)

Kind regards,

Maarten Engels

The answer to that somewhat depends. We'd now encourage you to only put parsers into your channel pipeline, and extract your business logic to the async world. It's hard to be sure based on your handler names.

1 Like

Yeah, I can imagine.

The pipeline handles parsing text commands coming in. Only the ParseHandler actually drives any business logic (by creating a promise and spinning up a task to perform actual work):

// source: https://github.com/maartene/NIOSwiftMUD/blob/main/Sources/NIOSwiftMUD/Server/ParseHandler.swift 
let promise = context.eventLoop.makePromise(of: Void.self)
promise.completeWithTask {
  let response = await self.createMudResponse(mudCommand: mudCommand)
            
  eventLoop.execute {
    fireChannelRead(self.wrapInboundOut(response))
  }
}

So if I understand you correctly

  • the handlers should remain part of the pipeline?
  • Maybe the ParseHandler needs to change because of the async API?

KR Maarten

It seems like ParseHandler should come out of the pipeline, and instead use a NIOAsyncChannel.

1 Like

Yeah, that makes sense. Thank you! :pray:

Even for something like a MUD handler (which I assume is pretty similar to IRC)? That means hopping threads for just a few bytes (like "go left") of traffic?

allow setting MTELG.singleton as Swift Concurrency executor by weissi · Pull Request #2564 · apple/swift-nio · GitHub ?

We aren't deprecating or removing the existing API surface, so you can always use the older API. For now that will have performance benefits, though it comes at the cost of a much more mentally challenging programming model.

In the medium term we are investigating ways to use Task executors to let you run your business logic on NIO's ELs to skip the thread hops. Until then, as @johannesweiss demonstrates, it will be possible to force all concurrency code to run on NIO's ELs.

2 Likes