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 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 😃 EM sử dụng select() của Linux trong quá trình chạy qua các vòng lặp để kiểm tra đầu vào mới trên các file description. Một lý do phổ biến để sử dụng EventMachine là trường hợp khi bạn có rất nhiều hoạt động I/O và bạn không muốn đối phó với các hoạt động này bằng tay. Các chuỗi xử lý thủ công có thể khó khăn hoặc thường quá đắt so với quan điểm sử dụng tài nguyên. Với EM, bạn có thể xử lý nhiều yêu cầu HTTP với một luồng đơn theo mặc định.

# em.rb
EM.run do
  EM.add_timer(1) do
    puts 'sleeping...'
    EM.system('sleep 1') { puts "woke up!" }
    puts 'continuing...'
  end
  EM.add_timer(3) { EM.stop }
end
$ ruby em.rb
sleeping...
continuing...
woke up!

Ví dụ trên cho thấy làm thế nào để chạy mã không đồng bộ bằng cách thực thi EM.system (hoạt động I/O) và chạy một block như một callback cái mà sẽ được thực hiện khi lệnh hệ thống đã hoàn tất. Ưu điểm:

  • Có thể đạt được hiệu suất tuyệt vời cho các ứng dụng được nối mạng chậm như máy chủ web và proxy với một luồng duy nhất.
  • Nó cho phép bạn tránh các chương trình đa luồng phức tạp, những bất lợi được mô tả ở trên.

Nhược điểm:

  • Mỗi toán tử I/O nên được hỗ trợ EM không đồng bộ. Điều này có nghĩa là bạn nên sử dụng các phiên bản cụ thể của hệ thống, DB adapter, HTTP client ... có thể dẫn đến các phiên bản vá lỗi monkey-patched, thiếu hỗ trợ và các tùy chọn hạn chế.
  • Công việc thực hiện trong luồng chính cho mỗi vòng lặp nên nhỏ. Ngoài ra, có thể sử dụng Defer, nó thực hiện mã trong các luồng riêng biệt từ thread pool, tuy nhiên, nó có thể dẫn đến các vấn đề đa luồng thảo luận trước đó.
  • Khó để thực hiện các hệ thống phức tạp vì các lỗi xử lý và callbacks. Callback Hell cũng có thể có trong Ruby, nhưng nó có thể được ngăn chặn bằng Fibres.
  • EventMachine chính là một sự phụ thuộc rất lớn: 17K LOC (dòng mã) trong Ruby và 10K LOC trong C ++.

Ví dụ:

  • Goliath - một máy chủ không đồng bộ đơn luồng.
  • AMQP - RabbitMQ client. Tuy nhiên, người tạo ra gem này đề xuất sử dụng non-EM-based phiên bản Bunny. Lưu ý rằng các công cụ di chuyển sang cài đặt EM-less là một xu hướng chung. Ví dụ: người sáng tạo ActionCable đã quyết định sử dụng nio4r cấp thấp, người sáng tạo sinatra-synchrony viết lại nó với Celluloid, v.v.

Fibers

Fibers là các primitives nhẹ trong thư viện chuẩn Ruby, có thể được tạm dừng, tiếp tục và lập lịch bằng tay. Chúng khá giống với ES6 Generator nếu bạn đã quen thuộc với JavaScript. Có thể chạy hàng chục nghìn Fibers trong một luồng đơn. Thông thường, Fibers được sử dụng với EventMachine để tránh callback và làm cho code nhìn đồng bộ. Vì vậy, đoạn code sau đây:

EventMachine.run do
  page = EM::HttpRequest.new('https://google.ca/').get       
  page.errback { puts "Google is down" }
  page.callback {
    url = 'https://google.ca/search?q=universe.com'
    about = EM::HttpRequest.new(url).get
    about.errback  { ... }
    about.callback { ... }     
  }
end

Có thể được viết lại như sau:

EventMachine.run do
  Fiber.new {
    page = http_get('http://www.google.com/')     
    if page.response_header.status == 200
      about = http_get('https://google.ca/search?q=universe.com') 
      # ... 
    else 
      puts "Google is down"
    end  
  }.resume 
end
def http_get(url)
  current_fiber = Fiber.current
  http = EM::HttpRequest.new(url).get    
  http.callback { current_fiber.resume(http) }   
  http.errback  { current_fiber.resume(http) }    
  Fiber.yield
end

Vì vậy, về cơ bản, Fiber#yield trở lại ngữ cảnh đã khôi phục lại Fiber và trả về giá trị đã được truyền cho Fiber#resume. Ưu điểm:

  • Fibers cho phép bạn đơn giản hóa mã không đồng bộ bằng cách thay thế các callbacks lồng nhau.

Nhược điểm:

  • Không thực sự giải quyết các vấn đề concurrency.
  • Chúng ít khi được sử dụng trực tiếp trong code cấp ứng dụng.

Ví dụ:

  • Em-synchrony - một thư viện, được viết bởi Ilya Grigorik, một kỹ sư tại Google, giúp tích hợp EventMachine với Fibres cho các client khác nhau như MySQL2, Mongo, Memcached ...

Kết luận

Hãy chọn một mô hình concurrency tùy thuộc vào nhu cầu của bạn. Ví dụ, cần phải chạy CPU và bộ nhớ mã chuyên sâu và có đủ nguồn lực - sử dụng processes. Phải thực hiện nhiều hoạt động I/O như yêu cầu HTTP - sử dụng threads. Cần phải mở rộng quy mô tối đa - sử dụng EventMachine.

Tham khảo: https://engineering.universe.com/introduction-to-concurrency-models-with-ruby-part-i-550d0dbb970