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àmthen
, nghĩa là nó sẽ được gọi khimyPromise
được fulfilled.myAnswer
là kết quả cuối cùng củamyPromise
. Ta gọi hàm này là then-callback.(myError) => console.error(myError)
. Hàm này là parameter của hàmcatch
, nghĩa là nó sẽ được gọi nếumyPromise
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 then
và catch
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 Promise
là myAnswer
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 then
và catch
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 return
và reject
đó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 then
và reject
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
/await
và then
/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
redesignedFunction
ở VD 12const 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ị rejectedconst 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à 0const 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:- 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.
- 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.
- Thực thi một macrotask từ hàng đợi macrotask.
- 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
myAsyncFunction
ở VD 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
- 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.
- 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.
- Các hàm callback (trong lệnh
then
,catch
, v.v.) chỉ được đưa vào hàng đợi microtask sau khiPromise
hoàn thành. - Các lệnh sẽ chạy liên tục không ngừng nghỉ trừ khi gặp
await
. - 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 khiPromise
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 username1
và username2
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ápawait
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.
- Promise - Mozilla
- What the heck is the event loop anyway? - Philip Roberts
- 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);
}
}