Giới thiệu về các mô hình Concurrency trong Ruby
Bài viết mô tả sự khác nhau giữa Processes, Threads, GIL là gì, EventMachine và Fibres trong Ruby. Khi nào thì sử dụng mỗi mô hình, các dự án mã nguồn mở nào sử dụng chúng, và ưu khuyết điểm của chúng là gì. Processes Chạy nhiều tiến trình không phải là cơ chế concurrency (Ứng dụng thực hiện ...
Bài viết mô tả sự khác nhau giữa Processes, Threads, GIL là gì, EventMachine và Fibres trong Ruby. Khi nào thì sử dụng mỗi mô hình, các dự án mã nguồn mở nào sử dụng chúng, và ưu khuyết điểm của chúng là gì.
Processes
Chạy nhiều tiến trình không phải là cơ chế concurrency (Ứng dụng thực hiện nhiều task trong một thời điểm) mà là parallelism (ứng dụng có thể chia task thành các sub-task chạy song song trên các CPU). Mặc dù parallelism và concurrency thường bị nhầm lẫn, nhưng chúng là hoàn toàn khác nhau. Hãy xem ví dụ đơn giản này:
- Concurrentcy: có một người tung hứng nhiều quả bóng với chỉ một tay. Dù thế nào, người đó cũng chỉ có thể bắt/ ném một quả bóng một lúc.
- Parallelism: có nhiều người cùng tung những quả bóng cùng 1 lúc.
Thực hiện tuần tự Hãy tưởng tượng, chúng ta có một dãy số, chúng ta cần phải chuyển đổi dãy số sang một mảng và tìm index cho một phần tử cụ thể:
# sequential.rb range = 0...10_000_000 number = 8_888_888 puts range.to_a.index(number) $ time ruby sequential.rb 8888888 ruby test.rb 0.41s user 0.06s system 95% cpu 0.502 total
Đoạn mã này thực hiện mất khoảng 500ms và sử dụng 1 CPU. Thực hiện song song Chúng ta có thể viết lại đoạn mã bên trên bằng cách sử dụng nhiều tiến trình song song và chia nhỏ phạm vi. Với phương thức fork từ thư viện chuẩn của Ruby, chúng ta có thể tạo ra một tiến trình con và thực thi đoạn mã trong block. Với tiến trình cha, chúng ta có thể đợi cho tới khi tất cả các tiến trình con hoàn thành với lệnh Process.wait:
# parallel.rb range1 = 0...5_000_000 range2 = 5_000_000...10_000_000 number = 8_888_888 puts "Parent #{Process.pid}" fork { puts "Child1 #{Process.pid}: #{range1.to_a.index(number)}" } fork { puts "Child2 #{Process.pid}: #{range2.to_a.index(number)}" } Process.wait $ time ruby parallel.rb Parent 32771 Child2 32867: 3888888 Child1 32865: ruby parallel.rb 0.40s user 0.07s system 153% cpu 0.309 total
Bởi vì mỗi tiến trình trong parallel làm việc với một nửa phạm vi của dãy số, đoạn mã bên trên hoạt động nhanh hơn một chút và tiêu tốn nhiều hơn 1 CPU. Cây tiến trình trong suốt quá trình hoạt động có thể biểu diễn như sau:
# - 32771 ruby parallel.rb (parent process) # | - 32865 ruby parallel.rb (child process) # | - 32867 ruby parallel.rb (child process)
Ưu điểm
- Các tiến trình không chia sẻ bộ nhớ vì vậy bạn không thể biến đổi một tiến trình này sang một tiến trình khác, điều này giúp dễ dàng hơn trong việc code và debug.
- Các tiến trình trong Ruby MRI là cách duy nhất dể sử dụng nhiều hơn 1 single-core vì có một GIL( global interpreter lock), nó có thể hữu ích trong một số tính toán.
- Chia nhỏ các tiến trình con có thể tránh các rò rỉ bộ nhớ không mong muốn, một khi tiến trình kết thúc, nó sẽ giải phóng tất cả các tài nguyên.
Nhược điểm
- Vì các tiến trình không chia sẻ bộ nhớ nên chúng sử dụng rất nhiều bộ nhớ - có nghĩa là nếu chạy hàng trăm tiến trình sẽ là một vấn đề. Lưu ý rằng phương thức fork trong Ruby 2.0 sử dụng hệ điều hành Copy-On-Write, cho phép các tiến trình chia sẻ bộ nhớ miễn là nó không có các giá trị khác nhau.
- Tiến trình create và destroy chậm.
- Các tiến trình có thể yêu cầu cơ chế giao tiếp liên quá trình. Ví dụ, DRb.
- Hãy cẩn thận với các tiến trình mồ côi (tiến trình con mà có tiến trình cha đã hoàn thành hoặc chấm dứt) hoặc quá trình zombie (quy trình con đã hoàn thành nhưng vẫn chiếm dung lượng trong bảng tiến trình).
Ví dụ:
- Unicorn server - tải trước ứng dụng, phân chia tiến trình tổng thể thành nhiều worker để xử lý các yêu cầu HTTP.
- Resque để background processing - hoạt động như một worker, thực hiện mỗi công việc tuần tự trong một quá trình con đã phân chia.
Threads
Mặc dù Ruby sử dụng các luồng hệ điều hành gốc từ phiên bản 1.9, chỉ có một luồng có thể được thực hiện bất kỳ lúc nào trong một tiến trình đơn, ngay cả khi bạn có nhiều CPU. Điều này là do thực tế MRI có GIL, nó cũng tồn tại trong các ngôn ngữ lập trình khác như Python. Tại sao GIL tồn tại? Có một vài lý do, ví dụ:
- Tránh hiện tượng tranh chấp dữ liệu (Race Conditions) trong các tiện ích mở rộng C, không cần phải lo lắng về thread-safe.
- Dễ cài đặt hơn, không cần sử dụng cấu trúc dữ liệu thread-safe của Ruby.
Trở lại năm 2014, Matz bắt đầu suy nghĩ về việc dần dần loại bỏ GIL. Bởi vì GIL không thực sự đảm bảo rằng mã Ruby là thread-safe và không cho phép chúng ta sử dụng concurrency tốt hơn.
Race-conditions Đây là một ví dụ cơ bản về race-condition:
# threads.rb @executed = false def ensure_executed unless @executed puts "executing!" @executed = true end end threads = 10.times.map { Thread.new { ensure_executed } } threads.each(&:join) $ ruby threads.rb executing! executing!
Chúng ta tạo ra 10 luồng để thực hiện phương thức ensure_executed và gọi join cho mỗi luồng, vì vậy luồng chính sẽ đợi cho đến khi tất cả các luồng khác kết thúc. Đoạn mã trả về executing! hai lần vì các luồng của chúng ta chia sẻ cùng một biến @executed. Hành động đọc (unless @executed) và đặt (@executed = true) không phải là hoạt động nguyên tử, có nghĩa là một khi chúng ta đọc được giá trị nó có thể bị thay đổi trong các luồng khác trước khi chúng ta đặt một giá trị mới. GIL and Blocking I/O Nhưng có GIL, cái mà không cho phép thực thi nhiều luồng cùng một lúc, không có nghĩa là nhiều luồng là không hữu ích. Thread giải phóng GIL khi nó chặn các hoạt động I/O như yêu cầu HTTP, truy vấn DB, viết/đọc từ đĩa và thậm chí là sleep:
# sleep.rb threads = 10.times.map do |i| Thread.new { sleep 1 } end threads.each(&:join) $ time ruby sleep.rb ruby sleep.rb 0.08s user 0.03s system 9% cpu 1.130 total
Như bạn thấy, tất cả 10 luồng sleep trong 1 giây và kết thúc gần như cùng một lúc. Khi một luồng kết thúc việc sleep, nó đã vượt qua sự thực hiện tới một luồng khác mà không chặn GIL. Ưu điểm:
- Sử dụng ít bộ nhớ hơn các tiến trình, có thể chạy được hàng ngàn luồng, create và destroy thực hiện nhanh chóng.
- Nhiều luồng rất hữu ích khi có chặn chậm hoạt động I/O.
- Có thể truy cập vào khu vực bộ nhớ từ các luồng khác nếu cần.
Nhược điểm:
- Yêu cầu sự đồng bộ hóa rất cẩn thận để tránh race-conditions, thông thường bằng cách sử dụng khóa nguyên thủy, điều này đôi khi có thể dẫn đến deadlocks. Tất cả làm cho nó khá khó khăn để viết, test và debug mã thread-safe.
- Bạn phải đảm bảo rằng không chỉ mã của bạn là thread-safe, mà rằng bất kỳ các phụ thuộc bạn đang sử dụng cũng phải là thread-safe.
- Bạn càng chia nhỏ thread, càng có nhiều thời gian và tài nguyên sẽ được chi cho việc chuyển ngữ cảnh và càng mất ít thời gian hơn để thực hiện công việc thực tế.
Ví dụ:
- Puma server - cho phép sử dụng nhiều luồng trong mỗi tiến trình (chế độ phân cụm). Tương tự như Unicorn nó tải trước ứng dụng và chia nhỏ tiến trình tổng thể, nơi mà mỗi tiến trình con có luồng riêng. Các luồng hoạt động tốt trong hầu hết các trường hợp bởi vì mỗi yêu cầu HTTP có thể được xử lý trong một luồng riêng biệt và chúng không chia sẻ nhiều tài nguyên giữa các yêu cầu.
- Sidekiq for background processing - chạy một tiến trình đơn với 25 luồng theo mặc định. Mỗi luồng xử lý một công việc ở cùng một thời điểm.
EventMachine
EventMachine (aka EM) là một gem được viết bằng C++ và Ruby. Nó cung cấp event-driver I/O sử dụng Reactor pattern và về cơ bản có thể làm cho mã Ruby của bạn trông giống như Node.js