Callback Free Concurrency:
From Node to Elixir
Chris Geihsler
@seejee
- Background
- Web server architecture
- Node drawbacks
- Application comparison
Agenda
- 3,000,000 students
- 7,000,000 problems / day
- 1,000,000,000 problems / year
+
Live Math Tutoring
- 1 2X Heroku Dyno
- 1 Redis DB
- $50 / month
- ~70 concurrent sessions
- 2,000,000 sessions / year
+
Web Server Architecture
Joe Stanco, Packt Publishing
http://i.stack.imgur.com/BTm1H.png
Drawbacks
Callback Hell
Drawback:
var db = require('db');
var Account = require('models/account');
var AuditLog = require('models/audit_log');
function withdrawMoney(accountId, amount) {
var trx = db.beginTransaction();
try {
var account = Account.find(accountId);
account.withdraw(amount);
AuditLog.createEntry("$" + amount + " withdrawn from account.");
trx.commit();
} catch (error) {
trx.rollback();
throw error;
}
}
try {
withdrawMoney(17, 50.00);
} catch (error) {
console.log(error);
}
db.beginTransaction(function(err, trx) {
if(err) {
callback(err);
return;
}
Account.find(accountId, function(err, account) {
if(err) {
trx.rollback(function() {
callback(err);
});
return;
}
account.withdraw(amount, function(err) {
if(err) {
trx.rollback(function() {
callback(err);
});
return;
}
AuditLog.createEntry("$" + amount +" withdrawn from account.", function(err) {
if(err) {
trx.rollback(function() {
callback(err);
});
return;
}
trx.commit(function() {
callback(err);
});
});
});
});
});
var trx = db.beginTransaction();
trx
.then(function() {
return Account.find(accountId);
})
.then(function(account) {
return account.withdraw(amount);
})
.then(function() {
return AuditLog.createEntry("$" + amount + " withdrawn from account.");
})
.then(trx.commit)
.catch(trx.rollback);
Fault Tolerance
Drawback:
The safest way to respond to a thrown error is to shut down the process.
Concurrency
Drawback:
var db = require('db');
var slowQueryHandler = function(req, res) {
db.exec('SELECT * from really_big_table', function(err, results) {
// 2 minutes later
res.render('slow_query', results);
});
};
var jsonEcho = function(req, res) {
var params = JSON.parse(req.body);
// what if req.body is really big?
res.render('json_echo', params);
};
What do we really want?
Server Requirements
- Concurrency (CPU and I/O)
- Request Isolation
- Durability
- Developer Friendliness
+
- Functional
- Dynamic
- Immutable
- Pattern Matching
- Efficient Concurrency
- Hot code-reloading
- Thread: 8MB
- Erlang process: 2KB
Memory overhead
- Threads: 100-1,000s
- Erlang processes: 10,000-100,000s
Simultaneous Connections
- 10-100x faster to preempt an Erlang process.
- Erlang processes are preempted on I/O and CPU.
Context Switching
- OS threads share the heap.
- Erlang processes share nothing.
Isolation
Server Requirements
- Concurrency (CPU and I/O)
- Request Isolation
- Durability
- Developer Friendliness
OTP
- "Open Telecom Protocol"
- "Let it fail" philosophy
- Server building-blocks
- Nine nines uptime
Server Requirements
- Concurrency (CPU and I/O)
- Request Isolation
- Durability
- Developer Friendliness
- Ruby-ish syntax
- Erlang semantics
- Compiles to Erlang bytecode
- Metaprogramming
- Strong tools
- Web framework written in Elixir
- Ruby on Rails inspired
- First-class PubSub, WebSocket support
Server Requirements
- Concurrency (CPU and I/O)
- Request Isolation
- Durability
- Developer Friendliness
Sample Application
(finally!)
Students
- Wait in a queue
- Private chat with a single teacher
- Respond to every teacher message
- Disconnect when teacher ends chat
Teachers
- Chat with five students simultaneously
- Pull students from the queue
- End chat after receiving 50 messages
System
- Know which users are connected
- Know which chats are in-progress
- Record the chat logs
Two* Implementations:
- Node 0.10.33
- Express 4.9.8
- Socket.io 1.0
- Erlang 17
- Elixir 1.0.2
- Phoenix 0.11
Model Layer
Teacher Roster
- Which teachers are connected?
- Which students are chatting with a teacher?
function TeacherRoster() { this.teachers = {}; }
TeacherRoster.prototype.add = function(teacher, callback) {
this.teachers[teacher.id] = teacher;
callback(null, teacher);
};
TeacherRoster.prototype.find = function(id, callback) {
var t = this.teachers[id];
callback(null, t);
};
TeacherRoster.prototype.canAcceptMoreStudents = function(teacherId, callback) {
this.find(teacherId, function(err, teacher) {
var canAccept = teacher.students.length < 5;
callback(null, canAccept);
});
};
TeacherRoster.prototype.claimStudent = function(teacherId, studentId, callback) {
this.find(teacherId, function(err, teacher) {
teacher.students.push(studentId);
callback(null, teacher);
});
};
TeacherRoster.prototype.stats = function(callback) {
var stats = { total: _.keys(@teachers).length };
callback(null, stats);
};
defmodule ElixirChat.TeacherRoster do
def new do
HashDict.new
end
def add(roster, teacher) do
teacher = Map.merge(teacher, %{student_ids: []})
Dict.put(roster, teacher.id, teacher)
end
def find(roster, teacher_id) do
roster[teacher_id]
end
def can_accept_more_students?(teacher) do
length(teacher.student_ids) < 5
end
def claim_student(roster, teacher_id, student_id) do
Dict.update!(roster, teacher_id, fn(t) ->
%{t | student_ids: t.student_ids ++ [student_id]}
end)
end
def stats(roster) do
%{
total: length(Dict.values(roster)),
}
end
end
iex(1)> alias ElixirChat.TeacherRoster, as: TeacherRoster
nil
iex(2)> roster = TeacherRoster.new
#HashDict<[]>
iex(3)> TeacherRoster.add(roster, %{id: 1})
#HashDict<[{1, %{id: 1, student_ids: []}}]>
iex(4)> roster
#HashDict<[]>
iex(5)> roster = TeacherRoster.add(roster, %{id: 1})
#HashDict<[{1, %{id: 1, student_ids: []}}]>
iex(6)> roster
#HashDict<[{1, %{id: 1, student_ids: []}}]>
iex(7)> roster = TeacherRoster.add(roster, %{id: 2})
#HashDict<[{2, %{id: 2, student_ids: []}}, {1, %{id: 1, student_ids: []}}]>
iex(8)> TeacherRoster.stats(roster)
%{total: 2}
iex(9)> TeacherRoster.new
|> TeacherRoster.add(%{id: 10})
|> TeacherRoster.add(%{id: 20})
|> TeacherRoster.add(%{id: 30})
|> TeacherRoster.stats
%{total: 3}
Where is the current state?
defmodule Actor do
def start do
spawn fn -> loop(initial_state) end
end
def initial_state do
# TODO: set some initial state
end
def loop(state) do
new_state = receive do
# TODO: handle some messages
end
loop(new_state)
end
end
defmodule Actor do
def start do
spawn fn -> loop(initial_state) end
end
def initial_state do
0
end
def loop(state) do
new_state = receive do
{:add, amount} -> state + amount
{:subtract, amount} -> state - amount
{:print } -> IO.puts(state); state
end
loop(new_state)
end
end
iex(1)> pid = Actor.start
#PID<0.167.0>
iex(2)> send pid, {:print}
0
iex(3)> send pid, {:add, 10}
iex(4)> send pid, {:add, 90}
iex(5)> send pid, {:print}
100
iex(6)> send pid, {:subtract, 50}
iex(7)> send pid, {:print}
50
defmodule Actor do
# client process
def add(pid, amount) do
send pid, {:add, amount}
pid
end
def subtract(pid, amount) do
send pid, {:subtract, amount}
pid
end
def print(pid) do
send pid, {:print}
pid
end
# server process
def start do
spawn fn -> loop(initial_state) end
end
def initial_state do
0
end
def loop(state) do
# Same as before
end
end
iex(1)> Actor.start
|> Actor.add(10)
|> Actor.add(90)
|> Actor.subtract(20)
|> Actor.print
80
defmodule ElixirChat.TeacherRoster do
def new do
end
def add(roster, teacher) do
end
def find(roster, teacher_id) do
end
def can_accept_more_students?(teacher) do
end
def claim_student(roster, teacher_id, student_id) do
end
def stats(roster) do
end
end
OTP GenServer
- Robust Actor implementation
- Casts
- Fire and forget
- Client continues
- Calls
- Request/Response
- Client blocks
defmodule ElixirChat.TeacherRosterServer do
use GenServer
alias ElixirChat.TeacherRoster, as: Roster
# client process
def start_link do
GenServer.start_link(__MODULE__, nil, name: :teacher_roster_server)
end
def add(teacher) do
GenServer.call(:teacher_roster_server, {:add, teacher})
end
def can_accept_more_students?(teacher_id) do
GenServer.call(:teacher_roster_server, {:can_accept_more_students, teacher_id})
end
# server process
def init(_) do
{:ok, Roster.new}
end
def handle_call({:add, teacher}, _from, roster) do
roster = Roster.add(roster, teacher)
{:reply, teacher, roster}
end
def handle_call({:can_accept_more_students, teacher_id}, _from, roster) do
teacher = Roster.find(roster, teacher_id)
result = Roster.can_accept_more_students?(roster, teacher)
{:reply, result, roster}
end
end
defmodule ElixirChat.TeacherRosterServer do
use ExActor.GenServer, export: :teacher_roster_server
alias ElixirChat.TeacherRoster, as: Roster
defstart start_link do
Roster.new |> initial_state
end
defcall add(teacher), state: roster do
roster |> Roster.add(teacher) |> set_and_reply(teacher)
end
defcall can_accept_more_students?(teacher_id), state: roster do
teacher = Roster.find(roster, teacher_id)
roster |> Roster.can_accept_more_students?(teacher) |> reply
end
end
Student Roster
- Which students are connected?
- Who is the next student in the queue?
- Which students are chatting?
function StudentRoster() {}
// add, remove, stats, chatFinished omitted
StudentRoster.prototype.next = function(callback) {
students = _.values(this.students)
s = _.find(students, (s) -> s.status is 'waiting')
callback(null, s);
};
StudentRoster.prototype.assignTo = function(studentId, teacherId, callback) {
this.find(studentId, function(err, student) {
student.status = 'chatting'
student.teacherId = teacherId
callback(null, student);
});
};
defmodule ElixirChat.StudentRoster do
# new, add, remove, stats, chat_finished omitted
def next_waiting(roster) do
Dict.values(roster)
|> Enum.filter(fn(s) -> s.status == "waiting" end)
|> Enum.sort_by(fn(s) -> s.id end)
|> Enum.at(0)
end
def assign_to(roster, teacher_id, student_id) do
Dict.update!(roster, student_id, fn(s) ->
%{s | teacher_id: teacher_id}
end)
end
end
Chat Log
- Which chats are in progress?
- What was said in each chat?
Chat Lifetime
- Matches a teacher with the next student
- Creates a new chat
- Ends a chat
function ChatLifetime(teachers, students, chatLog) {
this.teachers = teachers;
this.students = students;
this.chatLog = chatLog;
}
ChatLifetime.prototype.createChatForNextStudent = function(teacherId, callback) {
var _this = this;
_this.teachers.canAcceptMoreStudents(teacherId, function(err, canAccept) {
if(canAccept) {
_this.students.next(function(err, student) {
if(student) {
_this.students.assignTo(student.id, teacherId, function(err) {
_this.teachers.claimStudent(teacherId, student.id, function(err) {
_this.chatLog.new(teacherId, student.id, function(err, chat) {
callback(null, chat);
});
});
});
}
});
}
});
};
defmodule ElixirChat.ChatLifetimeServer do
use ExActor.GenServer, export: :chat_lifetime_server
alias ElixirChat.ChatLogServer, as: Chats
alias ElixirChat.TeacherRosterServer, as: Teachers
alias ElixirChat.StudentRosterServer, as: Students
defcall create_chat_for_next_student(teacher_id), state: _ do
chat = nil
if Teachers.can_accept_more_students?(teacher_id) do
student_id = Students.next_student(teacher_id)
if student_id do
:ok = Students.assign_student_to_teacher(student_id, teacher_id)
:ok = Teachers.claim_student(teacher_id, student_id)
chat = Chats.new(teacher_id, student_id)
end
end
reply(chat)
end
end
WebSocket Layer
Presence Channel
- Knows when users connect and disconnect
- Knows when a teacher starts a new chat
- Broadcasts student queue length
function PresenceChannel(faye) {
// continued
this.chatLifetime = new ChatLifetime(this.teachers, this.students, this.chatLog)
this.chatChannel = new ChatChannel(this.faye, this.chatLog, this.chatLifetime)
}
PresenceChannel.prototype.attach = function() {
// continued
this.faye.subscribe('/presence/claim_student', this.onClaimStudent.bind(this));
}
PresenceChannel.prototype.onClaimStudent = function(payload) {
var _this = this;
_this.chatLifetime.createChatForNextStudent(payload.teacherId, function(err, chat) {
_this.chatChannel.attach(chat.id);
_this.publishNewChat(chat);
});
};
PresenceChannel.prototype.publishNewChat = function(chat) {
var teacherChannel = "/presence/new_chat/teacher/" + chat.teacherId;
var studentChannel = "/presence/new_chat/student/" + chat.studentId;
this.faye.publish(teacherChannel, chat.teacherChannels);
this.faye.publish(studentChannel, chat.studentChannels);
};
defmodule ElixirChat.PresenceChannel do
use Phoenix.Channel
alias ElixirChat.ChatLifetimeServer, as: Chats
alias ElixirChat.TeacherRosterServer, as: Teachers
alias ElixirChat.StudentRosterServer, as: Students
def join(socket, topic, %{"userId" => id, "role" => "teacher"}) do
Teachers.add(%{id: id})
socket = assign(socket, :id, id)
{:ok, socket}
end
def join(socket, topic, %{"userId" => id, "role" => "student"}) do
Students.add(%{id: id})
socket = assign(socket, :id, id)
broadcast_status
{:ok, socket}
end
def broadcast_status do
data = %{
teachers: Teachers.stats,
students: Students.stats
}
broadcast "presence", "teachers", "user:status", data
end
end
defmodule ElixirChat.PresenceChannel do
def leave(socket, _message) do
Students.remove(socket.assigns[:id])
broadcast_status
socket
end
def event(socket, "claim:student", %{"teacherId" => teacher_id}) do
chat = Chats.create_chat_for_next_student(teacher_id)
if chat do
reply socket, "new:chat:#{chat.teacher_id}", chat
broadcast "presence", "student:#{chat.student_id}", "new:chat", chat
end
socket
end
end
Chat Channel
- Private channel for a single chat
- Relays messages between student and teacher
- Terminates a chat
Student Client
- Connect
- Wait for a teacher to start a chat
- Reply to every teacher message
- Disconnect when teacher ends chat
var client = new faye.Client(url);
client.subscribe('/presence/new_chat/student/' + id, function(chat) {
var messageCount = 0;
client.subscribe(chat.receiveChannel, function(data) {
messageCount++;
client.publish(chat.sendChannel, {
message: 'Message #' + messageCount + ' from student ' + id
});
});
client.subscribe(chat.terminatedChannel, function(data) {
client.publish('/presence/student/disconnect', {
userId: id,
role: 'student'
});
client.disconnect();
});
client.publish(chat.joinedChannel, { userId: id });
});
client.publish('/presence/student/connect', {
userId: id,
role: 'student'
});
var socket = new Phoenix.Socket(url);
var student = {userId: id, role: 'student'};
socket.join("presence", "student:" + id, student, function(channel) {
channel.on("new:chat", function(chat) {
socket.join("chats", chat.id, student, function(chatChan) {
var messageCount = 0;
chatChan.on("chat:terminated", function(data) {
channel.leave();
socket.close();
});
chatChan.on("student:receive", function(data) {
messageCount++;
chatChan.send("student:send", {
message: "Message #" + messageCount + " from student: " + id
});
});
chatChan.send("student:joined", {});
});
});
channel.send("student:ready", {userId: id});
});
Teacher Client
- Connect
- Start a chat with a student (up to 5)
- Reply to every student message
- End chat after 50 messages
Review
- Concurrency without callbacks
- Synchronization without locks
- Scalability across cores or machines
Fault Tolerance
The safest way to respond to a thrown error is to shut down the process.
The safest way to respond to a thrown error is to shut down the process.
+
OTP Supervisor
- Restarts processes when they die
- Several restart strategies
defmodule ElixirChat.ModelSupervisor do
use Supervisor
def start_link do
Supervisor.start_link(__MODULE__, [])
end
def init([]) do
children = [
worker(ElixirChat.ChatLifetimeServer, []),
worker(ElixirChat.ChatLogServer, []),
worker(ElixirChat.TeacherRosterServer, []),
worker(ElixirChat.StudentRosterServer, []),
]
supervise(children, strategy: :one_for_one)
end
end
Performance
- 1000 students
- 10 teachers
- ~225,000 messages
Macbook Pro, 1 Core (Jan 2014)
- Node: 42s
- Elixir: 54s
Macbook Pro, 1 Core (April 2014)
- Node: 41s
- Elixir: 29s
Macbook Pro, 8 cores
- Node: 41s
- Elixir: 24s
Macbook Pro, 8 cores, split clients
- Node: 41s
- Elixir: 14s
Scaling
Scaling
(in January)
80 hours later...
- Storing application state in Redis
- Using sticky Load Balancer Sessions
- Manually routing chats to different processes
- Fighting race conditions
After...
Load Balancer
Presence
Chat 1
Chat 2
Chat 8
...
Redis
Scalable
Scalable node, 8 cores, split clients
- Node: 25s
- Elixir: 14s
to
- Rewriting internal tools in Elixir
- Replacing Node PubSub with Elixir/Phoenix
+
Thank you!
Chris Geihsler
@seejee
elixir-v-node
By Chris Geihsler
elixir-v-node
- 3,357