HTTP/2引入了多路复用,允许在单条TCP连接上并发传输多个请求和响应,但并发也会带来资源竞争。服务端和客户端需要一种机制来声明资源的相对重要性,依赖树配合权重就是解决这个问题的核心手段。权重调整算法决定了一个节点在父节点下获得的带宽比例,直接影响到页面关键资源的加载顺序。用Ruby实现这一算法,需要先把RFC 7540中描述的依赖关系转成可操作的数据结构,再处理动态调整时的各种细节。下面从模型定义开始,逐步给出完整实现。

HTTP/2依赖树与权重模型
HTTP/2的流(stream)之间可以建立父子依赖关系。每个流可以有一个父流,父流的权重决定了子流之间如何共享父流的可用资源。具体来说,一个父节点如果有多个子节点,每个子节点分配到的资源比例等于该子节点权重除以所有子节点权重之和。权重取值范围为1到256,默认值为16。例如父节点下有两个子流A和B,权重分别为12和4,那么A理论获得75%的资源,B获得25%。这种比例关系是HTTP/2优先级调度的基础。
依赖关系还可以设置独占标志(exclusive flag)。当某个流被设置为独占子节点时,它会成为父节点的唯一子节点,而父节点原来的其他子节点会变成这个新子节点的子节点。这种机制使得优先级调整可以同时完成提升优先级和重组依赖树两个动作。在实际场景中,浏览器经常利用独占标志把关键资源提升到最前面,同时把原来的一批资源整体挂到它下面,形成新的层级。
在实现中,我们需要跟踪每个流的父流ID、权重、是否独占,以及它的子流集合。Ruby的Hash和自定义类可以很好地表达这种树形结构。此外,还要注意根节点通常使用流ID为0的虚拟节点,所有没有显式依赖的流都直接挂在根节点下。动态调整意味着树结构频繁变化,因此增删改查的效率至关重要,但HTTP/2连接上的流数量一般有限,简单的数组遍历在大多数情况下已经足够。
Ruby实现核心数据结构与权重计算
在Ruby中,可以用一个Stream类表示每个流节点,包含stream_id、parent_id、weight、exclusive等属性,并用一个children数组存储子节点引用。为了避免在更新依赖时出现空引用,所有流都注册到一个以stream_id为键的哈希表中。下面是基础的类定义:
class Http2Stream
attr_accessor :stream_id, :parent_id, :weight, :exclusive
attr_reader :children
def initialize(stream_id, parent_id = 0, weight = 16, exclusive = false)
@stream_id = stream_id
@parent_id = parent_id
@weight = weight
@exclusive = exclusive
@children = []
end
def add_child(child)
@children << child unless @children.include?(child)
end
def remove_child(child)
@children.delete(child)
end
end
上面的代码中,add_child方法使用<<操作向children数组追加子节点,同时用include?去重,避免同一子节点被重复添加。remove_child则利用数组的delete方法直接移除。值得注意的是,我们使用attr_accessor开放了parent_id和weight的写权限,因为在动态调整时这些字段需要被修改。
接下来构建依赖树的管理类DependencyTree,它负责维护根节点和所有流的索引。当收到PRIORITY帧更新某个流时,需要先断开该流与旧父节点的关系,再根据新父节点和独占标志重新挂载。如果设置了独占标志,还要把新父节点的现有子节点迁移到该流下面。具体实现如下:
class DependencyTree
def initialize
@streams = {}
@root = Http2Stream.new(0, nil, 0, false)
@streams[0] = @root
end
def update_priority(stream_id, parent_id, weight, exclusive)
stream = @streams[stream_id] ||= Http2Stream.new(stream_id)
old_parent = @streams[stream.parent_id]
old_parent&.remove_child(stream)
new_parent = @streams[parent_id] ||= Http2Stream.new(parent_id)
if exclusive
new_parent.children.dup.each do |child|
new_parent.remove_child(child)
child.parent_id = stream_id
stream.add_child(child)
end
end
stream.parent_id = parent_id
stream.weight = weight
stream.exclusive = exclusive
new_parent.add_child(stream)
end
end
这段代码中有几个关键点。old_parent使用了安全导航运算符&.,在old_parent为nil时不会抛出异常,因为根节点的parent_id为nil而不是0,这一点需要额外注意。处理独占标志时,我们先通过dup复制子节点数组,避免在遍历过程中修改原数组导致迭代错误。每次迁移子节点时,都要更新子节点的parent_id并加入当前流的children列表。最后统一设置当前流的属性并挂到新父节点下。这样就能保证依赖树始终连通。
权重计算本身相对简单,只需获取父节点的所有子节点权重总和,然后用当前流权重除以总和即可得到相对比例。如果需要模拟调度顺序,可以依据相对权重进行加权轮询,后面的小节会展开讨论。这里的核心是保证每次更新后树的结构正确,而不是急于计算具体比例,因为调度器会实时根据当前树状态分配资源。
边界情况处理与动态调度优化
动态调整依赖关系时,一个严重的错误是产生循环依赖。例如流A依赖于流B,若此时又更新流B使其依赖于流A,就会形成环,导致调度算法陷入死循环。为了避免这种情况,在应用新的依赖之前需要检查新父节点是否在当前流的子孙路径上。一个简单的检测方法是从新父节点开始沿parent_id向上遍历,如果途中遇到了当前流,则说明会形成环。
def would_create_cycle?(stream_id, new_parent_id)
current_id = new_parent_id
while current_id != 0
return true if current_id == stream_id
node = @streams[current_id]
break unless node
current_id = node.parent_id
end
false
end
这个方法在每次update_priority之前调用,如果返回true就拒绝更新或调整为其他方案。虽然HTTP/2协议本身假设对端不会发送非法的优先级帧,但健壮的实现应该具备防御性检查。另外,独占迁移也可能间接产生环,因此建议对所有可能改变父子关系的操作都先做循环检测。
权重归一化是另一个容易忽视的问题。多个流同时调整权重时,某个流可能因为权重持续被杀低而长期得不到调度,产生饥饿。RFC 7540并未规定具体的调度算法,只定义了权重分配的比例原则。为了在Ruby中实现更平滑的调度,可以采用加权轮询算法。下面代码展示了一个简单的平滑加权轮询调度器,用于模拟选择下一个发送数据的流:
class WeightedScheduler
def initialize(streams)
@streams = streams
@current_weights = {}
streams.each { |s| @current_weights[s.stream_id] = 0 }
end
def next_stream
total = @streams.sum(&:weight)
best = nil
@streams.each do |s|
@current_weights[s.stream_id] += s.weight
if best.nil? || @current_weights[s.stream_id] > @current_weights[best.stream_id]
best = s
end
end
@current_weights[best.stream_id] -= total
best
end
end
这个调度器维护每个流的当前权重变量,每轮选择当前权重最大的流,选中后减去总权重。这样长时间运行后,各流被选中的次数比例会趋近于其权重比例,同时避免某个流完全饿死。在实际HTTP/2实现中,调度器还要考虑流量控制窗口、数据帧大小等因素,但权重层面的模拟已经能够验证调整算法的正确性。
性能方面,更新依赖关系的时间复杂度主要取决于子节点迁移和查找操作。使用哈希表索引流节点可以做到O(1)查找,迁移子节点时需要遍历父节点的children数组,复杂度为O(子节点数)。对于典型HTTP/2连接,这一开销完全可以接受。若需要处理成千上万个并发流,可以考虑用双向链表或堆结构优化,但Ruby实现的简洁性通常比极端性能更重要。
通过本文的逐步实现,可以看到HTTP/2依赖树权重调整算法并不复杂,但细节较多。正确维护父子关系、处理独占标志、防止循环依赖,以及用平滑调度避免饥饿,是保证优先级机制按预期工作的关键。Ruby的面向对象特性和丰富的集合操作让这些逻辑表达得清晰直观。将上述代码整合到一个完整的HTTP/2客户端或服务端框架中时,只需在收到PRIORITY帧时调用DependencyTree的update_priority方法,并在发送数据帧时用WeightedScheduler决定下一个流即可。