Artillery gun outside Auckland War Memorial Museum
Article / 18th Oct 2017

Channels 2: October

It's been a little while since I've written a blog post about Channels - this is partially because I've been taking some time off for the conflux of conferences, work and light medical issues that have cropped up, but things have been slowly rattling away in the background and I'm now back working at a decent pace on the changes.

A lot of this has been figuring out where to take Channels 2, and struggling with my own personal hangups - specifically, being divided between wanting to make the upgrade as backwards-compatible and painless as possible but also feeling like I want to fix some major design issues in the process.

A big part of this has been Python 3 and native async syntax, as well - for a long time, I wanted to support Python 2, but as time as moved on and Django moves away from supporting it entirely in Django 2.0, and Twisted's asyncio support is mostly mature, it's time to drop that handle as well.

The result is a Channels that is really quite substantially different in terms of underlying architecture and design, but which ends up with a very similar interface to the end developer. Let's take a look at some of those changes.

Running code in-process

Perhaps the biggest change of all is that Channels 2 runs your handling code in process with the HTTP (or other) server, rather than having separate worker processes and dishing it out over the network. While there are definite advantages to the network-worker model, it makes a lot of things harder and bugs more subtle, and generally isn't the right match for a project where we don't have a lot of resources to do bulletproof network testing.

The reason I avoided this in Channels 1 was wanting to keep async and sync code separate, but after finally getting into the depths of asyncio, Twisted and threading, I have a decent solution for calling async functions from sync code and sync functions from async code, allowing the boundary to not only be crossed in-process but work a lot better than having it over the network.

This also means that the ASGI "specification" has received a major overhaul, and is now much simpler - a much worthier spiritual successor to WSGI. Much like WSGI, you provide the protocol server (e.g. a HTTP server) with an application object, which gets instantiated per-connection. You can see the new, short spec over at channels.readthedocs.io/en/2.0/asgi.html, but the key thing is that there's an instance of the application per connection (with what a "connection" is being defined per protocol - it's a request for HTTP, and the client socket lifetime for WebSocket), and an async-only main method that can await for events happening on that connection.

Thus, all protocol servers are expected to be natively async, and applications have to expose that as an interface as well. Of course, Django is synchronous, so how does that interact?

Synchrony

The work I mentioned before about calling sync functions from async code comes into play. Channels 2 will happily let you write fully async-native code if you like - and don't want to call the Django ORM - but it also provides plenty of helpers to write synchronous code and have it run in a threadpool alongside the server's async loop.

This means that the consumers you write in Channels 2 look just like Channels 1 - classes with synchronous code in methods:

class ChatConsumer(WebsocketConsumer):

    def connect(self, message):
        self.accept()
        self.username = "Anonymous"
        self.send(text="[Welcome %s!]" % self.username)

    def receive(self, text, bytes=None):
        if text.startswith("/name"):
            self.username = text[5:].strip()
            self.send(text="[set your username to %s]" % self.username)
        else:
            self.send(text=self.username + ": " + text)

There's a few small syntactical changes, such as the way send works being a bit more sensible, but it's largely familiar. However, everything is now async right down to the consumer - including Channels' routing layer - so you're also more than able to write a fully async consumer:

class AsyncChatConsumer(AsyncConsumer):
    """
    This uses the base Async consumer class as there's no high-level
    AsyncWebsocketConsumer.
    """

    async def websocket_connect(self, message):
        await self.send({
            "type": "websocket.accept",
        })
        self.username = "Anonymous"
        await self.send({
            "type": "websocket.send",
            "text": "[Welcome %s!]" % self.username,
        })

    async def websocket_receive(self, message):
        text = message["text"]
        if text.startswith("/name"):
            self.username = text[5:].strip()
            await self.send({
                "type": "websocket.send",
                "text": "[set your username to %s]" % self.username,
            })
        else:
            await self.send({
                "type": "websocket.send",
                "text": self.username + ": " + text,
            })

You can mix and match both in the same project! This is, of course, all part of a bigger plan. (I always have big plans, they just rarely work out.)

Making Django async

Many people have asked me over the last couple of years if I am trying to "make Django async", perhaps not realising the gargantuan nature of the task. There's also part of that question that implies that async is somehow better, that we should always choose it in preference to synchronous code.

If you ask me, that's not true - some code is better written synchronously where you don't need the async performance advantage. It's easier to reason about, easier to debug, and often quicker to write well.

However, it's clear that parts of Django could happily become async, like the HTTP handling and database access. Since we can't just do a massive conversion of this code in one go and keep it backwards-compatible, we need a more iterative approach.

That's where Channels 2 is trying to make a difference - the entire first part of the stack is async-native, from the HTTP server down to the routing layer. Only once it hits the Django view system or a SyncConsumer does it switch over into a thread and run synchronously.

This means that making more parts of the stack async-native can be a slow, iterative process, and an optional one at that, as there is an easy way to jump from async to sync. The reverse is also true - even if, some day, we made the ORM natively async (a task that needs someone cleverer than I), we'd want to allow synchronous view code to still use it, which is where the sync-to-async bridge comes in.

I have no immediate plans to touch core Django in this manner, but with Channels 2 you'll be able to, if you want, write async-native Consumers that only drop into threadpools when they need to interact with the Django ORM or something else natively synchronous.

Separating the Channel Layer

If you went to read through the ASGI specification, you may have noticed there's no mention of channel layers. That's because forcing my weird interprocess communication ideas into a base async protocol handling spec is probably not the best of ideas - in Channels 1, the server had to support it as it was responsible for connecting to the layer to relay messages around.

In an early design of Channels 2 that still supported synchronous servers, the server still had to manage the channel layer connection so it could tie the listening into its event loop. Now that all ASGI applications have to be natively async, though - even the SyncConsumers expose an async interface outside - they are free to make their own connections out to a channel layer and await on it.

That means the channel layer has become something that Channels Consumers implement directly, but that are not baked into the core of the ASGI spec (or Daphne). They're also totally optional now if you don't want to use them. It does, unfortunately, mean a major overhaul of their codebases to be async-compatible, which is a shame as the current transport layers are proving themselves pretty stable in production environments I've seen them in.

Still, this is probably a good thing overall for making the interoperability work beyond Django. I'd still like to encourage other frameworks and servers to start supporting some common standard, and the new ASGI is much more fit for purpose in that sense. The entire ASGI interface is:

class Application:

    def __init__(self, scope):
        ...

    async def __call__(self, receive, send):
        message = await receive()
        ...

None of the channel layer stuff, or weird channel names. Just receive event messages with one awaitable, and send them with another.

Django for Everything

This is all part of the overall goal that Channels has really been from the start - Django useful for everything. Not just WebSockets or HTTP, but anything else a system might need to react on. As a small hint of what I would like to aim for, here is one of my code sketches for routing from DjangoCon US this year:

# The URL router is also the top-level ASGI application
application = ProtocolTypeRouter({
    # Do one native async HTTP handler then let Django views do the rest
    "http": URLRouter([
        url("^longpoll/$", LongpollConsumer),
        url("^", DjangoViewSystem),
    ]),
    # WebSockets can also be routed by URL
    "websocket": URLRouter([
        url("^chat/$", AsyncChatConsumer),
    ]),
    # No routing for the MQTT handler
    "mqtt": MqttTemperatureConsumer,
    # IMAP-reading protocol server allows emails to be handled too
    "email": EmailToRouter([
        regex("@support.aeracode.org", SupportTicketHandler),
        regex("@twitter.aeracode.org", TwitterEmailGateway),
    ]),
    # SMS webhook receiving server allows SMS handling too
    "sms": SMSFromRouter([
        phone("+44", UKTextHandler),
        phone("+1", USTextHandler),
        default(UnknownTextHandler),
    ]),
})

Django is, in my view, a system to help us write better systems. As we move from basic websites to connected devices, native applications and rich interfaces, we need to make the framework that backs our servers be capable of supporting all of this. WebSockets is a first step, but Channels has never really been about that as the final goal.

Is that ambitious? Sure. But the other question that I've been pondering for the last year or so is simple - what is Django? Is it just a web framework? Or is it a way of building projects and systems? Should we be happy with where Django is now and make small incremental improvements, or start prepping for a very different future?

If you know the answer, please let me know, as I certainly don't know for sure. What I do know is that it's worth investigating just how Django might be able to grow.

Next Steps

I have Channels 2 up and serving HTTP requests and WebSocket requests, with routing and static files working as well - if you have a lot of patience, you can check out the 2.0 branches of the channelsasgiref and daphne repos and make them talk to each other, but they're not really recommended for public consumption yet.

Next is reworking the Redis and IPC channel layers to be async-native - not a small task - and then starting to clean up the repositories and start overhauling the documentation ready for a more publicly-consumable beta phase. I'll try and put up blog posts more regularly now I've mostly got through the design-prototype stage and feel happy with where things are going!