Lesson 15: TCP Socket Programming
Open TCP sockets with gen_tcp, accept connections, and build an echo server that gives every client its own process
The supervision tree from lesson 14 keeps our processes alive, but nothing outside the VM can talk to them yet. A chat server needs a network connection per user. Erlangβs gen_tcp module provides TCP sockets, and they fit the process model well. Each connection gets its own process, and incoming data can arrive as ordinary messages.
Two Kinds of Sockets
A TCP server works with two kinds of sockets. A listen socket is bound to a port and waits for clients. Every client that connects gets its own connection socket, and all data flows over that one. We can play both roles in a single erl shell.
1> {ok, Listen} = gen_tcp:listen(9000, [binary, {packet, raw}, {active, false}, {reuseaddr, true}]).{ok,#Port<0.2>}2> {ok, Client} = gen_tcp:connect("localhost", 9000, [binary, {active, false}]).{ok,#Port<0.4>}3> {ok, Socket} = gen_tcp:accept(Listen).{ok,#Port<0.5>}gen_tcp:accept/1 blocks until a client connects. Here the client was already waiting in the operating systemβs queue, so it returned at once. Client and Socket are the two ends of the same connection.
The options decide how data is delivered.
| Option | Meaning |
|---|---|
binary |
Deliver data as binaries. The default is lists of bytes, which waste memory. |
{packet, raw} |
Deliver bytes as they arrive, with no framing. {packet, 0} means the same. |
{packet, line} |
Deliver one full line at a time. |
{active, false} |
Passive mode. We fetch data with gen_tcp:recv/2. |
{reuseaddr, true} |
Allow a restart to bind the port again without waiting for the OS to release it. |
Connection sockets inherit their options from the listen socket. Port 0 asks the OS for any free port, and inet:port/1 tells you which one you got. That is handy in tests.
TCP Is a Byte Stream
TCP delivers bytes in order, but it does not preserve message boundaries. Send twice, then read once.
4> gen_tcp:send(Client, <<"hello ">>).ok5> gen_tcp:send(Client, <<"world">>).ok6> gen_tcp:recv(Socket, 0).{ok,<<"hello world">>}7> gen_tcp:send(Client, <<"abc">>).ok8> gen_tcp:recv(Socket, 2).{ok,<<"ab">>}9> gen_tcp:recv(Socket, 0).{ok,<<"c">>}10> gen_tcp:recv(Socket, 0, 1000).{error,timeout}The second argument of recv is a byte count, and 0 means βwhatever is availableβ. Two sends came out as one binary. A large send over a real network can just as well arrive in several pieces. The receiver must never assume that one recv equals one message. It has to find the boundaries itself, usually by keeping a buffer of unprocessed bytes. Lessons 16 and 18 do exactly that for HTTP and WebSocket. For a line-based protocol the {packet, line} option does the buffering for us.
Passive and Active Mode
gen_tcp:recv blocks the calling process. A chat connection process cannot afford that, because it must also react to messages from other processes. In active mode the socket sends incoming data to its owner as messages.
11> inet:setopts(Socket, [{active, once}]).ok12> gen_tcp:send(Client, <<"first">>).ok13> flush().Shell got {tcp,#Port<0.5>,<<"first">>}ok14> gen_tcp:send(Client, <<"second">>).ok15> flush().ok16> inet:setopts(Socket, [{active, once}]).ok17> flush().Shell got {tcp,#Port<0.5>,<<"second">>}ok18> gen_tcp:close(Client).ok19> inet:setopts(Socket, [{active, once}]).ok20> flush().Shell got {tcp_closed,#Port<0.5>}okData arrives as {tcp, Socket, Data}. A closed connection arrives as {tcp_closed, Socket}.
There are three settings. With {active, true} every packet becomes a message immediately. A fast or malicious client can then fill the mailbox faster than the process handles it, and memory grows without limit. With {active, false} nothing is pushed, but we have to block in recv. {active, once} delivers one message and then switches the socket back to passive. Unread data stays in the OS buffer, the TCP window fills up, and the sender has to slow down. That is flow control. The process asks for the next message only when it is ready for it, as prompt 16 shows.
The Socket Owner
Active mode messages go to one process, the controlling process. At first that is the process that called accept. If a different process should handle the connection, ownership must be handed over with gen_tcp:controlling_process/2. Only the current owner can do that. A socket is also linked to its owner. When the owner dies, the socket closes, so a crashed connection process never leaks a socket.
The Echo Server
The server has one acceptor process that loops on accept. For each client it spawns a handler, transfers ownership, and goes straight back to accepting. A slow client never delays the next one. The demo uses {packet, line} so every message is one line of text.
Create echo_server.erl:
-module(echo_server).-export([start/0, start/1]).
start() -> start(8080).
start(Port) -> spawn(fun() -> listen(Port) end).
listen(Port) -> Options = [binary, {packet, line}, {active, false}, {reuseaddr, true}], {ok, ListenSocket} = gen_tcp:listen(Port, Options), io:format("Echo server listening on port ~p~n", [Port]), accept_loop(ListenSocket).
accept_loop(ListenSocket) -> {ok, Socket} = gen_tcp:accept(ListenSocket), Pid = spawn(fun() -> wait_for_socket() end), ok = gen_tcp:controlling_process(Socket, Pid), Pid ! {socket, Socket}, accept_loop(ListenSocket).
wait_for_socket() -> receive {socket, Socket} -> io:format("~p: client connected~n", [self()]), loop(Socket) end.
loop(Socket) -> ok = inet:setopts(Socket, [{active, once}]), receive {tcp, Socket, <<"quit", _/binary>>} -> gen_tcp:send(Socket, <<"bye\n">>), gen_tcp:close(Socket); {tcp, Socket, Line} -> gen_tcp:send(Socket, [<<"echo: ">>, Line]), loop(Socket); {tcp_closed, Socket} -> io:format("~p: client disconnected~n", [self()]); {tcp_error, Socket, Reason} -> io:format("~p: socket error ~p~n", [self(), Reason]) end.The listen socket is passive, so accepted sockets start passive too. No data can be delivered to the acceptor by accident. The handler waits for the {socket, Socket} message, which is sent only after the ownership transfer, and then enables {active, once} itself. gen_tcp:send/2 accepts iodata, so we send a list of two binaries instead of building a new one.
Start it in the shell.
1> c(echo_server).{ok,echo_server}2> echo_server:start().<0.91.0>Echo server listening on port 8080Connect from another terminal with nc (or telnet localhost 8080).
$ nc localhost 8080helloecho: hellohow are you?echo: how are you?quitbyeOpen a second nc while the first is still connected. Both work at the same time, each with its own process. Close one with Ctrl+C and the Erlang shell reports it.
<0.93.0>: client connected<0.94.0>: client connected<0.94.0>: client disconnectedExercises
Exercise 1: Passive handler
Rewrite loop/1 to use gen_tcp:recv/2 instead of active mode. Does the handler still need controlling_process? What happens to the socket if the handler crashes?
loop(Socket) -> case gen_tcp:recv(Socket, 0) of % {ok, Line} -> ... % {error, closed} -> ... end.Exercise 2: Idle timeout
Disconnect clients that send nothing for 30 seconds. Add an after clause to the receive in loop/1.
% Expected in nc after 30 idle seconds:% idle timeout, goodbyeExercise 3: See the byte stream
Change the listen options to {packet, raw} and print every binary the handler receives. Paste several lines into nc at once.
% Expected: one {tcp, Socket, Data} message can contain several lines,% for example <<"one\ntwo\nthree\n">>Exercise 4: Count connections
Make the acceptor pass a connection number to each handler and include it in the log output.
% Expected: accept_loop(ListenSocket, Count)% #1: client connected% #2: client connectedKey Takeaways
- Listen socket and connection sockets are different things.
gen_tcp:listen/2opens the port and everygen_tcp:accept/1returns a new socket for one client - One process per connection keeps clients independent. The acceptor only accepts and hands the socket over
- Passive mode uses the blocking
gen_tcp:recv/2. Active mode delivers{tcp, Socket, Data}and{tcp_closed, Socket}messages {active, once}gives flow control. The process re-arms the socket when it is ready, so a fast client cannot flood the mailbox- The controlling process receives the messages and keeps the socket alive. Hand it over with
gen_tcp:controlling_process/2 - TCP is a byte stream. Sends can arrive merged or split, so protocols need framing and receivers need a buffer
binarymode and iodata keep network code cheap. Send lists of binaries without concatenating them
Interactive Koans
Koans - Test Your Understanding
Fill in the blanks and press Enter or click Run to test your knowledge!
What does recv return for the data the client sent?
π‘ Hint
π Explanation
What error reason does recv return after the client closed the connection?
π‘ Hint
π Explanation
The client sends twice. What does a recv of exactly 3 bytes return?
π‘ Hint
π Explanation
Which atom tags the message that carries data from an active socket?
π‘ Hint
π Explanation
Which packet option makes recv return one line at a time?
π‘ Hint
π Explanation
Which value re-arms the socket for exactly one more message?
π‘ Hint
π Explanation
Which gen_tcp function makes the handler the owner, so it receives the data?
π‘ Hint
π Explanation
Finished this lesson?
Mark it as complete to track your progress
This open source tutorial is brought to you by East Branch Software - we build reliable software systems.
Found an issue? Edit this page on GitHub or open an issue