Task starvation
Khi các tác vụ ưu tiên thấp liên tục bị trì hoãn do sự quá tải của hàng đợi microtask hoặc các tác vụ đồng bộ dài hơi, gây đóng băng giao diện người dùng.
Bản chất của Hiện tượng Bỏ đói Tác vụ trên Đơn Luồng (Single-thread)
Môi trường thực thi JavaScript trong trình duyệt là đơn luồng (single-threaded). Điều này đồng nghĩa với việc Main Thread phải gánh vác cả việc thực thi logic JavaScript lẫn các tác vụ cập nhật giao diện (Style, Layout, Paint) và phản hồi tương tác từ người dùng (Input Handling).
Hiện tượng bỏ đói tác vụ (Task Starvation) xảy ra khi một chuỗi các tác vụ đồng bộ nặng (Heavy Synchronous Code) hoặc một vòng lặp đệ quy tạo ra liên tục các microtask (Microtask Loop) chiếm dụng hoàn toàn Main Thread. Trình duyệt bị đẩy vào trạng thái cạn kiệt tài nguyên thực thi cho các tác vụ quan trọng khác.
Điểm cốt lõi cần nhớ là cơ chế ưu tiên của Event Loop: Hàng đợi Microtask (Microtask Queue) luôn có độ ưu tiên cao hơn và được dọn dẹp sạch sẽ trước khi Event Loop chuyển sang Task (Macrotask) tiếp theo hoặc tiến hành chu kỳ Paint. Do đó, việc liên tục đẩy các microtask mới sẽ chặn đứng hoàn toàn các sự kiện click, gõ phím của người dùng và các frame rendering, trực tiếp làm tăng chỉ số INP (Interaction to Next Paint) và gây đơ UI.
CẢNH BÁOViệc bọc một tác vụ đồng bộ nặng trong Promise.resolve().then(...) chỉ đẩy tác vụ đó vào hàng đợi microtask của chu kỳ Event Loop hiện tại, hoàn toàn KHÔNG giải phóng Main Thread như nhiều lập trình viên lầm tưởng.
VÌ SAOTrình duyệt chỉ có thể thực hiện vẽ lại giao diện (Paint) sau khi call stack và microtask queue hoàn toàn rỗng. Bất kỳ tác vụ nào chiếm dụng luồng chính quá 50ms đều được coi là một Long Task và đe dọa độ phản hồi của UI.
Chiến lược Tối ưu: Batching và Chủ động Nhường luồng (Yielding)
Để giải quyết triệt để Task Starvation, lập trình viên cần chia nhỏ các tập dữ liệu lớn thành từng cụm nhỏ (Batching) và chủ động trả quyền kiểm soát lại cho trình duyệt sau mỗi batch (Yielding).
Trước đây, kỹ thuật nhường luồng thường sử dụng setTimeout(fn, 0) hoặc setImmediate(). Tuy nhiên, setTimeout(fn, 0) có nhược điểm lớn là giới hạn thời gian chờ tối thiểu bị đẩy lên 4ms khi lồng nhau quá 5 cấp, đồng thời nó đẩy tác vụ xuống cuối hàng đợi Macrotask mà không quan tâm đến mức độ ưu tiên.
Hiện nay, Prioritized Task Scheduling API cung cấp giải pháp tối ưu thông qua scheduler.yield() và scheduler.postTask(). Cơ chế này cho phép nhường Main Thread một cách thông minh: trình duyệt sẽ xử lý các tác vụ render và input khẩn cấp trước, sau đó ngay lập tức tiếp tục xử lý batch tiếp theo mà không bị trễ 4ms.
// Hàm tiện ích hỗ trợ nhường luồng thông minh với cơ chế fallback
async function yieldToMainThread(): Promise<void> {
if (typeof globalThis.scheduler?.yield === "function") {
// API hiện đại: Nhường luồng nhưng giữ nguyên vị trí ưu tiên tương đối
await globalThis.scheduler.yield();
} else {
// Cơ chế Fallback sử dụng MessageChannel (tốt hơn setTimeout vì không bị trễ 4ms)
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
}
}
interface ProcessOptions {
batchSize?: number;
yieldIntervalMs?: number;
}
async function processLargeDataset<T>(
items: T[],
processor: (item: T) => void,
options: ProcessOptions = {}
): Promise<void> {
const { batchSize = 50, yieldIntervalMs = 16 } = options;
let lastYieldTime = performance.now();
for (let i = 0; i < items.length; i++) {
processor(items[i]);
// Nhường luồng khi đạt giới hạn batch hoặc khi công việc kéo dài quá 1 frame (16.6ms)
const shouldYield =
(i % batchSize === 0) ||
(performance.now() - lastYieldTime > yieldIntervalMs);
if (shouldYield) {
await yieldToMainThread();
lastYieldTime = performance.now(); // Reset mốc thời gian
}
}
}NÊNKết hợp kiểm tra thời gian thực thi (performance.now()) để nhường luồng linh hoạt thay vì chỉ dựa vào số đếm phần tử cố định.
KHÔNGKhông chạy các vòng lặp đệ quy tạo Promise vô hạn hoặc xử lý JSON.parse() trên chuỗi dữ liệu hàng chục Megabyte một cách đồng bộ.