Skip to content

Lesson 1.4.7 — Closure + Async ​

Bài 1.4.7 — Closure + Async
Async Execution, Captured State & Lifetime Across Time • 28 phút
0:00 / 0:00

0. Metadata (Thông tin bài học) ​

FieldValue
Stage1
Module1.4 — Closures
Lesson1.4.7 — Closure + Async
CompetencyC02 — JavaScript Runtime (C02.5 Closure)
Depth TargetL4 (Debug)
PrerequisitesClosure Formation (1.4.1), Closure Lifetime (1.4.2), Closure + Callback (1.4.6), Variable Resolution (1.2.6), Call Stack (1.5)
Estimated Cognitive LoadHigh

Out of Scope (Ngoài phạm vi)

  • Event Loop algorithm, task queue và microtask queue (Stage 3)
  • Promise / async / await semantics (Stage 3)
  • Timer timing guarantees và browser scheduling details (Stage 3/4)
  • Race condition, cancellation, retry và concurrency control (Stage 3)
  • React stale closure (Stage 8)
  • Garbage Collection internals và memory profiling (Stage 11)

1. Why This Exists (Vì sao cần học) ​

Ở Lesson 1.4.6, bạn đã thấy callback có thể được truyền sang nơi khác nhưng vẫn giữ lexical environment nơi nó được tạo.

Bây giờ thêm một yếu tố mới:

Callback không chạy ngay.

Ví dụ:

js
function search(query) {
  setTimeout(() => {
    console.log(query);
  }, 1000);
}

search("javascript");

search("javascript") kết thúc trước khi callback chạy. Nhưng sau đó callback vẫn đọc được query.

Câu hỏi trung tâm của lesson này là:

Tại sao một callback chạy sau vẫn truy cập được binding của function đã return?

Nếu chỉ nghĩ:

"Function return thì local variable biến mất."

thì async callback sẽ trông như một phép màu.

Mental model đúng phải nối được:

text
function call
→ environment created
→ callback created
→ callback captures binding
→ outer function returns
→ callback remains reachable
→ callback runs later
→ captured binding still resolves

Nếu không hiểu chuỗi này, bạn sẽ khó:

  • Trace async callback giữ state nào.
  • Debug callback dùng shared mutable state ngoài dự kiến.
  • Phân biệt dữ liệu tại call time với captured binding.
  • Hiểu vì sao request/timer handler vẫn dùng parameter cũ.
  • Chuẩn bị cho stale closure ở lesson tiếp theo.
  • Học Event Loop ở Stage 3 mà không bị lẫn giữa scheduling và Closure.

2. Prerequisites (Yêu cầu đầu vào) ​

Trước khi học bài này, bạn phải:

  • [ ] Giải thích được Closure Formation.
  • [ ] Biết outer function return nhưng environment vẫn có thể reachable.
  • [ ] Phân biệt creation site và call site của callback.
  • [ ] Trace được callback capture parameter/local binding nào.
  • [ ] Biết Function Execution Context rời Call Stack khi function kết thúc.
  • [ ] Hiểu rằng setTimeout nhận một callback để host gọi lại sau; chưa cần biết queue internals.

Nếu thiếu, quay lại Lesson 1.4.1, 1.4.2 và 1.4.6 trước.

3. Learning Objectives (Mục tiêu học tập) ​

Sau bài này, bạn có thể:

  1. Trace lifecycle của một async callback từ lúc outer function được gọi đến lúc callback chạy.
  2. Giải thích tại sao callback vẫn resolve được captured bindings sau khi outer function return.
  3. Phân biệt Closure mechanism với async scheduling mechanism.
  4. Dự đoán output của các callback chạy sau với captured bindings.
  5. Debug một async callback dùng nhầm shared outer state.
  6. Implement delayed callback giữ parameter/local state đúng ownership.
  7. Chuẩn bị mental model cho Stale Closure ở Lesson 1.4.8.

4. Mental Model (Mô hình tư duy) ​

Mental Model — Closure sống qua thời gian

Async không tạo ra Closure.

Closure đã hình thành khi callback function được tạo.

Async chỉ làm cho callback được gọi ở một thời điểm sau.

text
Outer Function Called
        ↓
Outer Environment Created
        ↓
Async Callback Created
        ↓
Callback keeps needed outer bindings
        ↓
Callback registered / handed to host
        ↓
Outer Function Returns
        ↓
Outer Execution Context leaves stack
        ↓
Callback still reachable
        ↓
Callback Called Later
        ↓
Captured bindings are resolved

Ví dụ:

js
function search(query) {
  setTimeout(() => {
    console.log(query);
  }, 1000);
}

Mental model:

text
search("javascript")
      ↓
Environment
└── query = "javascript"
      ↑
      └── callback
           ↓
        registered
           ↓
search() returns
           ↓
callback still reachable
           ↓
callback runs later
           ↓
reads query = "javascript"

Điểm quan trọng:

Closure trả lời "callback vẫn truy cập được dữ liệu nào?"

Còn:

Async scheduling trả lời "callback được gọi khi nào?"

Đây là hai vấn đề khác nhau.

Common Misconception

Không nên nói:

"setTimeout giữ local variable sống."

Mental model tốt hơn:

  • Callback được tạo trong lexical environment của search.
  • Callback cần binding query.
  • Callback còn được hệ thống giữ để gọi sau.
  • Vì callback còn reachable và cần outer binding, environment cần thiết vẫn reachable.

setTimeout chỉ là nơi nhận callback; Closure semantics không phụ thuộc riêng vào timer API.

5. Core Concepts (Các khái niệm cốt lõi) ​

Essential (Bắt buộc) ​

  • Async Callback: Callback được gọi ở thời điểm sau bởi một host/API/scheduler.
  • Captured Parameter: Parameter của outer function được callback dùng qua Closure.
  • Delayed Execution: Callback chạy sau khi creator function có thể đã return.
  • Retention through Callback: Callback còn reachable nên bindings cần thiết vẫn có thể reachable.
  • Closure vs Scheduling: Closure quyết định lexical data access; scheduling quyết định thời điểm callback được gọi.

Supporting (Hỗ trợ) ​

  • Timer callback là ví dụ đơn giản để quan sát delayed execution.
  • Callback có thể capture primitive binding hoặc object reference.
  • Nhiều async callbacks có thể giữ bindings của các creator invocations khác nhau.
  • Shared outer mutable binding có thể làm nhiều callbacks ảnh hưởng nhau.

Awareness (Biết tồn tại) ​

  • Timer không bảo đảm callback chạy chính xác sau đúng số millisecond yêu cầu.
  • Promise callbacks có scheduling semantics khác timer callbacks.
  • Race condition và stale closure sẽ xuất hiện khi state thay đổi theo thời gian.
  • Các vấn đề đó được học sâu hơn ở Stage 3 và Lesson 1.4.8.

6. Worked Example (Ví dụ phân tích từng bước) ​

Worked Example — Trace callback sau khi outer return:

js
function search(query) {
  const normalized = query.trim().toLowerCase();

  setTimeout(() => {
    console.log("Searching:", normalized);
  }, 1000);

  return "scheduled";
}

const result = search(" JavaScript ");

console.log(result);

Step 1 — search(" JavaScript ") được gọi:

Tạo Function Execution Context và environment:

text
query → " JavaScript "
normalized → "javascript"

Step 2 — Callback được tạo:

js
() => {
  console.log("Searching:", normalized);
};

Callback cần resolve binding:

text
normalized

nên giữ reference cần thiết đến outer environment.

Step 3 — Callback được đăng ký:

Callback được truyền cho setTimeout.

Ở mức lesson này, chỉ cần hiểu:

Có một external/host mechanism giữ callback để gọi sau.

Chưa cần trace task queue hay Event Loop.

Step 4 — search() return:

js
return "scheduled";

Execution Context của search rời Call Stack.

Nhưng callback vẫn còn reachable qua timer registration và vẫn cần normalized.

Step 5 — Code ngoài tiếp tục:

js
console.log(result);

in:

text
scheduled

Step 6 — Callback được gọi sau:

Khi host gọi callback, callback resolve:

text
normalized → "javascript"

Output:

text
Searching: javascript

Step 7 — Kết luận:

text
outer execution ended
≠
captured environment immediately gone

Async chỉ kéo thời điểm callback execution ra xa creator function. Closure giúp callback vẫn có lexical access tới dữ liệu cần thiết.

7. Prediction Exercise (Bài tập dự đoán) ​

Đừng chạy code. Dự đoán output và giải thích bằng callback creation + captured binding.

Prediction 1

js
function schedule(message) {
  setTimeout(() => {
    console.log(message);
  }, 0);
}

schedule("A");

Callback sẽ in gì?

Prediction 2

js
function schedule(value) {
  value = value + 1;

  setTimeout(() => {
    console.log(value);
  }, 0);
}

schedule(10);

Callback sẽ in gì?

Prediction 3

js
let value = 1;

function schedule() {
  setTimeout(() => {
    console.log(value);
  }, 0);
}

schedule();

value = 2;

Callback có thể đọc binding nào?

[Đáp án & Giải thích]
  • Prediction 1:

    • Callback in "A".
    • Callback được tạo trong invocation schedule("A") và capture parameter binding message.
  • Prediction 2:

    • Callback in 11.
    • Parameter binding value của invocation được update thành 11 trước khi callback chạy. Callback đọc current value của cùng binding đó.
  • Prediction 3:

    • Callback capture global binding value, không phải một copy cố định của số 1.
    • Khi callback chạy sau khi value = 2, callback có thể đọc value hiện tại 2 của cùng binding.
    • Đây là cầu nối sang Stale Closure, nhưng lesson này chỉ yêu cầu nhận ra binding được capture, chưa phân tích stale-state patterns sâu hơn.

Transfer Check — Hai invocation độc lập:

js
function schedule(label) {
  setTimeout(() => {
    console.log(label);
  }, 0);
}

schedule("A");
schedule("B");

Hãy trả lời:

  1. Có bao nhiêu invocation của schedule?
  2. Có bao nhiêu parameter binding label?
  3. Callback A giữ binding nào?
  4. Callback B giữ binding nào?
  5. Hai callbacks có bắt buộc cùng in một value không?
Gợi ý trả lời
  1. Có hai invocations.
  2. Có hai parameter bindings label.
  3. Callback đầu giữ label của invocation đầu, có value "A".
  4. Callback sau giữ label của invocation sau, có value "B".
  5. Không. Mỗi callback giữ environment của creator invocation tương ứng.

8. Implementation Lab (Bài lab thực hành) ​

Lab 1 — Guided: Delayed Greeter ​

Implement:

js
function greetLater(name) {
  // TODO
}

greetLater("Alice");

Expected callback behavior:

text
Hello, Alice

Yêu cầu:

  • name phải đến từ parameter binding của greetLater.
  • Callback phải dùng Closure.
  • Không copy name sang global variable.
[Đáp án tham khảo]
js
function greetLater(name) {
  setTimeout(() => {
    console.log(`Hello, ${name}`);
  }, 0);
}
  • Giải thích: Callback capture parameter name. greetLater return trước khi callback chạy, nhưng callback vẫn có thể resolve binding đó.

Lab 2 — Partial Scaffold: Capture normalized value ​

Hoàn thiện:

js
function logSearchLater(query) {
  const normalized = query.trim().toLowerCase();

  setTimeout(() => {
    // TODO
  }, 0);
}

logSearchLater(" React ");

Expected:

text
react

Sau đó trả lời:

  1. Callback capture query, normalized, hay cả hai?
  2. Identifier nào callback thực sự dùng?
  3. Engine có bắt buộc giữ mọi local binding của outer function không?
[Đáp án tham khảo]
js
function logSearchLater(query) {
  const normalized = query.trim().toLowerCase();

  setTimeout(() => {
    console.log(normalized);
  }, 0);
}
  • Callback trực tiếp dùng normalized.
  • Ở mental model level, ta chỉ cần nói callback cần outer binding normalized.
  • Không nên kết luận engine phải giữ toàn bộ mọi local binding nếu callback không cần chúng; optimization detail nằm ngoài scope.

Lab 3 — Independent: Per-request callback state ​

Implement:

js
function scheduleRequest(requestId, payload) {
  // TODO
}

scheduleRequest("req-1", { type: "login" });
scheduleRequest("req-2", { type: "logout" });

Expected callback output tương ứng:

text
req-1: login
req-2: logout

Constraints:

  • Không dùng shared global currentRequest.
  • Mỗi invocation phải giữ request data riêng.
  • Callback phải đọc requestId và payload từ creator invocation.
  • Không dùng Promise hoặc async/await.
[Đáp án tham khảo]
js
function scheduleRequest(requestId, payload) {
  setTimeout(() => {
    console.log(`${requestId}: ${payload.type}`);
  }, 0);
}
  • Giải thích: Mỗi invocation tạo parameter bindings riêng. Callback của req-1 và callback của req-2 giữ hai environments khác nhau.

9. Edge Cases (Các trường hợp ngoại lệ) ​

Edge Case 1 — Capture binding, không snapshot primitive ​

js
function schedule() {
  let value = 1;

  setTimeout(() => {
    console.log(value);
  }, 0);

  value = 2;
}

schedule();

Phân tích

Callback capture binding value.

Outer function đổi chính binding đó từ 1 sang 2 trước khi kết thúc.

Khi callback chạy sau, nó đọc current value của binding và in 2.

Không nên nói callback "nhớ value lúc đăng ký".

Edge Case 2 — Capture object reference ​

js
function schedule(config) {
  setTimeout(() => {
    console.log(config.mode);
  }, 0);

  config.mode = "production";
}

const config = {
  mode: "development",
};

schedule(config);

Phân tích

Callback capture parameter binding config, binding này trỏ tới một object.

Sau đó object bị mutate.

Khi callback chạy, nó đọc property hiện tại của cùng object reference và có thể in "production".

Closure không deep-copy object state.

Edge Case 3 — Hai async callbacks dùng shared outer binding ​

js
let currentId = null;

function schedule(id) {
  currentId = id;

  setTimeout(() => {
    console.log(currentId);
  }, 0);
}

schedule("A");
schedule("B");

Phân tích

Hai callbacks không capture parameter id; chúng cùng dùng shared global binding currentId.

Call thứ hai đổi currentId thành "B".

Khi callbacks chạy sau, cả hai có thể đọc cùng current value "B".

Đây là ownership bug, không phải timer bug.

10. Debug Lab (Bài lab gỡ lỗi) ​

Debug Lab — Hai async callbacks cùng đọc request cuối

Symptom (Triệu chứng): Developer mong đợi:

text
A
B

nhưng cả hai callbacks đều log:

text
B
B

Reproduction (Tái hiện lỗi):

js
let currentRequestId;

function scheduleRequest(requestId) {
  currentRequestId = requestId;

  setTimeout(() => {
    console.log(currentRequestId);
  }, 0);
}

scheduleRequest("A");
scheduleRequest("B");

Evidence (Bằng chứng):

  • Có hai invocations của scheduleRequest.
  • Mỗi invocation có parameter binding requestId riêng.
  • Nhưng callback không dùng requestId.
  • Callback dùng global/shared binding currentRequestId.
  • Invocation thứ hai reassign shared binding thành "B" trước khi callbacks chạy.

Hypothesis (Giả thuyết):

Cả hai callbacks cùng resolve một global binding currentRequestId, nên đều đọc value cuối cùng.

Verification (Xác minh):

Đổi callback thành:

js
setTimeout(() => {
  console.log(requestId);
}, 0);

Nếu output trở thành:

text
A
B

thì giả thuyết được xác nhận.

Root Cause (Nguyên nhân gốc rễ):

Data ownership sai:

text
request-specific data
should belong to
each creator invocation

but code stores it in
one shared global binding

Fix (Sửa):

Capture parameter binding của từng invocation:

js
function scheduleRequest(requestId) {
  setTimeout(() => {
    console.log(requestId);
  }, 0);
}

Prevention (Phòng ngừa):

  • Khi async callback cần per-request data, ưu tiên creator parameter/local binding tương ứng.
  • Tránh shared mutable binding nếu ownership thuộc từng async operation.
  • Khi nhiều async callbacks trả cùng value ngoài dự kiến, trace binding identity trước.
  • Tách hai câu hỏi:
    • Callback chạy khi nào?
    • Callback đọc binding nào?

11. Design Exercise (Bài tập thiết kế giải pháp) ​

Không áp dụng Design Exercise đầy đủ ở depth L4.

Thay vào đó, làm một state ownership decision.

Bạn cần schedule nhiều jobs:

js
scheduleJob("job-A");
scheduleJob("job-B");

Requirement:

Mỗi callback phải log đúng job id của chính operation.

Hai options:

Option A — Shared state

js
let currentJobId;

function scheduleJob(jobId) {
  currentJobId = jobId;

  setTimeout(() => {
    console.log(currentJobId);
  }, 0);
}

Option B — Per-invocation state

js
function scheduleJob(jobId) {
  setTimeout(() => {
    console.log(jobId);
  }, 0);
}

Decision đúng là Option B.

Engineering Decision

Khi async callback cần data, hãy hỏi:

text
Data belongs to:
this operation?
all operations?
latest operation?
global system?

Binding placement phải phản ánh ownership đó.

12. Production Scenario (Tình huống thực tế) ​

Production Scenario — Delayed Audit Log

Một hệ thống muốn ghi audit sau khi request handler đã hoàn tất setup:

js
function handleRequest(requestId, userId) {
  const message = `${requestId}:${userId}`;

  setTimeout(() => {
    console.log("AUDIT", message);
  }, 0);
}

Context: Mỗi request có requestId và userId riêng.

Constraint: Audit callback chạy sau khi handleRequest đã return.

Reasoning:

  • Mỗi call handleRequest tạo environment riêng.
  • message là local binding của từng invocation.
  • Callback được tạo trong invocation đó.
  • Callback capture message.
  • Outer execution kết thúc.
  • Callback vẫn reachable qua host scheduling mechanism.
  • Khi callback chạy, nó resolve đúng message của creator invocation.

Mental model:

text
Request A
└── message_A
    ↑
    └── callback_A

Request B
└── message_B
    ↑
    └── callback_B

Production lesson:

Async code thường giữ context qua thời gian bằng Closure.

Nhưng nếu context ownership bị đặt vào shared mutable state, nhiều async operations có thể ảnh hưởng nhau. Stage 3 sẽ mở rộng vấn đề này thành race condition và concurrency.

13. AI-assisted Exercise (Bài tập với AI) ​

Level B — Challenge (Thách thức)

  1. Tự trả lời trước: Viết flow từ search(query) tới lúc timer callback dùng lại query.
  2. Hỏi AI: "Why can a JavaScript setTimeout callback access a function parameter after the outer function has returned? Explain closure separately from event-loop scheduling."
  3. Inspect: AI có trộn "Closure" với "Event Loop" thành một cơ chế duy nhất không?
  4. Challenge: Đưa code shared-state bug:
js
let currentId;

function schedule(id) {
  currentId = id;
  setTimeout(() => console.log(currentId), 0);
}

và hỏi binding nào callback thực sự capture.

  1. Verify: Tự trace creation site, binding ownership và callback lifetime.

Gợi ý

Câu trả lời đủ depth phải tách:

text
Closure:
what data remains accessible

Async scheduling:
when callback runs

Nếu AI nói "Event Loop stores variables for callback", explanation đó không đạt yêu cầu.

Đáp án tham khảo
  • Callback được tạo trong lexical environment của outer function.
  • Callback sử dụng parameter/local binding nên giữ khả năng resolve binding đó.
  • Callback được truyền cho host API và còn reachable sau khi outer function return.
  • Execution Context của outer function rời Call Stack, nhưng environment cần thiết vẫn có thể reachable.
  • Host gọi callback sau theo scheduling mechanism riêng.
  • Khi callback chạy, Variable Resolution vẫn theo lexical environment chain cũ.
  • Event Loop không phải cơ chế tạo Closure.

14. Teach Back (Dạy lại) ​

Giải thích cho một đồng nghiệp junior trong 2 phút:

"Function search(query) đã return rồi. Tại sao callback trong setTimeout vẫn dùng được query khi chạy sau?"

Yêu cầu:

  • Dùng đúng terminology: lexical environment, closure, callback, reference, Execution Context.
  • Tách Closure khỏi async scheduling.
  • Không nói timer "copy biến".
  • Giải thích được shared-state counterexample.
Mô phỏng
  • Bạn nói:
    • Khi search(query) chạy, một invocation và lexical environment được tạo, trong đó có parameter binding query.
    • Callback của setTimeout được tạo trong environment đó và dùng identifier query, nên Closure giữ khả năng resolve binding này.
    • Callback được truyền cho host API để gọi sau. Vì callback vẫn được giữ reference, environment cần thiết vẫn có thể reachable.
    • Sau đó search return và Execution Context của nó rời Call Stack. Điều đó không đồng nghĩa captured environment lập tức biến mất.
    • Khi callback được gọi sau, nó vẫn đi theo lexical environment chain nơi nó được tạo và tìm thấy query.
    • Async scheduling chỉ quyết định callback chạy sau; Closure quyết định callback vẫn truy cập được data nào.
    • Nếu callback dùng một global currentQuery thay vì parameter query, nhiều async callbacks có thể cùng đọc shared latest value.

💡 Hình dung callback là một lá thư được gửi đi sau nhưng vẫn mang địa chỉ quay về đúng environment nơi nó được tạo.

Gợi ý đánh giá bản thân

  • Bạn có trace được call → create callback → register → return → callback run không?
  • Bạn có phân biệt outer Execution Context với retained environment không?
  • Bạn có chỉ ra callback capture parameter hay shared global binding không?
  • Bạn có giải thích được vì sao timer không phải nguyên nhân tạo Closure không?

15. Assessment (Đánh giá) ​

Objective (Mục tiêu)Hình thức đánh giá
Trace async callback lifecycleWorked Example + Teach Back
Giải thích Closure sau outer returnTeach Back
Phân biệt Closure vs schedulingMental Model + AI Exercise
Dự đoán captured binding behaviorPrediction — 3/3 đúng
Implement per-operation async stateLab 1–3
Debug shared async stateDebug Lab — xác định đúng binding ownership
Chuẩn bị cho stale closurePrediction 3 + Edge Case 1

16. Exit Criteria (Tiêu chí qua bài) ​

  • [ ] Có thể trace call → create callback → capture → register → outer return → callback run.
  • [ ] Có thể giải thích tại sao callback vẫn dùng được parameter sau outer return.
  • [ ] Có thể phân biệt Closure mechanism với async scheduling mechanism.
  • [ ] Dự đoán đúng 3/3 prediction scenarios.
  • [ ] Có thể chỉ ra callback capture parameter/local binding hay shared outer binding.
  • [ ] Implement được async callback giữ per-operation data riêng.
  • [ ] Debug được case hai callbacks cùng đọc shared state cuối cùng.
  • [ ] Không nói setTimeout "copy variables" hoặc "tạo Closure".
  • [ ] Không cần dùng Event Loop internals để giải thích lexical access.
  • [ ] Sẵn sàng phân tích captured binding thay đổi theo thời gian ở Lesson 1.4.8.

17. Spiral Connection (Liên kết xoắn ốc) ​

Previous (Trước): Closure Formation → Lifetime → Private State → Factory Functions → Closure trong Loop → Closure + Callback. Bạn đã biết callback giữ lexical environment nơi nó được tạo.

Current (Hiện tại): Closure + Async. Bạn thêm yếu tố thời gian: outer function có thể return trước, nhưng callback chạy sau vẫn resolve captured bindings.

Next (Tiếp theo):

  • Lesson 1.4.8 — Stale Closure (captured state và "latest state" không phải lúc nào cũng giống nhau)
  • Stage 3 — Event Loop, Promise, async/await, concurrency và race condition
  • Stage 4 — Browser scheduling / Web APIs
  • Stage 8 — React Hooks và stale closure
  • Stage 11 — Memory retention và debugging
📴 Offline Mode — Content served from cache