jsrosetta

Bất đồng bộ

Truyền ngữ cảnh theo request: AsyncLocalStorage và context.Context

AsyncLocalStorage của Node.js tự mang request ID theo chuỗi async, so với context.Context của Go phải truyền tường minh qua từng hàm.

Tác giả:
Phiên bản tối thiểu
Node.js ≥ 14.13.1Go ≥ 1.25
Đã chạy thử trên
Node.js 24.12.0Go 1.27.1

Code Node.js là ES module: lưu file .mjs hoặc đặt "type": "module" trong package.json.

Một request đi qua handler, service, repository rồi tới logger. Logger muốn in request ID, nhưng chẳng ai muốn thêm tham số requestId vào mọi hàm ở giữa. Node.js giải quyết ngầm định: AsyncLocalStorage (trong node:async_hooks) gắn một "store" vào chuỗi bất đồng bộ đang chạy, và store đó tự đi theo qua await, promise, timer — hàm ở tầng sâu nhất chỉ cần gọi getStore(). Go cố tình không có goroutine-local storage: goroutine không có ID công khai, không có biến "của riêng goroutine này". Thay vào đó, context.Context mang các giá trị theo request một cách tường minh — mọi hàm cần nó đều nhận ctx làm tham số đầu tiên. Ngầm định thì gọn hơn; tường minh thì nhìn chữ ký hàm là biết hàm nào phụ thuộc vào ngữ cảnh.

Gắn giá trị cho một request và đọc ở tầng sâu (run/getStore vs WithValue/Value)

import { AsyncLocalStorage } from "node:async_hooks";
 
const requestContext = new AsyncLocalStorage();
 
// Logger ở tầng sâu nhất: không nhận requestId qua tham số.
function log(message) {
  const store = requestContext.getStore(); // undefined nếu không nằm trong run()
  console.log(`[${store?.requestId ?? "-"}] ${message}`);
}
 
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
async function loadUser(id) {
  await sleep(10);
  log(`loaded user ${id}`);
  return { id };
}
 
function handleRequest(requestId, userId) {
  // Mọi thứ chạy bên trong callback, kể cả sau await, đều thấy cùng store.
  return requestContext.run({ requestId }, async () => {
    log("start");
    await loadUser(userId);
    log("done");
  });
}
 
// Hai request chạy xen kẽ nhau nhưng không lẫn requestId.
await Promise.all([handleRequest("req-1", 1), handleRequest("req-2", 2)]);
log("outside");
// → [req-1] start
// → [req-2] start
// → [req-1] loaded user 1
// → [req-1] done
// → [req-2] loaded user 2
// → [req-2] done
// → [-] outside

Ngữ cảnh đi theo timer, promise và goroutine

Sự khác biệt rõ nhất: callback được lên lịch bên trong run() sẽ mang store theo, dù tới lúc nó chạy thì run() đã return từ lâu. Goroutine mới ở Go thì không thừa hưởng gì — nó chỉ có những gì được truyền vào.

import { AsyncLocalStorage } from "node:async_hooks";
import { EventEmitter } from "node:events";
 
const requestContext = new AsyncLocalStorage();
const currentId = () => requestContext.getStore()?.requestId ?? "-";
 
requestContext.run({ requestId: "req-1" }, () => {
  // Callback được lên lịch bên trong run() mang theo store, dù chạy sau khi run() đã return.
  setTimeout(() => console.log("timeout:", currentId()), 10);
  Promise.resolve().then(() => console.log("then:", currentId()));
});
 
// Lên lịch bên ngoài run(): không có store.
setTimeout(() => console.log("outside:", currentId()), 20);
 
// Listener của EventEmitter chạy trong ngữ cảnh của emit(), không phải của on().
const emitter = new EventEmitter();
requestContext.run({ requestId: "req-1" }, () => {
  emitter.on("job", () => console.log("listener:", currentId()));
});
requestContext.run({ requestId: "req-2" }, () => emitter.emit("job"));
// → listener: req-2
// → then: req-1
// → timeout: req-1
// → outside: -

Store là object có thể sửa (gom dữ liệu theo request)

Store chỉ là một giá trị JavaScript bình thường. Nếu đó là object, mọi hàm trong cùng chuỗi async đều sửa được nó, và người gọi run() thấy được thay đổi sau khi xong. Context của Go thì bất biến: muốn gom dữ liệu thì cất một con trỏ vào context, và tự lo đồng bộ khi nhiều goroutine cùng ghi.

import { AsyncLocalStorage } from "node:async_hooks";
 
const requestContext = new AsyncLocalStorage();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 
async function query(sql) {
  requestContext.getStore().queries.push(sql); // store là object thường: sửa trực tiếp được
  await sleep(1);
}
 
async function handleRequest() {
  await query("SELECT * FROM users");
  await Promise.all([query("SELECT * FROM orders"), query("SELECT * FROM items")]);
}
 
const store = { requestId: "req-1", queries: [] };
await requestContext.run(store, handleRequest);
 
// Thay đổi bên trong vẫn nhìn thấy được từ bên ngoài, vì cùng một object.
console.log(`${store.requestId} ran ${store.queries.length} queries`);
// → req-1 ran 3 queries

Khác biệt chính

Node.js Go
Cách truyền ngầm định, theo chuỗi async tường minh, ctx là tham số đầu tiên
Gắn và đọc giá trị als.run(store, fn) / als.getStore() context.WithValue(ctx, key, v) / ctx.Value(key)
Qua timer, promise / goroutine tự đi theo setTimeout, .then, await không tự đi theo: phải truyền ctx vào goroutine hay closure
Sửa giá trị store là object thường, sửa trực tiếp được bất biến: WithValue tạo ctx mới; muốn gom dữ liệu thì cất con trỏ + lock
Kiểu dữ liệu store là giá trị JS bất kỳ Value trả về any, cần type assertion (v, ok := …(string))