惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

酷 壳 – CoolShell
酷 壳 – CoolShell
H
Hacker News: Front Page
P
Palo Alto Networks Blog
T
ThreatConnect
Apple Machine Learning Research
Apple Machine Learning Research
博客园_首页
T
True Tiger Recordings
P
Privacy & Cybersecurity Law Blog
B
Blog
IT之家
IT之家
Last Week in AI
Last Week in AI
F
Full Disclosure
Hacker News: Ask HN
Hacker News: Ask HN
C
Comments on: Blog
Microsoft Azure Blog
Microsoft Azure Blog
C
Cybersecurity and Infrastructure Security Agency CISA
Microsoft Security Blog
Microsoft Security Blog
博客园 - 【当耐特】
N
News and Events Feed by Topic
NISL@THU
NISL@THU
腾讯CDC
雷峰网
雷峰网
Security Latest
Security Latest
李成银的技术随笔
M
Microsoft Research Blog - Microsoft Research
L
LangChain Blog
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Check Point Blog
Y
Y Combinator Blog
Recent Announcements
Recent Announcements
博客园 - Franky
N
News | PayPal Newsroom
V
V2EX
A
About on SuperTechFans
The Register - Security
The Register - Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Google Online Security Blog
Google Online Security Blog
MyScale Blog
MyScale Blog
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
WordPress大学
WordPress大学
C
Cyber Attacks, Cyber Crime and Cyber Security
The Hacker News
The Hacker News
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
爱范儿
爱范儿
A
Arctic Wolf
L
LINUX DO - 最新话题
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More

博客园 - .NET骚操作

C++ vs .NET 数组原地反转实测:小数组 C++ 碾压,大数组 .NET 反杀? Sdcb Chats 1.10 私有化代码执行器部署教程 复刻 ChatGPT 高级数据分析!Sdcb Chats 1.10 重磅发布:能分析Excel、做PPT .NET 10了,HttpClient还是不能用using吗?我做了一个实验 两天烧掉200美元!我AI大模型网关终于支持了Claude模型 Sdcb Chats 1.8:又一次底层重构,彻底将模型提供商解耦 没有前端后,我把 MCP 做进了 Chats 1.7.0 AI 网关 不服跑个分?.NET 10 大整数计算对阵 Java,结果令人意外 从 OpenAI 兼容到 Anthropic 崛起:大模型“交错思考”协议的演进与变局 当加密ID需要变成Guid:为什么我选择了AES-CBC而非GCM? AES 加密模式演进:从 ECB、CBC 到 GCM 的 C# 深度实践 美丽而脆弱的天体运动:当C#遇见宇宙混沌 我是不是很有钱? Chats 1.6.8 发布:全面支持最新的 gpt-5 模型等 Sdcb Chats 1.6.6 发布,彻底移除 Azure.AI.OpenAI 专用包 那些被推迟的 C# 14 特性及其背后的故事 我最喜欢的 C# 14 新特性 更复杂的代码,为何跑得快了10倍?一次Draw Call优化引发的思考 当物理定律与高精度计算相遇:我的新开源项目 N-Body
一个被BCL遗忘的高性能集合:C# CircularBuffer<T>深度解析
.NET骚操作 · 2025-08-04 · via 博客园 - .NET骚操作

大家好,在最近的一个业余项目——天体运行模拟器中,我遇到了一个有趣的需求:我需要记录每个天体最近一段时间的历史位置,从而在屏幕上为它们画出一条长长而漂亮的轨迹线。

你可能会说,用一个 List<T> 不就行了?但问题在于,如果模拟持续运行,这个 List<T> 会无限增长,最终会消耗大量内存,甚至可能导致程序崩溃。我真正需要的是一个“固定大小”的集合,当新数据加进来时,最老的数据能被自动丢弃。这正是“循环缓冲区”(Circular Buffer)大显身手的场景。CircularBuffer<T> 完美地满足了我的需求。

image

CircularBuffer<T> 的实现

C# 的基础类库(BCL)中并没有内置 CircularBuffer<T> 这个类型,但这完全不妨碍我们自己动手,丰衣足食。下面就是我所使用的 CircularBuffer<T> 的完整实现,它支持泛型,并且实现了 IEnumerable<T> 接口,可以方便地进行遍历。

using System.Collections;
using System.Collections.Generic;
using System;

namespace Sdcb.NBody.Common;

/// <summary>
/// 表示一个固定容量的循环缓冲区(或环形列表)。
/// 当缓冲区满时,添加新元素会覆盖最早的元素。
/// </summary>
/// <typeparam name="T">缓冲区中元素的类型。</typeparam>
public class CircularBuffer<T> : IEnumerable<T>
{
    private readonly T[] _data;
    private int _end; // 指向下一个要写入元素的位置(尾部)

    /// <summary>
    /// 获取缓冲区中实际存储的元素数量。
    /// </summary>
    public int Count { get; private set; }

    /// <summary>
    /// 获取缓冲区的总容量。
    /// </summary>
    public int Capacity => _data.Length;

    /// <summary>
    /// 获取一个值,该值指示缓冲区是否已满。
    /// </summary>
    public bool IsFull => Count == Capacity;

    /// <summary>
    /// 计算并获取第一个元素(头部)在内部数组中的索引。
    /// </summary>
    private int HeadIndex
    {
        get
        {
            if (Count == 0) return 0;
            // 当 Count < Capacity 时,_end 就是 Count,结果为 0。
            // 当缓冲区满时 (Count == Capacity),_end 会循环,这个公式能正确计算出头部的索引。
            return (_end - Count + Capacity) % Capacity;
        }
    }

    /// <summary>
    /// 初始化 <see cref="CircularBuffer{T}"/> 类的新实例。
    /// </summary>
    /// <param name="capacity">缓冲区的容量。必须为正数。</param>
    /// <exception cref="ArgumentException">当容量小于等于 0 时抛出。</exception>
    public CircularBuffer(int capacity)
    {
        if (capacity <= 0)
        {
            throw new ArgumentException("Capacity must be a positive number.", nameof(capacity));
        }
        _data = new T[capacity];
        _end = 0;
        Count = 0;
    }

    /// <summary>
    /// 将一个新元素添加到缓冲区的尾部。
    /// 如果缓冲区已满,此操作会覆盖最早的元素。
    /// </summary>
    /// <param name="item">要添加的元素。</param>
    public void Add(T item)
    {
        _data[_end] = item;
        _end = (_end + 1) % Capacity;

        if (Count < Capacity)
        {
            Count++;
        }
    }

    /// <summary>
    /// 清空缓冲区中的所有元素。
    /// </summary>
    public void Clear()
    {
        // 只需重置计数器和指针即可,无需清除数组数据
        Count = 0;
        _end = 0;
    }

    /// <summary>
    /// 获取或设置指定逻辑索引处的元素。
    /// 索引 0 是最早的元素,索引 Count-1 是最新的元素。
    /// </summary>
    /// <param name="index">元素的逻辑索引。</param>
    /// <exception cref="IndexOutOfRangeException">当索引超出范围时抛出。</exception>
    public T this[int index]
    {
        get
        {
            if (index < 0 || index >= Count)
            {
                throw new IndexOutOfRangeException("Index is out of the valid range of the buffer.");
            }
            int actualIndex = (HeadIndex + index) % Capacity;
            return _data[actualIndex];
        }
        set
        {
            if (index < 0 || index >= Count)
            {
                throw new IndexOutOfRangeException("Index is out of the valid range of the buffer.");
            }
            int actualIndex = (HeadIndex + index) % Capacity;
            _data[actualIndex] = value;
        }
    }

    /// <summary>
    /// 获取缓冲区中的第一个(最早的)元素。
    /// </summary>
    /// <exception cref="InvalidOperationException">当缓冲区为空时抛出。</exception>
    public T First
    {
        get
        {
            if (Count == 0) throw new InvalidOperationException("Buffer is empty.");
            return _data[HeadIndex];
        }
    }

    /// <summary>
    /// 获取缓冲区中的最后一个(最新的)元素。
    /// </summary>
    /// <exception cref="InvalidOperationException">当缓冲区为空时抛出。</exception>
    public T Last
    {
        get
        {
            if (Count == 0) throw new InvalidOperationException("Buffer is empty.");
            // _end 指向下一个要写入的位置,所以上一个写入的位置是 (_end - 1)
            int lastIndex = (_end - 1 + Capacity) % Capacity;
            return _data[lastIndex];
        }
    }

    /// <summary>
    /// 返回一个循环访问集合的枚举器。
    /// </summary>
    public IEnumerator<T> GetEnumerator()
    {
        int head = HeadIndex;
        for (int i = 0; i < Count; i++)
        {
            yield return _data[(head + i) % Capacity];
        }
    }

    /// <summary>
    /// 返回一个循环访问集合的枚举器。
    /// </summary>
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

原理浅析

这个类的核心思想非常巧妙:

  1. 内部存储:它内部使用一个固定长度的数组 _data 作为元素的物理存储空间。
  2. 指针管理:它使用一个 _end 指针来标记下一个新元素应该插入的位置(尾部),以及一个 Count 属性来记录当前存储的元素数量。
  3. 循环的奥秘HeadIndex 这个计算属性是关键,它通过 _endCount 的位置动态计算出“逻辑上”第一个元素(头部)在物理数组中的实际索引。取模运算 % Capacity 保证了无论是 _end 指针的移动还是 HeadIndex 的计算,都能在数组的边界内“循环”。
  4. 添加与覆盖:当你调用 Add 方法时,新元素会被放到 _end 指向的位置。如果缓冲区还没满 (Count < Capacity),Count 会加一。如果已经满了,Count 不再变化,而旧的头部元素就会被自然而然地覆盖掉。整个过程对于调用者来说是无感的。

骚操作之:在天体模拟中使用

得益于 CircularBuffer<T> 的优雅设计,在我的天体模拟器中使用它变得异常简单。我只需要关心一件事:把新的位置点加进去。

这就是它在我的模拟循环中的样子:每当一个天体的位置变化超过了2个像素(为了避免轨迹点过于密集),我就把它新的坐标 Add 到它的轨迹历史记录 TrackingHistory 中。

// _system.MoveNext();
// _acc += _system.Current.Timestamp - _lastSnapshot.Timestamp;

for (int i = 0; i < _system.Current.Bodies.Length; ++i)
{
    BodySnapshot star = _system.Current.Bodies[i];
    BodyUIProps props = _uiProps[i];

    Vector2 now = new(star.Px, star.Py);
    // 如果历史轨迹中有数据,则判断距离
    if (props.TrackingHistory.Count > 0)
    {
        Vector2 old = props.TrackingHistory.Last;
        float dist = Vector2.Distance(old, now);
        // 只有当移动距离超过阈值时才添加新点
        if (dist > 2 / _scale.Value)
        {
            props.TrackingHistory.Add(now);
        }
    }
    else
    {
        // 如果是第一个点,直接添加
        props.TrackingHistory.Add(now);
    }
}

我完全不需要写 if (list.Count > MAX_COUNT) { list.RemoveAt(0); } 这样的代码。CircularBuffer<T> 自动为我处理了覆盖最早元素逻辑,代码因此变得更加简洁和高效。

骚操作之:遍历与渲染

当需要绘制轨迹线时,由于 CircularBuffer<T> 实现了 IEnumerable<T> 接口,我可以直接使用 foreach 循环来遍历其中的所有点,并将它们连接成线。下面的代码片段使用 Direct2D 来绘制几何路径:

if (prop.TrackingHistory.Count < 2) continue;

using ID2D1PathGeometry1 path = XResource.Direct2DFactory.CreatePathGeometry();
using ID2D1GeometrySink sink = path.Open();

// 将画笔移动到轨迹的第一个点
sink.BeginFigure(prop.TrackingHistory.First, FigureBegin.Hollow);

// 遍历并连接后续的点
foreach (Vector2 pt in prop.TrackingHistory.Skip(1))
{
    sink.AddLine(pt);
}

sink.EndFigure(FigureEnd.Open);
sink.Close();

// 绘制几何路径
ctx.DrawGeometry(path, XResource.GetColor(prop.Color), 0.02f);

这里的 foreach 会按照元素添加的先后顺序,从最早的(头部)到最新的(尾部)依次返回,完美符合我绘制轨迹的需求。

性能分析:为什么它如此高效?

我们实现的这个 CircularBuffer<T> 不仅用起来方便,其性能也相当出色。让我们来分析一下它主要操作的时间复杂度:

  • 插入 (Add): O(1)。添加一个新元素仅涉及一次数组写入和一次指针(_end)的算术运算,无论缓冲区有多大,耗时都是固定的。
  • 获取元素数量 (Count): O(1)。这只是返回一个字段的值,是瞬时操作。
  • 索引访问 (this[index]): O(1)。通过索引获取元素,需要经过 HeadIndex 的 O(1) 计算来定位实际的数组下标,然后进行一次数组访问,总体仍然是 O(1)。
  • 获取头/尾元素 (First/Last): O(1)。与索引访问类似,都是通过计算直接定位,无需遍历。
  • 查找/遍历 (foreach): O(n)。和大多数集合一样,如果要查找一个不确定位置的元素或完整遍历,需要访问所有 n 个元素。

现在,让我们来对比一下,如果使用 List<T> 并手动在列表满时执行 list.RemoveAt(0) 来模拟这个行为,会发生什么。

List<T>.RemoveAt(0) 是一个非常昂贵的操作,其时间复杂度为 O(n)。这是因为它需要将索引 0 之后的所有元素在内存中向前移动一位来填补空缺。如果你的缓冲区很大(比如存储几千个历史点),每次添加新元素都可能触发一次大规模的内存复制,这会带来巨大的性能开销。

相比之下,我们的 CircularBuffer<T> 仅仅通过移动一个整数指针就巧妙地“移除”了最旧的元素,整个 Add 操作的复杂度始终是 O(1)。在这种需要固定大小并频繁读写的场景下,其效率比 List<T> 的模拟方案好得不知道哪里去了,性能简直是天壤之别。

总结

虽然 C# 基础类库里没有提供 CircularBuffer<T>,但它无疑是一个非常实用的数据结构。它在需要固定容量、自动淘汰旧数据的场景下表现出色。

除了今天演示的天体运行轨迹记录,它还可以广泛应用于:

  • 日志记录:只保留最近的 N 条日志。
  • 性能监控:记录最近一段时间的 CPU 或内存使用率。
  • 实时数据流处理:缓存最新的数据点用于分析。
  • 轮询调度(Round-Robin):在多个任务或资源间循环切换。

希望这个 CircularBuffer<T> 的实现能对你有所启发。

感谢阅读到这里,如果感觉本文对您有帮助,请不吝评论点赞,这也是我持续创作的动力!
也欢迎加入我的 .NET骚操作 QQ群:495782587,一起交流.NET 和 AI 的各种有趣玩法!