Lập trình Socket
Sau khi nắm mô hình client-server cùng cặp địa chỉ và cổng, bài này bắt tay viết mã thật. Ta sẽ dựng một máy chủ và máy khách echo trên TCP, rồi nâng cấp máy chủ để phục vụ nhiều máy khách cùng lúc bằng đa luồng, và cuối cùng nhìn sang UDP với DatagramSocket. Bài này dùng lại kỹ thuật đa luồng đã học như một công cụ, không giảng lại chi tiết.
Hãy bắt đầu từ một việc rất quen. Bạn gõ địa chỉ một trang web, vài trăm mili giây sau nội dung hiện ra: máy bạn đã tìm ra IP của máy chủ, mở kết nối tới cổng của nó, gửi một yêu cầu, nhận về một chuỗi byte, rồi đóng kết nối. Bỏ phần mã hoá và giao thức HTTP, đó chính là những gì ta sắp tự viết. Cũng cần dặn trước rằng lập trình mạng khó hơn lập trình cục bộ không phải vì cú pháp mà vì môi trường: bên kia có thể đã tắt máy, dây mạng có thể vừa bị rút. Mã mạng viết tốt là mã luôn giả định mọi thứ đều có thể chậm và có thể hỏng.
Mô hình khách chủ, địa chỉ và cổng
Điểm cốt lõi của mô hình khách chủ là sự bất đối xứng về vai trò lúc thiết lập kết nối. Máy chủ khởi động trước, gắn mình vào một cổng đã biết, rồi ngồi im chờ, không biết ai sẽ tới và khi nào; máy khách thì biết chính xác mình muốn nói chuyện với ai nên nó chủ động gọi. Sau khi kết nối dựng xong, hai bên hoàn toàn bình đẳng. Hệ quả thực hành là máy chủ phải chạy trước và phải đứng ở một cổng ổn định thì máy khách mới tìm được. Địa chỉ IP xác định máy nào, còn cổng (port) phân biệt chương trình nào trên máy đó; một kết nối TCP được nhận dạng duy nhất bởi bộ bốn gồm IP nguồn, cổng nguồn, IP đích, cổng đích, và chính bộ bốn ấy giải thích vì sao một máy chủ trên cùng cổng 5000 giữ được hàng nghìn kết nối mà không lẫn. Dải cổng dưới 1024 dành cho dịch vụ chuẩn và thường đòi quyền quản trị, nên khi học hãy chọn cổng từ 1024 trở lên.
Khái niệm socket
Socket là một đầu mối giao tiếp ở mức phần mềm, đại diện cho một điểm cuối của kết nối mạng. Qua một socket, chương trình đọc và ghi dữ liệu y như đọc ghi tệp, mà không cần bận tâm gói tin đi qua mạng thế nào.
Java mô hình hóa socket TCP bằng hai lớp trong java.net. Lớp ServerSocket dành cho phía máy chủ: nó gắn vào một cổng và lắng nghe các yêu cầu kết nối đến. Lớp Socket đại diện cho một kết nối đã thiết lập, dùng ở cả hai phía. Khi một máy khách kết nối thành công, phía máy chủ nhận về một đối tượng Socket riêng cho kết nối đó. Từ mỗi Socket ta lấy được luồng vào qua getInputStream để đọc dữ liệu đối phương gửi, và luồng ra qua getOutputStream để gửi dữ liệu đi.
Trình tự một phiên TCP diễn ra như sau. Phía máy chủ tạo ServerSocket(port), rồi gọi accept() để chờ kết nối; khi có máy khách, nó đọc InputStream và ghi OutputStream, cuối cùng gọi close(). Phía máy khách tạo Socket(ip, port) để kết nối, rồi ghi OutputStream, đọc InputStream, và cũng close() khi xong.
TCP so với UDP
TCP có kết nối và tin cậy: hai bên bắt tay trước, sau đó mọi byte gửi đi bảo đảm tới nơi, đúng thứ tự, không nhân đôi, gói nào thất lạc thì nó tự gửi lại mà tầng ứng dụng không hay biết. Cái giá là độ trễ, vì khi một gói mất thì dữ liệu tới sau phải nằm chờ trong bộ đệm. UDP thì ngược hẳn: đóng dữ liệu vào gói, ghi cổng đích, gửi, hết. Không bắt tay, không xác nhận, không gửi lại, và gói có thể mất mà bạn không được báo. Bù lại nó nhẹ, nên hợp với những thứ mà dữ liệu cũ thà mất còn hơn tới muộn, ví dụ gọi thoại hay cập nhật vị trí trong trò chơi.
TCP là một dòng byte, không phải một dòng thông điệp. Nó bảo đảm thứ tự byte nhưng không bảo toàn ranh giới bản tin. Nếu máy khách gọi write ba lần với ba chuỗi, máy chủ hoàn toàn có thể nhận cả ba trong một lần đọc, hoặc nhận nửa chuỗi đầu rồi phần còn lại ở lần đọc sau. Nguyên nhân gốc: TCP gom dữ liệu vào bộ đệm rồi cắt thành các đoạn theo kích thước gói của mạng, độc lập với cách bạn gọi write.
Hệ quả: ứng dụng phải tự phân định khung (framing), bằng ký tự kết thúc như dấu xuống dòng, bằng tiền tố độ dài, hoặc bằng bản tin kích thước cố định. Bài này chọn cách thứ nhất, và đó là lý do mọi ví dụ đều dùng readLine cùng println: dấu xuống dòng chính là ranh giới bản tin của ta.
Máy chủ echo đơn giản
Để làm quen, ta viết một máy chủ echo (echo server): nhận một dòng văn bản từ máy khách rồi gửi trả lại đúng dòng đó. Đây là bài tập kinh điển vì nó nhỏ nhưng đủ thể hiện toàn bộ vòng đời một kết nối. Phiên bản đầu chỉ phục vụ một máy khách tại một thời điểm.
import java.io.*;
import java.net.*;
public class EchoServer {
public static void main(String[] args) throws IOException {
int port = 5000;
// ServerSocket binds to the port and listens for clients
try (ServerSocket server = new ServerSocket(port)) {
System.out.println("Server listening on port " + port);
// accept() blocks until a client connects, then returns its Socket
try (Socket client = server.accept()) {
System.out.println("Client connected: " + client.getInetAddress());
// wrap byte streams into character streams for line-based text
BufferedReader in = new BufferedReader(
new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(
client.getOutputStream(), true); // true = auto-flush
String line;
while ((line = in.readLine()) != null) { // null means client closed
System.out.println("Received: " + line);
out.println("ECHO: " + line); // send the line back
}
}
}
}
}
Đoạn mã trên gói gọn nhiều ý quan trọng. Câu lệnh server.accept() là điểm then chốt: nó chặn (block) luồng hiện tại cho tới khi có máy khách kết nối, rồi trả về một Socket riêng cho kết nối đó. Hai luồng byte thô từ socket được bọc lại: InputStreamReader và BufferedReader biến luồng byte vào thành luồng ký tự đọc theo dòng, còn PrintWriter cho phép ghi văn bản theo dòng ra luồng. Tham số true của PrintWriter bật chế độ tự đẩy dữ liệu (auto-flush) mỗi lần xuống dòng, nếu quên thì dữ liệu có thể nằm trong bộ đệm mà chưa thực sự gửi đi. Vòng lặp đọc dừng khi readLine trả về null, dấu hiệu máy khách đã đóng kết nối.
Có một chỗ hay nhầm cần tách bạch: ServerSocket không bao giờ truyền dữ liệu, nó chỉ là cái cửa, làm đúng hai việc là gắn vào cổng và sinh kết nối mới qua accept. Mỗi lần accept trả về, bạn nhận một Socket riêng cho đúng một cuộc trò chuyện, nên ba máy khách cùng nối tới thì có một ServerSocket và ba Socket. Về luồng vào ra, socket chỉ cho ta byte thô, nên ta xếp chồng ba lớp: getInputStream cho byte, InputStreamReader diễn giải byte thành ký tự theo một bảng mã, BufferedReader gom ký tự thành dòng để có readLine. Đây chính là mẫu hình trang trí đã gặp ở bài luồng vào ra tệp, nên với mã Java thì đọc từ mạng gần như giống hệt đọc từ tệp.
Mọi socket và luồng đều là tài nguyên hệ thống phải đóng sau khi dùng. Hãy mở chúng trong try-with-resources như ví dụ trên để Java tự đóng kể cả khi có ngoại lệ. Quên đóng socket sẽ dần cạn cổng và bộ mô tả tệp (file descriptor).
Cơ chế bên dưới: mọi đối tượng khai trong ngoặc của try phải cài AutoCloseable, và trình biên dịch tự sinh một khối finally gọi close, nên dù thân try kết thúc bình thường, ném ngoại lệ, hay thoát bằng return, tài nguyên vẫn được trả lại. Đóng Socket sẽ đóng luôn hai luồng gắn với nó.
Máy khách echo
Phía máy khách đơn giản hơn vì không phải lắng nghe. Nó chủ động tạo Socket trỏ tới địa chỉ và cổng của máy chủ, gửi một dòng, rồi đọc dòng trả về.
import java.io.*;
import java.net.*;
public class EchoClient {
public static void main(String[] args) throws IOException {
String host = "localhost"; // server address, here the same machine
int port = 5000;
try (Socket socket = new Socket(host, port)) { // connect to server
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
// also read user input from the keyboard
BufferedReader keyboard = new BufferedReader(
new InputStreamReader(System.in));
String userLine;
System.out.println("Type a message (empty line to quit):");
while ((userLine = keyboard.readLine()) != null && !userLine.isEmpty()) {
out.println(userLine); // send to server
String reply = in.readLine(); // wait for the echo
System.out.println("Server says: " + reply);
}
}
}
}
Để chạy thử, ta mở hai cửa sổ dòng lệnh: một bên khởi động EchoServer, bên kia khởi động EchoClient. Gõ một câu ở máy khách, máy chủ in ra câu nhận được và gửi lại bản echo, máy khách hiển thị dòng trả về. Vì cùng trên một máy, ta dùng localhost; nếu chạy trên hai máy khác nhau trong cùng mạng nội bộ, chỉ cần thay host bằng địa chỉ IP thật của máy chủ.
Có thể nâng nhẹ máy khách để đo độ trễ khứ hồi (round-trip time): ghi mốc thời gian ngay trước out.println, ghi mốc thứ hai ngay sau khi nhận reply, rồi in hiệu số bằng System.nanoTime(). Cách này cho thấy chi phí đi và về của một thông điệp qua TCP, rất nhỏ khi chạy nội bộ nhưng tăng rõ khi máy chủ ở xa.
Phục vụ nhiều máy khách bằng đa luồng
Máy chủ echo ở trên có một hạn chế nghiêm trọng: sau khi accept một máy khách, nó mắc kẹt trong vòng lặp phục vụ máy khách ấy, nên mọi máy khách khác phải xếp hàng chờ. Một máy chủ thực tế phải phục vụ nhiều máy khách song song. Giải pháp kinh điển là: luồng chính chỉ làm mỗi việc accept, và mỗi khi có máy khách mới thì giao nguyên kết nối đó cho một luồng riêng xử lý, rồi quay lại accept ngay.
Hãy truy nguyên hạn chế đó cho rõ, vì đây là chỗ nhiều người học hiểu lơ mơ. Nguyên nhân gốc không phải accept chậm hay mạng yếu, mà là mọi lời gọi đọc trên socket đều chặn. Khi luồng chính đang nằm trong in.readLine() chờ máy khách thứ nhất gõ phím, nó đứng im ở đúng dòng đó và không thể quay lại accept; máy khách thứ hai chỉ được xếp vào hàng đợi của hệ điều hành, và nếu hàng đợi đầy thì bị từ chối thẳng. Nhìn từ phía họ, máy chủ trông như treo, dù CPU của nó đang rảnh gần hết. Ta đã trộn hai công việc có nhịp độ khác hẳn nhau: đón khách thì rất nhanh, còn phục vụ khách thì kéo dài tuỳ tốc độ gõ của con người ở đầu kia.
Mẫu hình một luồng cho mỗi kết nối (thread-per-connection): luồng chính lặp accept, sinh một luồng phục vụ cho từng máy khách. Luồng chính không bao giờ bị một máy khách chậm làm tắc nghẽn.
import java.io.*;
import java.net.*;
import java.util.concurrent.*;
public class MultiEchoServer {
public static void main(String[] args) throws IOException {
int port = 5000;
// a thread pool reuses worker threads instead of creating one per client
ExecutorService pool = Executors.newFixedThreadPool(20);
try (ServerSocket server = new ServerSocket(port)) {
System.out.println("Multi-client server on port " + port);
while (true) { // keep accepting forever
Socket client = server.accept(); // blocks until a client arrives
pool.submit(new ClientHandler(client)); // hand off to a worker
}
}
}
}
// Each connection is served by its own task running on a pool thread.
class ClientHandler implements Runnable {
private final Socket socket;
ClientHandler(Socket socket) { this.socket = socket; }
@Override
public void run() {
try (Socket s = socket) { // ensures the socket is closed
BufferedReader in = new BufferedReader(
new InputStreamReader(s.getInputStream()));
PrintWriter out = new PrintWriter(s.getOutputStream(), true);
String line;
while ((line = in.readLine()) != null) {
out.println("ECHO: " + line);
}
} catch (IOException e) {
System.err.println("Client error: " + e.getMessage());
}
}
}
Điểm khác biệt nằm ở luồng chính: nó lặp vô hạn, mỗi vòng chỉ accept rồi giao Socket cho một tác vụ ClientHandler chạy trên một luồng của bể luồng (thread pool). Ta dùng ExecutorService thay vì tự tạo new Thread cho mỗi máy khách, vì bể luồng tái dùng một số luồng cố định, tránh chi phí tạo luồng liên tục và tránh việc một lượng máy khách quá lớn sinh ra hàng nghìn luồng làm sập máy chủ. Mỗi ClientHandler cài Runnable, nên toàn bộ logic phục vụ một máy khách nằm gọn trong run. Nhờ vậy máy chủ có thể nói chuyện với hai mươi máy khách cùng lúc mà chúng không chờ nhau.
Vì sao là bể luồng chứ không phải new Thread cho mỗi máy khách? Vì với new Thread, số luồng không do bạn quyết định mà do số máy khách bên ngoài quyết định: chỉ cần một kẻ cố tình mở hàng vạn kết nối là máy chủ sinh hàng vạn luồng rồi chết vì hết bộ nhớ. Bể luồng đảo ngược thế cờ, vì bạn ấn định trần tài nguyên và kết nối vượt trần thì nằm chờ trong hàng đợi chứ không làm sập hệ thống. Chậm còn hơn chết. Đổi lại, mẫu hình này chỉ hợp tới cỡ vài nghìn kết nối đồng thời; đông hơn nữa thì người ta chuyển sang vào ra không chặn với java.nio hoặc dùng luồng ảo.
Khi nhiều luồng cùng phục vụ và cùng ghi vào một tài nguyên chung, ví dụ một danh sách người dùng đang trực tuyến hay một bộ đếm, ta lại gặp vấn đề tranh chấp (race condition). Hãy dùng cấu trúc dữ liệu an toàn luồng như ConcurrentHashMap hoặc đồng bộ hóa khi cần. Bản thân từng socket thì chỉ một luồng dùng nên không cần khóa.
Địa chỉ đã được dùng (BindException): cổng bạn định gắn đang bị tiến trình khác giữ, thường vì bản máy chủ cũ chưa thoát hẳn. Nguyên nhân tinh vi hơn là sau khi tiến trình đóng, TCP còn giữ kết nối ở trạng thái TIME_WAIT một lúc để dọn nốt gói tin đi lạc, nên cổng chưa nhả ngay. Chữa bằng cách kết thúc tiến trình cũ, đổi cổng, hoặc gọi setReuseAddress(true) trước khi gắn.
Kết nối bị từ chối (ConnectException): gói tin đã tới máy đích nhưng không ai lắng nghe ở cổng đó, nên bên kia từ chối ngay. Nguyên nhân gốc là máy chủ chưa chạy, chạy ở cổng khác, hoặc máy khách trỏ nhầm địa chỉ. Lỗi này tới rất nhanh, và lỗi nhanh thì gần như chắc chắn không phải vấn đề đường truyền.
Hết thời gian chờ (SocketTimeoutException): gói gửi đi mà không có hồi âm nào cho tới lúc hết hạn, thường do tường lửa lặng lẽ nuốt gói hoặc máy đích đã tắt. Lỗi tới chậm, và chính độ chậm ấy là manh mối. Còn kết nối bị đặt lại (Connection reset) là khi phía kia đột ngột cắt, hoặc khi bên kia đã đóng socket mà bên này vẫn ghi tiếp.
Giới thiệu UDP với DatagramSocket
Khi không cần độ tin cậy của TCP mà cần sự nhẹ nhàng, ta dùng UDP. Java mô hình hóa UDP bằng DatagramSocket để gửi nhận, và DatagramPacket đại diện cho một gói dữ liệu kèm đích đến. Khác với TCP, không có khái niệm kết nối hay luồng: ta đóng dữ liệu vào một gói, ghi rõ địa chỉ và cổng nhận, rồi gửi.
import java.net.*;
// --- sender side ---
public class UdpSender {
public static void main(String[] args) throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
byte[] data = "Hello UDP".getBytes();
InetAddress addr = InetAddress.getByName("localhost");
// a packet bundles the bytes with the destination endpoint
DatagramPacket packet =
new DatagramPacket(data, data.length, addr, 6000);
socket.send(packet); // fire and forget, no guarantee
System.out.println("Datagram sent");
}
}
}
// --- receiver side ---
class UdpReceiver {
public static void main(String[] args) throws Exception {
try (DatagramSocket socket = new DatagramSocket(6000)) { // bind to port
byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
socket.receive(packet); // blocks until a datagram arrives
String text = new String(packet.getData(), 0, packet.getLength());
System.out.println("Received: " + text);
}
}
}
Hãy để ý sự khác biệt về tư duy. Bên gửi chỉ send rồi quên: nếu gói lạc đường, không có thông báo nào. Bên nhận chuẩn bị một mảng đệm đủ lớn vì mỗi gói UDP có giới hạn kích thước. Không có bắt tay, không có luồng vào ra liên tục như TCP. Sự đơn giản này đổi lấy việc người lập trình phải tự lo độ tin cậy nếu ứng dụng cần, ví dụ tự đánh số gói và yêu cầu gửi lại. Vì thế với phần lớn ứng dụng phổ thông, TCP vẫn là lựa chọn mặc định an toàn hơn.
Bài tập thực hành
Bài 1. Trong máy chủ echo một máy khách, điều gì xảy ra nếu bỏ tham số true của PrintWriter? Giải thích vai trò của tự đẩy dữ liệu (auto-flush) và đề xuất cách khắc phục nếu không dùng tham số đó.
Gợi ý
Không có auto-flush, dữ liệu ghi qua out.println có thể nằm lại trong bộ đệm mà chưa thực sự gửi qua socket, khiến máy khách chờ mãi không nhận được phản hồi. Cách khắc phục là gọi out.flush() thủ công sau mỗi lần ghi để buộc đẩy dữ liệu trong bộ đệm ra luồng ngay lập tức.
Bài 2. (Lập trình) Mở rộng máy chủ echo đa luồng thành một máy chủ chuyển đổi chữ hoa: với mỗi dòng nhận được, trả về dòng đã viết hoa toàn bộ. Viết kèm một máy khách thử nghiệm và mô tả cách chạy.
Gợi ý
Trong ClientHandler.run, thay out.println("ECHO: " + line) bằng out.println(line.toUpperCase()). Phần còn lại giữ nguyên. Máy khách dùng lại EchoClient; chạy MultiEchoServer ở một cửa sổ và một hay nhiều EchoClient ở các cửa sổ khác, gõ chuỗi thường và quan sát phản hồi viết hoa.
Bài 3. (Lập trình) Xây dựng một phòng chat nhóm đơn giản trên TCP: máy chủ giữ danh sách các máy khách đang kết nối trong một ConcurrentHashMap; khi một máy khách gửi tin, máy chủ phát lại tin đó cho mọi máy khách khác. Giải thích vì sao cần cấu trúc dữ liệu an toàn luồng ở đây.
Gợi ý
Mỗi máy khách được phục vụ bởi một luồng riêng, và mọi luồng cùng đọc, ghi vào danh sách kết nối chung khi có người vào, ra hay khi phát tin. Nếu dùng HashMap thường, các thao tác đồng thời này có thể gây tranh chấp và làm hỏng cấu trúc dữ liệu. ConcurrentHashMap cho phép nhiều luồng truy cập an toàn mà không cần tự khóa thủ công toàn bộ. Để phát tin, duyệt qua các PrintWriter của mọi máy khách và gọi println, bỏ qua người gửi nếu muốn.
Bài 4. Dựa trên ví dụ UDP, hãy lập luận điều gì có thể xảy ra nếu phía gửi gửi liên tiếp một nghìn gói trong khi phía nhận xử lý chậm. So sánh tình huống này với cùng kịch bản trên TCP và giải thích sự khác nhau.
Gợi ý
Với UDP, bộ đệm nhận của hệ điều hành có giới hạn; khi nó đầy vì phía nhận xử lý không kịp, các gói đến sau bị loại bỏ lặng lẽ, phía gửi không hề biết và không có cơ chế gửi lại. Với TCP, giao thức có điều khiển luồng (flow control): phía nhận báo cho phía gửi biết khả năng tiếp nhận, phía gửi chậm lại để không tràn, và mọi byte được bảo đảm tới nơi đúng thứ tự. Khác biệt cốt lõi nằm ở chỗ TCP có bảo đảm độ tin cậy còn UDP thì không.
Bài 5. (Lập trình) Máy chủ hiện tại treo vô hạn ở readLine nếu một máy khách kết nối rồi im lặng, không gửi gì và cũng không đóng. Hãy sửa ClientHandler để mỗi kết nối tự ngắt sau 30 giây không có dữ liệu, và giải thích vì sao cần chuyện đó.
Lời giải
Nguyên nhân gốc là mọi thao tác đọc socket đều chặn vô thời hạn theo mặc định, nên một máy khách chỉ cần kết nối rồi ngồi im là chiếm giữ vĩnh viễn một luồng của bể. Hai mươi máy khách như vậy làm cạn sạch bể hai mươi luồng, và máy chủ ngừng phục vụ người dùng thật dù CPU vẫn rảnh; đây là một kiểu tấn công từ chối dịch vụ rất rẻ tiền. Cách chữa là đặt thời hạn chờ đọc bằng setSoTimeout: khi quá hạn, readLine không treo nữa mà ném SocketTimeoutException, ta bắt lấy, đóng kết nối, và trả luồng về bể.
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
public class TimedClientHandler implements Runnable {
private final Socket socket;
public TimedClientHandler(Socket socket) { this.socket = socket; }
@Override
public void run() {
try (Socket s = socket) {
s.setSoTimeout(30_000); // a stalled read now throws instead of hanging forever
BufferedReader in = new BufferedReader(
new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(s.getOutputStream(), StandardCharsets.UTF_8), true);
String line;
while ((line = in.readLine()) != null) {
out.println("ECHO: " + line);
}
} catch (SocketTimeoutException e) {
System.err.println("Idle client dropped after timeout");
} catch (IOException e) {
System.err.println("Client error: " + e.getMessage());
}
}
}
Chú ý ta cũng nói rõ bảng mã UTF-8 ở cả hai chiều, để tiếng Việt không vỡ khi hai máy có bảng mã mặc định khác nhau.
Bài 6. Máy khách gọi out.print("AB") rồi out.print("CD") rồi out.flush(), không có dấu xuống dòng nào, còn máy chủ thì dùng readLine. Máy chủ nhận được gì? Giải thích bằng khái niệm phân định khung.
Lời giải
Máy chủ không nhận được gì cả, nó vẫn nằm chờ trong readLine. Bốn byte đã tới bộ đệm nhận, nhưng readLine chỉ trả về khi gặp dấu xuống dòng hoặc khi luồng kết thúc, mà ở đây ranh giới bản tin chưa xuất hiện. TCP đã làm tròn phận sự vì bốn byte tới nơi đúng thứ tự; nó không biết đâu là điểm kết thúc một bản tin, bởi khái niệm bản tin không tồn tại ở tầng TCP.
Tự kiểm tra
Vì sao luồng chính của máy chủ đa luồng không nên tự xử lý từng máy khách mà phải giao cho luồng khác?
Nếu luồng chính tự phục vụ một máy khách, nó mắc kẹt trong vòng lặp đọc, ghi của máy khách đó cho tới khi kết nối đóng, nên không thể quay lại accept để đón máy khách mới. Mọi máy khách khác phải xếp hàng chờ. Bằng cách giao mỗi kết nối cho một luồng riêng, luồng chính chỉ làm mỗi việc accept rồi quay lại ngay, nhờ vậy nhiều máy khách được phục vụ song song mà không chờ nhau.
Câu hỏi tự kiểm
- 1Lời gọi server.accept() làm gì?
- 2Vì một máy khách gọi write ba lần, máy chủ có chắc đọc được đúng ba lần khớp ranh giới không?
- 3Vì sao máy chủ đa luồng nên dùng bể luồng (thread pool) thay vì new Thread cho mỗi máy khách?
- 4BindException khi khởi động máy chủ thường nghĩa là gì?
Tóm tắt
- Máy chủ chờ ở một cổng cố định, máy khách chủ động gọi tới; sau khi kết nối dựng xong thì hai bên bình đẳng.
- TCP tin cậy và đúng thứ tự, nhưng là dòng byte nên không bảo toàn ranh giới bản tin; ứng dụng phải tự phân định khung, ở đây bằng dấu xuống dòng. UDP thì nhẹ nhưng có thể mất gói và đảo thứ tự.
ServerSocketchỉ mở cửa vàaccept; mỗi kết nối là mộtSocketriêng, và đóng mọi thứ bằngtry-with-resources.- Vì đọc socket là thao tác chặn, máy chủ một luồng chỉ phục vụ được một máy khách; tách phần phục vụ sang luồng khác là lời giải, và bể luồng an toàn hơn tự tạo luồng vì nó đặt trần tài nguyên.
- Đọc lỗi mạng như manh mối: từ chối nhanh là không ai nghe cổng, chờ lâu rồi hết hạn là gói bị chặn hoặc máy không tồn tại.
Socket TCP qua ServerSocket và Socket cho ta đọc ghi mạng như đọc ghi tệp; kết hợp đa luồng với một luồng cho mỗi kết nối, máy chủ phục vụ được nhiều máy khách cùng lúc. Khi cần nhẹ và nhanh mà không đòi bảo đảm, DatagramSocket mở cánh cửa tới UDP. Bài tiếp theo bàn cách gửi nguyên một đối tượng có cấu trúc qua những kết nối này.