10M Concurrent Websockets

March 9th, 2016

The C10M Problem is about how on a modern server, you should be able to easily handle 10M concurrent connections with solid throughput and low jitter. Handling that level of traffic generally requires a more specialized approach than is offered by a stock Linux kernel.

Using a stock debian-8 image and a Go server you can handle 10M concurrent connections with low throughput and moderate jitter if the connections are mostly idle. The server design for this example is just about the simplest websocket server that is useful for anything. It is similar to a push notification server like the iOS Apple Push Notification Service, but without the ability to store messages if the client is offline.

The server accepts websocket connections ports 10000-11000 (to avoid exhaustion of ephemeral ports on the clients during testing) and in the url the client specifies a channel to connect to, such as:

ws : //<server>:10000/<channel>

After the websocket connection has been setup, the server never reads any data from the connection, it only writes messages to the client. Publishing to the channel is handled by redis, using the PUBLISH/PSUBSCRIBE commands. This is unnecessary for a single server machine, but is nice when you have multiple servers and you need some sort of central place to handle message routing.

Whenever a message is published on a channel, the server will send a message to each connected client subscribed to that channel. To make sure clients are still connected, the server will also send a ping message every 5 minutes. The client can use a missing ping message to detect if it has been disconnected.

func handleConnection ( ws * websocket . Conn , channel string ) { sub : = subscribe ( channel ) t : = time . NewTicker ( pingPeriod ) var message [ ] byte for { select { case < - t . C : message = nil case message = < - sub : } ws . SetWriteDeadline ( time . Now ( ) . Add ( 30 * time . Second ) ) err : = ws . WriteMessage ( websocket . TextMessage , message ) if err ! = nil { break } } t . Stop ( ) ws . Close ( ) unsubscribe ( channel , sub ) }

go get goroutines.com/10m-server

You can run the server like this:

apt - get update apt - get install - y redis - server echo "bind *" > > / etc / redis / redis . conf systemctl restart redis - server sysctl - w fs . file - max = 11000000 sysctl - w fs . nr_open = 11000000 ulimit - n 11000000 sysctl - w net . ipv4 . tcp_mem = "100000000 100000000 100000000" sysctl - w net . core . somaxconn = 10000 sysctl - w net . ipv4 . tcp_max_syn_backlog = 10000 10 m - server

The client connects to a server specified on the command line and makes a number of connections also specified on the command line. It starts on port 10000 and increments the port for every 50k connections.

func createConnection ( url string ) { ws , _ , err : = dialer . Dial ( url , nil ) if err ! = nil { return } ws . SetReadLimit ( maxMessageSize ) for { ws . SetReadDeadline ( time . Now ( ) . Add ( idleTimeout ) ) _ , message , err : = ws . ReadMessage ( ) if err ! = nil { break } if len ( message ) > 0 { fmt . Println ( "received message" , url , string ( message ) ) } } ws . Close ( ) }

go get goroutines.com/10m-client

You can run the client like this:

sysctl - w fs . file - max = 11000000 sysctl - w fs . nr_open = 11000000 ulimit - n 11000000 sysctl - w net . ipv4 . ip_local_port_range = "1025 65535" sysctl - w net . ipv4 . tcp_mem = "100000000 100000000 100000000" 10 m - client < ip address > < number of connections >

This server was run on an n1-highmem-32 instance on GCE. This is a 32-core machine with 208GB of memory. Sending a ping every 5 minutes at 10M connections was roughly the limit of what the server could handle. This ends up being only about 30k pings per second, which is not a terribly high number. It seems to be limited by the kernel or network settings, as using 8 4-core machines (the same number of cores) could handle at least 5x the pings per second that a single 32-core machine could. Since the connections are mostly idle, the channel message traffic is assumed to be insignificant compared to the pings.

At the full 10M connections, the server's CPUs are only at 10% load and memory is only half used with the default GOGC=100 , so it's likely that the hardware could handle 20M connections and a much higher ping rate without any fancy optimizations to the server code. The garbage collector is surprisingly performant, even with 100GB of memory allocated to the process.

n1-highmem-4 instances, with 1.3M connections each and putting them behind Google's excellent By using smaller instances, such asinstances, with 1.3M connections each and putting them behind Google's excellent layer-3 load balancer you can more easily scale to whatever the maximum number of connections allowed for the load balancer is, if it's limited at all.