Một trong những khái niệm khó hiểu nhất khi bạn lần đầu tiên học JavaScript là mô hình xử lý không đồng bộ của ngôn ngữ. Đối với đa số chúng ta, việc học lập trình bất đồng bộ trông khá giống thế này

Dù khó học nhưng lập trình async rất quan trọng nếu bạn muốn sử dụng JavaScript và Node.js để xây dựng các ứng dụng web và máy chủ – bởi vì mã JS là không đồng bộ theo mặc định.
Nguyên tắc cơ bản về lập trình không đồng bộ
Vậy mô hình xử lý không đồng bộ chính xác là gì, hay non-blocking I/O
model (mà bạn có thể đã nghe nói đến nếu bạn là người dùng Node.js)?
Đây là mô tả TL; DR: trong mô hình xử lý không đồng bộ, khi công cụ ứng dụng của bạn tương tác với các bên bên ngoài (như hệ thống tệp hoặc mạng), nó sẽ không đợi cho đến khi nhận được kết quả từ các bên đó. Thay vào đó, nó tiếp tục thực hiện các nhiệm vụ tiếp theo và chỉ quay lại với các bên bên ngoài trước đó sau khi nhận được tín hiệu về kết quả.
Để hiểu mô hình xử lý không đồng bộ mặc định của Node.js, chúng ta hãy xem xưởng giả định của ông già Noel. Trước khi bất kỳ công việc gì có thể bắt đầu, ông già Noel sẽ phải đọc từng bức thư đáng yêu của các em nhỏ trên khắp thế giới.

Sau đó, anh ấy sẽ tìm ra món quà được yêu cầu, dịch tên vật phẩm sang ngôn ngữ Yêu tinh, rồi chuyển hướng dẫn cho từng yêu tinh làm việc chăm chỉ của chúng ta, những người có chuyên môn khác nhau: đồ chơi bằng gỗ cho Đỏ, đồ chơi nhồi bông cho Xanh lam và đồ chơi robot cho Xanh lục .

Năm nay, do đại dịch COVID-19, chỉ một nửa chú lùn của ông già Noel có thể đến xưởng của ông để giúp đỡ. Tuy nhiên, vì là người khôn ngoan, ông già Noel quyết định rằng thay vì đợi mỗi yêu tinh chuẩn bị xong một món quà (tức là làm việc đồng bộ), ông sẽ tiếp tục dịch và đưa ra hướng dẫn từ đống thư của mình.

Vv và Vv…

Khi anh ấy chuẩn bị đọc một bức thư khác, Red thông báo cho ông già Noel rằng anh ấy đã hoàn thành
chuẩn bị món quà đầu tiên. Sau đó, ông già Noel nhận quà từ Red và đặt nó sang một bên.

Và sau đó anh ấy tiếp tục dịch và chuyển hướng dẫn từ bức thư tiếp theo.

Chỉ cần quấn một chú rô-bốt bay làm sẵn, Green có thể nhanh chóng hoàn thành khâu chuẩn bị và chuyển quà cho ông già Noel.

Sau cả ngày làm việc chăm chỉ và không đồng bộ, ông già Noel và các chú lùn đã hoàn thành mọi công việc chuẩn bị hiện tại. Với mô hình làm việc không đồng bộ được cải tiến của mình, xưởng của ông già Noel đã hoàn thành trong thời gian kỷ lục mặc dù bị ảnh hưởng nặng nề bởi đại dịch.

Vì vậy, đó là ý tưởng cơ bản của mô hình xử lý I/O không đồng bộ hoặc không chặn. Bây giờ hãy xem cụ thể nó được thực hiện như thế nào trong Node.js.
Vòng lặp sự kiện Node.js
Bạn có thể đã nghe nói rằng Node.js là một luồng đơn. Tuy nhiên, chính xác là, chỉ có vòng lặp sự kiện trong Node.js, tương tác với một nhóm các luồng công nhân C++ nền, là luồng đơn. Có bốn thành phần quan trọng đối với mô hình xử lý Node.js:
- Hàng đợi sự kiện: Các tác vụ được khai báo trong một chương trình hoặc được trả về từ nhóm luồng xử lý thông qua các cuộc gọi lại. (Tương đương với điều này trong xưởng ông già Noel của chúng tôi là chồng thư cho ông già Noel.)
- Vòng lặp sự kiện: Chuỗi Node.js chính hỗ trợ hàng đợi sự kiện và nhóm luồng công nhân thực hiện các hoạt động – cả không đồng bộ và đồng bộ. (Đây là ông già Noel. 🎅)
- Nhóm luồng nền: Các luồng này thực hiện xử lý thực tế các tác vụ, mà
có thể là chặn I/O (ví dụ: gọi và chờ phản hồi từ API bên ngoài). (Đây là những yêu tinh chăm chỉ 🧝🧝♀️🧝♂️ từ xưởng của chúng tôi.)
Bạn có thể hình dung mô hình xử lý này như sau:

Hãy xem xét một đoạn mã thực tế để xem chúng hoạt động như thế nào:
console.log("Hello");
https.get("https://httpstat.us/200", (res) => {
console.log(`API returned status: ${res.statusCode}`);
});
console.log("from the other side");
Nếu chúng tôi thực thi đoạn mã trên, chúng tôi sẽ nhận được mã này trong đầu ra tiêu chuẩn của mình:
Hello
from the other side
API returned status: 200
Vậy làm thế nào để công cụ Node.js thực hiện đoạn mã trên? Nó bắt đầu với ba chức năng trong ngăn xếp cuộc gọi:

“Xin chào” sau đó được in ra bàn điều khiển với lệnh gọi hàm tương ứng bị xóa khỏi ngăn xếp.

Hàm gọi đến https.get
(nghĩa là thực hiện yêu cầu nhận tới URL tương ứng) sau đó được thực thi và ủy quyền cho nhóm luồng công nhân có đính kèm lệnh gọi lại.

Hàm tiếp theo gọi đến console.log
được thực thi và “từ phía bên kia” được in ra bàn điều khiển.

Bây giờ cuộc gọi mạng đã trả về phản hồi, cuộc gọi chức năng gọi lại sau đó sẽ được xếp hàng đợi bên trong hàng đợi gọi lại. Lưu ý rằng bước này có thể xảy ra trước bước ngay trước đó (nghĩa là “từ phía bên kia” được in), mặc dù thông thường thì không phải như vậy.

Cuộc gọi lại sau đó được đặt bên trong ngăn xếp cuộc gọi của chúng tôi:

và sau đó chúng ta sẽ thấy “Trạng thái trả về API: 200” trong bảng điều khiển của mình, như sau:

Bằng cách hỗ trợ hàng đợi gọi lại và ngăn xếp cuộc gọi, vòng lặp sự kiện trong Node.js thực thi hiệu quả mã JavaScript của chúng tôi theo cách không đồng bộ.
Lịch sử đồng bộ của JavaScript & Node.js async/await
Bây giờ bạn đã hiểu rõ về thực thi không đồng bộ và hoạt động bên trong của vòng lặp sự kiện Node.js, hãy đi sâu vào async/await trong JavaScript. Chúng ta sẽ xem xét cách nó hoạt động theo thời gian, từ triển khai dựa trên lệnh gọi lại ban đầu đến các từ khóa async/await sáng bóng mới nhất.
Gọi lại trong JavaScript
Cách OG xử lý bản chất không đồng bộ của các công cụ JavaScript là thông qua các cuộc gọi lại. Các cuộc gọi lại về cơ bản là các chức năng sẽ được thực thi, thông thườngkhi kết thúc hoạt động chặn đồng bộ hoặc I/O.
Một ví dụ đơn giản của mẫu này là tích hợp sẵn setTimeout
chức năng sẽ đợi một số mili giây nhất định trước khi thực hiện gọi lại.
setTimeout(2000, () => {
console.log("Hello");
});
Mặc dù thuận tiện khi chỉ đính kèm các lệnh gọi lại vào các hoạt động chặn, mẫu này cũng gây ra một số vấn đề:
- địa ngục gọi lại
- Đảo ngược kiểm soát (không phải là loại tốt!)
Gọi lại địa ngục là gì?
Hãy xem lại một ví dụ về ông già Noel và những chú lùn của ông ấy. Để chuẩn bị một món quà, xưởng của ông già Noel sẽ phải thực hiện một số bước khác nhau (mỗi bước mất một lượng thời gian khác nhau được mô phỏng bằng setTimeout
):
function translateLetter(letter, callback) {
return setTimeout(2000, () => {
callback(letter.split("").reverse().join(""));
});
}
function assembleToy(instruction, callback) {
return setTimeout(3000, () => {
const toy = instruction.split("").reverse().join("");
if (toy.includes("wooden")) {
return callback(`polished ${toy}`);
} else if (toy.includes("stuffed")) {
return callback(`colorful ${toy}`);
} else if (toy.includes("robotic")) {
return callback(`flying ${toy}`);
}
callback(toy);
});
}
function wrapPresent(toy, callback) {
return setTimeout(1000, () => {
callback(`wrapped ${toy}`);
});
}
Các bước này cần được thực hiện theo một thứ tự cụ thể:
translateLetter("wooden truck", (instruction) => {
assembleToy(instruction, (toy) => {
wrapPresent(toy, console.log);
});
});
// This will produced a "wrapped polished wooden truck" as the final result
Khi chúng ta làm mọi thứ theo cách này, việc thêm nhiều bước hơn vào quy trình có nghĩa là đẩy các cuộc gọi lại bên trong sang bên phải và kết thúc trong địa ngục cuộc gọi lại như thế này:

Các lệnh gọi lại có vẻ tuần tự, nhưng đôi khi thứ tự thực hiện không tuân theo những gì được hiển thị trên màn hình của bạn. Với nhiều lớp gọi lại lồng nhau, bạn có thể dễ dàng đánh mất bức tranh toàn cảnh về toàn bộ luồng chương trình và tạo ra nhiều lỗi hơn hoặc chỉ trở nên chậm hơn khi viết mã của bạn.
Vậy làm thế nào để bạn giải quyết vấn đề này? Đơn giản chỉ cần mô đun hóa các hàm gọi lại lồng nhau thành các hàm được đặt tên và bạn sẽ có một chương trình được căn trái độc đáo, dễ đọc.
function assembleCb(toy) {
wrapPresent(toy, console.log);
}
function translateCb(instruction) {
assembleToy(instruction, assembleCb);
}
translateLetter("wooden truck", translateCb);
đảo ngược kiểm soát
Một vấn đề khác với mẫu gọi lại là bạn không quyết định cách các hàm bậc cao hơn sẽ thực thi các lệnh gọi lại của bạn. Họ có thể thực thi nó ở cuối hàm, điều này là thông thường, nhưng họ cũng có thể thực thi nó ở đầu hàm hoặc thực thi nó nhiều lần.
Về cơ bản, bạn phụ thuộc vào chủ sở hữu phụ thuộc của mình và bạn có thể không bao giờ biết khi nào họ sẽ phá mã của bạn.
Để giải quyết vấn đề này, với tư cách là người dùng phụ thuộc, bạn không thể làm gì nhiều về vấn đề này. Tuy nhiên, nếu bản thân bạn đã từng là chủ sở hữu phụ thuộc, vui lòng luôn:
- Bám sát chữ ký gọi lại thông thường với lỗi làm đối số đầu tiên
- Chỉ thực hiện gọi lại một lần khi kết thúc chức năng bậc cao hơn của bạn
- Ghi lại bất kỳ điều gì khác thường hoàn toàn cần thiết và luôn hướng tới khả năng tương thích ngược
Lời hứa trong JavaScript
Các lời hứa đã được tạo để giải quyết các vấn đề được đề cập ở trên với các cuộc gọi lại. Lời hứa đảm bảo rằng người dùng JavaScript:
- Bám sát một quy ước cụ thể với chữ ký của họ
resolve
vàreject
chức năng. - Xâu chuỗi các chức năng gọi lại thành một luồng được căn chỉnh tốt và từ trên xuống.
Ví dụ trước của chúng ta với xưởng chuẩn bị quà của ông già Noel có thể được viết lại với lời hứa như sau:
function translateLetter(letter) {
return new Promise((resolve, reject) => {
setTimeout(2000, () => {
resolve(letter.split("").reverse().join(""));
});
});
}
function assembleToy(instruction) {
return new Promise((resolve, reject) => {
setTimeout(3000, () => {
const toy = instruction.split("").reverse().join("");
if (toy.includes("wooden")) {
return resolve(`polished ${toy}`);
} else if (toy.includes("stuffed")) {
return resolve(`colorful ${toy}`);
} else if (toy.includes("robotic")) {
return resolve(`flying ${toy}`);
}
resolve(toy);
});
});
}
function wrapPresent(toy) {
return new Promise((resolve, reject) => {
setTimeout(1000, () => {
resolve(`wrapped ${toy}`);
});
});
}
với các công đoạn được thực hiện bài bản theo dây chuyền:
translateLetter("wooden truck")
.then((instruction) => {
return assembleToy(instruction);
})
.then((toy) => {
return wrapPresent(toy);
})
.then(console.log);
// This would produce the exact same present: wrapped polished wooden truck
Tuy nhiên, những lời hứa cũng không phải là không có vấn đề. Dữ liệu trong mỗi mắt của chuỗi của chúng tôi có phạm vi khác nhau và chỉ có dữ liệu truy cập được truyền từ bước ngay trước đó hoặc phạm vi gốc.
Ví dụ: bước gói quà của chúng tôi có thể muốn sử dụng dữ liệu từ bước dịch:
function wrapPresent(toy, instruction) {
return Promise((resolve, reject) => {
setTimeout(1000, () => {
resolve(`wrapped ${toy} with instruction: "${instruction}`);
});
});
}
Đây là một vấn đề “chia sẻ bộ nhớ” cổ điển với luồng. Để giải quyết vấn đề này, thay vì sử dụng các biến trong phạm vi cha, chúng ta nên sử dụng Promise.all
và “chia sẻ dữ liệu bằng cách giao tiếp, thay vì giao tiếp bằng cách chia sẻ dữ liệu”.
translateLetter("wooden truck")
.then((instruction) => {
return Promise.all([assembleToy(instruction), instruction]);
})
.then((toy, instruction) => {
return wrapPresent(toy, instruction);
})
.then(console.log);
// This would produce the present: wrapped polished wooden truck with instruction: "kcurt nedoow"
Không đồng bộ/Đang chờ trong JavaScript
Cuối cùng nhưng không kém phần quan trọng, đứa trẻ sáng sủa nhất trong khu nhà không đồng bộ/chờ đợi. Nó rất dễ sử dụng nhưng nó cũng có một số rủi ro.
Async/await giải quyết vấn đề chia sẻ bộ nhớ của các lời hứa bằng cách đặt mọi thứ trong cùng một phạm vi. Ví dụ trước của chúng ta có thể được viết lại dễ dàng như sau:
(async function main() {
const instruction = await translateLetter("wooden truck");
const toy = await assembleToy(instruction);
const present = await wrapPresent(toy, instruction);
console.log(present);
})();
// This would produce the present: wrapped polished wooden truck with instruction: "kcurt nedoow"
Tuy nhiên, việc viết mã không đồng bộ với async/await rất dễ dàng, nhưng cũng dễ mắc lỗi tạo ra lỗ hổng hiệu suất.
Bây giờ chúng ta hãy bản địa hóa kịch bản hội thảo của ông già Noel mẫu của chúng ta để gói quà và chất chúng lên xe trượt tuyết.
function wrapPresent(toy) {
return Promise((resolve, reject) => {
setTimeout(5000 * Math.random(), () => {
resolve(`wrapped ${toy}`);
});
});
}
function loadPresents(presents) {
return Promise((resolve, reject) => {
setTimeout(5000, () => {
let itemList = "";
for (let i = 0; i < presents.length; i++) {
itemList += `${i}. ${presents[i]}\n`;
}
});
});
}
Một sai lầm phổ biến mà bạn có thể mắc phải là thực hiện các bước theo cách này:
(async function main() {
const presents = [];
presents.push(await wrapPresent("wooden truck"));
presents.push(await wrapPresent("flying robot"));
presents.push(await wrapPresent("stuffed elephant"));
const itemList = await loadPresents(presents);
console.log(itemList);
})();
Nhưng ông già Noel có cần phải await
cho mỗi món quà được gói từng cái một trước khi tải? Chắc chắn không phải! Các món quà nên được gói đồng thời. Bạn có thể mắc lỗi này thường xuyên vì nó rất dễ viết await
mà không cần suy nghĩ về tính chất chặn của từ khóa.
Để giải quyết vấn đề này, chúng ta nên gộp các bước gói quà lại với nhau và thực hiện tất cả chúng cùng một lúc:
(async function main() {
const presents = await Promise.all([
wrapPresent("wooden truck"),
wrapPresent("flying robot"),
wrapPresent("stuffed elephant"),
]);
const itemList = await loadPresents(presents);
console.log(itemList);
})();
Dưới đây là một số bước được đề xuất để giải quyết vấn đề về hiệu năng đồng thời trong mã Node.js của bạn:
- Xác định các điểm phát sóng có nhiều lượt chờ liên tiếp trong mã của bạn
- Kiểm tra xem chúng có phụ thuộc lẫn nhau không (nghĩa là một hàm sử dụng dữ liệu được trả về từ hàm khác)
- Thực hiện các cuộc gọi chức năng độc lập đồng thời với
Promise.all
Kết thúc (bài báo, không phải quà Giáng sinh 😂)
Chúc mừng bạn đã đến cuối bài viết này, tôi đã cố gắng hết sức để thực hiện
bài đăng này ngắn hơn, nhưng chủ đề không đồng bộ trong JavaScript quá rộng.
Dưới đây là một số điểm chính:
- Mô đun hóa các lệnh gọi lại JavaScript của bạn để tránh địa ngục gọi lại
- Bám sát quy ước cho các cuộc gọi lại JS
- Chia sẻ dữ liệu bằng cách giao tiếp thông qua
Promise.all
khi sử dụng lời hứa - Hãy cẩn thận về hàm ý hiệu suất của mã async/await
Chúng tôi ❤️ JavaScript 🙂
Cảm ơn bạn đã đọc!
Cuối cùng nhưng không kém phần quan trọng, nếu bạn thích các bài viết của tôi, vui lòng truy cập blog của tôi để xem các bài bình luận tương tự và theo dõi tôi trên Twitter. 🎉