Skip to content

Instantly share code, notes, and snippets.

@keithrbennett
Last active September 20, 2024 16:40
Show Gist options
  • Save keithrbennett/719af73894458a4378aa3e3a5cc9b70a to your computer and use it in GitHub Desktop.
Save keithrbennett/719af73894458a4378aa3e3a5cc9b70a to your computer and use it in GitHub Desktop.
Illustrates getting responses from multiple HTTPS resources in Ruby using conventional, thread, and fiber approaches.
#!/usr/bin/env ruby
# frozen_string_literal: true
# IMPORTANT: You may need to increase the number of available file handles available to this process.
# The number should be greater than the maximum number of requests you want to make, because each process
# opens at least 3 file handles (stdin, stdout, stderr), and other files may be opened during the program's
# run (e.g. for the logger).
# ulimit -n 300 && scripts/compare_request_methods.rb
require 'async/http/internet' # gem install async-http if necessary
require 'awesome_print'
require 'benchmark'
require 'json'
require 'logger'
require 'net/http'
require 'pry'
require 'yaml'
# These are the external gems that must be installed for the program to run.
REQUIRED_EXTERNAL_GEMS = %w[async-http awesome_print pry].freeze
Thread.abort_on_exception = true
Thread.report_on_exception = true
class Benchmarker
attr_reader :logger, :request_count_per_run, :sleep_seconds, :url
def initialize(request_count_per_run, sleep_seconds, logger)
@request_count_per_run = request_count_per_run
@sleep_seconds = sleep_seconds
@logger = logger
@url = "https://httpbin.org/delay/#{sleep_seconds}"
end
def get_responses_synchrously(count)
logger.debug("Getting #{count} responses synchronously")
count.times.with_object([]) do |_n, responses|
responses << Net::HTTP.get(URI(url))
end
end
def get_responses_using_threads(count)
logger.debug("Getting #{count} responses using threads")
threads = Array.new(count) do
Thread.new { Net::HTTP.get(URI(url)) }
end
threads.map(&:value)
end
def get_responses_using_fibers(count)
logger.debug("Getting #{count} responses using fibers")
responses = []
Async do
begin
internet = Async::HTTP::Internet.new(connection_limit: count)
count.times do
Async do
begin
response = internet.get(url)
responses << response
ensure
response&.finish
end
end
end
ensure
internet&.close
end
end.wait
responses
end
def self.call(request_count_per_run, sleep_seconds, logger)
self.new(request_count_per_run, sleep_seconds, logger).call
end
def output_results(results)
logger.info('-' * 60)
logger.info(results.to_json)
ap(results)
end
def call
logger.info("Starting run with #{request_count_per_run} requests each sleeping #{sleep_seconds} seconds")
results = {
time: Time.new.utc,
sleep: sleep_seconds,
count: request_count_per_run,
fibers: Benchmark.measure { get_responses_using_fibers(request_count_per_run) }.real,
threads: Benchmark.measure { get_responses_using_threads(request_count_per_run) }.real,
synchronous: Benchmark.measure { get_responses_synchrously(request_count_per_run) }.real,
}
output_results(results)
results
end
end
class Runner
def self.call() = new.call
def setup_logger
logger = Logger.new('compare_request_methods.log')
logger.level = Logger::INFO
logger.info('=' * 60)
logger
end
# Measure the time it takes to run a block of code
# @return [Array] The return value of the block and the duration in seconds
def time_it
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
return_value = yield
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
duration_in_seconds = end_time - start_time
[return_value, duration_in_seconds]
end
def write_results(logger, results, duration_secs)
timestamp = Time.now.utc.strftime('%Y-%m-%d-%H-%M-%S')
File.write("#{timestamp}-results.yaml", results.to_yaml)
logger.info(results.to_json)
puts("Done. Entire suite took #{duration_secs.round(2)} seconds.")
end
def call
counts = [1, 2, 4, 8, 16, 32, 64, 128, 256]
logger = setup_logger
puts "Starting run with counts: #{counts.join(', ')}"
results, duration_secs = time_it do
counts.map { |count| Benchmarker.call(count, 0.0001, logger) }
end
write_results(logger, results, duration_secs)
end
end
class GemChecker
def self.call(required_external_gems) = new.ensure_gems_available(required_external_gems)
def gem_exists?(gem_name)
begin
gem(gem_name)
true
rescue Gem::MissingSpecError
false
end
end
def find_missing_gems(required_gems)
required_gems.reject { |name| gem_exists?(name) }
end
def ensure_gems_available(required_external_gems)
missing_gems = find_missing_gems(required_external_gems)
if missing_gems.any?
puts "Need to install missing gems: #{missing_gems.join(', ')}"
exit(-1)
end
end
end
GemChecker.call(REQUIRED_EXTERNAL_GEMS)
Runner.call
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment