Event-Driven Programming với hệ thống tải cao
Ngày nay, một trong những thách thức lớn nhất với các Developers là phải tối ưu hệ thống của mình, đặc biệt các hệ thống chịu tải cao, tới từng micro-seconds để đáp ứng số người dùng không ngừng tăng lên. Tuy nhiên, hầu hết các hệ thống lại có một sự lãng phí không hề nhỏ dành cho việc chờ đợi ...
Ngày nay, một trong những thách thức lớn nhất với các Developers là phải tối ưu hệ thống của mình, đặc biệt các hệ thống chịu tải cao, tới từng micro-seconds để đáp ứng số người dùng không ngừng tăng lên. Tuy nhiên, hầu hết các hệ thống lại có một sự lãng phí không hề nhỏ dành cho việc chờ đợi đọc/ghi dữ liệu hoặc các tác vụ khác.
Hãy nhìn vào thực tế cuộc sống, đó là một “tiến trình bất đồng bộ” giống như ví dụ sau đây:
- Bạn bước vào quán cafe, nhân viên đứng quầy mời bạn gọi đồ
- Sau đó, bạn ra bàn ngồi đồng thời lôi smartphone của mình ra check-in trên Facebook
- Nhân viên làm xong và đưa đồ uống ra bàn cho bạn
- Bạn thưởng thức tách cafe mà không quên kiểm tra comment tại post của mình
Trong ngữ cảnh hoàn toàn khác, thử tưởng tượng bạn (đặc biệt là vị khách đứng sau bạn) sẽ cảm thấy thế nào khi phải chôn chân đợi nhân viên pha chế xong đồ? Chắc hẳn sẽ rất khó chịu và thấy thật lãng phí thời gian + kém hiệu quả Tương tự vậy, trong phần mềm, hướng tiếp cận “bất đồng bộ” cũng khiến tăng năng lực phục vụ cho hệ thống của bạn.
I. Các khái niệm cơ bản
1. Mô hình Blocking I/O
Đây là cách mà các thread dành thời gian để thực hiện một tác vụ. Trong đó, bao gồm cả việc đợi kết quả trả về từ quá trình IO hoàn thành. Ví dụ như việc chờ đợi I/O của disk, network…
2. Mô hình Non-Blocking I/O
Ngược lại với mô hình Blocking IO, mô hình Non-Blocking cung cấp cách thức khiến các thread không cần thiết chờ đợi việc đọc/ghi IO. Như vậy, thread có thể tiếp tục thực hiện tác vụ khác qua đó giúp ứng dụng giảm bớt độ trễ do chờ đợi không cần thiết.
3. Asynchronous (bất đồng bộ)
Đây là phương thức khiến tác vụ trả về kết quả ngay lập tức (qua 1 đối tuợng như Future, Promise, Deferred) dù chưa thực sự hoàn thành, nhưng sau đó chúng tiếp tục được thực hiện 1 cách song song (parallel) tại 1 thread khác và trả kết quả thực sự thông qua callback. Chính nhờ vậy mà thread sẽ không bị block bởi việc chờ đợi các tác vụ khác.
Khái niệm Asynchronous khá tương đồng với non-blocking. Trong thực tế, nhiều trường hợp 2 khái niệm này hoàn toàn có thể thay thế cho nhau.
II. Thread-based vs Event-based
1. Công nghệ
Ngày nay, phần lớn các lập trình viên thường làm việc với các công nghệ dựa trên Thread. Có thể kể ra đây như:
-
Apache httpd
-
Ruby on Rails
-
Django
-
PHP
Mỗi khi có 1 request được gửi tới, Web Server sẽ tạo ra 1 thread mới và đưa lần lượt vào threadpools (hay còn gọi là worker thread) để xử lý. Sau đó, các ngôn ngữ và framework trên sẽ xử lý các tác vụ này 1 cách độc lập. Thêm vào đó, chúng sẽ bị block (synchronous) đến khi kết quả được trả về client. Do việc khởi tạo ra nhiều thread trong pool khá đắt đỏ (mặc định stack size dành cho 1 thread là 1MB trên JVM 64bit), vì vậy size của threadpool thường được fix cố định.
Tuy nhiên trong hệ thống có traffic lớn, nó lại dẫn chúng ta đến 1 nghịch lý. Đó là có quá nhiều thread trong pool thì hệ thống trở nên khó kiểm soát, cost dành cho memory và context switching tăng lên. Ngược lại, duy trì quá ít thread trong pool lại khiến tăng độ trễ và giảm khả năng đáp ứng của hệ thống -> “game of the threadpools size”.
Thêm nữa, 1 ứng dụng dạng truyền thống xây dựng trên Thread-based thì mọi ý tưởng sẽ xoay quanh 1 thứ đó là callstack. Các chức năng sẽ được gọi và chờ đợi lẫn nhau (blocked) để cùng trả về 1 kết quả cuối cùng. Với các ứng dụng như vậy, callstack sẽ trở nên giống như thế này
Ngược lại với mô hình trên, đó là hướng tiếp cận theo Event-based giúp chúng ta giải quyết các nhược điểm trên. Các công nghệ dựa vào mô hình này như:
-
Play
-
Twisted
-
Node.js
-
Nginx
-
Redis
Event-based sử dụng duy nhất 1 thread (trên mỗi core CPU) và dùng event-loop để xử lý các event trong queue. Mọi xử lý IO đều được gọi một cách bất đồng bộ thông qua callback. Đây cũng là cách non-blocking event handle thường dùng.
Tóm lại, kiến trúc event-based có ưu điểm:
-
Single-thread trên mỗi core CPU
-
Không tốn cost cho context-switching
-
Không lo lắng đến pool size
-
-
Sử dụng event loop gọi I/O 1 cách bất đồng bộ (sử dụng callback) ~>
- Không chờ đợi 1 cách vô ích
2. Kỹ thuật lập trình
Multi-thread programming
Trong thực tế thường gặp của Developer, khi cần hoàn thành nhanh chóng 1 tác vụ, chúng ta thường thiết kế chạy nó 1 cách song song (parallel). Đó là một ý tưởng tốt, tuy nhiên, điều này không thực sự đơn giản bởi chúng thường sẽ gặp các vấn đề như:
-
Deadlocks – các thread cùng tranh chấp với nhau 1 resource khi cùng truy cập vào nó.
-
Thread không thể khởi chạy vì tài nguyên dùng chung bị chặn.
Cách giải quyết tốt là sử dụng 1 threadpools .Nhưng khi đó, chúng ta lại rơi vào cái bẫy “nghịch lý threadpool” phía trên
Event-driven programming
Event-driven dựa trên nguyên lý trigger các event. Khi đó các event sẽ được monitor(watched, listened…) bởi 1 hoặc nhiều observers khác nhau. Khác biệt cơ bản đó là việc mỗi khi event được phát ra (broadcast, emit…), các observer sẽ xử lý chúng và chương trình sẽ không bị blocked để chờ đợi kết quả trả về, nhờ đó event-loop sẽ tiếp tục xử lý các request mới. Khi đó ứng dụng của ta trở nên :
-
Có khả năng chịu tải cao hơn (high concurrency)
-
Loose coupling – các thành phần trong hệ thống không còn phụ thuộc lẫn nhau
-
Có khả năng mở rộng (scalable)
Thế nhưng, khi lập trình theo cách này, điều các developers (như NodeJS) thường xuyên gặp phải đó là callback hell khiến các code của ứng dụng trở nên như một mớ bòng bong, rối rắm(spaghetti code) gây khó khăn cho việc mở rộng và maintain của hệ thống. Và việc debug trong hệ thống cũng trở nên khó khăn hơn so với ứng dụng truyền thống.
Với Scala, địa ngục này có thể bớt đau đớn hơn chút bằng cách sử dụng async/await (cùng nhiều bí kíp Functional Programming khác như ví dụ này http://engineering.roundupapp.co/the-future-is-not-good-enough-coding-with-async-await/
Nhưng trên quan điểm thiết kế, chúng ta có thể sử dụng Message-Driven Programming(hay Actor-based). Kiến trúc hướng message là sự mở rộng của Event-driven và cũng là nền tảng của 1 ứng dụng Reactive. Nó dựa trên việc truyền tải các message trực tiếp đến các actor(do đó được gọi là actor-based), khác với việc chỉ đơn giản broadcast ra event (có thể là qua 1 trung gian như eventbus) của Event-driven. Hiện này nhiều thư viện support tốt Event/Message-driven programming khiến việc tiếp cận phương pháp này trở nên dễ dàng hơn, trong đó nổi bật là:
-
Akka
-
RabbitMQ
-
Apache Kafka
III. Kết luận
Ngày nay, để giải quyết những thách thức này 1 lớn (như C10k problem và C10M problem), chúng ta cần phải thay đổi nhận thức và tiếp cận bằng phương pháp lập trình mới. Khi đó Event/Message-Driven Programming sẽ là lựa chọn hàng đầu.
IV. Tham khảo
-
http://www.eecs.berkeley.edu/~rcs/research/interactive_latency.html
-
http://www.reactivemanifesto.org/
-
http://www.slideshare.net/brikis98/the-play-framework-at-linkedin/133-Open_source_modules_and_plugins
-
http://www.ibm.com/developerworks/library/j-jvmc3/index.html
-
https://en.wikipedia.org/wiki/Context_switch
-
http://berb.github.io/diploma-thesis/original/042_serverarch.html