UNIT - IV
Network and Multithreaded Programming: Introduction to sockets and networking protocols
(TCP, UDP), Building basic TCP/UDP clients and servers, HTTP client/server implementation,
Multithreading and multiprocessing, Thread synchronization, shared memory, priority queues.
Python, sockets are a way to enable communication between devices over a network. They
allow data to be sent and received between two endpoints, such as a client and a server.
Networking Protocols:
• TCP (Transmission Control Protocol): Reliable, connection-oriented protocol. Ensures
data is received in order and without errors.
• UDP (User Datagram Protocol): Fast, connectionless protocol. Does not guarantee
delivery or order, making it ideal for real-time applications like gaming or streaming.
Using Sockets in Python
Python provides the socket module to work with networking protocols. Here's a simple
example:
TCP Server
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP socket
server_socket.bind(("127.0.0.1", 12345)) # Bind to localhost and port 12345
server_socket.listen(1) # Listen for connections
print("Server is listening...")
conn, addr = server_socket.accept() # Accept a connection
print(f"Connected to {addr}")
data = conn.recv(1024) # Receive data
print(f"Received: {data.decode()}")
conn.sendall(b"Hello from server!") # Send response
conn.close()
server_socket.close()
TCP Client
import socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect(("127.0.0.1", 12345)) # Connect to server
client_socket.sendall(b"Hello, Server!") # Send data
response = client_socket.recv(1024) # Receive response
print(f"Received: {response.decode()}")
client_socket.close()
UDP (User Datagram Protocol) is a connectionless protocol, meaning it doesn't require a
handshake before sending data. It's faster but doesn't guarantee delivery or order.
UDP Server
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket
server_socket.bind(("127.0.0.1", 12345)) # Bind to localhost and port 12345
print("UDP server is listening...")
while True:
data, addr = server_socket.recvfrom(1024) # Receive data
print(f"Received from {addr}: {data.decode()}")
server_socket.sendto(b"Hello from server!", addr) # Send response
UDP Client
import socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP socket
server_address = ("127.0.0.1", 12345)
client_socket.sendto(b"Hello, Server!", server_address) # Send data
response, _ = client_socket.recvfrom(1024) # Receive response
print(f"Received: {response.decode()}")
client_socket.close()
With UDP, since there's no connection, the client can send data anytime, and the server
listens indefinitely.
HTTP client and server
Implementing an HTTP client and server in Python can be done using the built-in http.server
module for the server and requests or http.client for the client.
HTTP Server using http.server
from http.server import SimpleHTTPRequestHandler, HTTPServer class
MyHandler(SimpleHTTPRequestHandler): de
from http.server import SimpleHTTPRequestHandler, HTTPServer
class MyHandler(SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h1>Hello, HTTP Client!</h1></body></html>")
server_address = ("127.0.0.1", 8080)
httpd = HTTPServer(server_address, MyHandler)
print("Serving HTTP on port 8080...")
httpd.serve_forever()
HTTP Client using requests
import requests response = requests.get("http://127.0.0.1:8080") print(f"Response Status:
{response.status_code}") print
import requests
response = requests.get("http://127.0.0.1:8080")
print(f"Response Status: {response.status_code}")
print(f"Response Body: {response.text}")
This example sets up a simple HTTP server responding with an HTML message, and a client
fetching that page.
Multithreading and multiprocessing
Python help improve performance by running tasks concurrently.
1. Multithreading in Python
• Uses the threading module.
• Best for I/O-bound tasks (e.g., network requests, file operations).
• Threads share the same memory space, avoiding unnecessary duplication.
Example: Using threading
import threading import time def print_numbers(): for i in range(5): print(f"Number {i}")
time.sleep(1) thread = threading.Thread(target=print_numb
import threading
import time
def print_numbers():
for i in range(5):
print(f"Number {i}")
time.sleep(1)
thread = threading.Thread(target=print_numbers)
thread.start()
thread.join() # Wait for the thread to finish
print("Thread execution completed.")
2. Multiprocessing in Python
• Uses the multiprocessing module.
• Best for CPU-bound tasks (e.g., heavy computations).
• Processes have separate memory spaces, avoiding global interpreter lock (GIL)
issues.
Example: Using multiprocessing
import multiprocessing
def worker_function(num):
print(f"Processing {num}")
if __name__ == "__main__":import multiprocessing
def worker_function(num):
print(f"Processing {num}")
if __name__ == "__main__":
process = multiprocessing.Process(target=worker_function, args=(5,))
process.start()
process.join() # Wait for the process to finish
print("Process execution completed.") process =
multiprocessing.Process(target=worker_function, args=(5,))
process.start()
process.join() # Wait for the process to finish
print("Process execution completed.")
Thread synchronization
Python is crucial when multiple threads need to share resources safely. Since Python
threads run in the same memory space, improper synchronization can lead to race
conditions, where threads modify shared data unpredictably.
Key Synchronization Methods:
1. Locks (threading.Lock) – Used to prevent multiple threads from accessing a shared
resource at the same time.
2. RLocks (threading.RLock) – Allows a thread to acquire the same lock multiple times.
3. Semaphores (threading.Semaphore) – Limits the number of threads accessing a
resource.
4. Events (threading.Event) – Used for signaling between threads.
5. Condition Variables (threading.Condition) – Used when one thread waits for another
to meet certain conditions.
Example: Using threading.Lock to Prevent Race Conditions
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1000000):
with lock: # Lock ensures only one thread modifies 'counter' at a time
counter += 1
thread1 = threading.Thread(target=increment)
thread2 = threading.Thread(target=increment)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(f"Final Counter Value: {counter}")
Without the lock, counter may not reach the expected value due to race conditions.
Shared memory
Python allows multiple processes to access and modify the same data without needing
expensive inter-process communication (IPC). The multiprocessing module provides tools to
facilitate shared memory.
1. Using multiprocessing.Value and multiprocessing.Array
The Value and Array classes create shared variables that multiple processes can modify.
Example: Shared Memory with multiprocessing.Value
import multiprocessing
shared_counter = multiprocessing.Value("i", 0) # 'i' means integer type
def increment(shared_counter):
for _ in range(1000000):
shared_counter.value += 1
process1 = multiprocessing.Process(target=increment, args=(shared_counter,))
process2 = multiprocessing.Process(target=increment, args=(shared_counter,))
process1.start()
process2.start()
process1.join()
process2.join()
print(f"Final Counter Value: {shared_counter.value}")
However, this can lead to race conditions, so a Lock should be used.
2. Using multiprocessing.shared_memory for Fast Data Sharing
Python 3.8 introduced shared_memory, which allows direct access to memory.
Example: Shared Memory with multiprocessing.shared_memory
import multiprocessing.shared_memory
import numpy as np
# Create shared memory
shm = multiprocessing.shared_memory.SharedMemory(create=True, size=1024)
array = np.ndarray((256,), dtype=np.int32, buffer=shm.buf)
# Modify the shared array
array[:] = np.arange(256)
print("Shared memory created!")
shm.close()
shm.unlink() # Cleanup after use
This method is faster and efficient compared to Value and Array.
Priority queues
Python allow elements to be processed based on priority rather than order of arrival. They
are especially useful in tasks like scheduling, pathfinding algorithms (like Dijkstra’s), and task
management.
1. Using heapq for Priority Queues
Python’s heapq module provides an efficient way to implement a priority queue using a
min-heap.
Example: Simple Priority Queue
import heapq
queue = []
heapq.heappush(queue, (3, "Task 3")) # Priority 3
heapq.heappush(queue, (1, "Task 1")) # Priority 1 (highest priority)
heapq.heappush(queue, (2, "Task 2")) # Priority 2
while queue:
priority, task = heapq.heappop(queue)
print(f"Processing {task} with priority {priority}")
Output:
Processing Task 1 with priority 1
Processing Task 2 with priority 2
Processing Task 3 with priority 3
Since heapq uses a min-heap, lower numbers have higher priority.
2. Using queue.PriorityQueue
The queue.PriorityQueue class provides a thread-safe implementation of a priority queue.
Example: Thread-Safe Priority Queue
import queue
pq = queue.PriorityQueue()
pq.put((3, "Task 3"))
pq.put((1, "Task 1"))
pq.put((2, "Task 2"))
while not pq.empty():
priority, task = pq.get()
print(f"Processing {task} with priority {priority}")
This behaves similarly to heapq, but with built-in synchronization for multithreading.