0

Bài 6: Phá vỡ giới hạn 1-1: Xử lý đa kết nối với Đa luồng (Multithreading & Goroutines)

Ở các bài học trước, các TCP Server mà chúng ta xây dựng đều hoạt động theo mô hình đơn luồng tuần tự (Sequential Server): khi server đang bận phục vụ một kết nối hoặc nhận một file từ một Client, mọi Client khác kết nối vào lúc đó sẽ bị treo chờ (blocked) hoặc từ chối. Trên thực tế, một server hiện đại phải có khả năng phục vụ hàng ngàn, thậm chí hàng triệu Client cùng lúc.

Trong bài học này, chúng ta sẽ bước sang kỹ thuật xử lý đồng thời (Concurrency / Multithreading), tìm hiểu cách hệ điều hành và ngôn ngữ lập trình giải quyết bài toán đa kết nối.

1. Giới hạn của mô hình tuần tự (Single-threaded Server)

Hãy tưởng tượng Server giống như một cửa hàng ăn chỉ có đúng một nhân viên phục vụ.

  • Nhân viên đó ra mở cửa chào vị khách số 1, ngồi ghi lại toàn bộ món ăn, chạy vào bếp đợi nấu xong, bưng ra bàn, rồi mới quay lại cửa để đón vị khách số 2.

  • Nếu vị khách số 1 gọi một món ăn rất phức tạp hoặc cứ đứng ì ở cửa trò chuyện, hàng dài khách phía sau sẽ phải đứng chờ đến mệt mỏi.

Trong lập trình mạng, khi Server gọi hàm conn.Read() hoặc conn.Write(), tiến trình sẽ bị chặn (blocking) cho đến khi dữ liệu thực sự đến. Nếu chỉ có một luồng duy nhất, server hoàn toàn bị tê liệt trước các kết nối chậm chạp.

2. Các mô hình xử lý đa kết nối trong lịch sử

Để giải quyết bài toán phục vụ nhiều client cùng lúc, ngành công nghệ đã trải qua các tiến hóa kiến trúc kinh điển:

  1. Mô hình Thread-per-Client (Mỗi Client một Thread):

    • Cứ mỗi khi có một Client mới kết nối (accept), hệ điều hành sẽ cấp phát một Thread (Luồng hệ điều hành) riêng biệt để phục vụ client đó.

    • Nhược điểm: Thread của hệ điều hành ngốn rất nhiều bộ nhớ RAM (thường vài MB cho mỗi stack thread) và tốn chi phí chuyển đổi ngữ cảnh (Context Switching). Khi số lượng client chạm mốc 10.000, máy chủ sẽ cạn kiệt tài nguyên và sập nguồn (C10K problem).

  2. Mô hình Event-driven / Non-blocking I/O (Epoll / Select):

    • Sử dụng một hoặc vài luồng đơn để theo dõi hàng vạn socket thông qua cơ chế ngắt sự kiện từ nhân hệ điều hành (sẽ được tìm hiểu chi tiết ở Bài 7).
  3. Mô hình Lightweight Thread (Goroutines trong Golang):

    • Golang giải quyết bài toán này một cách thiên tài bằng Goroutines — các luồng siêu nhẹ (Green Threads) do Go runtime quản lý thay vì trực tiếp tạo thread của hệ điều hành. Một goroutine chỉ tốn vài KB bộ nhớ ban đầu, cho phép bạn bật hàng triệu goroutine cùng lúc một cách mượt mà.

3. Hiện thực hóa Concurrent TCP Server với Goroutines trong Go

Nhìn lại đoạn code ở Bài 4, chúng ta đã ngầm ứng dụng go handleClient(conn) để mở một luồng chạy ngầm ngay khi có một kết nối mới được chấp nhận. Hãy tinh chỉnh lại kiến trúc này thành một Concurrent Chat Server hoàn chỉnh, có khả năng quản lý danh sách nhiều client và phát tán tin nhắn (Broadcasting).

Go

package main

import (
	"bufio"
	"fmt"
	"net"
	"sync"
)

// Quản lý danh sách các client đang kết nối
type ChatServer struct {
	mu      sync.Mutex
	clients map[net.Conn]string
}

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		fmt.Printf("Lỗi khởi động server: %v\n", err)
		return
	}
	defer listener.Close()

	server := &ChatServer{
		clients: make(map[net.Conn]string),
	}

	fmt.Println("🚀 Concurrent TCP Server đang chạy tại cổng :8080...")

	for {
		// Chấp nhận kết nối từ client mới
		conn, err := listener.Accept()
		if err != nil {
			fmt.Printf("Lỗi accept: %v\n", err)
			continue
		}

		// Đăng ký client vào danh sách quản lý
		server.mu.Lock()
		server.clients[conn] = conn.RemoteAddr().String()
		server.mu.Unlock()

		fmt.Printf("🔗 Client mới kết nối: %s (Tổng số client: %d)\n", conn.RemoteAddr().String(), len(server.clients))

		// PHÁ VỚI GIỚI HẠN 1-1: Khởi chạy một Goroutine riêng biệt cho mỗi client
		go server.handleClient(conn)
	}
}

func (s *ChatServer) handleClient(conn net.Conn) {
	// Đảm bảo dọn dẹp kết nối khi hàm kết thúc
	defer func() {
		conn.Close()
		s.mu.Lock()
		delete(s.clients, conn)
		s.mu.Unlock()
		fmt.Printf("❌ Client ngắt kết nối: %s\n", conn.RemoteAddr().String())
	}()

	conn.Write([]byte("Chào mừng bạn đến với Go Concurrent Chat Room!\n"))

	scanner := bufio.NewScanner(conn)
	for scanner.Scan() {
		msg := scanner.Text()
		clientAddr := conn.RemoteAddr().String()

		fmt.Printf("💬 [%s]: %s\n", clientAddr, msg)

		// Broadcast tin nhắn tới tất cả các client khác đang kết nối
		s.broadcast(fmt.Sprintf("[%s]: %s\n", clientAddr, msg), conn)
	}
}

func (s *ChatServer) broadcast(message string, sender net.Conn) {
	s.mu.Lock()
	defer s.mu.Unlock()

	for client := range s.clients {
		// Không gửi lại tin nhắn cho chính người gửi
		if client != sender {
			client.Write([]byte(message))
		}
	}
}

4. Giải phẫu cơ chế hoạt động của Concurrent Server

  1. Vòng lặp listener.Accept() chớp nhoáng: Main goroutine chỉ làm đúng một việc duy nhất là đứng ở cửa, hễ thấy client nào connect vào là lập tức nhấc máy, bốc tách ra một conn riêng biệt.

  2. Giao phó qua go server.handleClient(conn): Ngay lập tức, Go runtime tạo một goroutine mới toanh để chăm sóc riêng cho client đó. Main goroutine quay lại cửa ngay lập tức để đón vị khách tiếp theo.

  3. Đồng bộ hóa an toàn (sync.Mutex): Vì nhiều goroutine cùng truy cập và chỉnh sửa chung một bản đồ (s.clients) chứa danh sách kết nối, chúng ta phải dùng sync.Mutex để khóa an toàn (Thread-safe), tránh tình trạng tranh chấp dữ liệu (Data Race).

Tổng kết

Thông qua mô hình Đa luồng / Goroutines, chúng ta đã phá vỡ hoàn toàn giới hạn 1-1, biến một chiếc server đơn giản thành một phòng chat đa người dùng (Multi-client Chat Room) có khả năng phục vụ vô số kết nối song song một cách mượt mà.

Tuy nhiên, khi số lượng kết nối tăng vọt từ hàng nghìn lên hàng triệu (Bài toán C10K), việc cấp phát mỗi client một goroutine/thread riêng bắt đầu tiêu tốn tài nguyên chuyển đổi ngữ cảnh. Làm thế nào để các hệ thống hiệu năng cao hiện đại (như Nginx, Redis hay Node.js) xử lý hàng triệu kết nối chỉ với một vài luồng duy nhất?

Đó chính là nội dung của Phần 3 mở đầu với Bài 7: Blocking vs Non-blocking Socket & Mô hình Epoll.

Bạn đã sẵn sàng bước sang Phần 3 chưa?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí