当Ruby进程需要同时维护成百上千个TCP长连接时,为每个连接分配一个线程并不是最优解。线程栈内存通常占用数百KB到数MB,大量线程还会触发频繁的上下文切换,导致CPU缓存失效。更轻量的做法是采用事件驱动加协程:让主循环监听所有连接的IO状态,用Fiber保存每个连接的执行上下文。Ruby标准库中的IO.select恰好能胜任事件监听,而Fiber提供了协作式的挂起与恢复能力。这篇文章会从非阻塞IO的设置讲起,逐步构建一个基于IO.select和Fiber的协程网络调度器,并演示一个可运行的回显服务器。

非阻塞IO与IO.select的事件模型
在默认情况下,Ruby的套接字读写是阻塞的。例如调用 socket.read 时,如果对端没有发送数据,当前线程会被挂起直到数据到达或连接关闭。如果主循环中只有一个连接,这种阻塞会让所有其他连接得不到处理。解决思路是把套接字设置为非阻塞模式,调用读写方法时如果没有数据就立即返回 Errno::EAGAIN 或 Errno::EWOULDBLOCK 异常,而不是让线程停住。
要把一个套接字设为非阻塞,可以调用 socket.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK),或者使用 Ruby 的 Socket#accept_nonblock、Socket#recv_nonblock 和 Socket#write_nonblock 等方法。这些方法配合 IO.select 使用,就能实现单线程监听多个连接。
IO.select(read_array, write_array, error_array, timeout) 接受三个IO对象数组和一个可选的超时秒数。它会阻塞直到至少有一个IO准备就绪或超时。返回值是三个数组,分别表示可读、可写、有异常的IO。需要注意,IO.select 不保证每次都返回列表中的全部就绪对象,因此调度器需要遍历返回的数组,逐个处理。
下面是一个使用非阻塞套接字和 IO.select 的简单示例,它展示了如何监听服务端套接字和已接受的连接。
require 'socket'
require 'fcntl'
server = TCPServer.new('127.0.0.1', 9000)
server.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
clients = []
loop do
begin
readable, writable, errors = IO.select([server] + clients, nil, nil, 1)
next unless readable
readable.each do |io|
if io == server
client = server.accept_nonblock
client.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
clients << client
else
data = io.recv_nonblock(1024)
puts "收到: #{data}"
end
end
rescue IO::WaitReadable, Errno::EINTR
retry
end
end
Fiber调度器的核心设计
Ruby 的 Fiber 是一种轻量级协作式并发单元。它由 Fiber.new 创建,通过 resume 启动或恢复执行,在内部调用 Fiber.yield 时挂起并让出控制权。与线程不同,Fiber 的切换完全由程序员控制,切换成本非常低,不需要操作系统介入,也没有线程安全问题。
在事件驱动模型里,Fiber 非常适合保存每个连接的处理状态。传统回调式代码需要把每个逻辑拆成多个回调函数,状态管理复杂;而 Fiber 允许我们用同步风格写逻辑,遇到未就绪的IO时挂起当前 Fiber,等到 IO.select 报告就绪后再恢复执行。调度器维护两个哈希表,把等待读写的 IO 和对应的 Fiber 关联起来。
调度器主循环通常包含以下步骤:调用 IO.select 获取就绪的 IO 列表;遍历可读集合,从等待读表中取出对应 Fiber 并 resume;遍历可写集合做同样处理;如果发生错误则关闭连接并清理相关 Fiber。这里要注意,同一个 IO 可能同时出现在可读和可写集合中,调度器需要按顺序处理,避免重复恢复一个 Fiber。
class Scheduler
def initialize
@readable = {}
@writable = {}
@running = true
end
def wait_readable(io)
@readable[io] = Fiber.current
Fiber.yield
end
def wait_writable(io)
@writable[io] = Fiber.current
Fiber.yield
end
def run
while @running
readable, writable, _ = IO.select(@readable.keys, @writable.keys, nil, 1)
readable.each do |io|
fiber = @readable.delete(io)
fiber.resume if fiber
end
writable.each do |io|
fiber = @writable.delete(io)
fiber.resume if fiber
end
end
end
def stop
@running = false
end
end
在这个调度器中,Fiber 执行到等待读写时调用 wait_readable 或 wait_writable,把自己放入对应哈希表并 Fiber.yield 暂停。主循环每次通过 IO.select 得到就绪集合后,从哈希表中取出 Fiber 对象,调用 resume 让逻辑继续。整个过程在单个线程内完成,没有锁竞争。
完整协程回显服务器实现
有了上面的调度器,我们可以写出一个完整的 TCP 回显服务器。服务端套接字设为非阻塞,主 Fiber 负责接受连接,每个连接由独立的 Fiber 处理。读写操作均使用非阻塞方法,遇到 IO::WaitReadable 或 IO::WaitWritable 时挂起当前 Fiber,把控制权交还给调度器。
下面的代码实现了这个服务器,同时处理了部分写入和异常关闭。写数据时,如果内核缓冲区满,write_nonblock 可能只写入部分字节或抛出 IO::WaitWritable,需要循环写入直到全部数据发送完成。读数据时一旦返回空字符串,说明对端已关闭连接,应该清理资源。
require 'socket'
require 'fcntl'
class FiberScheduler
def initialize
@readable = {}
@writable = {}
@running = true
end
def wait_readable(io)
@readable[io] = Fiber.current
Fiber.yield
end
def wait_writable(io)
@writable[io] = Fiber.current
Fiber.yield
end
def run
while @running
readable, writable, _ = IO.select(@readable.keys, @writable.keys, nil, 1)
readable.each do |io|
fiber = @readable.delete(io)
fiber.resume if fiber
end
writable.each do |io|
fiber = @writable.delete(io)
fiber.resume if fiber
end
end
end
def stop
@running = false
end
end
$scheduler = FiberScheduler.new
def handle_client(client)
loop do
begin
data = client.recv_nonblock(1024)
if data.empty?
client.close
break
end
total_written = 0
while total_written < data.bytesize
begin
written = client.write_nonblock(data.byteslice(total_written..-1))
total_written += written
rescue IO::WaitWritable
$scheduler.wait_writable(client)
end
end
rescue IO::WaitReadable
$scheduler.wait_readable(client)
rescue EOFError, Errno::ECONNRESET
client.close
break
end
end
end
server = TCPServer.new('127.0.0.1', 9000)
server.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
accept_fiber = Fiber.new do
loop do
begin
client = server.accept_nonblock
client.fcntl(Fcntl::F_SETFL, Fcntl::O_NONBLOCK)
Fiber.new { handle_client(client) }.resume
rescue IO::WaitReadable
$scheduler.wait_readable(server)
end
end
end
accept_fiber.resume
$scheduler.run
这个服务器可以启动后用 nc 127.0.0.1 9000 测试,发送任意文本都会原样返回。它完全运行在单线程中,却能同时服务大量连接。如果某个连接处理过程中抛出未捕获异常,整个调度器会终止。生产环境需要为每个连接的 Fiber 添加 rescue 捕获,记录日志并确保连接被关闭,避免单个坏连接拖垮整个服务。
超时、背压与生产环境注意点
IO.select 的最后一个参数可以设置超时。在上面的示例中使用了 1 秒超时,这样调度器每隔一秒就会醒过来一次,即使没有就绪事件也能执行一些后台任务,例如清理空闲连接、更新统计信息。如果希望更低的延迟,可以将超时设得更短或直接设为 nil 无限等待,但 nil 会导致无法响应信号,需要配合自管道或其他唤醒机制。
背压问题是网络服务必须面对的。如果客户端读取速度很慢,服务端不断向其写入数据会导致内核缓冲区填满,进而让 write_nonblock 持续触发 IO::WaitWritable。调度器会把该连接的 Fiber 挂起,直到可写事件到达,这样不会阻塞其他连接。但服务端自身的发送队列仍可能无限增长,因此需要设置一个应用层发送缓冲上限,超过上限时主动断开连接或暂停读取。
与线程模型相比,基于 IO.select 和 Fiber 的方案在单机可维护的连接数上通常高出一个量级,因为每个连接只占用一个 Fiber 和少量内存,切换成本极低。但它的劣势是代码逻辑必须遵守协作式约束,任何阻塞调用都会冻结整个事件循环。因此,像 DNS 查询、文件系统 IO 等操作也应该使用非阻塞方式或拆到独立线程中处理,避免影响整体吞吐。
另一个常见误区是认为 IO.select 能精确返回全部就绪事件。实际上它在不同平台上的表现有差异,也可能有虚假唤醒。调度器必须能容忍重复处理同一 IO,并在 Fiber 恢复后重新检查状态。比如一个连接可读事件被报告两次,但第一次已经读完了数据,第二次 recv_nonblock 会返回 IO::WaitReadable,只需再次挂起即可。
Ruby IO.selectFiber调度器非阻塞IO修改时间:2026-09-27 02:57:48