Last active
September 20, 2024 16:40
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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