Execution context và call stack: đọc được stack trace là kỹ năng debug nền tảng
Mỗi khi một hàm JavaScript chạy, engine tạo ra một execution context cho lần gọi đó — một khung chứa thông tin về scope, giá trị this, và các biến local. Các context này được xếp chồng lên nhau trong call stack. Hiểu rõ cơ chế xếp chồng này không phải kiến thức hàn lâm: nó là thứ quyết định bạn đọc được một stack trace dài 40 dòng trong vài giây hay ngồi đoán mò, và là lý do Maximum call stack size exceeded xuất hiện đúng lúc traffic tăng.
Stack hoạt động thế nào
Khi script bắt đầu, engine push một global execution context. Mỗi lần gọi hàm push thêm một context lên đỉnh stack; khi hàm return, context đó được pop ra. Stack chạy theo LIFO, và đỉnh stack luôn là hàm đang thực thi.
function loadUser(id) {
return enrich(fetchRow(id))
}
function fetchRow(id) {
return db.query('select ...', [id]) // hàm này throw
}
function enrich(row) { /* ... */ }
loadUser(42)
Khi db.query throw, stack tại thời điểm đó là global → loadUser → fetchRow → query. Stack trace in ra chính là ảnh chụp của stack này, đọc từ trên xuống: dòng đầu là nơi lỗi xảy ra, các dòng sau là chuỗi hàm đã gọi tới nó. Đọc trace là đọc ngược con đường thực thi.
Vấn đề gặp trong production
Hai nhóm bug gắn trực tiếp với call stack, và cả hai đều hay xuất hiện dưới tải thật.
Thứ nhất: stack overflow do đệ quy không kiểm soát. Call stack có giới hạn kích thước (thường vài nghìn frame). Một hàm đệ quy thiếu base case, hoặc đệ quy trên cấu trúc dữ liệu sâu bất ngờ, sẽ đẩy stack tới giới hạn:
// Duyệt cây danh mục, tưởng là cây nhưng dữ liệu thật có cycle (A -> B -> A)
function flatten(node, acc = []) {
acc.push(node.id)
for (const child of node.children) flatten(child, acc) // cycle -> overflow
return acc
}
Bug này thường không lộ ở local với dữ liệu test nhỏ, mà nổ ở production khi gặp một record có cycle hoặc một cây sâu hàng nghìn cấp. Cách phòng đúng là chuyển sang vòng lặp với stack tường minh, vừa tránh overflow vừa kiểm soát được cycle:
function flatten(root) {
const acc = []
const seen = new Set()
const stack = [root]
while (stack.length) {
const node = stack.pop()
if (seen.has(node.id)) continue // cắt cycle
seen.add(node.id)
acc.push(node.id)
for (const child of node.children) stack.push(child)
}
return acc
}
Bản iterative không tăng call stack theo độ sâu dữ liệu, và seen xử lý cycle — thứ mà bản đệ quy ngây thơ bỏ sót.
Thứ hai: stack trace bị "nuốt" trong code bất đồng bộ. Đây là nguyên nhân khiến nhiều log production vô dụng. Khi một lỗi đi qua callback hoặc promise mà không được chain đúng, trace mất đoạn nối tới nơi gọi thật:
// Trace chỉ tới handler nội bộ của thư viện, không thấy code gọi
queue.process(async (job) => {
await handlePayment(job.data) // nếu throw ở đây mà nuốt mất, trace cụt
})
Giữ trace đầy đủ là lý do tồn tại của exception chaining. Khi bắt rồi ném lại, luôn giữ nguyên nhân gốc:
try {
await chargeCard(payment)
} catch (cause) {
// không nuốt trace gốc; gắn context nghiệp vụ vào
throw new Error(`charge failed for order ${payment.orderId}`, { cause })
}
{ cause } (chuẩn từ ES2022) giữ lại stack của lỗi gốc trong error.cause, để log production có cả ngữ cảnh nghiệp vụ lẫn dòng code thật sự fail.
Cách debug và monitor
Khi điều tra một crash, ba thứ cần nhìn ngay trên trace: dòng trên cùng (nơi throw), dòng đầu tiên thuộc code của ứng dụng (bỏ qua các frame của thư viện/node_modules), và độ sâu bất thường (trace dài hàng trăm frame lặp lại cùng một hàm là dấu hiệu đệ quy mất kiểm soát).
Để bắt overflow trước khi nó hạ process, đặt giới hạn độ sâu tường minh cho các thao tác đệ quy trên dữ liệu không tin cậy (payload từ client, cấu trúc tự tham chiếu), thay vì dựa vào giới hạn ngầm của engine. Giới hạn của engine cho một thông báo lỗi mơ hồ; giới hạn của bạn cho một lỗi nghiệp vụ rõ ràng và một dòng log hữu ích.
Tradeoff
Đệ quy thường ngắn gọn và bám sát định nghĩa toán học của bài toán (cây, đồ thị, divide-and-conquer), nên dễ đọc hơn bản iterative. Cái giá là mỗi lần gọi tốn một stack frame, và độ sâu bị chặn bởi kích thước stack. Với dữ liệu có biên rõ ràng và nông, đệ quy là lựa chọn sạch. Với dữ liệu do người dùng cung cấp, có thể sâu hoặc có cycle, bản iterative với explicit stack là lựa chọn an toàn cho production — đánh đổi vài dòng code lấy việc không bao giờ làm sập process vì một input bất thường.
Câu hỏi phỏng vấn
Execution context và call stack hoạt động như thế nào, và điều đó liên quan gì tới
Maximum call stack size exceeded?
Trả lời theo cơ chế: mỗi lần gọi hàm tạo một execution context được push lên call stack, pop ra khi return; stack chạy LIFO và đỉnh là hàm đang chạy. Maximum call stack size exceeded xảy ra khi số frame vượt giới hạn — gần như luôn do đệ quy thiếu base case hoặc đệ quy trên cấu trúc sâu/cycle. Điểm ăn điểm là nêu cách xử lý thực tế: chuyển sang iterative với explicit stack và một Set để cắt cycle, đồng thời giữ exception chaining bằng { cause } để trace không bị nuốt.
Hands-on
Lấy một thuật toán duyệt cây thật (ví dụ build menu phân cấp từ một bảng phẳng id, parent_id), viết cả bản recursive lẫn iterative, rồi nạp một dataset cố ý có cycle và một cây sâu ~50.000 cấp. Quan sát bản recursive ném Maximum call stack size exceeded, còn bản iterative chạy ổn định và phát hiện cycle. Sau đó bọc thao tác trong try/catch dùng { cause } và in ra error.cause.stack để thấy trace gốc được giữ nguyên — đúng cái sẽ xuất hiện trong log production.
Top comments (0)