Xử lý bất đồng bộ trong JavaScript

Javascript nâng cao | by Học Javascript

Trong JavaScript, bất đồng bộ (asynchronous) là một cơ chế quan trọng giúp chương trình có thể thực thi nhiều tác vụ mà không bị chặn (blocking). Điều này đặc biệt hữu ích khi làm việc với các thao tác mất thời gian như gọi API, đọc/ghi file, hoặc xử lý sự kiện người dùng.

JavaScript là một ngôn ngữ đơn luồng (single-threaded), có nghĩa là nó chỉ có thể thực thi một đoạn mã tại một thời điểm. Nếu không có cơ chế bất đồng bộ, các tác vụ nặng sẽ làm chương trình bị treo và không thể phản hồi. Để giải quyết vấn đề này, JavaScript sử dụng các cơ chế như callback, Promise, và async/await để xử lý bất đồng bộ một cách hiệu quả.

Trong bài viết này, chúng ta sẽ tìm hiểu về khái niệm bất đồng bộ, cách nó hoạt động, các kỹ thuật xử lý bất đồng bộ, cũng như các ứng dụng thực tế và cách khắc phục các vấn đề thường gặp.

Bất đồng bộ trong JavaScript là gì?

Bất đồng bộ (asynchronous) trong JavaScript là một cơ chế giúp chương trình có thể thực thi nhiều tác vụ mà không cần chờ một tác vụ hoàn thành trước khi bắt đầu tác vụ tiếp theo.

Trong lập trình đồng bộ (synchronous), các dòng lệnh được thực thi theo thứ tự từ trên xuống dưới. Nếu một tác vụ mất thời gian (như tải dữ liệu từ API hoặc đọc file), chương trình sẽ bị chặn (blocking) cho đến khi tác vụ đó hoàn tất. Điều này có thể làm chậm ứng dụng và gây ra trải nghiệm người dùng không mượt mà.

Với lập trình bất đồng bộ, thay vì chờ một tác vụ hoàn thành, JavaScript có thể tiếp tục thực thi các tác vụ khác và chỉ xử lý kết quả khi tác vụ bất đồng bộ hoàn tất. Điều này giúp cải thiện hiệu suất và tối ưu hóa trải nghiệm người dùng.

Cách hoạt động của bất đồng bộ trong JavaScript

JavaScript là một ngôn ngữ đơn luồng (single-threaded), nghĩa là nó chỉ có một luồng thực thi duy nhất. Để hỗ trợ bất đồng bộ, JavaScript sử dụng Event Loop, Web APIs, và Callback Queue để quản lý các tác vụ không đồng bộ.

Cơ chế hoạt động như sau:

  • Khi một hàm bất đồng bộ (như setTimeout, fetch, hoặc sự kiện click) được gọi, JavaScript chuyển nó vào Web APIs để xử lý mà không làm gián đoạn luồng chính.

  • Trong khi Web APIs xử lý tác vụ, luồng chính tiếp tục thực thi các lệnh tiếp theo.

  • Khi tác vụ bất đồng bộ hoàn tất, nó được đưa vào hàng đợi (Callback Queue hoặc Microtask Queue).

  • Event Loop kiểm tra nếu luồng chính đã rảnh, nó sẽ lấy tác vụ từ hàng đợi và thực thi.

Ví dụ về bất đồng bộ trong thực tế

Ví dụ 1: Sử dụng setTimeout để mô phỏng bất đồng bộ

console.log("Bắt đầu");

setTimeout(() => {
    console.log("Tác vụ bất đồng bộ đã hoàn thành!");
}, 2000);

console.log("Kết thúc");

Kết quả đầu ra:

Bắt đầu
Kết thúc
Tác vụ bất đồng bộ đã hoàn thành!

Mặc dù setTimeout được gọi trước console.log("Kết thúc"), nhưng do nó là bất đồng bộ, chương trình không chờ 2 giây mà tiếp tục thực thi lệnh tiếp theo. Sau khi 2 giây trôi qua, callback của setTimeout mới được thực thi.

Ví dụ 2: Gọi API bất đồng bộ với fetch

console.log("Bắt đầu gọi API...");

fetch("https://jsonplaceholder.typicode.com/todos/1")
    .then(response => response.json())
    .then(data => console.log("Dữ liệu từ API:", data));

console.log("Gọi API hoàn tất (chưa nhận dữ liệu)");

Kết quả đầu ra:

Bắt đầu gọi API...
Gọi API hoàn tất (chưa nhận dữ liệu)
Dữ liệu từ API: {userId: 1, id: 1, title: "...", completed: false}

Mặc dù JavaScript đã gửi yêu cầu API, nhưng thay vì chờ phản hồi, chương trình tiếp tục thực thi lệnh tiếp theo. Khi API trả về dữ liệu, then() mới xử lý kết quả.

Cơ chế hoạt động của bất đồng bộ trong JavaScript

JavaScript là một ngôn ngữ đơn luồng (single-threaded), có nghĩa là nó chỉ có một luồng thực thi duy nhất. Để hỗ trợ các tác vụ bất đồng bộ, JavaScript sử dụng một mô hình xử lý dựa trên Call Stack, Web APIs, Task Queue, và Event Loop.

Call Stack (Ngăn xếp gọi hàm)

Call Stack là nơi JavaScript thực thi các lệnh theo cơ chế LIFO (Last In, First Out), nghĩa là hàm được gọi sau cùng sẽ được thực thi trước.

Ví dụ: Hoạt động của Call Stack với các hàm đồng bộ

function greet() {
    console.log("Hello");
}

function sayGoodbye() {
    console.log("Goodbye");
}

greet();
sayGoodbye();

Quá trình thực thi của Call Stack:

  • greet() được đưa vào Call Stack và thực thi (console.log("Hello")).

  • Sau khi greet() kết thúc, nó được xóa khỏi Call Stack.

  • sayGoodbye() được đưa vào Call Stack và thực thi (console.log("Goodbye")).

  • sayGoodbye() kết thúc và bị xóa khỏi Call Stack.

Kết quả in ra:

Hello
Goodbye

Với các hàm đồng bộ, Call Stack xử lý tuần tự từng hàm một. Nhưng với các tác vụ bất đồng bộ, Call Stack sẽ không chờ mà tiếp tục xử lý các lệnh khác.

Web APIs (Các API trình duyệt hỗ trợ bất đồng bộ) trong JavaScript

Khi JavaScript gặp một hàm bất đồng bộ (như setTimeout, fetch, hoặc addEventListener), nó sẽ chuyển tác vụ đó sang Web APIs (do trình duyệt cung cấp) thay vì giữ nó trong Call Stack.

Ví dụ:

console.log("Bắt đầu");

setTimeout(() => {
    console.log("Đã hoàn thành!");
}, 2000);

console.log("Kết thúc");

Cách Web APIs xử lý:

  • console.log("Bắt đầu") → Đưa vào Call Stack, thực thi, rồi bị xóa.

  • setTimeout(() => { console.log("Đã hoàn thành!"); }, 2000);

    • Call Stack gửi setTimeout đến Web APIs.

    • Web APIs bắt đầu đếm 2 giây mà không chặn chương trình.

  • console.log("Kết thúc") → Đưa vào Call Stack, thực thi, rồi bị xóa.

  • Sau 2 giây, Web APIs đưa callback (console.log("Đã hoàn thành!")) vào Task Queue chờ xử lý.

Kết quả in ra:

Bắt đầu
Kết thúc
Đã hoàn thành!

Web APIs giúp xử lý các tác vụ bất đồng bộ mà không làm chậm chương trình.

Task Queue (Hàng đợi tác vụ)

Khi một tác vụ bất đồng bộ hoàn thành, nó sẽ được đưa vào Task Queue (hoặc Microtask Queue đối với Promise).

Task Queue chứa các callback từ Web APIs chờ được thực thi.

  • Task Queue không thực thi ngay mà chờ đến khi Call Stack trống.

Ví dụ:

console.log("1");

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

console.log("3");

Quá trình thực thi:

  • console.log("1") → Thực thi ngay.

  • setTimeout(..., 0) → Chuyển callback vào Web APIs.

  • console.log("3") → Thực thi ngay.

  • Callback từ setTimeout được đưa vào Task Queue và đợi Call Stack trống.

  • Khi Call Stack trống, Event Loop lấy callback từ Task Queue và thực thi.

Kết quả in ra:

1
3
2

Mặc dù setTimeout đặt thời gian là 0ms, nhưng callback vẫn phải đợi Call Stack trống mới được thực thi.

Event Loop (Vòng lặp sự kiện giúp xử lý bất đồng bộ)

Event Loop là cơ chế giúp JavaScript kiểm tra Call Stack và Task Queue để quyết định khi nào thực thi các callback bất đồng bộ.

Cách hoạt động của Event Loop:

  • Nếu Call Stack có hàm đang chạy → Event Loop chờ.

  • Nếu Call Stack trống → Event Loop lấy tác vụ từ Task Queue đưa vào Call Stack để thực thi.

  • Lặp lại quá trình trên liên tục.

Sự khác biệt giữa Task Queue và Microtask Queue:

  • Task Queue: Chứa các callback từ setTimeout, setInterval, event listeners.

  • Microtask Queue: Chứa các callback từ Promise.then(), async/await, MutationObserver.

Microtask Queue có độ ưu tiên cao hơn Task Queue, nghĩa là nó sẽ được thực thi trước khi Task Queue.

Ví dụ:

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => console.log("C"));

console.log("D");

Thứ tự thực thi:

  • console.log("A") → Call Stack → Thực thi ngay.

  • setTimeout(...) → Chuyển callback vào Web APIs.

  • Promise.resolve().then(...) → Đưa callback vào Microtask Queue.

  • console.log("D") → Call Stack → Thực thi ngay.

  • Microtask Queue được thực thi trước → console.log("C").

  • Call Stack trống, Event Loop lấy từ Task Queue → console.log("B").

Kết quả in ra:

A
D
C
B

Promise (C) chạy trước setTimeout (B) vì Microtask Queue có độ ưu tiên cao hơn Task Queue.

Ví dụ minh họa về cách JavaScript xử lý bất đồng bộ

console.log("1. Bắt đầu");

setTimeout(() => console.log("2. setTimeout"), 0);

Promise.resolve().then(() => console.log("3. Promise"));

console.log("4. Kết thúc");

Thứ tự thực thi:

  • console.log("1. Bắt đầu") → Call Stack → Thực thi ngay.

  • setTimeout(...) → Đưa callback vào Web APIs.

  • Promise.resolve().then(...) → Đưa callback vào Microtask Queue.

  • console.log("4. Kết thúc") → Call Stack → Thực thi ngay.

  • Microtask Queue thực thi trước Task Queue → console.log("3. Promise").

  • Call Stack trống, Event Loop lấy từ Task Queue → console.log("2. setTimeout").

Kết quả in ra:

1. Bắt đầu
4. Kết thúc
3. Promise
2. setTimeout

Các kỹ thuật xử lý bất đồng bộ trong JavaScript

JavaScript cung cấp nhiều kỹ thuật để xử lý các tác vụ bất đồng bộ, giúp cải thiện hiệu suất và khả năng đọc hiểu mã nguồn. Ba phương pháp phổ biến nhất là Callback, Promise, và Async/Await.

Callback là gì?

Callback là một hàm được truyền vào một hàm khác dưới dạng đối số và sẽ được thực thi khi tác vụ bất đồng bộ hoàn thành.

Ví dụ đơn giản về Callback:

function greet(name, callback) {
    console.log("Xin chào, " + name);
    callback();
}

function sayGoodbye() {
    console.log("Tạm biệt!");
}

greet("Alice", sayGoodbye);
Kết quả in ra:
Xin chào, Alice
Tạm biệt!

Cách hoạt động:

  • sayGoodbye được truyền vào greet dưới dạng callback.

  • Khi greet hoàn thành, nó gọi callback(), tức là thực thi sayGoodbye().

Ví dụ về Callback trong xử lý bất đồng bộ (setTimeout)

console.log("Bắt đầu");

setTimeout(() => {
    console.log("Hoàn thành sau 2 giây");
}, 2000);

console.log("Kết thúc");
Kết quả in ra:
Bắt đầu
Kết thúc
Hoàn thành sau 2 giây

Giải thích:

  • setTimeout là một tác vụ bất đồng bộ, nên console.log("Hoàn thành sau 2 giây") sẽ thực thi sau cùng.

Nhược điểm của Callback: Callback Hell

Callback Hell xảy ra khi có nhiều callback lồng nhau, khiến mã khó đọc và khó bảo trì.

Ví dụ về Callback Hell:

function step1(callback) {
    setTimeout(() => {
        console.log("Bước 1 hoàn thành");
        callback();
    }, 1000);
}

function step2(callback) {
    setTimeout(() => {
        console.log("Bước 2 hoàn thành");
        callback();
    }, 1000);
}

function step3(callback) {
    setTimeout(() => {
        console.log("Bước 3 hoàn thành");
        callback();
    }, 1000);
}

// Callback Hell
step1(() => {
    step2(() => {
        step3(() => {
            console.log("Tất cả các bước đã hoàn thành!");
        });
    });
});
Kết quả in ra:
Bước 1 hoàn thành
Bước 2 hoàn thành
Bước 3 hoàn thành
Tất cả các bước đã hoàn thành!

Nhược điểm:

  • Hàm lồng nhau quá sâu, gây khó đọc.

  • Dễ mắc lỗi khi bảo trì hoặc mở rộng mã.

Giải pháp: Promise và Async/Await giúp viết mã dễ đọc hơn.

Promise trong JavaScript

Promise là một đối tượng đại diện cho một giá trị sẽ có vào một thời điểm trong tương lai, khi một tác vụ bất đồng bộ hoàn thành.

Promise có 3 trạng thái:

  • Pending (Đang chờ): Chưa hoàn thành.

  • Fulfilled (Thành công): Tác vụ đã hoàn tất.

  • Rejected (Thất bại): Có lỗi xảy ra.

Cách tạo và sử dụng Promise

Ví dụ về Promise đơn giản:

const myPromise = new Promise((resolve, reject) => {
    let success = true;
    setTimeout(() => {
        if (success) {
            resolve("Thành công!");
        } else {
            reject("Thất bại!");
        }
    }, 2000);
});

myPromise
    .then(result => console.log(result))  // Xử lý khi thành công
    .catch(error => console.log(error));  // Xử lý khi thất bại
Kết quả in ra (sau 2 giây):
Thành công!

Lợi ích của Promise:

  • Tránh lồng quá nhiều callback.

  • Dễ dàng xử lý lỗi với .catch().

Ví dụ: Promise trong chuỗi công việc bất đồng bộ

Thay thế Callback Hell bằng Promise:

function step1() {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log("Bước 1 hoàn thành");
            resolve();
        }, 1000);
    });
}

function step2() {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log("Bước 2 hoàn thành");
            resolve();
        }, 1000);
    });
}

function step3() {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log("Bước 3 hoàn thành");
            resolve();
        }, 1000);
    });
}

// Chuỗi Promise thay vì Callback Hell
step1()
    .then(step2)
    .then(step3)
    .then(() => console.log("Tất cả các bước đã hoàn thành!"));

Kết quả in ra:

Bước 1 hoàn thành
Bước 2 hoàn thành
Bước 3 hoàn thành
Tất cả các bước đã hoàn thành!

So với Callback Hell, Promise giúp mã dễ đọc hơn.

Async/Await

Async/Await là cách viết mã bất đồng bộ một cách đồng bộ hơn, giúp dễ hiểu và dễ quản lý hơn so với Callback và Promise.

Từ khóa async biến một hàm thành hàm bất đồng bộ.
Từ khóa await giúp chờ một Promise hoàn thành trước khi tiếp tục.

Cách sử dụng Async/Await để xử lý bất đồng bộ

Ví dụ:

function fetchData() {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve("Dữ liệu đã tải xong!");
        }, 2000);
    });
}

async function getData() {
    console.log("Bắt đầu tải dữ liệu...");
    let data = await fetchData();
    console.log(data);
}

getData();

Kết quả in ra:

Bắt đầu tải dữ liệu...
(đợi 2 giây)
Dữ liệu đã tải xong!

Lợi ích:

  • Mã dễ đọc hơn so với .then().

  • Giúp xử lý tuần tự mà không bị lồng nhau.

So sánh Async/Await với Promise và Callback

Kỹ thuật Ưu điểm Nhược điểm
Callback Dễ hiểu với tác vụ đơn giản Callback Hell khi lồng quá nhiều
Promise Tránh Callback Hell, xử lý lỗi tốt với .catch() Viết .then() dài dòng khi có nhiều bước
Async/Await Mã dễ đọc, viết như mã đồng bộ Phải dùng try...catch để xử lý lỗi

Ví dụ: Xử lý lỗi với try...catch trong Async/Await

async function getData() {
    try {
        let data = await fetchData();
        console.log(data);
    } catch (error) {
        console.log("Lỗi:", error);
    }
}

Các ứng dụng thực tế của bất đồng bộ trong JavaScript

Bất đồng bộ là một phần quan trọng trong JavaScript, giúp xử lý các tác vụ mất thời gian mà không làm gián đoạn chương trình. Dưới đây là một số ứng dụng phổ biến của bất đồng bộ trong thực tế:

Xử lý yêu cầu API với fetch()

fetch() là một API bất đồng bộ dùng để lấy dữ liệu từ máy chủ.

Ví dụ: Gửi request đến API và xử lý kết quả với Async/Await

async function getUserData() {
    try {
        let response = await fetch("https://jsonplaceholder.typicode.com/users/1");
        let data = await response.json();
        console.log("Dữ liệu người dùng:", data);
    } catch (error) {
        console.log("Lỗi khi lấy dữ liệu:", error);
    }
}

getUserData();

Lợi ích của fetch():

  • Hoạt động bất đồng bộ, không làm chặn luồng xử lý.

  • Hỗ trợ Promise để xử lý kết quả dễ dàng với .then() hoặc async/await.

Đọc/ghi file với fs trong Node.js

Trong Node.js, module fs hỗ trợ thao tác với file theo cách bất đồng bộ.

Ví dụ: Đọc file bằng fs.promises.readFile()

const fs = require('fs').promises;

async function readFileAsync() {
    try {
        let data = await fs.readFile("example.txt", "utf8");
        console.log("Nội dung file:", data);
    } catch (error) {
        console.log("Lỗi khi đọc file:", error);
    }
}

readFileAsync();

Lợi ích của đọc file bất đồng bộ:

  • Không chặn chương trình trong khi file đang được đọc.

  • Hiệu suất cao hơn trong các ứng dụng xử lý nhiều tệp tin.

Xử lý sự kiện người dùng trong trình duyệt

Các sự kiện như click, keypress, scroll đều được xử lý bất đồng bộ.

Ví dụ: Xử lý sự kiện click bằng callback

document.getElementById("btn").addEventListener("click", () => {
    console.log("Nút đã được nhấn!");
});

Lợi ích của xử lý sự kiện bất đồng bộ:

  • Không chặn UI, giúp trang web phản hồi nhanh chóng.

  • Tối ưu hiệu suất, không làm gián đoạn các tác vụ khác.

Gọi cơ sở dữ liệu trong ứng dụng web

Hầu hết truy vấn cơ sở dữ liệu đều mất thời gian, nên chúng được thực hiện bất đồng bộ.

Ví dụ: Lấy dữ liệu từ MongoDB bằng Node.js và async/await

const mongoose = require("mongoose");

async function fetchUsers() {
    await mongoose.connect("mongodb://localhost:27017/mydatabase");
    const users = await User.find();
    console.log("Danh sách người dùng:", users);
}

fetchUsers();
  • Không làm chậm server, giúp xử lý nhiều request đồng thời.

  • Giữ giao diện mượt mà, không bị treo trong lúc truy vấn dữ liệu.

Các vấn đề thường gặp và cách khắc phục khi làm việc với bất đồng bộ trong JavaScript

Dù bất đồng bộ giúp cải thiện hiệu suất, nó cũng mang đến một số vấn đề phổ biến. Dưới đây là các lỗi thường gặp và cách khắc phục:

Callback Hell và cách tránh

Callback Hell xảy ra khi có quá nhiều callback lồng nhau, khiến mã khó đọc và bảo trì.

Ví dụ về Callback Hell:

getUserData(userId, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetails(orders[0], function(details) {
            console.log("Chi tiết đơn hàng:", details);
        });
    });
});
  • Code bị lồng nhau quá sâu.

  • Khó bảo trì, dễ mắc lỗi.

Cách khắc phục: Dùng Promise hoặc Async/Await

Chuyển sang Promise:

getUserData(userId)
    .then(user => getOrders(user.id))
    .then(orders => getOrderDetails(orders[0]))
    .then(details => console.log("Chi tiết đơn hàng:", details))
    .catch(error => console.log("Lỗi:", error));

Chuyển sang Async/Await:

async function getOrderDetailsAsync(userId) {
    try {
        let user = await getUserData(userId);
        let orders = await getOrders(user.id);
        let details = await getOrderDetails(orders[0]);
        console.log("Chi tiết đơn hàng:", details);
    } catch (error) {
        console.log("Lỗi:", error);
    }
}

getOrderDetailsAsync(1);
  • Dễ đọc hơn, không bị lồng quá nhiều.

  • Xử lý lỗi dễ dàng với .catch() hoặc try...catch.

Xử lý lỗi trong Promise với .catch()

Nếu không xử lý lỗi trong Promise, ứng dụng có thể bị crash.

Ví dụ về Promise với .catch() để xử lý lỗi:

fetch("https://api.example.com/data")
    .then(response => response.json())
    .then(data => console.log("Dữ liệu:", data))
    .catch(error => console.log("Lỗi khi lấy dữ liệu:", error));

Lợi ích của .catch():

  • Ngăn ứng dụng bị dừng đột ngột khi có lỗi.

  • Dễ dàng kiểm soát luồng xử lý.

Xử lý lỗi trong async/await với try...catch

Dùng try...catch để bắt lỗi khi sử dụng async/await.

Ví dụ:

async function fetchData() {
    try {
        let response = await fetch("https://api.example.com/data");
        let data = await response.json();
        console.log("Dữ liệu:", data);
    } catch (error) {
        console.log("Lỗi khi lấy dữ liệu:", error);
    }
}

fetchData();
  • Bảo vệ chương trình khỏi lỗi không mong muốn.

  • Dễ debug và kiểm soát lỗi hơn .catch().

Cách tối ưu hiệu suất khi làm việc với bất đồng bộ

Dùng Promise.all() khi có nhiều tác vụ bất đồng bộ độc lập.

async function getData() {
    let [users, orders] = await Promise.all([
        fetch("/users").then(res => res.json()),
        fetch("/orders").then(res => res.json())
    ]);
    console.log("Người dùng:", users);
    console.log("Đơn hàng:", orders);
}
  • Chạy nhiều request đồng thời, thay vì tuần tự.

  • Giảm thời gian chờ đợi.​

Kết bài

Bất đồng bộ là một phần quan trọng trong JavaScript, giúp ứng dụng hoạt động mượt mà và tối ưu hơn khi xử lý các tác vụ mất thời gian như gọi API, đọc/ghi file, xử lý sự kiện và truy vấn cơ sở dữ liệu.

  • Callback, PromiseAsync/Await là ba kỹ thuật phổ biến để xử lý bất đồng bộ, trong đó Async/Await giúp mã dễ đọc và bảo trì hơn.

  • Callback Hell có thể gây khó khăn trong việc quản lý code, nhưng có thể khắc phục bằng cách sử dụng Promise hoặc Async/Await.

  • Xử lý lỗi đúng cách với .catch() trong Promise và try...catch trong Async/Await giúp tránh crash ứng dụng.

  • Tối ưu hiệu suất bằng Promise.all() giúp chạy nhiều tác vụ đồng thời, giảm thời gian chờ.

Việc hiểu và sử dụng bất đồng bộ đúng cách không chỉ giúp lập trình viên viết code hiệu quả hơn mà còn giúp ứng dụng hoạt động nhanh, ổn định và dễ bảo trì hơn trong môi trường thực tế.

Bài viết liên quan