Python: Socket and Select System Call

Sockets provide the fundamental networking mechanism for communicating between processes (inter-process communication) on the same or different machines. They provide the fundamental building blocks for developing networking applications. Whether you’re building a web server, a chat application, or a networked game, understanding Python sockets is essential.

In this comprehensive guide, we’ll cover low-level socket programming in Python to build a deep understanding of network communication.

Socket Basics

Python sockets are implemented using the Berkeley Sockets API. The Berkeley Sockets API is a low-level networking API available on most operating systems.

A socket represents an endpoint in a communication channel. Sockets work using a client-server model. The server listens on a port while the client connects to the server’s socket.

Some key properties of sockets:

  • Allows bidirectional data flow between connected processes.
  • Abstracts away complexities of network communication.
  • Works over many types of transport like TCP, UDP, Unix Domain sockets, and more.

Socket Types

Python supports various socket types, each with its own use case. The most commonly used socket types are:

Stream Sockets (TCP):

  • Stream sockets, or TCP sockets, provide a reliable, connection-oriented communication channel.
  • They ensure that data is delivered in the correct order and without errors.
  • Use cases: HTTP, FTP, SSH, and more.

Datagram Sockets (UDP):

  • Datagram sockets, or UDP sockets, offer a connectionless, unreliable communication channel.
  • They are faster but do not guarantee the order or reliability of data delivery.
  • Use cases: Real-time video/audio streaming, online gaming.

Creating a Socket

To use sockets in Python, you need to import the socket module. This module provides a number of classes and functions for creating and managing sockets.

The most important class in the socket module is the socket class. This class represents a socket endpoint. A socket endpoint is a unique address on a network that can be used to identify a particular process.

To create a socket, you need to call the socket() function. This function takes two arguments: the address family and the socket type. The address family specifies the type of network that the socket will be used on. The socket type specifies the type of communication that the socket will support.

The most common address family is AF_INET. This address family is used for IPv4 networks. The most common socket type is SOCK_STREAM. This socket type is used for TCP connections.

Once you have bound the socket to an address, you need to listen for incoming connections. To do this, you need to call the listen() method. The listen() method takes one argument: the maximum number of pending connections.

When a client connects to the server and the server accept() the connection, the server will receive a new socket object. This socket object represents the connection with the client. This socket object is used for further communicating with the client.

The server can then use the socket object to send and receive data from the client. To send data, the server can call the send() method. To receive data, the server can call the recv() method.

When the server is finished communicating with the client, it should close the socket object. To do this, the server can call the close() method.

TCP

Here is a simple example of a Python socket server, that uses TCP protocol:

# simple socket_server.py
import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 8080))
server_socket.listen(10)

while True:
  client_socket, client_address = server_socket.accept()

  data = client_socket.recv(1024)
  print("Data received", data)
  client_socket.send(data.upper())

  client_socket.close()

This server will listen for incoming connections on port 8080. When a client connects, the server will send the client’s message back to the client in uppercase.

Here is a simple example of a Python socket client that uses TCP protocol:

# socket_client.py
import socket

client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect(('localhost', 8080))

message = 'hello world'
client_socket.send(message.encode())

response = client_socket.recv(1024)
print(response.decode())

client_socket.close()

This client will connect to the server on port 8080 and send the message “hello world” to the server. The client will then print the server’s response.

Run these two files in two different command terminals of your system. The server process will appear to hang or be blocked. That’s because the server is suspended, on .accept(). As soon as it receives a connection request from a client, it will process the request and wait again for new connections.

UDP

To create a UDP socket in Python, you need to import the socket module and then call the socket.socket() function with the following arguments:

  • socket.AF_INET: This specifies the address family. For UDP sockets, you should always use socket.AF_INET.
  • socket.SOCK_DGRAM: This specifies the socket type. For UDP sockets, you should always use socket.SOCK_DGRAM.

Here is an example of how to create a UDP socket:

import socket

# Create a UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

Once you have created a UDP socket, you can use it to send and receive datagrams. To send a datagram, you need to call the sock.sendto() method with the following arguments:

  • data: This is the data that you want to send.
  • addr: This is the address of the recipient. The address is a tuple containing the IP address and port number of the recipient.

Here is an example of how to send a datagram:

# Send a datagram to the address (127.0.0.1, 8080)
sock.sendto(b'Hello, world!', ('127.0.0.1', 8080))

Here is an example UDP server:

import socket

server_address = ('0.0.0.0', 12345)

udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_socket.bind(server_address)

while True:
  data, client_address = udp_socket.recvfrom(1024)
  print(f"Received data from {client_address}: {data.decode()}")

udp_socket.close()

To receive a datagram, you need to call the sock.recvfrom() method. This method will block until a datagram is received. When a datagram is received, the sock.recvfrom() method will return a tuple containing the data and the address of the sender.

Here is an example of how to receive a datagram:

# Receive a datagram
data, addr = sock.recvfrom(1024)

# Print the data and the address of the sender
print(data, addr)

Here is an example UDP client:

import socket

udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

server_address = ('server_ip', 12345)  # Replace with the server's IP address and port
data = b"Hello, UDP Server!"

udp_socket.sendto(data, server_address)

udp_socket.close()

UDP sockets are very simple to use, but they do have some limitations. For example, UDP sockets do not provide guaranteed delivery of datagrams. This means that it is possible for datagrams to be lost or to arrive out of order.

Despite these limitations, UDP sockets are a good choice for many types of applications, such as voice over IP (VoIP) and online gaming.

Non blocking Sockets

A non-blocking socket is a socket that does not block the thread when it is waiting for data. This is useful for applications that need to be responsive to other events, such as user input or to handle multiple clients.

To create a non-blocking socket in Python, you need to use the socket.setblocking() method with the argument False. This will set the socket to non-blocking mode.

To create a non-blocking socket server, you can follow these steps:

  1. Create a TCP socket and set it to non-blocking mode. You can do this using the socket.setblocking() method.
  2. Bind the socket to an address and listen for incoming connections. You can do this using the socket.bind() and socket.listen() methods, respectively.
  3. Start a loop where you call the select() system call to wait for activity on the server socket. The select() system call allows you to monitor multiple file descriptors for activity simultaneously.
  4. If the server socket is ready for reading, accept the incoming connection. You can do this using the socket.accept() method.
  5. Once you have accepted the incoming connection, you can start sending and receiving data with the client. You can do this using the socket.send() and socket.recv() methods, respectively.

Select system call

The select system call is a low-level I/O multiplexing mechanism provided by many operating systems, including Unix-like systems such as Linux. It allows a program to monitor multiple file descriptors (usually sockets, but also pipes and other file-like objects) for various events like data ready to be read, the ability to write without blocking, and exceptions like errors or closed connections. select is a crucial building block for building scalable network servers and other event-driven applications.

In Python, the select functionality is exposed through the select module in the standard library. The select module provides a high-level interface for working with select-like functionality in a cross-platform manner.

The select system call takes three arguments:

  • readlist: A list of file descriptors to be monitored for readability.
  • writelist: A list of file descriptors to be monitored for writability.
  • exceptionlist: A list of file descriptors to be monitored for exceptional conditions.

The select system call will block until at least one file descriptor in one of the three lists becomes active. When a file descriptor becomes active, it will be removed from the corresponding list.

Here are some additional examples of how the select system call can be used in Python:

  • Monitor multiple sockets for incoming connections and data.
  • Monitor a keyboard for user input.
  • Monitor a file for changes.
  • Monitor multiple pipes for data.

The select system call is a versatile tool that can be used to implement a wide variety of functionality.

Non blocking Server example code using select:

# select_server.py
import socket
import select

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 12345))
server_socket.listen(5)
server_socket.setblocking(False) # Set the server socket to non-blocking mode

inputs = [server_socket]
outputs = []

while True:
  readable, writable, exceptional = select.select(inputs, outputs, inputs)

  for s in readable:
    if s is server_socket:
      # New connection, accept it
      client_socket, client_address = server_socket.accept()
      print(f"Accepted connection from {client_address}")
      client_socket.setblocking(False)
      inputs.append(client_socket)
    else:
      # Handle data from an existing client
      data = s.recv(1024)
      if data:
        print(f"Received data from {s.getpeername()}: {data.decode()}")
        if s not in outputs:
          outputs.append(s)
      else:
        # No data received, client closed the connection
        print(f"Connection closed by {s.getpeername()}")
        if s in outputs:
          outputs.remove(s)
        inputs.remove(s)
        s.close()

  for s in writable:
    # Send a response back to the client
    response = b"Hello, client!"
    s.send(response)
    outputs.remove(s)

  for s in exceptional:
    # Handle exceptional conditions (errors or closed connections)
    print(f"Exceptional condition on {s.getpeername()}")
    inputs.remove(s)
    if s in outputs:
      outputs.remove(s)
    s.close()

The server code works as follows:

  1. It creates a TCP socket and sets it to non-blocking mode.
  2. It binds the socket to an address and listens for incoming connections.
  3. It creates a list of file descriptors to be monitored. The list initially contains only the server socket.
  4. It enters a loop where it calls the select system call to wait for activity on one of the monitored sockets.
  5. If the server socket is ready for reading, it accepts the incoming connection and adds the new client socket to the list of monitored sockets.
  6. For each client socket, it checks if it is ready for reading. If it is, it receives data from the client.
  7. If the client has disconnected, it removes the socket from the list of monitored sockets.
  8. Otherwise, it sends the data back to the client.

Client example code using select:

# select_client.py

import socket
import select
import sys

client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect(('localhost', 12345))
client_socket.setblocking(False) # Set the client socket to non-blocking mode

inputs = [client_socket]
outputs = [client_socket]

while True:
  readable, writable, exceptional = select.select(inputs, outputs, inputs)

  for s in readable:
    if s is client_socket:
      # Handle incoming data from the server
      data = s.recv(1024)
      if data:
        print(f"Received data from the server: {data.decode()}")
      else:
        print("Server closed the connection")
        sys.exit()

  for s in writable:
    # Send data to the server
    message = b"Hello, server!"
    s.send(message)
    outputs.remove(s)

The client code works as follows:

  1. It creates a TCP socket and sets it to non-blocking mode.
  2. It connects to the server.
  3. It creates a list of file descriptors to be monitored.
  4. It enters a loop where it calls the select system call to wait for activity on one of the monitored sockets.
  5. If the client socket is ready for reading, it receives data from the server.
  6. It the client socket is ready for writing, it sends data to the server.