19 Mar 2025Programming

Giới thiệu

Một trong những khái niệm quan trọng nhất của lập trình JavaScript là Promise. Người ta thường dùng nó khi làm việc với các tác vụ cần có sự chờ đợi, chẳng hạn như gửi một request về máy chủ và phải chờ câu trả lời, hay một hàm tính toán mất rất nhiều thời gian. Khái niệm này tuy được sử dụng rộng rãi, nhất là trong lập trình Web, nhưng lại thường gây hiểu lầm và rất dễ sinh ra bug nếu bạn không nắm vững các chi tiết kỹ thuật. Trong bài viết này, chúng ta sẽ tìm hiểu kỹ hơn về Promise nói riêng và lập trình asynchronous nói chung nhé.

Promise là gì?

Promise trong tiếng Anh là lời hứa, giống như kiểu bạn đòi ai đó trả tiền cho bạn, nhưng người đó chưa có tiền, nên chỉ cho bạn một lời hứa là họ sẽ trả bạn. Họ trả bao nhiêu thì phải đến lúc đó mới biết được. Lập trình cũng giống vậy, bạn yêu cầu máy chủ cung cấp thông tin gì đó cho bạn, nhưng cũng phải mất chút thời gian. Trong lúc này, máy tính của bạn có thể chuyển sang làm những công chuyện khác, cho tới khi nhận được câu trả lời thì mới quay sang xử lý nó khi rảnh. Và cũng giống như ngoài đời thực, khi người ta đã mang tiền tới nhà bạn, bạn cũng phải làm nốt công chuyện bạn đang làm dở, rồi mới sắp xếp chạy ra mở cửa cho người ta được.
Quá trình này còn được gọi là lập trình không đồng bộ (asynchronous programming). JavaScript khi chạy trên trình duyệt chỉ có duy nhất một thread (giống như bạn chỉ có một cơ thể), nên nó phải liên tục chuyển giữa các tác vụ. Một tác vụ đồng bộ (synchronous process) sẽ được thực thi liên tục từ đầu đến đích mà không ngừng nghỉ, nhưng một tác vụ không đồng bộ (asynchronous process) thì có thể sẽ được "tạm ngưng" ở một thời điểm nào đó (thời điểm này có quy tắc chứ cũng không phải tùy tiện muốn ngưng lúc nào cũng được), rồi trở lại tiếp tục sau. Thường thì một hàm không đồng bộ có thể được tạm ngưng khi nó đang chờ một hàm không đồng bộ khác.

Dùng Promise như thế nào?

Thay vì phải chờ tới khi có kết quả mới trả về, hàm không đồng bộ có thể trả về một giá trị Promise, giá trị này sẽ trả về một giá trị thuộc kiểu nào đó cụ thể hơn như number hoặc string trong tương lai, hoặc cũng có thể báo lỗi nếu code bị lỗi giữa chừng. Một Promise có ba trạng thái: đang chờ (pending), đã thực hiện thành công (fulfilled), hoặc đã gặp lỗi (rejected). Hai trạng thái thành công và gặp lỗi còn được gộp lại thành một nhóm, gọi là trạng thái đã hoàn tất (settled).
Trước khi tìm hiểu cách viết một hàm async, hãy giả sử như ta có hàm myAsyncFunction trả về một Promise nhé. Có hai cách để xử lý kết quả của một Promise, như sau:
VD 1: (dùng callback)
console.log("Program begins");
const myPromise = myAsyncFunction();
myPromise
  .then((myAnswer) => console.log(myAnswer))
  .catch((myError) => console.error(myError));
console.log("Program ends");
VD 2: (dùng await)
console.log("Program begins");
const myPromise = myAsyncFunction();
try {
  const myAnswer = await myPromise;
  console.log(myAnswer);
} catch (myError) {
  console.error(myError);
}
console.log("Program ends");
Hai đoạn code trên có cùng một mục tiêu: chờ myPromise hoàn thành (settle) rồi in ra kết quả, nếu kết quả bình thường thì in bằng console.log, nếu kết quả là lỗi thì in bằng console.error. Mỗi cách đều có một điểm mạnh và yếu riêng. Hãy cùng nhau phân tích nhé:

Dùng callback

Hàm callback là một hàm được gọi sau khi một tác vụ gì đó đã hoàn thành. Trong VD 1 ở trên, ta có hai hàm callback là:
  • (myAnswer) => console.log(myAnswer). Hàm này là parameter của hàm then, nghĩa là nó sẽ được gọi khi myPromise được fulfilled. myAnswer là kết quả cuối cùng của myPromise. Ta gọi hàm này là then-callback.
  • (myError) => console.error(myError). Hàm này là parameter của hàm catch, nghĩa là nó sẽ được gọi nếu myPromise bị rejected, hoặc cái hàm callback ở trên bị lỗi. Ta gọi hàm này là catch-callback.
Sở dĩ bạn có thể viết thành một chuỗi myPromise.then(...).catch(...) được là bởi vì bản thân hàm thencatch cũng đều trả về một Promise hết! Thế nghĩa là sao? Cùng xét một ví dụ phức tạp hơn nhé!

Chuỗi then/catch

VD 3:
console.log("Program begins");
const myPromise = myAsyncFunction();
myPromise
  .then((myAnswer) => {
    console.log("My first answer is: ", myAnswer);
    return anotherFunction(myAnswer);
  })
  .catch((error) => {
    console.error(error);
    return 0;
  })
  .then((anotherAnswer) => {
    console.log("My second answer is: ", anotherAnswer);
  });
console.log("Program ends");
Ở ví dụ trên, khi myPromise hoàn tất thành công, hàm then-callback sẽ được gọi, kết quả của PromisemyAnswer lại được dùng để cung cấp cho một hàm khác (anotherFunction) rồi trả về kết quả của hàm đó (hàng 6). Như vậy, bản thân đoạn myPromise.then(...) cũng là một Promise, cuối cùng nó sẽ trả về kết quả của hàm then-callback chứ không đơn thuần là kết quả của myPromise sau khi đã fulfilled nữa.
Lưu ý là Promise chưa settle mà đã có lỗi bị throw mà không có catch thì nó sẽ bị rejected ngay, tức là không những lỗi trong myAsyncFunction mà lỗi trong anotherFunction cũng có thể kích hoạt catch-callback.
Thú vị hơn, hàm catch-callback cũng có thể trả về một giá trị! Ta thường thấy cách làm này khi ta muốn trả về một giá trị mặc định trong trường hợp xử lý mà bị lỗi. Trong ví dụ này, nếu catch-callback này được thực thi thì cả cái chuỗi myPromise.then(...).catch(...) sẽ trả về 0, tức là anotherAnswer sẽ bằng 0. Nếu catch-callback không được thực thi (do không có lỗi), thì coi như không có gì xảy ra, coi như lệnh catch không có đó, myPromise.then(...) trả về được bao nhiêu thì myPromise.then(...).catch(...) trả về bấy nhiêu.
Lệnh then cuối cùng chỉ đơn thuần in ra kết quả của cái chuỗi myPromise.then(...).catch(...) ở trên. Bạn có thể nối thêm thencatch dài đến vô tận cũng được, hầu như không có giới hạn. Kết quả hàm callback trước sẽ là tham số của hàm callback sau. Catch-callback nếu không được gọi thì lấy kết quả của callback liền trước.

Dùng await

Bạn có thể dùng lệnh await đặt trước một giá trị Promise để báo hiệu chương trình có thể tạm dừng ở đây trong khi chờ Promise trả về kết quả (xem VD 2 ở trên). Bản chất của await chẳng qua là cắt toàn bộ phần code còn lại cho vào một cái then-callback (hoặc catch nếu bạn có khâu xử lý lỗi try/catch) giống Cách 1.

Tại sao await chỉ được dùng trong một hàm async?

Nếu bạn thử dùng await trong một hàm bình thường (mặc định là synchronous), bạn sẽ bị lỗi ngay. Để hiểu tại sao await phải nằm trong async, ta cần hiểu bản chất của một hàm async: Bất kể bạn trả về một giá trị gì, hàm async sẽ luôn wrap kết quả của bạn trong một Promise:
VD 4:
const myAsyncFunction = async () => {
  return 42; // bạn trả về kiểu number ở đây
};
console.log(myAsyncFunction()); // bạn sẽ thấy kiểu `Promise` ở đây
Giả sử bạn gọi await trong một hàm synchronous như sau:
VD 5:
const anotherFunction = () => {
  const myAnswer = await myAsyncFunction(); // code lỗi
  return myAnswer * 2;
};
Rõ ràng bạn cần đến myAnswer (giá trị thực, không phải Promise), để tính toán tiếp, tức là cái Promise đó phải hoàn thành. Vậy nên về mặt lý thuyết, hàm main này không thể chạy một cách synchronous được (synchronous là khi hàm chạy liên tục không ngừng nghỉ) mà cũng phải trả về một Promise. Để buộc lập trình viên tuân thủ thiết kế này, chương trình sẽ báo lỗi nếu bạn gọi await trong một hàm synchronous như ở trên.
Cách dùng đúng sẽ như sau:
VD 6:
const anotherFunction = async () => {
  const myAnswer = await myAsyncFunction(); // ok
  return myAnswer * 2;
};

Nếu cần phải gọi hàm async trong một hàm sync thì sao?

Có nhiều bối cảnh mà bạn không thể đột nhiên chuyển hàm sync sang async như VD 6 ở trên, chẳng hạn như code bạn chạy ở top level hoặc trong một hàm React Hook. Lúc ấy, ta có thể tạo một hàm async rồi gọi nó ngay lập tức:
VD 7:
const anotherFunction = () => {
  return (async () => {
    const myAnswer = await myAsyncFunction(); // ok
    return myAnswer * 2;
  })();
};
Về bản chất thì hàm này không khác gì VD 6, chỉ có điều để giữ cho anotherFunction synchronous, nó đã tạo một hàm async bên trong rồi gọi hàm đó ngay lập tức với cú pháp (async () => { ... })() để nhận một Promise, và ta trả về ngay lập tức luôn (dòng thứ 2). Như vậy, anotherFunction tuy là một hàm synchronous nhưng lại trả về một Promise giống như async. Ta còn có một cú pháp khác, đó là return new Promise..., mời bạn đọc tiếp.

Cách tạo một Promise

VD 8:
const anotherFunction = () => {
  return new Promise((resolve) => {
    myAsyncFunction().then((myAnswer) => {
      resolve(myAnswer * 2);
    });
  });
};
Trong ví dụ này, ta không còn dựa vào từ khóa async để tạo Promise cho ta nữa, mà ta tự tạo luôn. Cú pháp tạo một Promise thường là:
VD 9:
new Promise((resolve, reject) => {
  // ...
});
Trong đó resolve đóng vai trò như từ khóa returnreject đóng vai trò như throw khi bạn viết hàm bình thường. Cụ thể hơn, resolve chính là hàm callback bạn dùng ở lệnh thenreject chính là hàm callback bạn dùng ở lệch catch (xem lại VD 1). Sở dĩ bạn phải gọi resolve hoặc reject thay vì return hoặc throw là để báo với JS Engine là cái Promise này đã hoàn thành và hàm callback tương ứng sẽ được đưa vào hàng chờ để được thực thi tiếp theo. Nếu bạn bỏ qua không gọi resolve hay reject, Promise của các bạn có thể sẽ không bao giờ chấm dứt!
Việc biết cách tạo Promise thủ công như trên mà không cần đến async cũng rất quan trọng. Có thể bạn đã từng làm với những API cũ không hỗ trợ Promise mà luôn luôn đòi một hàm callback để xử lý kết quả, khi đó bạn có thể "hô biến" thiết kế này thành một hàm sử dụng Promise. Ví dụ ta có hàm legacyFunction để tính toán cho ra kết quả sau một quãng thời gian và nhận một hàm callback để gọi lại sau khi kết quả đã đạt được, cách dùng như sau:
VD 10:
legacyFunction(myInput, (myAnswer) => {
  // ...
})
Bạn hãy tưởng tượng nếu bạn cần gọi hàm này lần lượt ba lần thì code của bạn sẽ bắt đầu lồng ghép nhau kinh khủng đến độ nào:
VD 11:
legacyFunction(myInput, (myAnswer1) => {
  legacyFunction(myAnswer1, (myAnswer2) => {
    legacyFunction(myAnswer2, (myAnswer3) => {
      console.log(myAnswer3);
    });
  });
});
Ta có thể thay thế hàm này bằng một phiên bản hiện đại hơn -- dùng Promise!
VD 12:
const redesignedFunction = (input) => {
  return new Promise((resolve) => {
    legacyFunction(input, (answer) => {
      resolve(answer);
    });
  });
}
const myAnswer1 = await redesignedFunction(myInput);
const myAnswer2 = await redesignedFunction(myAnswer1);
const myAnswer3 = await redesignedFunction(myAnswer2);

Khác biệt giữa async/awaitthen/catch

Có thể nói hai cách dùng Promise này hầu như chỉ khác nhau về mặt cú pháp, bọn chúng hoàn toàn có thể thay thế qua lại lẫn nhau. Dùng async/await thường giúp code bạn dễ đọc hơn, thứ tự rõ ràng, lệnh nào viết trước thì thực thi trước. Trong khi đó, đôi khi dùng then/catch lại có thể rút gọn code của bạn đi rất nhiều một khi đã thuần thục. Cá nhân mình khuyến khích bạn dùng đúng nơi đúng chỗ, đừng nghe theo mấy người cực đoan chỉ nghiêng về một phía. Nếu code bạn phức tạp, dùng async/await sẽ dễ kiểm soát hơn. Nếu bạn thỉnh thoảng muốn đi lối tắt, có thể dùng then/catch. Thậm chí, bạn có thể dùng cả hai cũng được! Hãy điểm qua một vài ví dụ nhé!
VD 13: Rút gọn cách dùng hàm redesignedFunctionVD 12
const myAnswer3 = await redesignFunction(myInput)
  .then(redesignFunction)
  .then(redesignFunction);
VD 14: Gọi hàm myAsyncFunction nhưng đừng throw lỗi nếu Promise bị rejected
const myAnswer = await myAsyncFunction().catch();
VD 15: Gọi hàm myAsyncFunction nhưng nếu nó gặp lỗi thì trả về giá trị mặc định là 0
const myAnswer = await myAsyncFunction().catch(() => 0);
Bạn có thấy chúng ta dùng cả hai cú pháp không? Tận dụng điểm mạnh của cả hai! Dùng then/catch để biến tấu kết quả một chút, nhưng luôn dùng async/await để giữ cho tổng thể code có thứ tự rõ ràng.

Thứ tự thực thi của các lệnh như thế nào?

Code càng phức tạp, càng nhiều khâu xử lý không đồng bộ thì sẽ càng khó khăn trong việc phân tích và hiểu rõ thứ tự xử lý. Cho dù bạn cố gắng giữ cho code của bạn xanh sạch đẹp, cũng không chắc gì code cũ của công ty bạn được như vậy. Do đó, bạn cũng cần biết cách đọc code của các tác vụ async cho dù nó rối rắm đến cỡ nào đi nữa!
Trước tiên, nhiều người thường hay hiểu lầm là các hàm callback sẽ được thực thi ngay lập tức sau khi Promise hoàn thành, nhưng thực tế thì các hàm này sẽ được đẩy vào một hàng đợi, gọi là "microtask queue". Các hàm callback này và các microtask khác trong hàng đợi chỉ được chạy khi chương trình đã thực thi hết các tác vụ khác mà thôi. Cụ thể hơn, chương trình của bạn sẽ được điều phối bởi một bộ phận gọi là "event loop". Event loop sẽ lặp lại các bước sau đây:
  1. Hàm nào đang chạy trên call stack thì để cho nó chạy đến khi kết thúc và call stack trống hẳn.
  2. Lần lượt thực thi tất cả các microtask đang có sẵn trong hàng đợi microtask. Các microtask mới được sinh ra sẽ phải chờ đến bước 4.
  3. Thực thi một macrotask từ hàng đợi macrotask.
  4. Lần lượt thực thi tất cả các microtask đang có sẵn trong hàng đợi microtask. Các microtask mới được sinh ra sẽ phải chờ đến bước 2 của vòng lặp tiếp theo.
(Bạn có thể đọc thêm tại blog này: Medium)
Trở lại với VD 1:
console.log("Program begins");
const myPromise = myAsyncFunction();
myPromise
  .then((myAnswer) => console.log(myAnswer))
  .catch((myError) => console.error(myError));
console.log("Program ends");
Như đã nói, callback chỉ được chạy sau khi chương trình hiện tại kết thúc. Lấy hàm myAsyncFunctionVD 4, đoạn code trên sẽ in ra:
Program begins
Program ends
42
Trở lại với VD 2 (giả sử code dưới đây nằm trong một hàm async nhé):
console.log("Program begins");
const myPromise = myAsyncFunction();
try {
  const myAnswer = await myPromise;
  console.log(myAnswer);
} catch (myError) {
  console.error(myError);
}
console.log("Program ends");
Có thể nói, đoạn code này dường như đã chấm dứt ở hàng 4. Tất cả những hàng còn lại sẽ được ngầm sắp xếp vào những callback hết. Các callback trông sẽ như thế này:
VD 16
console.log("Program begins");
const myPromise = myAsyncFunction();
myPromise
  .then((myAnswer) => {
    console.log(myAnswer);
    console.log("Program ends");
  })
  .catch((myError) => {
    console.error(myError);
    console.log("Program ends");
  });
Kết quả được in ra sẽ là:
Program begins
42
Program ends

Tóm tắt

  1. Khi một hàm được tạo, các lệnh bên trong nó chỉ mới được định nghĩa chứ chưa chạy ngay.
  2. Khi một hàm được gọi, các lệnh bên trong nó sẽ bắt đầu được thực thi ngay.
  3. Các hàm callback (trong lệnh then, catch, v.v.) chỉ được đưa vào hàng đợi microtask sau khi Promise hoàn thành.
  4. Các lệnh sẽ chạy liên tục không ngừng nghỉ trừ khi gặp await.
  5. Nếu gặp await, tất cả các lệnh sau đó sẽ được gom lại thành các microtasks, đưa vào hàng đợi một khi Promise hoàn thành.

Vài ghi chú

Xử lý nhiều tác vụ async cùng lúc

Về mặt thiết kế, JS Engine chỉ có một luồng (single thread), nên không thể xử lý nhiều hơn một tác vụ cùng một lúc. Tuy nhiên code của bạn có thể chạy trong môi trường NodeJS hoặc trình duyệt, khi đó những môi trường này có thể xử lý đồng thời nhiều tác vụ và gửi kết quả vào hàng đợi cho chương trình bạn xử lý.
Lấy ví dụ đoạn code này:
VD 17:
const main = async () => {
  const user1 = await getUserInfo("username1");
  console.log(user1);
  const user2 = await getUserInfo("username2");
  console.log(user2);
};
Giả sử getUserInfo sẽ gọi một API nào đó để lấy thông tin và cần chờ server trả lời. Đoạn code này gửi request lấy thông tin username1, chờ đến khi có kết quả, in ra, rồi mới lấy thông tin username2. Làm như vậy sẽ khiến hàm main chạy lâu hơn. Nếu việc lấy thông tin của username1username2 không phụ thuộc gì với nhau, ta có thể gửi hai request gần như cùng một lúc để tiết kiệm thời gian chờ. Nếu bạn dùng callback, bạn sẽ đạt được mục đích này rất dễ dàng:
VD 18:
const main = () => {
  getUserInfo("username1").then((user1) => console.log(user1));
  getUserInfo("username2").then((user2) => console.log(user2));
}
Nhưng cái gì làm được bằng callback là sẽ làm được bằng async/await. Các bạn có thể dùng Promise.all([...]) để tạo ra một Promise fulfilled khi tất cả Promise con được fulfilled, rejected khi một trong các Promise con bị rejected.
VD 19:
const main = async () => {
  const users = await Promise.all([getUserInfo("username1"), getUserInfo("username2")]);
  console.log(users[0]);
  console.log(users[1]);
}
Nếu getUserInfo không có can hệ gì đến API hay các tác vụ đa luồng trong môi trường, khả năng cao là bạn tối ưu hóa nó cũng chẳng cải thiện được là bao, bởi vì JS Engine chỉ có một luồng xử lý mà thôi.

Hàm async nhưng lại không có await

Có thể có những lúc bạn thấy các hàm như VD 4 hoặc VD 16, không dùng await, nhưng lại là hàm async. Khả năng cao đây là một thiếu sót chứ không có ẩn ý gì sâu xa. Nếu bạn gọi một hàm async mà trong hàm không có từ khóa await, hàm sẽ chạy toàn bộ và settle ngay sau đó. Callback của Promise (nếu có) sẽ được thực thi sau khi chương trình chính chạy hết. Bạn có thể bỏ từ khóa async để chạy một cách đồng bộ hóa nhé.

Tạo một Promise mà không có await

Nếu một Promise được tạo (chẳng hạn như fetch API nào đó và đang chờ kết quả về) mà không có await thì:
  • Một là người viết dùng callback (then, catch, finally) để xử lý kết quả. Cú pháp callback là cú pháp cổ điển, cú pháp await chỉ mới ra đời sau này.
  • Hai là code ẩu. Khả năng này cao hơn.
Tại sao khả năng code ẩu lại cao hơn? Bởi vì một Promise mà không có await thường để lại nhiều hiệu ứng ngoài ý muốn. Mình đã từng thấy một ông dùng callback trong một hàm Lambda, cái callback được gọi sau khi cái Lambda instance đã bị hủy, sinh ra lỗi mà lúc đầu nhìn vào không ai hiểu nổi. Các bạn nên kiểm tra để đảm bảo hàm nào liên quan đến tác vụ async thì phải khai báo async, một Promise mà đã settle thì không nên để lại "dư chấn" gì theo sau.

Tư liệu tham khảo

Để viết được bài blog trên, mình đã tham khảo ba trang dưới đây cùng với nhiều tháng năm ngồi debug code bẩn.
  1. Promise - Mozilla
  2. What the heck is the event loop anyway? - Philip Roberts
  3. The anatomy of Asynchronous JavaScript - Hemant
Cảm ơn các bạn đã đọc bài viết này nhé.

Thử thách

Mời các bạn thử sức với những tình huống sau đây. Nếu nghĩ ra được đáp án thì thoải mái gửi mình qua i-meo nhé. Mình sẽ đăng câu trả lời và công bố những coder tài năng giải đúng đầu tiên ở các lần cập nhật sau (nếu rảnh) :p
Nội quy các thử thách: Không có gì là hoàn hảo, tất cả các hàm và API đều có thể có bug (ngoại trừ các hàm sẵn có của JS như fetch, console, v.v.).

Thử thách 1

Phần mềm của bạn gọi một API, nếu gọi thành công thì hiện kết quả lên UI, nếu không thành công thì báo lỗi trên UI. Gần đây khách hàng bắt đầu thấy lỗi "Cannot process API request" hiện ra. Dưới đây là code UI. Nếu bạn phải fix bug này thì bạn sẽ làm gì?
fetch("https://example.com/api/v1")
  .then((res) => {
    displayResponse(res);
    cacheResponse(res);
  })
  .catch((error) => {
    displayError("Cannot process API request");
  });

Thử thách 2

Bạn đang viết đoạn code sau để lấy thông tin người dùng. Tuy nhiên có vẻ như khi gọi await getUserInfo(username), bạn chẳng nhận được gì hơn ngoài giá trị undefined. Bạn cần phải làm gì?
const getUserInfo = async (username) => {
  fetch(`https://example.com/api/v1/${username}`)
    .then((res) => {
      return res;
    })
    .catch((error) => {
      console.error(error);
      throw error;
    });
};

Thử thách 3

Gần đây có nhiều báo cáo cho thấy con AI của công ty bạn không trả lời được câu hỏi của khách hàng mà chỉ báo "Failed to get an answer", bạn search cái lỗi này trong code base thì thấy đoạn này. Bạn cần làm gì tiếp theo?
const getAnswer = (question) => {
  return fetch(`https://example.com/api/v1/ask`, { body: JSON.stringify({ question }), /*..*/ });
};
// ...
try {
  const answer = await getAnswer(question);
  displayResponse(answer);
} catch (error) {
  displayError("Failed to get an answer");
}

Thử thách 4

setTimeout là một hàm nhận hai tham số: tham số thứ nhất là một hàm callback, tham số thứ hai là khoảng thời gian (tính bằng milisecond) chờ trước khi gọi hàm callback kia. Bạn thấy có một hàm delay như sau:
const delay = (timeInMs) => {
  return new Promise((resolve) => {
    setTimeout(resolve, timeInMs);
  });
};
Tại sao khi dùng nó thì lại không thấy delay gì cả?
const printNumbersWithDelay = () => {
  for (let i = 0; i < 10; ++i) {
    console.log(i);
    delay(1000);
  }
}