Network Programming · Part 9 — Concurrency & Scaling Network Servers
How Node handles thousands of connections on one thread, scaling with cluster and worker_threads, load balancers, timeouts, keep-alive, graceful shutdown, and client pooling — bilingual with TypeScript examples.
Đây là Phần 9 của series 10 bài về lập trình mạng với Node.js + TypeScript. Phần 2–7 đã xây TCP, UDP, DNS, HTTP, WebSocket và TLS — tất cả chạy trong một process Node mặc định. Hôm nay ta trả lời câu hỏi mọi server production cuối cùng đều gặp: một thread xử lý hàng nghìn kết nối thế nào, khi nào nó gãy, và scale qua nhiều core / nhiều máy ra sao.
Một thread, nhiều kết nối
Node.js single-threaded cho việc chạy JavaScript — một event loop mỗi process. Nghe như nút thắt cho đến khi bạn hiểu non-blocking I/O.
Khi handler gọi socket.read() hoặc http.request(), Node không ngồi chờ byte từ mạng. Nó đăng ký với OS (qua epoll trên Linux, kqueue trên macOS, IOCP trên Windows), rồi trả quyền điều khiển về event loop. Khi dữ liệu đến, kernel đánh thức Node và callback của bạn chạy.
┌─────────────────────────────────────────────────────────┐
│ Event loop (one thread) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ conn #1 │ │ conn #2 │ │ conn #N │ … idle │
│ │ waiting │ │ waiting │ │ waiting │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┴─────────────┘ │
│ │ │
│ OS multiplexer (epoll/kqueue) │
│ thousands of sockets, kernel notifies on I/O │
└─────────────────────────────────────────────────────────┘
Đây là tư duy bài toán C10k: một process có thể giữ hàng nghìn socket chủ yếu idle vì bộ nhớ mỗi kết nối nhỏ và CPU không bị block chờ I/O. Node mạnh khi kết nối phần lớn thời gian chờ — HTTP keep-alive, WebSocket, client chậm, gọi API upstream.
Ý chính: đồng thời trong Node là multiplexing hợp tác trên một thread, không phải một OS thread mỗi kết nối.
Khi event loop bị block
Mặt trái thì tàn khốc: bất kỳ việc CPU đồng bộ nào trên main thread đều đóng băng mọi kết nối. Hash mật khẩu, parse JSON 50 MB, resize ảnh, vòng for chặt — tất cả block loop đến khi xong.
import { createServer } from 'node:http';
// ❌ BAD — blocks ALL clients for ~2 seconds
const server = createServer((_req, res) => {
const start = Date.now();
while (Date.now() - start < 2_000) {
// busy-wait simulates CPU-heavy work on the main thread
}
res.end('done\n');
});
server.listen(3000);
Mở hai terminal và gọi server đồng thời: request thứ hai chờ đến khi request đầu xong — không phải vì TCP tuần tự, mà vì JavaScript không chạy hai callback cùng lúc. Quy tắc ngón tay cái: giữ handler async và I/O-bound; đẩy việc CPU sang worker thread hoặc service khác.
Scale qua nhiều core với node:cluster
Server hiện đại có nhiều core, nhưng một process Node chỉ dùng một core cho JavaScript. Module node:cluster fork worker process — thường một mỗi CPU — chia sẻ cùng socket listen.
Process primary bind port; khi client kết nối, OS cân bằng tải kết nối accept tới một worker. Mỗi worker có event loop riêng, nên bốn core chạy bốn loop song song.
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import { createServer } from 'node:http';
import process from 'node:process';
const PORT = 8080;
const WORKERS = availableParallelism(); // cores available to this process
if (cluster.isPrimary) {
console.log(`primary ${process.pid} — forking ${WORKERS} workers`);
for (let i = 0; i < WORKERS; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code) => {
console.warn(`worker ${worker.process.pid} exited (${code}), restarting`);
cluster.fork();
});
} else {
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`hello from worker ${process.pid}\n`);
});
server.listen(PORT, () => {
console.log(`worker ${process.pid} ready on :${PORT}`);
});
}
Gọi curl lặp lại và xem PID khác nhau trong response — bằng chứng OS phân tán kết nối. Sticky session không tự có: hai request từ cùng client có thể vào worker khác nhau, nên map session trong RAM sẽ hỏng trừ khi dùng Redis hoặc cookie.
worker_threads cho việc CPU-bound
cluster scale I/O đồng thời qua process; worker_threads offload việc CPU mà không fork cả process mỗi task. Main thread vẫn phản hồi; tính toán nặng chạy trong pool thread với message passing.
import { createServer } from 'node:http';
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const workerPath = fileURLToPath(new URL('./hash-worker.js', import.meta.url));
function hashInWorker(input: string): Promise<string> {
return new Promise((resolve, reject) => {
const worker = new Worker(workerPath, { workerData: input });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`worker stopped with code ${code}`));
});
});
}
const server = createServer(async (req, res) => {
const body = await new Promise<string>((resolve) => {
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => resolve(data));
});
const digest = await hashInWorker(body);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`${digest}\n`);
});
server.listen(3000);
File worker kèm theo hash-worker.js:
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
// Simulate expensive work without blocking the server's event loop
let hash = workerData;
for (let i = 0; i < 100_000; i++) {
hash = createHash('sha256').update(hash).digest('hex');
}
parentPort?.postMessage(hash);
Dùng worker pool (tái sử dụng worker) trên production — tạo Worker mới mỗi request tốn overhead.
Scale ngang và stateless
Cuối cùng một máy không đủ. Chạy nhiều instance sau load balancer (nginx, HAProxy, ALB cloud). Mỗi instance là process Node độc lập (thường worker cluster × N VM).
| Concern | Single instance | Nhiều instance sau LB |
|---|---|---|
| Session state | In-memory Map works | Phải dùng Redis, DB, hoặc cookie ký |
| WebSockets | One process owns the socket | Cần sticky session hoặc pub/sub bus để fan-out |
| File uploads | Local disk is fine | Dùng object storage; disk local là per-node |
| Deploy | Restart drops connections | Graceful shutdown + rolling deploy + health check |
WebSocket (Phần 6) đặc biệt khó: sau upgrade, kết nối TCP gắn với một worker trên một máy. LB round-robin request HTTP mới thì ổn, nhưng frame tiếp theo phải về cùng backend — cấu hình session affinity hoặc terminate WebSocket tại gateway fan-out.
Vệ sinh từng kết nối
Hàng nghìn socket idle rẻ; socket kẹt thì không. Server production cần timeout, giới hạn, và tắt sạch.
Timeout socket và server
import { createServer, type Server, type Socket } from 'node:net';
const MAX_CONNECTIONS = 10_000;
let activeConnections = 0;
const server = createServer((socket: Socket) => {
if (activeConnections >= MAX_CONNECTIONS) {
socket.destroy();
return;
}
activeConnections++;
socket.setTimeout(30_000); // idle timeout — no data for 30s → 'timeout' event
socket.on('timeout', () => {
socket.end('idle timeout\n');
});
socket.on('data', (chunk) => {
socket.write(chunk); // echo
});
socket.on('close', () => {
activeConnections--;
});
});
server.maxConnections = MAX_CONNECTIONS;
server.setTimeout(60_000); // default idle timeout for sockets without their own
server.listen(3000);
server.setTimeout(ms) đặt timeout idle mặc định; socket.setTimeout(ms) ghi đè từng kết nối. Với HTTP, framework có req.setTimeout() và server.requestTimeout.
TCP keep-alive
Keep-alive cho kernel phát hiện peer chết (client crash, NAT hết hạn) mà không cần traffic ứng dụng.
socket.setKeepAlive(true, 30_000); // probe after 30s idle
Đây là keep-alive tầng TCP, khác HTTP Connection: keep-alive (Phần 5) tái sử dụng một kết nối cho nhiều request.
Tắt nhẹ nhàng
Khi deploy hoặc SIGTERM, ngừng nhận kết nối mới, hoàn thành việc đang chạy, rồi thoát.
import { createServer, type ServerResponse } from 'node:http';
let shuttingDown = false;
const inFlight = new Set<ServerResponse>();
const server = createServer((_req, res) => {
if (shuttingDown) {
res.writeHead(503, { Connection: 'close' });
res.end('shutting down\n');
return;
}
inFlight.add(res);
res.on('finish', () => inFlight.delete(res));
// Simulate in-flight work
setTimeout(() => {
res.end('ok\n');
}, 500);
});
server.listen(3000);
function shutdown(signal: string): void {
console.log(`${signal} received — stop accepting, drain ${inFlight.size} in-flight`);
shuttingDown = true;
server.close(() => {
console.log('listener closed, no new connections');
});
const deadline = setTimeout(() => {
console.error('forced exit — connections still open');
process.exit(1);
}, 10_000);
const check = setInterval(() => {
if (inFlight.size === 0) {
clearInterval(check);
clearTimeout(deadline);
console.log('all in-flight done — exiting');
process.exit(0);
}
}, 100);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.close() ngừng accept; socket hiện có mở đến khi xong hoặc bạn gọi destroy(). Orchestrator (Kubernetes, systemd) gửi SIGTERM, chờ, rồi SIGKILL — thiết kế cửa sổ drain vừa grace period đó.
Connection pooling phía client
Scale không chỉ phía server. Client HTTP mặc định của Node mở kết nối TCP mới mỗi request trừ khi tái sử dụng http.Agent với keepAlive: true.
import http from 'node:http';
const agent = new http.Agent({
keepAlive: true,
maxSockets: 50, // cap concurrent connections per host
maxFreeSockets: 10, // idle sockets kept in the pool
timeout: 30_000,
});
function get(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = http.get(url, { agent }, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => resolve(body));
});
req.on('error', reject);
});
}
// Reuses TCP connections to localhost:3000 instead of 100 handshakes
await Promise.all(Array.from({ length: 100 }, () => get('http://localhost:3000/')));
Với HTTPS, dùng https.Agent tương tự. Thư viện như fetch (undici trong Node 18+) pool nội bộ, nhưng chỉnh maxSockets vẫn quan trọng khi tải cao.
So sánh: bốn cách scale
| Approach | What it solves | Isolation | Shared state | Best for |
|---|---|---|---|---|
| Single process | Simple dev / low traffic | None — one crash kills all | In-memory OK | Prototypes, internal tools |
node:cluster | Use all CPU cores for I/O | Process per worker — memory isolated | Not shared between workers | HTTP/TCP servers on one machine |
worker_threads | CPU-bound tasks without blocking loop | Thread isolation (lighter than process) | SharedArrayBuffer only if you opt in | Hashing, image ops, parsing |
| Multiple machines + LB | Throughput beyond one box | Full machine isolation | External store required (Redis, DB) | Production traffic, HA deploys |
Chọn cluster cho I/O đa core, worker_threads cho đỉnh CPU trên một máy, và scale ngang khi cluster chưa đủ hoặc cần dự phòng.
Lỗi người mới hay mắc
- Làm việc CPU nặng trong request handler — block loop cho mọi client đang kết nối.
- Không timeout — client chậm hoặc ác ý giữ socket mãi, cuối cùng gặp
EMFILE(quá nhiều file mở). - Tưởng state trong RAM (session, rate-limit, map phòng WebSocket) vẫn hoạt động sau khi thêm instance thứ hai hoặc worker cluster thứ hai.
- Không graceful shutdown — deploy giết process giữa request; client thấy reset và retry dồn vào pod mới.
Bài tập
Thử từng bài trước khi mở lời giải.
- Chạy ví dụ cluster và gửi 20 request
curl— đếm bao nhiêu PID worker khác nhau. - Thêm server echo với
socket.setTimeout(2000); kết nối bằngnc, gửi một dòng, chờ 5 giây không gõ — mô tả điều gì xảy ra. - Lưu counter biến module-level trong worker cluster; tăng mỗi request — giải thích vì sao tổng khác số request.
Lời giải
# Exercise 1 — expect up to N unique PIDs where N = availableParallelism()
for i in $(seq 1 20); do curl -s localhost:8080; done | sort -uVới 4 core thường thấy 4 PID; phân phối chính xác phụ thuộc lịch OS.
Bài 2: sau 2 giây im lặng, Node phát timeout trên socket; nếu handler gọi socket.end(), nc thấy kết nối đóng. Không có handler, socket có thể nửa mở đến khi TCP keep-alive hoặc client thoát.
Bài 3: mỗi worker có không gian nhớ riêng — counter của worker A không thấy increment của worker B. Mười request rải trên 4 worker có thể chỉ ~3 mỗi worker cục bộ, không phải 10 toàn cục. Sửa: Redis INCR hoặc DB.
Điều cốt lõi
Node xử lý nhiều kết nối trên một thread nhờ non-blocking I/O và event loop — điểm ngọt C10k cho workload idle, I/O-bound. CPU trên main thread block tất cả; cluster rải I/O qua core; worker_threads offload CPU; nhiều máy đòi thiết kế stateless và định tuyến WebSocket cẩn thận. Gắn kết bằng timeout, keep-alive, giới hạn kết nối, graceful shutdown, và client pooling — rồi ở Phần 10 ta bắt packet và debug cả stack khi vẫn có gì đó sai.