跳至正文
老丹的足迹 —— 代码写给机器,游记写给自己,感悟写给时间
老丹的足迹 老丹的足迹
老丹的足迹 老丹的足迹
  • 首页
  • 示例页面
  • 首页
  • 示例页面
老丹的足迹 老丹的足迹
老丹的足迹 老丹的足迹
  • 首页
  • 示例页面
  • 首页
  • 示例页面

令牌桶算法详解:从原理到工程实践

摘要

令牌桶(Token Bucket)是网络流量控制领域最经典、应用最广泛的算法之一。它通过一个简洁的数学模型,优雅地解决了”如何在允许突发流量的前提下保证长期平均速率”这一核心问题。本文将从算法原理、数学本质、工程实现到实际应用场景,全面剖析令牌桶算法,帮助读者不仅”会用”,更能”懂其所以然”。

关键词:令牌桶、流量控制、速率限制、拥塞控制、QoS

一、引言:为什么需要令牌桶

在计算机网络中,数据发送面临一个永恒的矛盾:

  • 发送太慢:浪费宝贵的带宽资源,传输效率低下
  • 发送太快:可能压垮接收端缓冲区,导致网络拥塞、丢包、重传,反而进一步恶化性能

理想的传输策略应该是:在绝大多数时间保持稳定的平均速率,但在网络空闲或数据积压时,允许短时间的”突发”传输以充分利用带宽。这个看似矛盾的需求,正是令牌桶算法的设计出发点。

令牌桶算法最早可以追溯到1986年,由Jonathan S. Turner在论文《New directions in communications(or which way to the information age?)》中提出,随后被广泛应用于各类网络设备和服务器的流量控制中,包括:

  • 路由器的流量整形(Traffic Shaping):限制出口流量的速率
  • 网络接口的QoS(Quality of Service):为不同业务分配带宽
  • API网关的限流(Rate Limiting):防止服务被突发请求冲垮
  • CDN和云服务的带宽控制:按照套餐限制用户带宽
  • 多媒体流式传输:平滑视频码率,防止缓冲区溢出

二、算法核心原理

2.1 直观理解

令牌桶可以用一个生活中的比喻来理解:

想象你有一个特殊的”水桶”。一个”水龙头”以恒定的速率往桶里滴水(生成令牌),桶的容量有限,水满了就会溢出(令牌堆积上限)。每次你要发送数据时,必须先从这个桶里”舀走”一定量的水(消耗令牌)。如果桶里的水不够,你就得等待(阻塞策略),或者放弃这次发送(非阻塞策略)。

在这个比喻中,每一滴水就是一个”令牌”(Token),代表”发送一定量数据的许可”。你有多少令牌,就能发送多少数据。

2.2 数学模型

令牌桶算法可以用三个参数精确描述:

  • 平均速率(r):令牌生成的速率,即长期平均发送速率,单位通常是”令牌/秒”
  • 桶容量(b):桶最多能容纳的令牌数,即最大突发流量,单位是”令牌”
  • 当前令牌数(T(t)):时刻 t 桶中实际拥有的令牌数

算法的核心状态更新方程如下:

T(t) = min(b, T(t₀) + r × (t – t₀) – C)

其中:

  • t₀ 是上次更新令牌的时刻
  • C 是从 t₀ 到 t 之间消耗的令牌总数
  • min 确保令牌数不超过桶容量 b

当 T(t) ≥ 需要消耗的令牌数 时,发送操作被允许;否则被拒绝或阻塞。

2.3 算法的行为特征

令牌桶算法在实际运行中呈现出规律的”充放电”动态特征:

在系统空闲或发送速率较低的时期,令牌持续积累,直到达到桶容量上限。当大量数据到达时,系统可以”突发”地消耗积攒的令牌,以高于平均速率的速度发送数据。突发结束后,令牌数降到低点,系统再次进入积累期,发送速率回落到平均水平。这一过程循环往复。

这种”积累-突发-恢复”的周期性行为,正是令牌桶能够在保证长期平均速率的同时,充分利用网络带宽的根本原因。

需要特别指出的是,令牌桶的”突发”能力由桶容量 b 决定:b 越大,允许的瞬时突发流量就越大,对带宽的利用率越高;但 b 过大也可能冲击接收端或网络链路,需要根据实际场景权衡。

2.4 令牌桶与漏桶的区别

很多人容易混淆”令牌桶”和”漏桶”(Leaky Bucket)。虽然名称相似,但行为有本质区别:

令牌桶(Token Bucket):允许突发输出——只要有足够的令牌,数据可以以任意高速率发送。输出速率是”有上限的尽力而为”。

漏桶(Leaky Bucket):强制平滑输出——无论输入速率多高,输出速率被强制固定在恒定值。输出速率是”硬性固定的”。

简单说:漏桶强制输出平滑,令牌桶允许突发但长期平均受限。在网络传输中,令牌桶更受欢迎,因为网络天然需要应对数据包的突发到达,完全平滑的输出反而可能浪费带宽。

三、令牌数变化曲线

为了直观理解令牌桶的动态行为,我们可以用 Python 绘制令牌数随时间变化的曲线图。运行下面的代码,会生成一张清晰的波形图,展示”积累→突发→恢复→再突发”的完整周期。

import matplotlib.pyplot as plt
import numpy as np

# 设置中文字体
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

# 模拟参数
BURST_CAP = 10.0       # 桶容量
RATE = 0.8             # 令牌生成速率
DT = 0.1               # 时间步长
TOTAL_TIME = 100       # 总模拟时间

t = np.arange(0, TOTAL_TIME, DT)
tokens = np.zeros_like(t)
current = BURST_CAP * 0.5  # 初始令牌数(半满)

for i in range(len(t)):
    # 时间流逝,补充令牌(不能超过桶容量)
    current = min(BURST_CAP, current + RATE * DT)
    
    # 模拟突发消耗(在特定时间段内高速消耗令牌)
    if 20 < t[i] < 25:
        current -= 1.2 * DT
    elif 55 < t[i] < 60:
        current -= 1.5 * DT
    elif 80 < t[i] < 83:
        current -= 2.0 * DT
    
    # 确保不低于0
    current = max(0, current)
    tokens[i] = current

# 创建图形
fig, ax = plt.subplots(figsize=(13, 6))

# 绘制令牌数曲线
ax.plot(t, tokens, linewidth=2.5, color='#1f77b4', label='当前令牌数')

# 标注桶容量
ax.axhline(y=BURST_CAP, color='#ff7f0e', linestyle='--', linewidth=2, 
           label=f'桶容量 B = {BURST_CAP}')

# 标注平均速率线(示意长期平均水平)
avg_line = RATE * 10
ax.axhline(y=avg_line, color='#2ca02c', linestyle=':', linewidth=2, 
           label=f'长期平均速率 (示意)')

# 标注突发区域(红色背景)
ax.axvspan(20, 25, alpha=0.15, color='red', label='突发发送期')
ax.axvspan(55, 60, alpha=0.15, color='red')
ax.axvspan(80, 83, alpha=0.15, color='red')

# 标注积累区域(绿色背景)
ax.axvspan(0, 20, alpha=0.08, color='green', label='令牌积累期')
ax.axvspan(25, 55, alpha=0.08, color='green')
ax.axvspan(60, 80, alpha=0.08, color='green')

# 添加标注箭头
ax.annotate('突发消耗↓\n令牌急剧下降', xy=(22.5, 4), xytext=(22.5, 8.5),
            arrowprops=dict(arrowstyle='->', color='red', lw=2),
            ha='center', fontsize=11, color='red')

ax.annotate('令牌逐渐恢复↑', xy=(40, 2), xytext=(40, 7.5),
            arrowprops=dict(arrowstyle='->', color='green', lw=2),
            ha='center', fontsize=11, color='green')

ax.annotate('再次突发消耗', xy=(57.5, 3), xytext=(57.5, 8.5),
            arrowprops=dict(arrowstyle='->', color='red', lw=2),
            ha='center', fontsize=11, color='red')

ax.annotate('令牌重新积累', xy=(70, 2), xytext=(70, 7),
            arrowprops=dict(arrowstyle='->', color='green', lw=2),
            ha='center', fontsize=11, color='green')

# 图表装饰
ax.set_xlabel('时间', fontsize=13)
ax.set_ylabel('令牌数', fontsize=13)
ax.set_title('令牌桶算法:令牌数随时间变化曲线', fontsize=16, fontweight='bold')
ax.legend(loc='upper right', fontsize=11)
ax.grid(True, alpha=0.3, linestyle='--')
ax.set_xlim(0, TOTAL_TIME)
ax.set_ylim(0, BURST_CAP * 1.15)

plt.tight_layout()

# 保存为高清图片(同时输出PNG和SVG格式)
plt.savefig('token_bucket_waveform.png', dpi=300, bbox_inches='tight')
plt.savefig('token_bucket_waveform.svg', bbox_inches='tight')
plt.show()

运行这段代码后,会得到一张平滑的波形图。图中蓝色曲线代表令牌数的实时变化,红色区域对应突发发送期(令牌快速消耗),绿色区域对应令牌积累期,橙色虚线和绿色点线分别标示了桶容量和长期平均速率的参考位置。

四、工程实现

4.1 朴素实现(有锁版本)

最直接的实现方式是使用一个独立的线程定时生成令牌,并加锁保护共享状态:

#include <mutex>
#include <thread>
#include <chrono>

class SimpleTokenBucket {
public:
    SimpleTokenBucket(double rate, size_t burst)
        : rate_(rate), burst_(burst), tokens_(burst) {
        running_ = true;
        refill_thread_ = std::thread(&SimpleTokenBucket::refillLoop, this);
    }

    ~SimpleTokenBucket() {
        running_ = false;
        if (refill_thread_.joinable()) {
            refill_thread_.join();
        }
    }

    bool consume(size_t tokens) {
        std::lock_guard<std::mutex> lock(mtx_);
        if (tokens_ >= tokens) {
            tokens_ -= tokens;
            return true;
        }
        return false;
    }

private:
    void refillLoop() {
        while (running_) {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            std::lock_guard<std::mutex> lock(mtx_);
            tokens_ = std::min(burst_, tokens_ + rate_);
        }
    }

    const double rate_;
    const size_t burst_;
    double tokens_;
    std::mutex mtx_;
    std::thread refill_thread_;
    bool running_ = true;
};

这种实现的问题:

首先,它需要一个独立的线程来定时补充令牌,这增加了系统开销和代码复杂度。其次,每秒只更新一次令牌数,精度有限,无法做到微秒级的平滑控制。最后,如果 consume 调用非常频繁,互斥锁会成为性能瓶颈。

4.2 惰性计算优化

更优雅的做法是不使用独立线程,而是在每次调用 consume 时根据时间差计算应该新增多少令牌。这就是”惰性计算”的核心思想:

class LazyTokenBucket {
public:
    LazyTokenBucket(double rate, size_t burst)
        : rate_(rate), burst_(burst), tokens_(burst) {
        last_update_ = std::chrono::steady_clock::now();
    }

    bool consume(size_t tokens) {
        std::lock_guard<std::mutex> lock(mtx_);
        updateTokens();
        if (tokens_ >= tokens) {
            tokens_ -= tokens;
            return true;
        }
        return false;
    }

private:
    void updateTokens() {
        auto now = std::chrono::steady_clock::now();
        double elapsed = std::chrono::duration<double>(now - last_update_).count();
        tokens_ = std::min(burst_, tokens_ + elapsed * rate_);
        last_update_ = now;
    }

    const double rate_;
    const size_t burst_;
    double tokens_;
    std::chrono::steady_clock::time_point last_update_;
    std::mutex mtx_;
};

优化效果:不需要独立线程;更新精度取决于时钟精度(通常微秒级);令牌计算完全由调用驱动,空闲时零开销。

4.3 无锁实现:高性能方案

rigtorp/TokenBucket 是目前C++社区最知名的无锁令牌桶实现,在 GitHub 上可以找到。它的核心设计思路如下:

数据结构:所有共享状态(当前令牌数、上次更新时间)都使用 std::atomic 包装,保证读写操作的原子性。

CAS循环:核心操作采用 Compare-And-Swap 循环。线程先读取当前状态的快照,计算新的令牌数,然后尝试原子性地更新。如果更新过程中数据被其他线程修改了,就重新读取并重试,直到成功为止。

惰性时间计算:同样采用惰性策略,在每次 consume 调用时根据时间差计算应补充的令牌数。

内存序优化:使用精细的 std::memory_order 控制内存可见性,在保证正确性的前提下最大化性能。

这种无锁设计避免了线程挂起和上下文切换,在高并发场景下拥有显著更好的性能和扩展性。

4.4 不同实现的性能对比

实现方案吞吐量延迟适用场景
独立线程+互斥锁低(锁竞争严重)较高低并发场景
惰性计算+互斥锁中等中等一般并发场景
惰性计算+无锁CAS高低高并发、高性能场景

五、应用操作指南

5.1 基础配置:如何选择合适的参数

参数选择取决于具体场景,以下是一些通用准则:

rate(平均速率)的确定:

  • 网络带宽限制场景:设为可用带宽的 80%~90%,留出协议开销余量
  • API限流场景:根据服务器处理能力设定,通常为峰值QPS的70%
  • 视频流传输场景:设为视频平均码率的 1.1~1.2 倍
  • 文件传输场景:根据带宽和接收端处理能力综合设定

burst(突发容量)的确定:

一般经验公式:burst = rate × T,其中 T 是你允许的最大突发持续时间(秒)。

  • 对实时性要求高:T = 1~3 秒,允许短时间高速发送
  • 对平滑性要求高:T = 0.1~0.5 秒,限速更严格
  • 视频流中关键帧(I帧)很大:T 至少能容纳一个关键帧的大小

5.2 完整代码示例:集成到网络发送

以下是一个完整的网络发送器示例,展示了如何将令牌桶集成到实际的发送逻辑中:

#include "TokenBucket.h"
#include <asio.hpp>  // 或者使用你的网络库
#include <iostream>
#include <fstream>
#include <thread>
#include <chrono>
#include <vector>

class RateLimitedSender {
public:
    RateLimitedSender(const std::string& host, int port, 
                     double rate_byte_per_sec, size_t burst_bytes)
        : limiter_(rate_byte_per_sec, burst_bytes)
        , socket_(io_context_) {
        asio::ip::tcp::resolver resolver(io_context_);
        auto endpoints = resolver.resolve(host, std::to_string(port));
        asio::connect(socket_, endpoints);
    }

    // 非阻塞发送:令牌不足时直接返回 false
    bool send(const std::vector<char>& data) {
        size_t data_size = data.size();
        if (!limiter_.consume(data_size)) {
            return false;
        }
        auto bytes_sent = asio::write(socket_, asio::buffer(data));
        return bytes_sent == data_size;
    }

    // 阻塞发送:确保数据一定能发送出去
    void sendBlocking(const std::vector<char>& data) {
        size_t data_size = data.size();
        while (!limiter_.consume(data_size)) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }
        asio::write(socket_, asio::buffer(data));
    }

private:
    rigtorp::TokenBucket limiter_;
    asio::io_context io_context_;
    asio::ip::tcp::socket socket_;
};

5.3 高级技巧:大包分块发送

当数据包超过 burst 容量时,应拆分成小块发送,避免单个大包永远无法通过限速器:

void sendLargeData(const std::vector<char>& data, 
                   RateLimitedSender& sender,
                   size_t chunk_size = 64 * 1024) {
    for (size_t offset = 0; offset < data.size(); offset += chunk_size) {
        size_t len = std::min(chunk_size, data.size() - offset);
        std::vector<char> chunk(data.begin() + offset, 
                                data.begin() + offset + len);
        while (!sender.send(chunk)) {
            std::this_thread::sleep_for(std::chrono::milliseconds(1));
        }
    }
}

5.4 常见陷阱与解决方案

时钟跳变问题:如果使用系统时间(如 system_clock),系统时间被修改会导致令牌计算错误。解决方案是使用 steady_clock,它提供单调递增的时钟,不受系统时间调整影响。

饥饿问题:非常大的数据包可能永远无法获得足够的令牌。解决方案是采用分块发送策略,将大包拆成多个小包分别过限速器。

精度损失:使用整数存储令牌数会导致小包累积误差。解决方案是使用浮点数存储令牌数,允许小数令牌的存在。

CPU空转:阻塞模式中使用 while 循环持续重试会占满 CPU。解决方案是在循环中添加 sleep_for 等待,或者使用条件变量。

突发过大:过大的 burst 参数可能冲击接收端缓冲区。解决方案是根据接收端的处理能力合理设置 burst,或者结合漏桶算法做二级平滑。

六、实际应用场景分析

6.1 场景一:视频流播发

视频流的特点是:I帧(关键帧)很大,P帧和B帧相对较小,码率波动剧烈。

配置建议:平均速率设为视频平均码率的 1.1 倍,突发容量至少能容纳一个最大 I 帧的大小,通常设为最大 I 帧的 2~3 倍。

这样配置的效果是:普通帧平滑发送,I帧来临时允许短时间突发,播放端缓冲区保持稳定。

6.2 场景二:API网关限流

API网关需要同时控制请求数量和总流量,保护后端服务不被冲垮。

配置建议:同时使用两个限速器——一个按请求数限流(如每秒1000个请求),一个按字节数限流(如每秒50MB)。每个请求必须同时通过两个限速器才能被处理。

6.3 场景三:TCP流量控制的补充

TCP自身有拥塞控制机制,但在应用层配合令牌桶可以做到更精细的速率控制。应用层限速作为”第一道防线”,保证即使TCP窗口允许高速发送,也不会过度占用网络带宽。

七、总结

令牌桶算法的核心价值在于:通过”以恒定速率生成令牌、消耗令牌才能发送”的机制,实现了对突发流量的容忍和对平均速率的精确控制。

从实现角度看,从简单的线程加锁方案,到高效的惰性计算加无锁CAS方案,性能不断提升。rigtorp/TokenBucket 代表了C++社区的最佳实践,推荐在生产环境中使用。

从应用角度看,参数选择要根据具体场景平衡”突发容忍度”和”速率平滑性”,没有放之四海皆准的配置。合理设置 rate 和 burst,配合分块发送、多级限流等高级技巧,可以应对绝大多数网络速率控制需求。

随着网络技术的演进,令牌桶算法也在不断发展,包括AI驱动的动态参数调整、多级令牌桶的精细化控制、以及硬件层面的加速实现。但无论技术如何演进,令牌桶这一经典算法的核心思想——”以许可机制平滑流量”——始终具有持久的生命力。

参考文献

  1. Turner, J. S. (1986). New directions in communications (or which way to the information age?). IEEE Communications Magazine, 24(10), 8-15.
  2. Tanenbaum, A. S., & Wetherall, D. J. (2011). Computer Networks (5th ed.). Pearson.
  3. Kurose, J. F., & Ross, K. W. (2017). Computer Networking: A Top-Down Approach (7th ed.). Pearson.
  4. ritorp/TokenBucket GitHub Repository: https://github.com/rigtorp/TokenBucket
作者

老丹

关注我
其他文章
上一个

PCRE正则表达式库深度剖析:架构、算法、性能与实战

下一个

从 ip maddr show 命令输出看 ens38 接口的多播组加入情况

关于博主

    老丹是一名C/C++后台开发工程师,信奉“无抽象不设计,无性能不生产”。

  • 技术栈:Modern C++、Linux环境编程、多线程/并发、网络编程等。
  • 信条:能用constexpr解决的问题绝不拖到运行时,能靠RAII避免的泄漏绝不写析构。
  • 正在填坑:从解封装到渲染的C++全链路实现,正在驯服FFmpeg与H.264/H.265。
  • 输出原则:这里的每一段代码都经过-Wall -Wextra -Werror -O2的洗礼。

近期文章

  • Ubuntu 防火墙迁移指南:从 UFW 到 firewalld 的完整实践 2026年9月12日
  • Nano 编辑器完全操作指南:从入门到熟练 2026年9月12日
  • SSCG:让自签名证书不再“危险”的生成工具 2026年9月12日
  • Ubuntu Samba 服务安装与配置完全指南 2026年9月12日
  • 从零开始:用 Docker 部署 Jellyfin 并启用英特尔核显硬件加速 2026年9月11日

文章分类

  • C/C++开发 (22)
  • Docker容器 (5)
  • Linux工具包 (17)
  • Linux服务配置 (50)
  • Linux系统 (16)
  • OpenWrt路由 (3)
  • Shell脚本 (3)
  • 代码管理 (1)
  • 安防技术 (4)
  • 数据安全 (36)
  • 未分类 (1)
  • 网络协议 (25)
  • 计算机理论 (23)
  • 音视频技术 (5)
联系我们:📍 地址:中国·广东省深圳市   |   ✉️ 邮箱:support@tanglinux.com   |   💬 QQ:870866607
版权所有:老丹的足迹粤ICP备2026061170号-1       公安备案图标 粤公网安备44030002013274号