Một câu hỏi khá thú vị về JavaScript là: “Liệu nó có thực sự đơn luồng không nhỉ? Nếu đơn luồng thì tại sao vẫn có những tác vụ bất đồng bộ (async task) như setTimeout, fetch hay Promise?” Nghe có vẻ hơi mâu thuẫn đúng không? Cùng mình tìm hiểu xem thực chất JavaScript hoạt động như thế nào nhé!
JavaScript “đơn luồng” là sao?
Trước hết, khi nói JavaScript là “đơn luồng”, ý là gì? Đơn giản, nó chỉ có một luồng thực thi chính – tức là tại một thời điểm, chỉ có một đoạn mã JavaScript được chạy trên call stack. Điều này giúp mọi thứ trở nên dễ dự đoán, không lo hai đoạn code “giành nhau” tài nguyên như các ngôn ngữ đa luồng khác.
Nhưng mà, nếu chỉ có một luồng, tại sao những thứ như setTimeout, fetch, hay Promise lại có thể “chạy song song” mà không làm đứng trình duyệt? Đó là nhờ vào cơ chế bất đồng bộ (asynchronous) và event loop thần thánh của JavaScript.
Event Loop – “ảo thuật gia” đứng sau mọi thứ
Trước hết ta cần làm quen các khái niệm sau:
- Callstack: Một stack các function được gọi. Khi một function được gọi, nó sẽ được xếp vào đây, và sẽ hoạt động theo nguyên tắc LIFO
- Callback queue: Một hàng đợi các callback chờ được thực thi
Rồi, bây giờ hãy tưởng tượng JavaScript như một đầu bếp chỉ có một tay (call stack) đầu bếp này sẽ thực hiện tuần tự các công việc được list ra từ trên xuống dưới (thứ tự các dòng code của bạn) Nhưng vị đầu bếp này lại có rất nhiều trợ lý (Web APIs, Browser APIs, thư viện libuv của Nodejs). Khi bạn gọi setTimeout hoặc fetch, đầu bếp sẽ “giao việc” cho trợ lý, rồi tiếp tục nấu các món khác. Như vậy, người thực hiện các tác vụ như đếm thời gian, gọi api bản chất không phải đầu bếp (JavaScript) mà là các trợ lý.
Khi trợ lý làm xong, họ sẽ báo lại cho đầu bếp (chính là cách đưa các callback vào queue), và đầu bếp sẽ xử lý những việc còn lại (callback) tiếp khi khi mà đầu bếp đã làm xong các việc trong list ban đầu.
Chính vì vậy, các công việc còn lại (callbacks) phải chờ đầu bếp làm xong các món chính đã.
Cơ chế này gọi là event loop. Nó giúp JavaScript xử lý các tác vụ bất đồng bộ mà không cần phải tạo thêm luồng mới. Nhờ vậy, giao diện web vẫn mượt mà, không bị “đơ” khi chờ dữ liệu từ server hay các tác vụ tốn thời gian.
Mình sẽ để biểu đồ flow ở dưới cùng của blog cho các bạn nào muốn tham khảo nhé :v
Chính vì các task nặng sau khi được thực hiện xong, callback của nó sẽ được đẩy vào 1 cái queue, chờ khi nào callstack rảnh thì mới được thực hiện. Mình đã nhận thấy ra một điều khá thú vị: rằng setTimeout sẽ thực sự không chuẩn.
Nếu thời gian của setTimeout hết, mà trong callstack vẫn còn function đang được thực thi, thì nó sẽ phải chờ, mà nếu function đó cũng rất tốn thời gian thì sẽ phải chờ càng lâu.
Mình cùng thử nhé, mình sẽ cho chạy một tỉ vòng lặp xem sao:
const start = Date.now();setTimeout(() => {console.log('Delay:', Date.now() - start, 'ms');}, 0);for (let i = 0; i < 1_000_000_000; i++) {}
Đầu bếp này, chỉ giao một việc rất rất cỏn con cho trợ lý thôi, rồi ông ta tiếp tục băm thịt 1 tỉ lần. Theo lý thuyết, công việc đếm thời gian kia mất 0s cho trợ lý thực hiện, rồi ông đầu bếp sẽ in ra độ trễ. Cùng xem kết quả thực tế nhé:

Trễ có 0,3s thôi sao, thử tăng lên 10 tỉ xem sao :))) :

Thấy chứ, hàm console.log('Delay:', Date.now() - start, 'ms'); đã thực sự đợi 10 tỉ vòng lặp xong mới được thực thi
Kết quả
Vậy JavaScript có thực sự đơn luồng không?
Câu trả lời là: Đúng và… không hẳn! Engine JavaScript (ví dụ V8 của Chrome) chỉ có một call stack, nên về bản chất là đơn luồng. Nhưng môi trường thực thi (trình duyệt hoặc Node.js) lại cung cấp các API chạy song song ở “hậu trường”. Khi các tác vụ này hoàn thành, chúng mới được đẩy vào hàng đợi (queue) để JavaScript xử lý tiếp.
Tổng kết
Tóm lại, JavaScript đơn luồng ở mức engine, nhưng nhờ event loop và các API của môi trường, nó vẫn có thể xử lý nhiều việc “gần như song song”. Vậy nên, đừng quá lo lắng về “đơn luồng” – chỉ cần hiểu cách hoạt động, bạn sẽ tận dụng được sức mạnh thật sự của JavaScript!
Dưới đây là biểu đồ minh họa cho quá trình chạy thực sự của JavaScript trên môi trường Nodejs:

