Ruby Concurrency và Parallelism

Trong quá trình phát triển ứng dụng, chắc hẳn các bạn đã có lần gặp phải các khái niệm về Concurrency (Đồng thời) và parallelism (song song) trong ruby code. Đôi lúc chúng ta hiểu lầm 2 khái niệm này giống nhau nhưng thực chất lại ngược lại. Trong quá trình tìm hiểu tôi có đọc được một bài báo về sự so sánh 2 khái niệm này cũng như kèm theo phân tích ví dụ cụ thể. Xin phép được chia sẻ lại cho mọi người cùng tìm hiểu.

Đi vào chi tiết, ruby concurrency (đồng thời) là khi 2 task có thể start, run và complete trong những khoảng thời gian trùng lặp với nhau. Tuy nhiên, điều đó không có nghĩa là chúng sẽ chạy cả hai task cùng một lúc (ví dụ: multiple threads trên một máy tính đơn nhân xử lý). Ngược lại, parallelism (song song) là khi 2 task chạy cùng lúc đúng theo nghĩa đen (ví dụ: multiple threads trên một máy tính đa nhân xử lý)

Điểm mấu chốt ở đây là các luồng và / hoặc quy trình concurrency sẽ không nhất thiết phải chạy song song. Chúng ta đi vào ví dụ cụ thể để tìm hiểu kĩ hơn sự khác nhau này.


Test Case

Ta tạo 1 class Mailer và thêm vào 1 hàm Fibonacci thay cho hàm sleep() để tăng thời gian xử lý mỗi request của CPU.

class Mailer

  def self.deliver(&block)
    mail = MailBuilder.new(&block).mail
    mail.send_mail
  end

  Mail = Struct.new(:from, :to, :subject, :body) do 
    def send_mail
      fib(30)
      puts "Email from: #{from}"
      puts "Email to  : #{to}"
      puts "Subject   : #{subject}"
      puts "Body      : #{body}"
    end

    def fib(n)
      n < 2 ? n : fib(n-1) + fib(n-2)
    end  
  end

  class MailBuilder
    def initialize(&block)
      @mail = Mail.new
      instance_eval(&block)
    end
    
    attr_reader :mail

    %w(from to subject body).each do |m|
      define_method(m) do |val|
        @mail.send("#{m}=", val)
      end
    end
  end
end

Sau đó chúng ta có thể gọi class Mailer này như sau để gửi mail:

Mailer.deliver do 
  from    "[email protected]"
  to      "[email protected]"
  subject "Threading and Forking"
  body    "Some content"
end

Mục tiêu so sánh chung để xây dựng benchmark ở bài test là thực hiện gửi thư 100 lần.

puts Benchmark.measure{
  100.times do |i|
    Mailer.deliver do 
      from    "eki_#{i}@eqbalq.com"
      to      "jill_#{i}@example.com"
      subject "Threading and Forking (#{i})"
      body    "Some content"
    end
  end
}

Kết qủa với vi xử lý 4 nhân và implementer MRI Ruby 2.0.0p353

user  CPU time       system CPU time      total               real
15.250000              0.020000                    15.270000    ( 15.304447)

Multiple Processes và Multithreading

Khi sử dụng Multiple Processes và Multithreading trong Ruby application đều có nhưng ưu điểm nhược điểm riêng

Processes Threads
Sử dụng nhiều tài nguyên bộ nhớ Sử dụng ít tài nguyên bộ nhớ
-------- --------
Nếu task cha chết trước khi task con hoàn thành, task con sẽ không bị hủy mà vẫn tồn tại trong hệ thống Tất cả các luồng sẽ bị hủy khi 1 luồng bị hủy
-------- --------
Khó khăn trong việc chia nhỏ các processes để chuyển context vì Hệ điều hành cần phải lưu và reload lại toàn bộ các tiến trình Threads đơn giản hơn vì giữa các thread chia sẻ không gian địa chỉ và bộ nhớ
-------- --------
Các processes được đưa vào một không gian bộ nhớ ảo riêng (process isolation) Threads chia sẻ cùng một bộ nhớ, vì vậy cần phải kiểm soát và giải quyết các vấn đề khi sử dụng chung ô nhớ
-------- --------
Yêu cầu sự giao tiếp giữa các process Có thể giao tiếp thông qua queues và shared memory
-------- --------
Chậm hơn khi create và destroy Nhanh hơn khi create và destroy
-------- --------
Dễ dàng code và debug Phức tạp hơn để code và debug

Ruby solutions sử dụng multiple processes:

Resque: Thư viện Redis-backed Ruby để tạo các background jobs, đặt các job vào multiple queues và xử lý sau. Unicorn: Một HTTP server cho các Rack applications cung cấp kết nối có độ trễ thấp, băng thông lớn và tận dụng được các tính năng hữu ích trong Unix/Unix-like kernels.

Ruby solutions sử dụng multithreading:

Sidekiq: Một framework cho Ruby với đầy dủ tính năng dùng cho background processing. Mục đích của nó là nằm tích hợp với bất kì ứng dụng rail nào và để đạt được hiệu suất cao hơn các giải pháp hiện có. Puma: Ruby web server built for concurrency. Thin: A very fast and simple Ruby web server.


Multiple Processes

Trong Ruby, fork() được gọi để tạo ra một bản sao của process hiện tại. Process mới này được lập lịch ở operating system level, vì vậy nó có thể được chạy đồng thời với process gốc, giống như bất kỳ process độc lập khác. (note: fork() là POSIX system call vì vậy không thể gọi khi chạy ruby trên window platform)

Chúng ta sẽ chạy test case, nhưng gọi fork() để sử dụng multiple processes:

puts Benchmark.measure{
  100.times do |i|
    fork do     
      Mailer.deliver do 
        from    "eki_#{i}@eqbalq.com"
        to      "jill_#{i}@example.com"
        subject "Threading and Forking (#{i})"
        body    "Some content"
      end
    end
  end
  Process.waitall
}

(Process.waitall để đợi tất cả các process con hoàn thành và trả về 1 mảng status của các process.)

Kết qủa với vi xử lý 4 nhân và implementer MRI Ruby 2.0.0p353

user  CPU time       system CPU time      total               real
0.000000                 0.030000                    27.000000   (  3.788106)

Kết quả là thời gian gửi mail đã nhanh hơn 5 lần.

Tuy giải quyết được vấn đề thời gian xử lý, nhưng nó lại tốn một lượng lớn bộ nhớ, vì vậy giá phải trả là khá đắt. Nếu ứng dụng của bạn sử dụng 20MB bộ nhớ, khi sử dụng pork() sẽ tiêu tốn thành 100 lần ~ 2Gb bộ nhớ!

Mặc dù multithreading phức tạp hơn nhưng điều đó nên được cân nhắc với việc sử dụng fork().


Ruby Multithreading

Và bây giờ chúng ta test với multithreading techniques

Multiple threads trong một single process sẽ có chi phí ít hơn đáng kể so với một số lượng process vì chúng chia sẻ không gian địa chỉ cũng như bộ nhớ:

threads = []

puts Benchmark.measure{
  100.times do |i|
    threads << Thread.new do     
      Mailer.deliver do 
        from    "eki_#{i}@eqbalq.com"
        to      "jill_#{i}@example.com"
        subject "Threading and Forking (#{i})"
        body    "Some content"
      end
    end
  end
  threads.map(&:join)
}

Kết qủa với vi xử lý 4 nhân và implementer MRI Ruby 2.0.0p353

user  CPU time       system CPU time      total               real
13.710000              0.040000                    13.750000    ( 13.740204)

Kết quả không khả quan hơn mấy mặc dù chúng ta đã sử dụng multithreading, đó là vì Global Interpreter Lock (GIL). Và vì GIL, MRI implementation không thực sự hỗ trợ threading.

Global Interpreter Lock là cơ chế được sử dụng trong ngôn ngữ thông dịch nhằm đồng bộ hóa quá trình thực thi các threads vì vậy chỉ có duy nhất 1 thread được thực thi tại một thời điểm. Một trình thông dịch sử dụng GIL sẽ chỉ cho pheps có duy nhất 1 thread được thực thi tại một thời điểm, dù là ở trên multi-core processor. Ruby MRI and CPython là 2 ví dụ trình thông dịch có GIL.

Vậy làm cách nào để sử dụng multithreading thực thi code? Chúng ta thay thế MRI bằng các Ruby implementation khác như JRuby, Rubinius, vì chúng không có GIL và có hỗ trợ parallel Ruby threading.

Và lần này là implement bằng JRuby (thay vì MRI):

user  CPU time       system CPU time      total               real
43.240000              0.140000                    43.380000     (  5.655000)

Threads cũng không phải miễn phí

Nâng cao hiệu suất bằng sử dụng multiple threads khiến chúng ta nghĩ rằng có thể tăng thêm số thread để tăng tốc độ lên thật nhanh và nhanh hơn nữa. Tuy nhiên thực thế lại không phải vậy. Số thread không phải vô hạn, chúng có giới hạn Chúng ta thực hiện test gửi mail 100 lần thay vì 10,000 lần:

threads = []

puts Benchmark.measure{
  10_000.times do |i|
    threads << Thread.new do     
      Mailer.deliver do 
        from    "eki_#{i}@eqbalq.com"
        to      "jill_#{i}@example.com"
        subject "Threading and Forking (#{i})"
        body    "Some content"
      end
    end
  end
  threads.map(&:join)
}

Kết quả:

can't create Thread: Resource temporarily unavailable (ThreadError)

Thread Pooling

Vì giới hạn về tài nguyên, chúng ta có 1 phương pháp thay thế hữu hiệu hơn: thread pooling

Thread pool là một nhóm các thread đã được khởi tạo, có thể tái sử dụng và có thể được gọi để thực thi nếu cần. Thread pools đặc biệt hữu ích với các tasks nhỏ nhưng có số lượng lớn. Điều này giúp ngăn chặn việc tạo ra một số lượng lớn hẳn các thread gây quá tải với hệ thống. Một key configuration cho thread pool thường là số lượng threads trong pool. Các threads có thể được khởi tạo cùng một lúc (khi pool được tạo) hoặc lazily (Nếu cần thiết đến khi số lượng thread trong pool đạt số lương max để tạo pool ). Khi pool được giao task để thực thi, nó gán task tới một thread đang idle. Nếu không có thread nào đang rảnh (số lượng thread max đã được tạo) nó chờ cho thread hoàn thành việc và idle sau đó gán task cho thread đó.

Quay trở lại với test case, chúng ta sử dụng Queue để thực thi như ví dụ đơn giản về thread pool:

require “./lib/mailer” require “benchmark” require ‘thread’

POOL_SIZE = 10

jobs = Queue.new

10_0000.times{|i| jobs.push i}

workers = (POOL_SIZE).times.map do
  Thread.new do
    begin      
      while x = jobs.pop(true)
        Mailer.deliver do 
          from    "eki_#{x}@eqbalq.com"
          to      "jill_#{x}@example.com"
          subject "Threading and Forking (#{x})"
          body    "Some content"
        end        
      end
    rescue ThreadError
    end
  end
end

workers.map(&:join)

Trong code chúng ta tạo 1 hàng đợi cho các job cần thực thi. Queue được sử dụng vì nó là thread-safe (nếu multiple threads truy cập tới nó chúng một thời điểm, nó sẽ vẫn duy trì tính nhất quán). Push IDs của mailers vào job queue và tạo pool với 10 thread. Trong mỗi thread, ta pop items từ jobs queue. Do đó, life-cycle của 1 thread được tiếp tục chờ cho tới khi task được đưa vào job Queue và được thực thi. Việc xử lý như trên sẽ không vấp phải bất kì lỗi nào, tuy nhiên code lại phức tạp, kể cả với ví dụ đơn giản như trên.

Celluloid

Vì có hệ sinh thái Ruby Gem đa dạng, rất nhiều multithreading phức tạp được đóng gói dễ dàng dưới dạng gem và dễ dàng để sử dụng, một ví dụ là Celluloid. Celluloid framework là cách đơn giản và dễ để thực thi, giúp chunsgt a có thể build các chương trình concurrent từ các đối tượng concurrent cũng dễ dàng như build các chương trình sequential từ các đối tượng sequential.

Sau đây là đoạn code ứng dụng multithreaded và sử dùngj Celluloid:

require "benchmark"
require "celluloid"

class MailWorker
  include Celluloid

  def send_email(id)
    Mailer.deliver do 
      from    "eki_#{id}@eqbalq.com"
      to      "jill_#{id}@example.com"
      subject "Threading and Forking (#{id})"
      body    "Some content"
    end       
  end
end

mailer_pool = MailWorker.pool(size: 10)

10_000.times do |i|
  mailer_pool.async.send_email(i)
end


Background Jobs

Một vài gem hỗ trợ background processing (lưu job vào hàng đợi và xử lý sau mà không ảnh hưởng tới thread hiện tại) ví dụ Sidekiq, Resque, Delayed Job, and Beanstalkd. Trong bài viết ta sử dung một ví dụ quen thuộc về sử dụng Sidekiq và Redis.

Cài đặt redis:

brew install redis
redis-server /usr/local/etc/redis.conf

Sử dụng Sidekiq:

require_relative "../lib/mailer"
require "sidekiq"

class MailWorker
  include Sidekiq::Worker
  
  def perform(id)
    Mailer.deliver do 
      from    "eki_#{id}@eqbalq.com"
      to      "jill_#{id}@example.com"
      subject "Threading and Forking (#{id})"
      body    "Some content"
    end  
  end
end

Chúng ta gọi Sidekiq mail_worker.rb file:

sidekiq  -r ./mail_worker.rb

Kết quả:

⇒  irb
>> require_relative "mail_worker"
=> true
>> 100.times{|i| MailWorker.perform_async(i)}
2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {}
=> 100

Kết luận

Một các tiếp cận đơn giản là sử dụng fork để chạy các process nhưng tốn tài nguyên và nguồn tài nguyên ấy lại có hạn. Các kĩ thuật khác để ứng dụng multithreading như thread pool nhưng phức tạp, tuy nhiên cũng có các gems đóng gói sẵn giúp việc sử dụng multithreading trong ứng dụng trở nên dễ dàng hơn, ví dụ như Celluloid. Một cách khác để xử lý các process tốn thời gian là sử dụng background processing. Có rất nhiều thư viện và dịch vụ cho phép bạn thực hiện background jobs trong ứng dụng. There are many libraries and services that allow you to implement background jobs in your applications. Vài công cụ phổ biến đó là database-backed job frameworks va message queues. Forking, threading, và background processing cũng là các thay thế tốt. Tùy thuộc và yêu cầu cũng như tính chất của ứng dụng mà các bạn có thể chọn cho hợp lý.

Nguồn: https://www.toptal.com/ruby/