Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Mastering Ruby Concurrency: Tools, Techniques, Và Future Directions

Concurrency và parallelism quan trọng hơn bao giờ hết cho Ruby developers. Chúng làm apps faster, utilizing hardware fullest potential. Bài này explore tools available cho Rubyists và Ruby promises deliver soon.

Không phải ai cũng dùng concurrency trực tiếp, nhưng tất cả dùng indirect qua tools như Sidekiq. Hiểu Ruby concurrency giúp build own solutions và understand/troubleshoot existing ones.

Nhưng trước hãy step back xem big picture.

Concurrency vs. Parallelism

Terms dùng loosely nhưng distinct meanings.

  • Concurrency: Nghệ thuật làm nhiều tasks, one at a time. Switch nhanh giữa chúng, appear simultaneous cho user.
  • Parallelism: Làm nhiều tasks literally same time. Không chỉ appear, chúng thực sự simultaneous.

Concurrency thường dùng cho IO heavy apps. Ví dụ web app regularly interact database hoặc nhiều network requests. Dùng concurrency, keep app responsive даже while waiting database respond.

Điều này possible vì Ruby VM cho phép threads khác chạy while one waiting during IO. Dù program làm dozens requests, dùng concurrency, requests made virtually same time.

Parallelism, other hand, hiện không supported by Ruby.

Tại Sao Không Parallelism Trong Ruby?

Hiện không cách achieve parallelism trong single Ruby process dùng default Ruby implementation (MRI/CRuby). Ruby VM enforce lock (GVL/Global VM Lock) ngăn multiple threads chạy Ruby code same time. Lock tồn tại protect internal VM state và prevent scenarios crash VM. Không great spot, nhưng hope not lost: Ruby 3 coming soon promise solve bằng concept codenamed Guild (giải thích cuối bài).

Threads

Threads là concurrency workhorse của Ruby. Hiểu better cách dùng và pitfalls, give example. Build program consume API store results datastore dùng concurrency.

Trước build API client, cần API. Dưới là tiny API accept number respond plain text even/odd. Syntax strange? Đừng lo. Không liên quan concurrency. Chỉ tool dùng.

app = Proc.new do |env|
  sleep 0.05
  qs = env['QUERY_STRING']
  number = Integer(qs.match(/number=(\d+)/)[1])
  [
    '200',
    { 'Content-Type' => 'text/plain' },
    [number.even? ? 'even' : 'odd']
  ]
end
run app

Chạy web app cần rack gem, execute rackup config.ru.

Cần mock datastore. Class simulate key-value database:

class Datastore
  # ... accessors và initialization omitted ...
  def read(key)
    data[key]
  end
  def write(key, value)
    data[key] = value
  end
end

Implementation concurrent solution. Method run concurrently fetch 1,000 records store datastore.

class ThreadPoweredIntegration
  # ... accessors và initialization ...
  def run
    threads = []
    (1..1000).each_slice(250) do |subset|
      threads << Thread.new do
        subset.each do |number|
          uri = 'https://localhost:9292/' \
            "even_or_odd?number=#{number}"
          status, body = AdHocHTTP.new(uri).blocking_get
          handle_response(status, body)
          rescue Errno::ETIMEDOUT
            retry # Try again nếu server timeout
        end
      end
    end
    threads.each(&:join)
  end
  # ...
end

Tạo 4 threads, mỗi process 250 records. Strategy này không overwhelm third-party API hoặc own systems.

Requests made concurrently multiple threads, total execution fraction time của sequential. Mỗi thread inactive moments during HTTP request steps, Ruby VM cho different thread run. Tại sao implementation nhanh hơn sequential.

Class AdHocHTTP là straightforward HTTP client implement cho bài này focus differences giữa threads và fibers. Beyond scope discuss implementation, check out nếu curious.

Cuối inner loop, handle server response. Method handle_response:

# ... inside ThreadPoweredIntegration class ...
attr_reader :ds
def initialize
  @ds = Datastore.new(even: 0, odd: 0)
end
# ...
def handle_response(status, body)
  return if status != '200'
  key = body.to_sym
  curr_count = ds.read(key)
  ds.write(key, curr_count + 1)
end

Method trông OK? Run xem datastore:

{ even: 497, odd: 489 }

Strange, giữa 1-1000 có 500 even, 500 odd. Next section hiểu bug và explore cách fix.

Threads And Data Races: The Devil Is In The Details

Threads cho IO heavy programs chạy faster, nhưng tough to get right. Error results trên caused by race condition in handle_response. Race condition khi two threads manipulate same data.

Operating trên shared resource (ds datastore object), careful với non-atomic operations. Notice read from datastore rồi — second statement — write incremented count. Problematic vì thread may stop after read before write. Another thread run increment key we care, original thread resume write out-of-date count.

Mitigate dangers threads: dùng higher-level abstractions structure concurrent implementation. Check concurrent-ruby gem cho patterns và safer thread-powered program.

Nhiều ways fix data race. Simple: dùng mutex. Synchronization mechanism enforce one-at-a-time access code segment. Implementation fixed by mutex:

# ... inside ThreadPoweredIntegration class ...
def initialize
  # ...
  @semaphore = Mutex.new
end
# ...
def handle_response(status, body)
  return if status != '200'
  key = body.to_sym
  semaphore.synchronize do
    curr_count = ds.read(key)
    ds.write(key, curr_count + 1)
  end
end

Plan dùng threads trong Rails app, official guide Threading and Code Execution in Rails must-read. Fail follow guidelines → unpleasant consequences, như leaking database connections.

Run corrected implementation, expected result:

{ even: 500, odd: 500 }

Thay mutex, cũng eliminate data races bằng drop threads, reach another concurrency tool: Fiber. Next section look at Fiber mechanism improve IO-heavy apps performance.

Fiber: A Slender Tool For Concurrency

Ruby Fibers cho cooperative concurrency trong single thread. Nghĩa là fibers không preempted, program itself must do scheduling. Vì programmer control khi fibers start/stop, easier avoid race conditions.

Khác threads, fibers không grant better performance khi IO happens. May mắn Ruby cung cấp asynchronous reads/writes qua IO class. Dùng async methods prevent IO operations block fiber-based code.

Same Scenario, Now With Fibers

Same example, nhưng dùng fibers combined với async capabilities của Ruby IO class. Beyond scope explain all async IO details. Still touch essential parts, look at implementation của AdHocHTTP methods (same client threaded solution) nếu curious.

Look at run method fiber-powered implementation:

class FiberPoweredIntegration
  # ... accessors và initialization ...
  def run
    (1..1000).each_slice(250) do |subset|
      Fiber.new do
        subset.each do |number|
          uri = 'https://127.0.0.1:9292/' \
            "even_or_odd?number=#{number}"
          client = AdHocHTTP.new(uri)
          socket = client.init_non_blocking_get
          yield_if_waiting(client, socket, :connect_non_blocking_get)
          yield_if_waiting(client, socket, :write_non_blocking_get)
          status, body = yield_if_waiting(client, socket, :read_non_blocking_get)
          handle_response(status, body)
          ensure
            client&.close_non_blocking_get
        end
      end.resume
    end
    wait_all_requests
  end
  # ...
end

Tạo fiber cho mỗi subset numbers check even/odd.

Loop numbers, call yield_if_waiting. Method responsible stop current fiber cho another resume.

Notice sau create fiber, call resume. Cause fiber start running. Call resume immediately sau creation, start HTTP requests trước khi main loop 1-1000 finishes.

Cuối run, call wait_all_requests. Method select fibers ready to run và guarantee make all intended requests. Xem last segment section này.

Xem yield_if_waiting detail:

# ... inside FiberPoweredIntegration ...
def initialize
  @ds = Datastore.new(even: 0, odd: 0)
  @waiting = { wait_readable: {}, wait_writable: {} }
end
# ...
def yield_if_waiting(client, socket, operation)
  res_or_status = client.send(operation)
  is_waiting = [:wait_readable, :wait_writable].include?(res_or_status)
  return res_or_status unless is_waiting
  waiting[res_or_status][socket] = Fiber.current
  Fiber.yield
  waiting[res_or_status].delete(socket)
  yield_if_waiting(client, socket, operation)
  rescue Errno::ETIMEDOUT
    retry # Try again nếu server timeout
end

Try perform operation (connect, read, write) dùng client. Two primary outcomes:

  • Success: Return.
  • Receive symbol: Means phải wait.

Làm sao "wait"?

  1. Tạo checkpoint: add socket + current fiber vào instance variable waiting (Hash).
  2. Store pair trong collection hold IO waiting reading/writing (thấy tại sao important lúc sau), depend trên result từ client.
  3. Stop current fiber execution, cho another run. Paused fiber resume work sau network socket ready. IO operation retry (succeed lần này).

Mọi Ruby program chạy trong fiber là part của thread (everything inside process). Consequence: create first fiber, run, yield → resume execution central part program.

Hiểu mechanism yield khi fiber waiting IO, explore last bit cần cho fiber-powered implementation.

def wait_all_requests
  while(waiting[:wait_readable].any? || waiting[:wait_writable].any?)
    ready_to_read, ready_to_write = IO.select(waiting[:wait_readable].keys, waiting[:wait_writable].keys)
    ready_to_read.each do |socket|
      waiting[:wait_readable][socket].resume
    end
    ready_to_write.each do |socket|
      waiting[:wait_writable][socket].resume
    end
  end
end

Chief idea: wait (loop) until all pending IO operations complete.

Dùng IO.select. Accept two collections pending IO objects: reading và writing. Return IO objects finished job. Vì associated IO objects với fibers responsible, simple resume those fibers.

Repeat steps until all requests fired và completed.

The Grand Finale: Comparable Performance, No Need For Locks

handle_response method same as initially used threaded code (version without mutex). Tuy nhiên, vì all fibers run same thread, no data races. Run code, expected result:

{ even: 500, odd: 500 }

Probably không muốn deal fiber switching business mỗi lần leverage async IO. May mắn gems abstract work, làm fibers usage developer không cần think about. Check async project great start.

Fibers Shine When High Scalability Is A Must

Dù reap benefits virtually eliminate data race risks even small scale, fibers great tool khi high scalability needed. Fibers lightweight hơn threads. Same resources, creating threads overwhelm system sooner fibers. Excellent exploration: presentation The Journey to One Million by Ruby Core Team's Samuel Williams.

Guild - Parallel Programming In Ruby

Seen two useful tools concurrency. Neither improve pure computations performance. Cần true parallelism, không exist Ruby (consider MRI).

May change Ruby 3 với feature "Guilds." Details hazy, following sections look at work-in-progress feature promise allow parallelism Ruby.

How Guilds Might Work

Significant pain implementing concurrent/parallel solutions: shared memory. Threads section saw easy slip write code seems innocuous but subtle bugs.

Koichi Sasada — Ruby Core Team member heading Guild development — hard at work designing solution tackle dangers sharing memory among threads. Presentation RubyConf 2018, explains using guilds won't simply share mutable objects. Main idea: prevent data races bằng chỉ allow immutable objects shared between guilds.

Specialized data structures introduced allow shared memory between guilds, details not fully fleshed out. API allow objects copied/moved between guilds, plus safeguard impede object referenced after moved different guild.

Using Guilds To Explore Common Scenario

Nhiều situations wish speed up computations parallel. Imagine calculate average và mean same dataset.

Example below show might do với guilds. Keep in mind code không currently work và might never work, even after guilds released.

# Frozen array numeric values = immutable object
dataset = [88, 43, 37, 85, 84, 38, 13, 84, 17, 87].freeze

# Overhead guilds probably considerable, chỉ parallelize khi dataset large / lots operations
g1 = Guild.new do
  mean = dataset.reduce(:+).fdiv(dataset.length)
  Guild.send_to(:mean, Guild.parent)
end

g2 = Guild.new do
  median = Median.calculate(dataset.sort)
  Guild.send_to(:median, Guild.parent)
end

results = {}
# Every Ruby program run inside main guild; receive messages in main section
Guild.receive(:mean, :median) do |tag, result|
  results[tag] = result
end

Summing It Up

Concurrency và parallelism không phải main strengths Ruby, nhưng even this department language offer tools probably good enough most use cases. Ruby 3 coming, things considerably better với Guild primitive. Ý kiến tôi, Ruby still very suitable choice many situations, community clearly hard at work making language better. Keep ear to ground cho what's coming!