Connecting devices to exchange information is what networking is all about. Sockets are an essential part of effective network communication as they are the underlying concept used to transmit messages between devices over local or global networks and different processes on the same machine. They provide a low-level interface that allows for fine-grained control over the traffic that is to be sent or received.
This low-level nature makes it possible to create very performant communication channels (or custom protocols) for specific use cases with low overhead that may be present in traditional protocols, which are built on top of socket communication.
This is what makes sockets exceptionally useful in real-time client-server applications that depend on instant message exchange or operate with huge amounts of data.
In this article, we will cover the basics of socket programming and provide a step-by-step guide to creating socket-based client and server applications using Python. So without further ado, let's dive right in!
Basics of Networking
Networking enables communication and information sharing of any kind.
It is a process of connecting two or more devices to allow them to exchange information. A collection of such interconnected devices is called a network.
There are a lot of networks that we can observe in our physical world: airline or powerline networks or cities interconnected with one another via highways are some good examples.
In much the same way, there are numerous networks in information technology; the most prominent and well-known of which is the internet, the global network of networks that connects myriad devices and the one that you are probably using right now to read this article.
Types of networks
The internet contains many more networks, which differ by scale or other properties, within itself: for example, local area networks (LANs), which typically link computers located in close proximity to one another. Machines in companies or other institutions (banks, universities, etc.) or even your home devices connected to a router comprise such a network.
There are also bigger or smaller types of networks like PANs (personal area network) which can simply be your smartphone connected to a laptop via Bluetooth, MANs (metropolitan area network), which can interconnect devices in the entire city, and WANs (wide area network), which can cover entire countries or the whole world. And yes, the biggest WAN network is the internet itself.
It goes without saying that computer networks can be very complex and consist of many elements. One of the most basic and crucial primitives is a communication protocol.
Types of network communication protocols
Communication protocols specify the rules of how and in what format information should be sent and received. These protocols are assembled into a hierarchy to manage the various tasks involved in network communication.
In other words, some protocols handle how hardware receives, sends, or routes packets, while others are more high-level and are concerned, for example, with application-level communication etc.
Some commonly used and widely well-known network communication protocols include:
Wi-Fi
An example of a link layer protocol, meaning it sits very close to the hardware and is responsible for physically sending data from one device to another in a wireless environment.
IP (Internet Protocol)
IP is a network layer protocol mainly responsible for routing packets and IP addressing.
TCP (Transmission Control Protocol)
A reliable, connection-oriented protocol that provides full duplex communication and ensures data integrity and delivery. This is a transport layer protocol, which manages connections, detects errors, and controls information flow.
UDP (User Datagram Protocol)
A protocol from the same protocol suite as TCP. The main difference is that UDP is a more simple, fast, but unreliable connectionless protocol that does not perform any delivery checks and follows the paradigm of “fire-and-forget.” As TCP, UPD is also located on the transport layer.
HTTP (Hypertext Transfer Protocol)
An application layer protocol and the most commonly used protocol for browser-to-server communication on the web, used to serve websites in particular. It goes without saying that this article that you are reading right now was also served via HTTP. HTTP protocol builds on top of TCP and manages and transfers information relevant to web applications like headers, which are used to transfer metadata and cookies, different HTTP methods (GET, POST, DELETE, UPDATE) etc.
MQTT (Message Queue Telemetry Transport)
Another example of an application-level protocol used for devices with limited processing power and battery life, operating in unreliable network conditions (for example, gas sensors on a mining site or simply a smart light bulb in your house). MQTT is a standard messaging protocol used in IoT (Internet of Things). It is both lightweight and simple to use, designed with built-in retransmission mechanisms for enhanced reliability. If you're interested in using this protocol with Python, you can read this Python MQTT guide that provides an in-depth overview of the Paho MQTT client.
An important observation is that all the abovementioned protocols use sockets under the hood but add their own logic and data processing on top. This is due to sockets being a low-level interface for any network communications in modern devices as we will discuss in the next section.
Key Concepts and Terms
Of course, there are a lot of other important concepts and terms used in the context of networks. Here is a quick run-down on some of the most prominent ones that may arise in the rest of the tutorial:
- Packet: a standard unit of data transmission in a computer network (one could colloquially compare it to the term “message”).
- Endpoint: a destination where packets arrive.
- IP address: a numerical identifier that uniquely identifies a device on the network. An example of an IP address is: 192.168.0.0
- Ports: a numerical identifier that uniquely identifies a process that is running on a device and handles particular network communications: for example, it serves your website over HTTP. While an IP address identifies the device, a port identifies the application (every application is a process or consists of processes). Some well-known port examples are: port 80, which is conventionally used by server applications to manage HTTP traffic, and port 443 for HTTPS (secure HTTP).
- Gateway: a special kind of network node (device) which serves as an access point from one network to another. These networks may even use different protocols, so some protocol translation might be necessary to be performed by the gateway. An example of a gateway can be a router which connects a home local network to the Internet.
Understanding Sockets
What is a socket?
A socket is an interface (gate) for communication between different processes located on the same or different machines. In the latter case, we speak about network sockets.
Network sockets abstract away connection management. You can think of them as connection handlers. In Unix systems, in particular, sockets are simply files that support the same write-read operations but send all the data over the network.
When a socket is in listening or connecting state, it is always bound to a combination of an IP address plus a port number which identifies the host (machine/device) and the process.
How socket connections work
Sockets can listen for incoming connections or perform outbound connections themselves. When a connection is established, the listening socket (server socket) gets additionally bound to the IP and the port of the connecting side.
Or alternatively, a new socket which is now bound to two pairs of IP addresses and port numbers of a listener and a requestor is created. This way, two connected sockets on different machines can identify one another and share a single connection for data transmission without blocking the listening socket that in the meantime continues listening for other connections.
In case of the connecting socket (client socket), it gets implicitly bound to the ip address of the device and a random accessible port number upon connection initiation. Then, upon connection establishment, a binding to the other communication side’s IP and port happens in much the same way as for a listening socket but without creating a new socket.
Sockets in the context of networks
In this tutorial, we are concerned not with socket implementation but with what sockets mean in the context of networks.
One can say that a socket is a connection endpoint (traffic destination) which is on one side associated with the host machine's IP address and the port number of the application for which the socket was created, and on the other, it is associated to the IP address and the port of the application running on another machine to which the connection is established.
Socket programming
When we talk about socket programming, we instantiate socket objects in our code and perform operations on them (listen, connect, receive, send etc.). In this context, sockets are simply special objects we create in our program that have special methods for working with network connections and traffic.
Under the hood those methods call your operating system kernel, or more specifically, the network stack, which is a special part of the kernel responsible for managing network operations.
Sockets and client-server communication
Now, it’s also important to mention that sockets often appear in the context of client-server communication.
The idea is simple: sockets relate to connections; they are connection handlers. On the web, whenever you want to send or receive some data, you initiate a connection (which is being initiated through the interface called sockets).
Now, either you or the party you are trying to connect to acts as a server and another party as a client. While a server serves data to clients, clients proactively connect and request data from a server. A server listens via a listening socket for new connections, establishes them, gets the client’s requests, and communicates the requested data in its response to the client.
On the other hand, a client creates a socket using the IP address and port of the server it wishes to connect to, initiates a connection, communicates its request to the server, and receives data in response. This seamless exchange of information between the client and server sockets forms the backbone of various network applications.
Sockets as a base for network protocols
The fact that sockets form a backbone also means that there are various protocols built and used on top of them. Very common ones are UDP and TCP, which we have briefly talked about already. Sockets that use one of these transport protocols are called UDP or TCP sockets.
IPC sockets
Apart from network sockets, there are also other types. For example, IPC (Inter Process Communication) sockets. IPC sockets are meant to transfer data between processes on the same machine, whereas network sockets can do the same across the network.
The good thing about IPC sockets is that they avoid a lot of the overhead of constructing packets and resolving the routes to send the data. Since in the context of IPC sender and receiver are local processes, communication via IPC sockets typically has lower latency.
Unix-sockets
A good example of IPC sockets are Unix-sockets which are, as with everything in Unix, just files on the filesystem. They are not identified by the IP address and port but rather by the file path on the filesystem.
Network sockets as IPC sockets
Note that you can just as well use network sockets for inter-process communications if both server and receiver are on localhost (i.e., have an IP address 127.0.0.1).
Of course, on the one hand, this adds additional latency because of the overhead associated with processing your data by the network stack, but on the other hand, this allows us not to worry about the underlying operating system, as network sockets are present and work on all systems as opposed to IPC sockets which are specific to a given OS or OS-family.
Python Socket Library
For socket programming in Python, we use the official built-in Python socket library consisting of functions, constants, and classes that are used to create, manage and work with sockets. Some commonly used functions of this library include:
- socket(): Creates a new socket.
- bind(): Associates the socket to a specific address and port.
- listen(): Starts listening for incoming connections on the socket.
- accept(): Accepts a connection from a client and returns a new socket for communication.
- connect(): Establishes a connection to a remote server.
- send(): Sends data through the socket.
- recv(): Receives data from the socket.
- close(): Closes the socket connection.
Python Socket Example
Let’s take a look at socket programming with a practical example written in Python. Here, our goal is to connect two applications and make them communicate with one another. We will be using Python socket library to create a server socket application that will communicate and exchange information with a client across a network.
Considerations and limitations
Note, however, that for educational purposes, our example is simplified, and the applications will be running locally and not talk over the actual network - we will use a loopback localhost address to connect the client to the server.
This means that both client and server will run on the same machine and the client will be initiating a connection to the same machine it is running on, albeit to a different process that represents the server.
Running on different machines
Alternatively, you could have your applications on two different devices and have them both connected to the same Wi-Fi router, which would form a local area network. Then the client running on one device could connect to the server running on a different machine.
In this case, however, you would need to know the IP addresses that your router assigned to your devices and use them instead of localhost (127.0.0.1) loopback IP address (to see IP addresses, use ifconfig
terminal command for Unix-like systems or ipconfig
- for Windows). After you obtain the IP addresses of your applications, you can change them in the code accordingly, and the example will still work.
Anyway, we are going to start with our example. You will, of course, need to have Python installed if you want to follow along.
Creating socket server in Python
Let’s start with creating a socket server (Python TCP server, in particular, since it will be working with TCP sockets, as we will see), which will exchange messages with clients. To clarify the terminology, while technically any server is a socket server, since sockets are always used under the hood to initiate network connections, we use the phrase “socket server” because our example explicitly makes use of socket programming.
So, follow the steps below:
Creating python file with some boilerplate
- Create a file named
server.py
- Import the
socket
module in your Python script.
import socket
- Add a function called
run_server
. We will be adding most of our code there. When you add your code to the function, don’t forget to properly indent it:
def run_server():
# your code will go here
Instantiating socket object
As a next step, in run_server
, create a socket object using the socket.socket()
function.
The first argument (socket.AF_INET
) specifies the IP address family for IPv4 (other options include: AF_INET6
for IPv6 family and AF_UNIX
for Unix-sockets)
The second argument (socket.SOCK_STREAM)
indicates that we are using a TCP socket.
In case of using TCP, the operating system will create a reliable connection with in-order data delivery, error discovery and retransmission, and flow control. You will not have to think about implementing all those details.
There is also an option for specifying a UDP socket: socket.SOCK_DGRAM
. This will create a socket which implements all the features of UDP under the hood.
In case you want to go more low-level than that and build your own transport layer protocol on top of the TCP/IP network layer protocol used by sockets, you can use socket.RAW_SOCKET
value for the second argument. In this case the operating system will not handle any higher level protocol features for you and you will have to implement all the headers, connection confirmation and retransmission functionalities yourself if you need them. There are also other values that you can read about in the documentation.
# create a socket object
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
Binding server socket to IP address and port
Define the hostname or server IP and port to indicate the address which the server will be reachable from and where it will listen for incoming connections. In this example, the server is listening on the local machine - this is defined by the server_ip
variable set to 127.0.0.1
(also called localhost).
The port
variable is set to 8000
, which is the port number that the server application will be identified by by the operating system (It is recommended to use values above 1023 for your port numbers to avoid collisions with ports used by system processes).
server_ip = "127.0.0.1"
port = 8000
Prepare the socket to receive connections by binding it to the IP address and port which we have defined before.
# bind the socket to a specific address and port
server.bind((server_ip, port))
Listening for incoming connections
Set up a listening state in the server socket using the listen
function to be able to receive incoming client connections.
This function accepts an argument called backlog
which specifies the maximum number of queued unaccepted connections. In this example, we use the value 0
for this argument. This means that only a single client can interact with the server. A connection attempt of any client performed while the server is working with another client will be refused.
If you specify a value that is bigger than 0
, say 1
, it tells the operating system how many clients can be put into the queue before the accept
method is called on them.
Once accept
is called a client is removed from the queue and is no longer counted towards this limit. This may become clearer once you see further parts of the code, but what this parameter essentially does can be illustrated as follows: once your listening server receives the connection request it will add this client to the queue and proceed to accepting it’s request. If before the server was able to internally call accept
on the first client, it receives a connection request from a second client, it will push this second client to the same queue provided that there is enough space in it. The size of exactly this queue is controlled by the backlog argument. As soon as the server accepts the first client, this client is removed from the queue and the server starts communicating with it. The second client is still left in the queue, waiting for the server to get free and accept the connection.
If you omit the backlog argument, it will be set to your system’s default (under Unix, you can typically view this default in the /proc/sys/net/core/somaxconn
file).
# listen for incoming connections
server.listen(0)
print(f"Listening on {server_ip}:{port}")
Accepting incoming connections
Next, wait and accept incoming client connections. The accept
method stalls the execution thread until a client connects. Then it returns a tuple pair of (conn, address)
, where address is a tuple of the client's IP address and port, and conn
is a new socket object which shares a connection with the client and can be used to communicate with it.
accept
creates a new socket to communicate with the client instead of binding the listening socket (called server
in our example) to the client's address and using it for the communication, because the listening socket needs to listen to further connections from other clients, otherwise it would be blocked. Of course, in our case, we only ever handle a single client and refuse all the other connections while doing so, but this will be more relevant once we get to the multithreaded server example.
# accept incoming connections
client_socket, client_address = server.accept()
print(f"Accepted connection from {client_address[0]}:{client_address[1]}")
Creating communication loop
As soon as a connection with the client has been established (after calling the accept
method), we initiate an infinite loop to communicate. In this loop, we perform a call to the recv
method of the client_socket
object. This method receives the specified number of bytes from the client - in our case 1024.
1024 bytes is just a common convention for the size of the payload, as it’s a power of two which is potentially better for optimization purposes than some other arbitrary value. You are free to change this value however you like though.
Since the data received from the client into the request
variable is in raw binary form, we transformed it from a sequence of bytes into a string using the decode
function.
Then we have an if statement, which breaks out of the communication loop in case we receive a ”close”
message. This means that as soon as our server gets a ”close”
string in request, it sends the confirmation back to the client and terminates its connection with it. Otherwise, we print the received message to the console. Confirmation in our case is just sending a ”closed”
string to the client.
Note that the lower
method that we use on the request
string in the if statement, simply converts it to lowercase. This way we don’t care whether the close
string was originally written using uppercase or lowercase characters.
# receive data from the client
while True:
request = client_socket.recv(1024)
request = request.decode("utf-8") # convert bytes to string
# if we receive "close" from the client, then we break
# out of the loop and close the conneciton
if request.lower() == "close":
# send response to the client which acknowledges that the
# connection should be closed and break out of the loop
client_socket.send("closed".encode("utf-8"))
break
print(f"Received: {request}")
Sending response back to client
Now we should handle the normal response of the server to the client (that is when the client doesn’t wish to close the connection). Inside the while loop, right after print(f"Received: {request}")
, add the following lines, which will convert a response string (”accepted”
in our case) to bytes and send it to the client. This way whenever server receives a message from the client which is not ”close”
, it will send out the ”accepted”
string in response:
response = "accepted".encode("utf-8") # convert string to bytes
# convert and send accept response to the client
client_socket.send(response)
Freeing resources
Once we break out from the infinite while loop, the communication with the client is complete, so we close the client socket using the close
method to release system resources. We also close the server socket using the same method, which effectively shuts down our server. In a real world scenario, we would of course probably want our server to continue listening to other clients and not shut down after communicating with just a single one, but don’t worry, we will get to another example further below.
For now, add the following lines after the infinite while loop:
# close connection socket with the client
client_socket.close()
print("Connection to client closed")
# close server socket
server.close()
Note: don’t forget to call the run_server
function at the end of your server.py
file. Simply use the following line of code:
run_server()
Complete server socket code example
Here is the complete server.py
source code:
import socket
def run_server():
# create a socket object
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_ip = "127.0.0.1"
port = 8000
# bind the socket to a specific address and port
server.bind((server_ip, port))
# listen for incoming connections
server.listen(0)
print(f"Listening on {server_ip}:{port}")
# accept incoming connections
client_socket, client_address = server.accept()
print(f"Accepted connection from {client_address[0]}:{client_address[1]}")
# receive data from the client
while True:
request = client_socket.recv(1024)
request = request.decode("utf-8") # convert bytes to string
# if we receive "close" from the client, then we break
# out of the loop and close the conneciton
if request.lower() == "close":
# send response to the client which acknowledges that the
# connection should be closed and break out of the loop
client_socket.send("closed".encode("utf-8"))
break
print(f"Received: {request}")
response = "accepted".encode("utf-8") # convert string to bytes
# convert and send accept response to the client
client_socket.send(response)
# close connection socket with the client
client_socket.close()
print("Connection to client closed")
# close server socket
server.close()
run_server()
Note that in order not to convolute and complicate this basic example, we omitted the error handling. You would of course want to add try-except blocks and make sure that you always close the sockets in the finally
clause. Continue reading and we will see a more advanced example.
Creating Client Socket in Python
After setting up your server, the next step is to set up a client that will connect and send requests to your server. So, let’s start with the steps below:
Creating python file with some boilerplate
- Create a new file named
client.py
- Import the socket library:
import socket
- Define the
run_client
function where we will place all our code:
def run_client():
# your code will go here
Instantiating socket object
Next, use the socket.socket()
function to create a TCP socket object which serves as the client's point of contact with the server.
# create a socket object
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
Connecting to server socket
Specify the IP address and port of the server to be able to connect to it. These should match the ip address and port that you set in server.py
before.
server_ip = "127.0.0.1" # replace with the server's IP address
server_port = 8000 # replace with the server's port number
Establish a connection with the server using the connect
method on the client socket object. Note that we did not bind the client socket to any IP address or port. This is normal for the client, because connect
will automatically choose a free port and pick up an IP address that provides the best route to the server from the system’s network interfaces (127.0.0.1
in our case) and bind the client socket to those.
# establish connection with server
client.connect((server_ip, server_port))
Creating communication loop
After having established a connection, we start an infinite communication loop to send multiple messages to the server. We get input from the user using Python’s built-in input
function, then encode it into bytes and trim to be 1024 bytes at max. After that we send the message to the server using client.send
.
while True:
# input message and send it to the server
msg = input("Enter message: ")
client.send(msg.encode("utf-8")[:1024])
Handling server’s response
Once the server receives a message from the client, it responds to it. Now, in our client code, we want to receive the server's response. For that, in the communication loop, we use the recv
method to read 1024 bytes at most. Then we convert the response from bytes into a string using decode
and then check if it is equal to the value ”closed”
. If this is the case, we break out of the loop which as we later see, will terminate the client’s connection. Otherwise, we print the server’s response into the console.
# receive message from the server
response = client.recv(1024)
response = response.decode("utf-8")
# if server sent us "closed" in the payload, we break out of the loop and close our socket
if response.lower() == "closed":
break
print(f"Received: {response}")
Freeing resources
Finally, after the while loop, close the client socket connection using the close
method. This ensures that resources are properly released and the connection is terminated (i.e. when we receive the “closed”
message and break out of the while loop).
# close client socket (connection to the server)
client.close()
print("Connection to server closed")
Note: Again, don’t forget to call the run_client
function, which we have implemented above, at the end of the file as follows:
run_client()
Complete client socket code example
Here is the complete client.py
code:
import socket
def run_client():
# create a socket object
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_ip = "127.0.0.1" # replace with the server's IP address
server_port = 8000 # replace with the server's port number
# establish connection with server
client.connect((server_ip, server_port))
while True:
# input message and send it to the server
msg = input("Enter message: ")
client.send(msg.encode("utf-8")[:1024])
# receive message from the server
response = client.recv(1024)
response = response.decode("utf-8")
# if server sent us "closed" in the payload, we break out of the loop and close our socket
if response.lower() == "closed":
break
print(f"Received: {response}")
# close client socket (connection to the server)
client.close()
print("Connection to server closed")
run_client()
Test your client and server
To test the the server and client implementation that we wrote above, perform the following:
- Open two terminal windows simultaneously.
- In one terminal window, navigate to the directory where the
server.py
file is located and run the following command to start the server:
python server.py
This will bind the server socket to the localhost address (127.0.0.1) on port 8000 and start listening for incoming connections.
- In the other terminal, navigate to the directory where the
client.py
file is located and run the following command to start the client:
python client.py
This will prompt for user input. You can then type in your message and press Enter. This will transfer your input to the server and display it in its terminal window. The server will send its response to the client and the latter will ask you for the input again. This will continue until you send the ”close”
string to the server.
Working with Multiple Clients – Multithreading
We have seen how a server responds to requests from a single client in the previous example, however, in many practical situations, numerous clients may need to connect to a single server at once. This is where multithreading comes in. Multithreading is used in situations where you need to handle several tasks (e.g. execute multiple functions) concurrently (at the same time).
The idea is to spawn a thread which is an independent set of instructions that can be handled by the processor. Threads are much more lightweight than the processes because they actually live within a process itself and you don’t have to allocate a lot of resources for themselves.
Limitations of multithreading in python
Note that multithreading in Python is limited. Standard Python implementation (CPython) cannot run threads truly in parallel. Only a single thread is allowed to execute at a time due to the global interpreter lock (GIL). This is, however, a separate topic, which we are not going to discuss. For the sake of our example, using limited CPython threads is enough and gets the point across. In a real-world scenario, however, if you are going to use Python, you should look into asynchronous programming. We are not going to talk about it now, because it is again a separate topic and it usually abstracts away some low-level socket operations which we specifically focus on in this article.
Multithreaded server example
Let's look at the example below on how multithreading may be added to your server to handle a large number of clients. Note that this time we will also add some basic error handling using the try-except-finally blocks. To get started, follow the steps below:
Creating thread-spawning server function
In your python file, import the socket
and threading
modules to be able to work with both sockets and threads:
import socket
import threading
Define the run_server
function which will, as in the example above, create a server socket, bind it and listen to the incoming connections. Then call accept
in an infinite while loop. This will always keep listening for new connections. After accept
gets an incoming connection and returns, create a thread using threading.Thread
constructor. This thread will execute the handle_client
function which we are going to define later, and pass client_socket
and addr
to it as arguments (addr
tuple holds an IP address and a port of the connected client). After the thread is created, we call start
on it to begin its execution.
Remember that accept
call is blocking, so on the first iteration of the while loop, when we reach the line with accept
, we halt and wait for a client connection without executing anything else. As soon as the client connects, accept
method returns, and we continue the execution: spawn a thread, which will handle said client and go to the next iteration where we will again halt at the accept
call waiting for another client to connect.
At the end of the function, we have some error handling which ensures that the server socket is always closed in case something unexpected happens.
def run_server():
server_ip = "127.0.0.1" # server hostname or IP address
port = 8000 # server port number
# create a socket object
try:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to the host and port
server.bind((server_ip, port))
# listen for incoming connections
server.listen()
print(f"Listening on {server_ip}:{port}")
while True:
# accept a client connection
client_socket, addr = server.accept()
print(f"Accepted connection from {addr[0]}:{addr[1]}")
# start a new thread to handle the client
thread = threading.Thread(target=handle_client, args=(client_socket, addr,))
thread.start()
except Exception as e:
print(f"Error: {e}")
finally:
server.close()
Note that the server in our example will only be stopped in case an unexpected error occurs. Otherwise, it will listen for the clients indefinitely, and you will have to kill the terminal if you want to stop it.
Creating client-handling function to run in separate thread
Now, above the run_server
function, define another one called handle_client
. This function will be the one executing in a separate thread for every client’s connection. It receives the client's socket object and the addr
tuple as arguments.
Inside this function, we do the same as we did in a single threaded example plus some error handling: we start a loop to get messages from the client using recv
.
Then we check if we got a close message. If so, we respond with the ”closed”
string and close the connection by breaking out of the loop. Otherwise, we print out the client’s request string into the console and proceed to the next loop iteration to receive the next client’s message.
At the end of this function, we have some error handling for unexpected cases (except
clause), and also a finally
clause where we release client_socket
using close
. This finally
clause will always be executed no matter what, which ensures that the client socket is always properly released.
def handle_client(client_socket, addr):
try:
while True:
# receive and print client messages
request = client_socket.recv(1024).decode("utf-8")
if request.lower() == "close":
client_socket.send("closed".encode("utf-8"))
break
print(f"Received: {request}")
# convert and send accept response to the client
response = "accepted"
client_socket.send(response.encode("utf-8"))
except Exception as e:
print(f"Error when hanlding client: {e}")
finally:
client_socket.close()
print(f"Connection to client ({addr[0]}:{addr[1]}) closed")
When handle_client
returns, the thread which executes it, will also be automatically released.
Note: Don’t forget to call the run_server
function at the end of your file.
Complete multithreaded server code example
Now, let's put the complete multithreading server code together:
import socket
import threading
def handle_client(client_socket, addr):
try:
while True:
# receive and print client messages
request = client_socket.recv(1024).decode("utf-8")
if request.lower() == "close":
client_socket.send("closed".encode("utf-8"))
break
print(f"Received: {request}")
# convert and send accept response to the client
response = "accepted"
client_socket.send(response.encode("utf-8"))
except Exception as e:
print(f"Error when hanlding client: {e}")
finally:
client_socket.close()
print(f"Connection to client ({addr[0]}:{addr[1]}) closed")
def run_server():
server_ip = "127.0.0.1" # server hostname or IP address
port = 8000 # server port number
# create a socket object
try:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to the host and port
server.bind((server_ip, port))
# listen for incoming connections
server.listen()
print(f"Listening on {server_ip}:{port}")
while True:
# accept a client connection
client_socket, addr = server.accept()
print(f"Accepted connection from {addr[0]}:{addr[1]}")
# start a new thread to handle the client
thread = threading.Thread(target=handle_client, args=(client_socket, addr,))
thread.start()
except Exception as e:
print(f"Error: {e}")
finally:
server.close()
run_server()
Note: In a real-world code, to prevent possible problems like race situations or data inconsistencies while dealing with multithreaded servers, it's vital to take thread safety and synchronization techniques into consideration. In our simple example this is, however, not a problem.
Client example with basic error handling
Now that we have a server implementation able to handle multiple clients concurrently, we could use the same client implementation as seen above in the first basic examples to initiate connection, or we could update it slightly and add some error handling. Below you can find the code, which is identical to the previous client example with an addition of try-except blocks:
import socket
def run_client():
# create a socket object
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_ip = "127.0.0.1" # replace with the server's IP address
server_port = 8000 # replace with the server's port number
# establish connection with server
client.connect((server_ip, server_port))
try:
while True:
# get input message from user and send it to the server
msg = input("Enter message: ")
client.send(msg.encode("utf-8")[:1024])
# receive message from the server
response = client.recv(1024)
response = response.decode("utf-8")
# if server sent us "closed" in the payload, we break out of
# the loop and close our socket
if response.lower() == "closed":
break
print(f"Received: {response}")
except Exception as e:
print(f"Error: {e}")
finally:
# close client socket (connection to the server)
client.close()
print("Connection to server closed")
run_client()
Testing the multithreading example
If you want to test the multi-client implementation, open several terminal windows for clients and one for the server. First start the server with python server.py
. After that start a couple clients using python client.py
. In the server terminal windows you will see how new clients get connected to the server. You can now proceed with sending messages from different clients by entering text into the respective terminals and all of them will be handled and printed to the console on the server side.
Socket Programming Applications in Data Science
While every network application uses sockets created by the OS under the hood, there are numerous systems that heavily rely on socket programming specifically, either for certain special use cases or to improve the performance. But how exactly is socket programming useful in the context of data science? Well, it definitely plays a meaningful role, whenever there is a need to receive or send huge amounts of data fast. Hence, socket programming is mainly used for data collection and real-time processing, distributed computing, and inter-process communication. But let's have a closer look at some particular applications in the field of data science.
Real-time data collection
Sockets are widely used to collect real-time data from different sources for further processing, forwarding to a database or to an analytics pipeline etc. For example, a socket can be used to instantly receive data from a financial system or social media API for subsequent processing by data scientists.
Distributed computing
Data scientists may use socket connectivity to distribute the processing and computation of huge data sets across multiple machines. Socket programming is commonly used in Apache Spark and other distributed computing frameworks for communication between the nodes.
Model deployment
Socket programming can be used when serving machine learning models to the users, allowing for instantaneous delivery of predictions and suggestions. In order to facilitate real-time decision-making, data scientists may use performant socket-based server applications that take in large amounts of data, process it using trained models to provide predictions, and then rapidly return the findings to the client.
Inter-Process Communication (IPC)
Sockets can be used for IPC, which allows different processes running on the same machine to communicate with each other and exchange data. This is useful in data science to distribute complex and resource intensive computations across multiple processes. In fact, Python’s subprocessing library is often used for this purpose: it spawns several processes to utilize multiple processor cores and increase application performance when performing heavy calculations. Communication between such processes may be implemented via IPC sockets.
Collaboration and communication
Socket programming allows for real-time communication and collaboration among data scientists. In order to facilitate effective collaboration and knowledge sharing, socket-based chat apps or collaborative data analysis platforms are used.
It’s worth saying that in many of the above applications, data scientists might not be directly involved in working with sockets. They would typically use libraries, frameworks, and systems that abstract away all the low-level details of socket programming. However, under the hood all such solutions are based on socket communication and utilize socket programming.
Socket Programming Challenges and Best Practices
Because sockets are a low-level concept of managing connections, developers working with them have to implement all the required infrastructure around to create robust and reliable applications. This of course comes with a lot of challenges. However, there are some best practices and general guidelines one may follow to overcome these issues. Below are some of the most often encountered problems with socket programming, along with some general tips:
Connection management
Working with many connections at a time; managing multiple clients, and ensuring efficient handling of concurrent requests can certainly be challenging and non-trivial. It requires careful resource management and coordination to avoid bottlenecks
Best practices
- Keep track of active connections using data structures like lists or dictionaries. Or use advanced techniques like connection pooling which also help with scalability.
- Use threading or asynchronous programming techniques to handle multiple client connections at the same time.
- Close connections properly to release resources and avoid memory leaks.
Error handling
Dealing with errors, such as connection failures, timeouts, and data transmission issues, is crucial. Handling these errors and providing appropriate feedback to the clients can be challenging, especially when doing low-level socket programming.
Best practices
- Use try-except-finally blocks to catch and handle specific types of errors.
- Provide informative error messages and consider employing logging to aid in troubleshooting.
Scalability and performance
Ensuring optimal performance and minimizing latency are key concerns when dealing with high-volume data streams or real-time applications.
Best practices
- Optimize your code for performance by minimizing unnecessary data processing and network overhead.
- Implement buffering techniques to efficiently handle large data transfers.
- Consider load balancing techniques to distribute client requests across multiple server instances.
Security and authentication
Securing socket-based communication and implementing proper authentication mechanisms can be difficult. Ensuring data privacy, preventing unauthorized access, and protecting against malicious activities require careful consideration and implementation of secure protocols.
Best practices
- Utilize SSL/TLS security protocols to ensure secure data transmission by encrypting the information.
- Ensure client identity by implementing secure authentication methods like token-based authentication, public-key cryptography, or username/password.
- Ensure that confidential data, such as passwords or API keys, are safeguarded and encrypted or ideally not stored at all (only their hashes if needed).
Network reliability and resilience
Dealing with network interruptions, fluctuating bandwidth, and unreliable connections can pose challenges. Maintaining a stable connection, handling disconnections gracefully, and implementing reconnection mechanisms are essential for robust networked applications.
Best practices
- Use keep-alive messages to detect inactive or dropped connections.
- Implement timeouts to avoid indefinite blocking and ensure timely response handling.
- Implement exponential backoff reconnection logic to establish a connection again if it's lost.
Code maintainability
Last but not the least mention is code maintainability. Because of the low-level nature of socket programming, developers find themselves writing more code. This might quickly turn into an unmaintainable spaghetti code, so it’s essential to organize and structure it as early as possible and spend extra effort on planning your code’s architecture.
Best practices
- Break up your code into classes or functions which ideally shouldn’t be too long.
- Write unit tests early on by mocking your client and server implementations
- Consider using more high-level libraries to deal with connections unless you absolutely must use socket programming.
Wrap-up: Socket programming in Python
Sockets are an integral part of all network applications. In this article, we have looked into socket programming in Python. Here are the key points to remember:
- Sockets are interfaces that abstract away connection management.
- Sockets enable communication between different processes (usually a client and a server) locally or over a network.
- In Python, working with sockets is done through the
socket
library, which among the rest, provides a socket object with various methods likerecv
,send
,listen
,close
. - Socket programming has various applications useful in data science, including data collection, inter-process communication, and distributed computing.
- Challenges in socket programming include connection management, data integrity, scalability, error handling, security, and code maintainability.
With socket programming skills, developers can create efficient, real-time network applications. By mastering the concepts and best practices, they can harness the full potential of socket programming to develop reliable and scalable solutions.
However, socket programming is a very low-level technique, which is difficult to use because application engineers have to take every little detail of application communication into account.
Nowadays, we very often do not need to work with sockets directly as they are typically handled by the higher level libraries and frameworks, unless there is a need to really squeeze the performance out of the application or scale it.
However, understanding sockets and having some insights into how things work under the hood leads to a better overall awareness as a developer or a data scientist and is always a good idea.
To learn more about Python’s role in network analysis, check out our Intermediate Network Analysis in Python course. You can also follow our Python Programming skill track to improve your Python programming skills.
Serhii is a software engineer with 3 years of experience in mostly backend technologies like DBMS, DB specific languages, Python, Nodejs etc.. He also has experience with frontend frameworks such as VueJS or ReactJS.
tutorial
Encapsulation in Python Object-Oriented Programming: A Comprehensive Guide
tutorial
Python Data Classes: A Comprehensive Tutorial
tutorial
Asyncio: An Introduction
DataCamp Team
12 min
tutorial
Definitive Guide: Threading in Python Tutorial
tutorial
Logging in Python Tutorial
tutorial