Tổng quan về Socket và Mô hình I/O
Gốc rễ của Socket bắt nguồn từ triết lý hệ điều hành Unix/Linux: "Mọi thứ đều là tệp tin". Theo đó, các thao tác trên phần cứng được thực hiện thông qua chuỗi các lệnh tiêu chuẩn: mở, đọc/ghi, đóng. Socket chính là một dạng đặc biệt của tệp tin mạng, cho phép ứng dụng giao tiếp với nhau qua các hàm hệ thống tương tự như thao tác với tập tin.
Khác với thư viện file truyền thống chỉ làm việc với đường dẫn local, module socket quản lý kết nối giữa máy chủ (server) và máy khách (client) thông qua địa chỉ IP và cổng kết nối.
Các mô hình xử lý Input/Output (I/O)
Vấn đề cốt lõi khi thiết kế hệ thống mạng nằm ở cách ứng dụng xử lý luồng dữ liệu đến. Trong môi trường Linux/Unix, có hai giai đoạn chính khi xảy ra một thao tác nhập liệu (như nhận gói tin TCP):
- Chuẩn bị dữ liệu: Dữ liệu chờ đợi từ mạng để đến bộ đệm nhân (Kernel Buffer).
- Copy dữ liệu: Sao chép dữ liệu từ bộ đệm nhân sang bộ đệm người dùng (User Space).
Dựa vào hành vi của tiến trình trong hai giai đoạn này, chúng ta phân biệt 4 loại mô hình I/O:
1. I/O Chặn (Blocking I/O)
Mặc định của hầu hết các socket là chế độ chặn. Khi tiến trình gọi hàm nhận dữ liệu (ví dụ: recv()):
- Nếu dữ liệu chưa đến, tiến trình sẽ dừng hoàn toàn (block) tại dòng lệnh đó cho đến khi dữ liệu sẵn sàng.
- Khi dữ liệu đã đến Kernel, nó tiếp tục block cho đến khi quá trình copy sang không gian người dùng hoàn tất.
Hạn chế lớn nhất: CPU sẽ nhàn rỗi trong suốt thời gian chờ dữ liệu mạng, trong khi đó các yêu cầu khác phải xếp hàng phía sau.
2. I/O Không Chặn (Non-Blocking I/O)
Khi đặt socket ở chế độ không chặn (setblocking(False)):
- Hệ thống trả về ngay lập tức dù dữ liệu có sẵn hay chưa.
- Nếu chưa có dữ liệu, lỗi
EWOULDBLOCKhoặcEAGAINsẽ được ném ra thay vì chặn tiến trình.
Bất lợi: Tiến trình buộc phải liên tục truy vấn (polling) trạng thái socket để biết khi nào có dữ liệu, gây lãng phí tài nguyên CPU.
3. I/O Đa hợp (I/O Multiplexing)
Đây là giải pháp cân bằng giữa hiệu năng và tài nguyên. Thay vì mỗi tiến trình chờ một socket, một tiến trình duy nhất có thể giám sát nhiều kết nối cùng lúc nhờ cơ chế như select, poll hoặc epoll.
Nguyên lý hoạt động: Ứng dụng yêu cầu OS theo dõi danh sách các file descriptor (FD). Khi có FD nào đó sẵn sàng để đọc/ghi, hàm đa hợp sẽ báo lại cho ứng dụng biết, sau đó ứng dụng mới thực hiện thao tác đọc thật sự.
4. I/O Bất đồng bộ (Asynchronous I/O)
Mô hình tiên tiến nhất (thường thấy qua aio hoặc iocp). Tiến trình khởi tạo lệnh I/O và tiếp tục thực thi công việc khác ngay lập tức. Khi thao tác hoàn tất, Kernel sẽ gửi tín hiệu (signal) hoặc callback để thông báo kết quả. Đây là mô hình hoàn toàn phi chặn cả hai giai đoạn.
Xử lý I/O Multiplexing với Module Select trong Python
Python cung cấp module select hỗ trợ giao diện chọn lọc các socket. Tùy thuộc vào hệ điều hành mà mức độ hỗ trợ khác nhau (Windows thường chỉ hỗ trợ select cho socket, còn Linux hỗ trợ thêm epoll hiệu suất cao hơn).
Cấu trúc hàm cơ bản:
readable_list, writable_list, exception_list = select.select(list_inputs, list_outputs, list_errors, timeout)
timeout: Thời gian chờ tối đa (giây). Nếu là 0 thì kiểm tra ngay lập tức, nếu là None thì chặn vô hạn.
Ví dụ 1: Giám sát đầu vào chuẩn (stdin) và Socket
Cod dưới đây minh họa việc sử dụng select để theo dõi đồng thời việc bàn phím người dùng nhập liệu và kết nối mạng:
import sys
import socket
import select
import selectors # Python 3.4+ recommended, nhưng dùng select trực tiếp để hiểu rõ
def run_monitoring():
inputs = [sys.stdin]
outputs = []
# Tạo socket giả lập
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(('localhost', 8080))
server_sock.listen(5)
server_sock.setblocking(False)
inputs.append(server_sock)
while True:
# Theo dõi danh sách đầu vào (có thể đọc được)
readable, _, _ = select.select(inputs, [], [], 2.0)
for sock in readable:
if sock == server_sock:
# Phát sinh kết nối mới
client_conn, addr = server_sock.accept()
print(f"Mới kết nối: {addr}")
client_conn.setblocking(False)
inputs.append(client_conn)
elif sock == sys.stdin:
# Người dùng gõ lệnh
command = sys.stdin.readline().strip()
print(f"Lệnh nhập: {command}")
else:
# Dữ liệu đến từ client đã kết nối
data = sock.recv(1024)
if data:
print(f"Nhận từ {sock.getpeername()[0]}: {data.decode()}")
else:
# Client ngắt kết nối
print("Client disconnected.")
inputs.remove(sock)
sock.close()
# Lưu ý: Hàm này cần chạy trong môi trường terminal tương thích
# run_monitoring()
Ví dụ 2: Xây dựng Server Echo đa người dùng
Thay vì dùng luồng (thread) riêng cho mỗi client, chúng ta sử dụng select để xử lý song song. Để tránh việc ghi dữ liệu làm chậm vòng lặp (khi socket không sẵn sàng viết), ta tách biệt kênh read và write:
import socket
import select
import queue
class SimpleEchoServer:
def __init__(self, host='127.0.0.1', port=6666):
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server_socket.bind((host, port))
self.server_socket.listen(5)
self.server_socket.setblocking(False)
# Danh sách socket đang được chọn lọc
self.inputs = [self.server_socket]
# Các socket đang có dữ liệu để gửi đi
self.outputs = []
# Hàng chứa thông điệp chờ gửi
self.message_queues = {}
def handle_accept(self, conn):
conn.setblocking(False)
self.inputs.append(conn)
# Khởi tạo queue riêng cho từng client
self.message_queues[conn] = queue.Queue()
print(f"[+] Kết nối mới từ {conn.getpeername()}")
def handle_data(self, conn):
data = conn.recv(1024)
if data:
# Nhận được dữ liệu -> Đưa vào queue chờ gửi lại
self.message_queues[conn].put(data)
# Thêm vào danh sách cần viết nếu chưa có
if conn not in self.outputs:
self.outputs.append(conn)
else:
# Ngắt kết nối
self.cleanup_connection(conn)
def cleanup_connection(self, conn):
print(f"[-] Hủy kết nối {conn.getpeername()}")
if conn in self.outputs:
self.outputs.remove(conn)
self.inputs.remove(conn)
conn.close()
# Xóa queue dữ liệu liên quan
if conn in self.message_queues:
del self.message_queues[conn]
def handle_writable(self, conn):
try:
msg = self.message_queues[conn].get_nowait()
conn.sendall(msg)
# Nếu vẫn còn dữ liệu trong queue, giữ lại trong outputs
if not self.message_queues[conn]:
self.outputs.remove(conn)
except queue.Empty:
if conn in self.outputs:
self.outputs.remove(conn)
def start(self):
print(f"Servers running on {self.server_socket.getsockname()}")
while True:
readable, writable, exceptional = select.select(
self.inputs,
self.outputs,
self.inputs,
1
)
for conn in readable:
if conn is self.server_socket:
conn, addr = conn.accept()
self.handle_accept(conn)
else:
self.handle_data(conn)
for conn in writable:
self.handle_writable(conn)
for conn in exceptional:
self.cleanup_connection(conn)
# Khởi chạy (Uncomment để chạy)
# server = SimpleEchoServer()
# server.start()
Sự khác biệt giữa Đồng bộ và Bất đồng bộ (POSIX Definition)
Rất dễ nhầm lẫn giữa Blocking/Non-blocking và Synchronous/Asynchronous. Tiêu chuẩn POSIX định nghĩa rõ ràng dựa trên việc chặn tiến trình:
- Synchronous (Đồng bộ): Thao tác I/O hoàn thành sẽ chặn tiến trình cho đến khi xong. Bao gồm cả Blocking, Non-blocking và Multiplexing (vì ở cuối cùng, quá trình copy dữ liệu từ Kernel sang User vẫn yêu cầu CPU chờ).
- Asynchronous (Bất đồng bộ): Lệnh gọi trả về ngay lập tức mà không cần chờ dữ liệu sẵn. Kernel sẽ xử lý toàn bộ (bao gồm cả bước copy) và thông báo kết quả qua tín hiệu sau đó.
Lý do Non-blocking được coi là Synchronous: Dù gọi hàm recvfrom không chặn chờ dữ liệu mạng, nhưng khi dữ liệu đã sẵn sàng, bước copy dữ liệu từ Kernel vào bộ nhớ người dùng vẫn chiếm quyền điều khiển CPU cho tới khi hoàn tất.
Kịch bản áp dụng thực tế
I/O Multiplexing trở nên hữu ích trong các tình huống:
- Một máy chủ phục vụ nhiều giao thức khác nhau (TCP và UDP cùng lúc).
- Một kết nối vừa dùng để lắng nghe vừa dùng để giao tiếp dữ liệu.
- Cần xử lý hàng nghìn kết nối concurrent nhưng tài nguyên CPU/Giới hạn Thread thấp.
Analogie so sánh các mô hình
Để dễ hình dung, hãy tưởng tượng 4 người thợ câu cá:
- Blocking (Chặn): Ngồi yên lặng canh cần, không làm gì khác cho đến khi giật cần. Hiệu quả nhưng tốn thời gian chờ.
- Non-blocking (Không chặn): Câu nhiều cần nhưng cứ 2 giây lại đi kiểm tra từng cái xem cá cắn chưa. Tốn công kiểm tra (polling).
- Multiplexing (Đa hợp): Một người trông nom nhiều cần câu, chỉ khi có con cá cắn (báo động) thì mới xử lý cần đó. Tiết kiệm sức lực hơn non-blocking.
- Asynchronous (Bất đồng bộ): Thuê ai đó câu hộ. Họ sẽ nhắn tin báo khi có cá, bạn hoàn toàn tự do làm việc khác trong khoảng thời gian chờ.