Faraday 是 Ruby 生态中广泛使用的 HTTP 客户端抽象层。通过切换 adapter,开发者可以在 Net::HTTP、Typhoeus、EventMachine HTTP 等底层实现之间灵活选择。其中 EMHttp adapter 依赖 EventMachine 的事件驱动模型,适合高并发非阻塞请求。然而这个 adapter 内部对 Singleton 的使用方式,在多线程环境下并不像表面看起来那么安全。

Singleton 在 Faraday::Adapter::EMHttp 中的实现方式
在 Faraday 的架构中,adapter 负责把统一的请求对象转换为底层 HTTP 库的调用。EMHttp adapter 的核心类是 Faraday::Adapter::EMHttp,它继承自 Faraday::Adapter,并混入了 Ruby 标准库的 Singleton 模块。混入 Singleton 后,构造函数被私有化,外部只能通过 instance 类方法获取唯一对象。这样做的好处是避免为每个 Faraday 连接对象重复创建 EventMachine 的连接句柄,因为 EM 的连接通常注册在全局 reactor 上,重复创建反而会浪费资源。
下面的代码展示了这一模块的典型用法:
require 'singleton'
require 'faraday'
require 'em-http-request'
module Faraday
class Adapter
class EMHttp < Faraday::Adapter
include Singleton
def initialize(app = nil, opts = {})
super(app)
@connections = {}
end
def call(env)
# 从 env 中提取请求信息,创建或复用 EM 连接
http = connection_for(env[:url])
http.send_request(env) do |client|
# 处理响应
end
end
private
def connection_for(url)
key = "#{url.scheme}://#{url.host}:#{url.port}"
@connections[key] ||= EventMachine::HttpRequest.new(url)
end
end
end
end
如果所有请求都发生在同一个线程中,这种单例设计确实能正常工作。EventMachine::HttpRequest 对象本身并不是线程安全的,但 EventMachine 的 reactor 通常运行在单独线程里,所有网络事件都通过事件循环串行分发。只要外部调用只从 reactor 线程发起,单例内部的 @connections 哈希就不会出现竞争条件。
问题在于,Faraday 的使用场景很少局限于单线程。Web 应用服务器如 Puma、Passenger 或后台任务框架 Sidekiq 都会启动多个工作线程。当这些线程同时调用 Faraday 客户端完成 HTTP 请求时,它们最终都会访问同一个 Faraday::Adapter::EMHttp.instance。此时单例的共享状态就暴露了隐患。
多线程环境下单例状态串扰的具体表现
要理解线程安全问题,需要区分两个层面:一是 Ruby 层的实例变量竞争,二是 EventMachine 回调闭包的上下文污染。第一个层面比较直观:@connections 是普通哈希,多个线程同时执行 @connections[key] ||= EventMachine::HttpRequest.new(url) 时,可能创建出多个重复连接。虽然 Ruby 的 GVL 能保护哈希操作本身不会崩溃,但业务逻辑上的重复创建仍然可能发生。更严重的是,如果某个线程正在遍历 @connections 进行清理或健康检查,另一个线程修改哈希,则可能抛出 ConcurrencyError 或产生不一致的视图。
第二个层面更隐蔽,也更容易被忽视。EventMachine 的请求是异步的,当多个线程共享同一个 EventMachine::HttpRequest 对象时,发起请求的线程会传入一个回调闭包。该闭包内部通常捕获了 Faraday 的 env 对象,用于在响应返回后填充结果。如果线程 A 发起的请求尚未完成,线程 B 又通过同一个连接发起了新请求,由于 EM 的底层连接可能复用,回调触发时无法保证 env 还是原来的那个。结果就是 A 请求拿到 B 响应,或者 B 请求的回调被 A 的状态污染。
下面用一个简化示例模拟这种串扰。假设两个线程几乎同时调用同一个 EMHttp 单例:
adapter = Faraday::Adapter::EMHttp.instance
t1 = Thread.new do
env = { url: URI('http://ipipp.com/a'), response: nil }
adapter.call(env)
env[:response]
end
t2 = Thread.new do
env = { url: URI('http://ipipp.com/b'), response: nil }
adapter.call(env)
env[:response]
end
puts t1.value.inspect
puts t2.value.inspect
示例中使用的是占位域名,实际运行时可以换成任意可访问的地址。即使替换后,运行结果也可能出现响应体错位。这是因为 EventMachine::HttpRequest 的连接复用逻辑与 Faraday 的请求上下文并非绑定关系。当 reactor 线程回调时,它只能看到最后登记的 env,无法区分请求来源。
改进方案:隔离、锁与连接池的权衡
解决上述问题并不意味着必须放弃单例模式。根据并发规模和业务特性,可以选择不同的策略。第一种是使用 Fiber local 或线程局部变量隔离 adapter 实例。Ruby 的 Thread.current[:faraday_emhttp_instance] 可以保证每个线程拥有独立的 adapter 对象,从根源上消除共享状态。但代价是每个线程都需要维护自己的 EventMachine 连接,连接数会随线程数量线性增长。
第二种思路是为共享单例的关键操作加互斥锁。在 call 方法入口和回调处理处使用 Mutex 同步,确保同一时间只有一个线程能通过该 adapter 发起请求。这种方案实现简单,但会严重降低并发吞吐量,尤其当请求耗时较长时,其他线程会全部阻塞在锁上。对于高并发场景,同步锁反而抵消了 EventMachine 非阻塞 I/O 的优势。
更务实的做法是引入连接池。将单例内部维护的 @connections 替换为带池化管理的结构,例如使用 connection_pool gem。每个线程从池中借用一个独立的连接对象,请求完成后归还。这样既避免了重复创建连接的开销,又隔离了不同线程之间的回调上下文。下面的代码展示了连接池的基本封装:
require 'connection_pool'
require 'singleton'
module Faraday
class Adapter
class EMHttpSafe < Faraday::Adapter
include Singleton
def initialize(app = nil, opts = {})
super(app)
@pool = ConnectionPool.new(size: 10, timeout: 5) do
EventMachine::HttpRequest.new
end
end
def call(env)
@pool.with do |http|
http.send_request(env) do |client|
env[:response] = client.response
end
end
end
end
end
end
在实际项目中,还可以结合事件循环的运行位置来决定是否需要加锁。如果 Faraday 调用总是发生在 EventMachine 的 reactor 线程内,例如在 EM.run 块里直接发请求,那么单例共享反而是安全的。需要警惕的是从普通工作线程发起的调用,此时必须使用线程局部实例或连接池隔离。此外,Faraday 的官方 issue 中曾有开发者建议直接移除 EMHttp adapter 的 Singleton 设计,改为每次连接创建独立 adapter 实例,但这会导致性能下降,因此社区更倾向于在调用方做好并发控制。
总结与选型建议
Faraday::Adapter::EMHttp 的单例模式本身是为减少 EventMachine 连接创建成本而引入的优化。在纯异步事件循环、单线程调度的场景下,它能够稳定工作。但生产环境往往混合了多线程 Web 服务器和后台作业,共享单例会带来重复连接、回调串扰和哈希并发修改等风险。
选择哪种方案取决于应用的调用特征。如果每个线程的请求频率较低且连接数可控,可以使用线程局部实例做完全隔离。如果请求并发量大且追求低延迟,连接池是最均衡的选择。如果不想引入额外依赖,也可以通过 Mutex 加锁快速止损,但要接受吞吐量下降。理解这些方案背后的权衡,才能在不同场景下做出准确的工程决策。
Ruby Faraday单例模式线程安全修改时间:2026-08-20 10:19:59