Among the protocols commonly used to develop networked applications, the
TCP
protocol is even more widely used than UDP.
It involves running a
server program that waits for
connections
arriving on a
port number it is
bound to.
This
server can be accessed by multiple
client programs that
connect to the
server's IP address and port.
Once a connection has been established, the client and the server use it
to exchange
bidirectional streams of bytes.
From your host system, with the convenience of your usual text editor,
create a file named
SHARED/prog_tcp_server.py with the following
initial contents:
#!/usr/bin/env python3
import sys
import socket
def main():
try:
port=int(sys.argv[1]) # expect a port number on the command line
except:
print(f'usage: {sys.argv[0]} port'); sys.exit(1)
#
server=socket.socket(socket.AF_INET, socket.SOCK_STREAM) # create TCP socket
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # (optional)
server.bind(('0.0.0.0', port)) # bind this socket to the chosen port number
server.listen() # dedicated to accepting connections
#
name=socket.gethostname() # determine the name of the current machine
print(f'host {name} waiting for TCP connections on port {port}')
#
while True:
(conn, (client_ip, client_port)) = server.accept() # wait for connection
handle_client(conn, client_ip, client_port)
#
server.close() # close TCP listener once it becomes useless (never reached)
def handle_client(conn, client_ip, client_port):
client=f'{client_ip}:{client_port}'
print(f'connection from', client)
#
conn_file=conn.makefile('rw', buffering=1) # helper for text-lines
while True:
request=conn_file.readline() # wait for text-line (ended with \n)
if not request: break # EOF on TCP socket
print(f'from {client}: {request!r}')
#
reply=f'{len(request)} chars: {request!r}\n' # prepare reply
conn_file.write(reply) # send text-line to client
#
print(f'{client} disconnected')
conn_file.close() # close text-line helper once it becomes useless
conn.close() # close TCP connection once it becomes useless
if __name__=='__main__': main()
Make an effort to understand how it works by reading the comments.
The parameters chosen when creating this
socket indicate that it
is to be dedicated to the
TCP protocol.
The
SO_REUSEADDR option is not generally necessary, but it makes
program development easier
When a TCP server is terminated while connections are still
established, the protocol prevents the port to which the server
was bound from being reused for a few seconds or minutes.
An immediate restart will therefore usually result in the .bind()
operation failing on that port.
Under these circumstances, you must either wait for the timeout
before reusing the port or change to a different port,
which is inconvenient during the development phase.
The SO_REUSEADDR option allows this port to be reused immediately
when the server is restarted.
.
In addition to the
.bind() operation, which binds the TCP socket
to a port number, the
.listen() operation tells the operating
system that this TCP socket is to be dedicated to detecting
incoming connections.
The main loop of a TCP server then consists of waiting for a connection
from a TCP client to be detected, and then communicating with that
client through the established connection.
As this code suggests, the
.accept() operation provides both
a connection (
conn, another TCP socket) and the details
(IP address and port number) of the client socket TCP that made
this connection.
The
handle_client() function is responsible for the entire dialogue
between the TCP client that has just connected and the TCP server.
For the sake of simplicity, since this example deals only with lines of text,
a helper (
conn_file) takes care of the conversions between a stream
of bytes and lines of text.
As in the case of the UDP server, each
request received is used to
produce and send back a
reply.
This dialogue ends when the TCP client closes the connection, causing
the end of file (
EOF) to be detected.
From machine
A, make this file executable using the command
chmod +x SHARED/prog_tcp_server.py and then run it with
SHARED/prog_tcp_server.py 9988.
On machines
B and
C
Also from machines D, E or F, if you created them.
, use the command
ncat A 9988.
Each
ncat command acts as a TCP client connecting to the specified
TCP server, and interacting with it; anything typed at the keyboard is
sent as a request through the connection, and the replies received
through that connection are displayed in the terminal.
On some of these machines, open another terminal
You can open new terminals in the virtual machine with the
Alt ⟶ or Alt ⟵ keys.
and run the command
ss -pant; you should see a line indicating
that the
ncat program has
ESTABlished a connection to your
TCP server.
On machine
A, open another terminal
You can open new terminals in the virtual machine with the
Alt ⟶ or Alt ⟵ keys.
and run the command
ss -pant; you should see a line indicating
that your Python program is indeed
LISTENing port 9988, and
some other lines about the connections to this server
ESTABlished
by the clients, as you just saw on the other machines.
Observe that only the first client connecting to the server actually
interacts; the other clients seem to have no incidence on the server.
However, as soon as the first client disconnects (pressing the
Ctrl d
key combination), observe that the next one is considered by the server,
and so on...
As it stands, your TCP server is capable of serving only one client
at a time, which is rather disappointing, but understandable.
Indeed, the source code shows that the dialogue with one client
(
handle_client()) must be completed before the
server considers the next connection (
.accept()).
To fix this limitation, modify your TCP server as follows:
# ...previous import statements...
import threading
# ...main program...
while True:
(conn, (client_ip, client_port)) = server.accept() # wait for connection
# handle_client(conn, client_ip, client_port)
threading.Thread(
target=handle_client, # invoke handle_client()
args=(conn, client_ip, client_port), # with these three parameters
daemon=True).start() # don't wait for its termination
The idea is to start a
thread
that executes the same function as before (it is left commented out for
reference), but
in parallel with the main program.
As a result, the main program loop immediately returns to waiting for the
next connection (
.accept()), while the dialogue with the client from
the previous connection can continue for as long as necessary in a
parallel execution.
Under these conditions, there are as many threads as there are established
connections (each executes the
handle_client() function to communicate
with the TCP client that created the connection), and the main program is
always ready to accept new connections from new TCP clients.
Stop your TCP server and your
ncat TCP clients by pressing
Ctrl c,
then restart the newly modified TCP server on machine
A with
SHARED/prog_tcp_server.py 9988.
On machines
B and
C
Also from machines D, E or F, if you created them.
, use again the command
ncat A 9988 to verify that multiple TCP
clients can now interact simultaneously, each at its own pace, with your
TCP server.
This solution, which consists of using one thread per connection, is
sufficient for a small-scale project such as this illustrative example.
However, such a server would not be able to cope with being contacted by a
very large number of clients simultaneously, because managing a huge number
of threads is generally very demanding in terms of system resources.
Modern solutions combine the use of a small number of threads (to take
advantage of the parallelism available on multi-processor and
multi-core hardware) with
asynchronous
programming to distribute the processing associated with multiple
connections across those few threads.