Monday, 29 October 2012

How to implement a real-time chat server in PHP using Server-Sent Events (update: added C benchmark)

Lessons learned:
  • A web server written in PHP can give more than 10000 req/s on small hardware
  • A web server written in PHP is not slower than being written in Java (without threads)
  • A web server written in PHP is 30 percent slower than being written in C (without threads)
  • Realtime applications can be developed in PHP without problems

PHP normally runs inside a web server like Apache or nginx. This keeps all requests separate from each other and does not allow sharing memory or connections. To implement a chat server, the browser has to poll the server regularly for new data. The data is stored in a database and looked up for each request. This is very slow, takes a lot of resources on the server and does not give messages in realtime.
Newer browsers support data being pushed from the server to the client. There are two techniques used: WebSockets (full-duplex) and Server-Sent Events (push notifications)
Using these techniques, one connection stays open for each client and the server sends (pushes) messages whenever new data is available. To handle many connections – that stay open for a long time – and share messages between these connections, we run PHP as a standalone process on the shell and implement a web server directly in PHP.
In the browser, we use Server-Sent Events to receive messages in realtime and AJAX requests to send new messages. We allow more than one connection (=browser tab) per user. We allow sending messages to all users or one specific user. If the user is offline, the message will be kept in memory for later delivery.

Here is the code:

// Usage: run "php server.php" and open a few browser tabs with "http://<ip>:8000/".

// server.php #1 html page
$html = "<html>
<body>
<script>
var inactive = 0;
// show 'username (x)' in document title when there are
// new messages and the tab is not visible
window.onfocus = function(){
inactive = 0;
document.title = document.forms[0].nick.value;
};
window.onblur = function(){ inactive = 1; };
var html = function(s){ return s.replace(/>/g,'>').replace(/</g,'<'); };
</script>

<!-- nickname form, start receiving messages on submit -->
<form onsubmit=\"
var source = new EventSource('/'+this.nick.value);
source.onmessage = function(event){
document.getElementById('content').innerHTML += html(event.data)+'<br>';
if (inactive) document.title = document.forms[0].nick.value+' ('+(inactive++)+')';
};
this.style.display = 'none';
document.forms[1].style.display = '';
document.forms[1].msg.focus();
document.title = this.nick.value;
return false;
\">
<input type='text' name='nick' required='true' autofocus='true' />
<input type='submit' value='Choose nickname'/>
</form>

<!-- message form, send new message as ajax request -->
<form onsubmit=\"
var ajax = new XMLHttpRequest();
ajax.open('GET', 'http://localhost:8000/msg/' + escape(document.forms[0].nick.value) +
'/'+escape(this.msg.value));
ajax.send();
this.msg.value = this.msg.value.substr(0, this.msg.value.indexOf(' ')+1);
this.msg.focus();
return false;
\" style='display:none;'>
<input type='text' name='msg' required='true' placeholder='nickname message' style='width:250px;' />
<input type='submit' value='Send message' />
<input type='button' value='Clear chat' onclick=\"document.getElementById('content').innerHTML='';\" />
</form>

<div id='content'><!-- chat messages --></div>
</body>";

// server.php #2 socket server (port 8000)
$socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $err) or die($err);
$conns = array($socket);
$conn_ids = array(0);
$conn_user = array();
$msgs = array();

// server loop
while (true) {
$reads = $conns;
// get number of connections with new data
$mod = stream_select($reads, $write, $except, 5);
if ($mod===false) break;

foreach ($reads as $read) {
if ($read===$socket) {
$conn = stream_socket_accept($socket);
$recv = fread($conn, 1024);
if (empty($recv)) continue;

if (strpos($recv, "GET / ")===0) {
// serve static html page from memory
fwrite($conn, "HTTP/1.1 200 OK\r\n". "Connection: close\r\n".
"Content-Type: text/html; charset=UTF-8\r\n\r\n");
fwrite($conn, $html);
stream_socket_shutdown($conn, STREAM_SHUT_RDWR);

} else if (strpos($recv, "GET /msg/")===0) {
// ajax request: send a message
// syntax: GET /msg/user_from/user_to%20message
// e.g. GET /msg/john/mary%20hello
stream_socket_shutdown($conn, STREAM_SHUT_RDWR);
preg_match("!GET /msg/([^/]+)/(\S+)!", $recv, $match);
$user = $match[1];
$match[2] = urldecode($match[2]);
if (!strpos($match[2], " ")) continue;
list($target, $msg) = explode(" ", $match[2], 2);

if ($target=="all") {
// send message to all users
foreach ($conns as $i=>$conn) {
if ($i!=0) fwrite($conn, "data: ".$user." to all: ".$msg."\n\n");
}

} else if (isset($conn_user[$target])) {
// send message to one user and to the originator
if ($target!=$user) foreach ($conn_user[$target] as $conn) {
fwrite($conn, "data: ".$user.": ".$msg."\n\n");
}
if (isset($conn_user[$user])) foreach ($conn_user[$user] as $conn) {
fwrite($conn, "data: You to ".$target.": ".$msg."\n\n");
}

} else {
// user is offline, keep message in memory for later delivery
if (!isset($msgs[$target])) $msgs[$target] = "";
$msgs[$target] .= "data: ".$user." (".@date("Y-m-d H:i")."): ".$msg."\n\n";
foreach ($conn_user[$user] as $conn) {
fwrite($conn, "data: You to ".$target." (offline): ".$msg."\n\n");
}
}

} else if (strpos($recv, "text/event-stream")===false) {
// block other requests like favicon.ico
stream_socket_shutdown($conn, STREAM_SHUT_RDWR);

} else {
// login as new user
// syntax: GET /username e.g. GET /john
preg_match("!GET /(\S+)!", $recv, $match);
if (!isset($match[1])) continue;
$user = $match[1];
echo "connect ".$user." from ".stream_socket_get_name($conn, true)."\n";

fwrite($conn, "HTTP/1.1 200 OK\r\n". "Connection: close\r\n".
"Content-Type: text/event-stream\r\n\r\n");
fwrite($conn, "data: Welcome ".$user."!\n\n");
fwrite($conn, "data: now online: ".implode(", ", array_keys($conn_user))."\n\n");

// deliver messages sent when user was offline
if (isset($msgs[$user])) {
fwrite($conn, $msgs[$user]);
unset($msgs[$user]);
}
// notify other users
foreach ($conns as $i=>$c) {
if ($i!=0) fwrite($c, "data: ".$user." has joined.\n\n");
}
// register connection in pool
$conns[] = $conn;
$conn_ids[] = $user;
// allow multiple connections for 1 user
$conn_user[$user][] = $conn;
}
} else {
$data = fread($read, 1024);
if ($data=="" or $data===false) {
// user/browser closed connection
if ($data!==false) stream_socket_shutdown($read, STREAM_SHUT_RDWR);
$conn_id = array_search($read, $conns, true);
unset($conns[$conn_id]);

// unregister connection for user
$user = $conn_ids[$conn_id];
unset($conn_ids[$conn_id]);
$conn_id = array_search($read, $conn_user[$user], true);
unset($conn_user[$user][$conn_id]);

if (empty($conn_user[$user])) {
unset($conn_user[$user]);
// notify other users
foreach ($conns as $i=>$c) {
if ($i!=0) fwrite($c, "data: ".$user." has left.\n\n");
} } } } } }
Please note that this implementation does not cover user authentication or saving messages to disk or database. Also, connection errors or timeouts are not handled on the browser side.
This code is just for demonstration of the concepts and the performance, so it is not OOP and it should not be used in production. Also it does not implement a complete HTTP stack or handle mime types.

To test the performance of our chat server, let's do some "GET / HTTP/1.1" and serve the static HTML output. ab works good here because it covers all possible scenarios.

php server.php (PHP 5.3.10, 3.4 GHz single core QEMU, VQ7 from Hetzner)

# serve static content
ab -n 10000 -c 10 http://localhost:8000/
Requests per second: 11601.30 [#/sec] (mean)
(memory usage is about 12.5M)

# receive messages (1st shell)
curl -sH ": text/event-stream" http://localhost:8000/john >/dev/null
# send messages (2nd shell)
ab -n 10000 -c 10 http://localhost:8000/msg/mary/john%20hello
Requests per second: 9872.04 [#/sec] (mean)

javac Server.java && java Server (IcedTea7 2.3.3, 1.7.0_09)

ab -n 10000 -c 10 http://localhost:8000/
Requests per second: 10440.45 [#/sec] (mean)

// Server.java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class Server {
private static String readFile(String pathname) throws IOException {
File file = new File(pathname);
StringBuilder fileContents = new StringBuilder((int) file.length());
Scanner scanner = new Scanner(file);
try {
while (scanner.hasNextLine())
fileContents.append(scanner.nextLine() + "\n");
return fileContents.toString();
} finally {
scanner.close();
}
}

public static void main(String[] args) throws Exception {
final String html = readFile("test.html");
try (ServerSocket socket = new ServerSocket(8000)) {
while (true) {
final Socket client = socket.accept();
try (Socket c = client) {
while (true) {
DataInputStream dis = new DataInputStream(c.getInputStream());
DataOutputStream dos = new DataOutputStream(c.getOutputStream());
BufferedReader in = new BufferedReader(new InputStreamReader(dis));
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(dos));
String recv = in.readLine();
if (recv != null && recv.indexOf("GET / ") == 0)
out.write("HTTP/1.1 200 OK\r\nConnection: close\r\n"
+ "Content-Type: text/html; charset=UTF-8\r\n\r\n" + html);
// using StringBuilder was slower ...
out.close();
in.close();
}
} catch (IOException e) {
// ok
}
}
} catch (IOException e) {
System.out.println(e);
} } }

gcc -o server.bin server.c && ./server.bin (gcc 4.6.3)

ab -n 10000 -c 10 http://localhost:8000/
Requests per second: 15254.44 [#/sec] (mean)
(memory usage is about 0.5M)

// server.c
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
int server, instance;
socklen_t clilen;
char buffer[256];
struct sockaddr_in srv, cli;
char html[] = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: text/html;\
charset=UTF-8\r\n\r\n\
<html>\
<body>\
<script>\
var inactive = 0;\
...
</body>";
int html_len = strlen(html);

server = socket(AF_INET, SOCK_STREAM, 0);
bzero((char *) &srv, sizeof(srv));
srv.sin_family = AF_INET;
srv.sin_addr.s_addr = INADDR_ANY;
srv.sin_port = htons(8000);
int opt = 1;
setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
if (bind(server, (struct sockaddr *) &srv, sizeof(srv)) < 0) {
perror("ERROR: bind");
exit(1);
}
listen(server,5);
clilen = sizeof(cli);
do {
instance = accept(server, (struct sockaddr *) &cli, &clilen);
if (instance < 0) continue;
bzero(buffer,256);
read(instance,buffer,256);
if (strstr(buffer, "GET / ")) write(instance, html, html_len);
close(instance);
} while(1);
return 0;
}

Apache (v2.2.22, from disk with logging enabled)

ab -n 10000 -c 10 http://localhost/public/test.html
Requests per second: 6491.84 [#/sec] (mean)

node test.js (node.js v0.6.12)

ab -n 10000 -c 10 http://localhost:8000/
Requests per second: 5554.03 [#/sec] (mean)

// test.js
var str = "<html>\
<body>\
<script>\
var inactive = 0;\
...
</body>";
var http = require('http');
http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(str);
}).listen(8000, '127.0.0.1');
node test2.js (node.js v0.6.12)

ab -n 10000 -c 10 http://localhost:8000/
Requests per second: 9075.90 [#/sec] (mean)

// test2.js
var str = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n\
<html>\
<body>\
<script>\
var inactive = 0;\
...
</body>";
var net = require('net');
var server = net.createServer();
server.listen(8000, '127.0.0.1');
server.on('connection', function(sock) {
sock.end(str);
});

No comments:

Post a Comment