跳到主要内容
  1. 我的文章/

一次foreach的优化之旅

·8 分钟

从一个实际的需求说起… #

我想很多人都遇到过生成表格的需求,比如将数据导出成Excel文件,或者渲染进PDF文件等等。当项目中有很多处都有类似需求时,往往就希望将表格处理的逻辑封装起来。大体逻辑很简单:

  1. 为表格的行建模TRow
  2. 以某种方式声明TRow到各列数据(通常是字符串)的映射关系;
  3. 遍历所有行,执行每行的写入或渲染操作。

大致伪代码如下:

public sealed class TableGenerator<TRow>(IReadOnlyList<TRow> Rows) where TRow: ITableRow
{
    public void Generate(ICellWriter writer)
    {
        foreach (var row in Rows)
        {
            foreach (var cellText in Row.Columns())
            {
                // Do something...
                writer.Write(cellText);
            }
        }
    }
}

public interface ICellWriter
{
    void Write(string text);
}

现在假设我们要生成一个简单的玩家表格,行定义如下:

public sealed class PlayerRow: ITableRow
{
    public required string Id { get; init; }

    public required string Name { get; init; }
}

最简单的2个实现 #

一个很自然的想法是使用数组:

public IEnumerable<string> Columns() => [Id, Name];

问题是:在遍历每一行时,我们都会创建一个string[]。数组占用的堆内存,除了自身的引用头外,还包括字符串元素的体积,即空间复杂度是O(N),N表示列数量。如果列很多的话,必然会导致大量的内存分配。

于是我们可能会想到使用迭代器,它可以缓解数组方案的内存分配问题:

public IEnumerable<string> Columns()
{
    yield return Id;
    yield return Name;
}

编译器会将yield return编译成一个迭代器类,以状态机的方式实现遍历。示例如下(随编译器升级结果未来可能会不断变化):

	[CompilerGenerated]
	private sealed class <Columns>d__8 : IEnumerable<string>, IEnumerable, IEnumerator<string>, IEnumerator, IDisposable
	{
		private int <>1__state;

		private string <>2__current;

		private int <>l__initialThreadId;

		[Nullable(0)]
		public PlayerRow <>4__this;

		string IEnumerator<string>.Current
		{
			[DebuggerHidden]
			get
			{
				return <>2__current;
			}
		}

		object IEnumerator.Current
		{
			[DebuggerHidden]
			[return: Nullable(0)]
			get
			{
				return <>2__current;
			}
		}

		[DebuggerHidden]
		public <Columns>d__8(int <>1__state)
		{
			this.<>1__state = <>1__state;
			<>l__initialThreadId = Environment.CurrentManagedThreadId;
		}

		[DebuggerHidden]
		void IDisposable.Dispose()
		{
			<>1__state = -2;
		}

		private bool MoveNext()
		{
			switch (<>1__state)
			{
			default:
				return false;
			case 0:
				<>1__state = -1;
				<>2__current = <>4__this.Id;
				<>1__state = 1;
				return true;
			case 1:
				<>1__state = -1;
				<>2__current = <>4__this.Name;
				<>1__state = 2;
				return true;
			case 2:
				<>1__state = -1;
				return false;
			}
		}

		bool IEnumerator.MoveNext()
		{
			//ILSpy generated this explicit interface implementation from .override directive in MoveNext
			return this.MoveNext();
		}

		[DebuggerHidden]
		void IEnumerator.Reset()
		{
			throw new NotSupportedException();
		}

		[DebuggerHidden]
		[return: Nullable(new byte[] { 1, 0 })]
		IEnumerator<string> IEnumerable<string>.GetEnumerator()
		{
			<Columns>d__8 <Columns>d__9;
			if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)
			{
				<>1__state = 0;
				<Columns>d__9 = this;
			}
			else
			{
				<Columns>d__9 = new <Columns>d__8(0);
				<Columns>d__9.<>4__this = <>4__this;
			}
			return <Columns>d__9;
		}

		[DebuggerHidden]
		IEnumerator IEnumerable.GetEnumerator()
		{
			return ((IEnumerable<string>)this).GetEnumerator();
		}
	}

原方法被编译为:

	[IteratorStateMachine(typeof(<Columns>d__8))]
	public IEnumerable<string> Columns()
	{
		<Columns>d__8 obj = new <Columns>d__8(-2);
		obj.<>4__this = this;
		return obj;
	}

可见此时在遍历每一行时,我们都会创建一个<Columns>d__8迭代器实例,但它占用的内存就只有自身的引用头等,跟列的数量无关,空间复杂度就降低到了O(1)。

这2种方式,都会在每行遍历时,在堆内存上产生一次性分配。在操作结束后,所分配的数组或迭代器实例,大概率会在Gen 0被GC回收,对于非热路径下的场景,如此已经足够。然而,如果代码是在热路径上执行呢?

结构体(struct)带来性能的飞跃 #

在热路径上的代码,任何一点可测量的优化都会因为海量执行次数放大优化的收益,产生很大的价值。

上述的迭代器实现,虽然使得空间复杂度跟表格列的数量无关,但还是跟行的数量线性相关。编译器出于对象生命周期的安全性考虑,默认生成的迭代器是引用类型,那么迭代器可否是值类型呢?试想若迭代器是一个结构体,那么遍历各行时,迭代器将不会分配在需要GC回收的堆内存上,而是即用即收,堪称一次性能飞跃!

答案是可以的!我们最常用的List<T>类,使用foreach用的就是struct枚举器:

        public struct Enumerator : IEnumerator<T>, IEnumerator
        {
            private readonly List<T> _list;
            private readonly int _version;

            private int _index;
            private T? _current;

            internal Enumerator(List<T> list)
            {
                _list = list;
                _version = list._version;
            }

            public void Dispose()
            {
            }

            public bool MoveNext()
            {
                List<T> localList = _list;

                if (_version != _list._version)
                {
                    ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion();
                }

                if ((uint)_index < (uint)localList._size)
                {
                    _current = localList._items[_index];
                    _index++;
                    return true;
                }

                _current = default;
                _index = -1;
                return false;
            }

            public T Current => _current!;

            object? IEnumerator.Current
            {
                get
                {
                    if (_index <= 0)
                    {
                        ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumOpCantHappen();
                    }

                    return _current;
                }
            }

            void IEnumerator.Reset()
            {
                if (_version != _list._version)
                {
                    ThrowHelper.ThrowInvalidOperationException_InvalidOperation_EnumFailedVersion();
                }

                _index = 0;
                _current = default;
            }
        }

List<T>类使用struct枚举器的原因显而易见,作为使用最广泛的底层基础类型之一,自然极其重视性能,否则每处foreach就产生一个引用类型的迭代器分配,极易造成GC压力。那么进一步问下去:为什么List<T>类可以使用struct枚举器?回答这个问题,我们先需要了解“默认生成的迭代器是引用类型”的设计动机。

迭代器模式一个非常强大和重要的功能是支持挂起(Suspension),比如:

    IEnumerable<string> GetData()
    {
        yield return "Hello";
        yield return "World";
    }

    // 调用方
    var enumerator = GetData().GetEnumerator();
    enumerator.MoveNext(); // 拿到 Hello
    // 此时方法执行到一半停住了
    // ... 这里可能隔了很久,甚至跨线程了 ...
    enumerator.MoveNext(); // 拿到 World

如果迭代器是值类型,它默认是分配在栈上的,一旦GetData()方法执行完毕(或者说当前的栈帧被弹出),这个迭代器就没了。但是yield return需要把“执行到一半的状态”保存下来,留给未来的某个时刻(给另一个方法/线程)继续调用。这种跨作用域的生命周期管理,只有堆上的引用类型能做到,而栈上的值类型做不到这一点。

那么,如果我们根本不需要“挂起”的能力呢?就比如List<T>,从源代码可以看出,它的成员已经固定在底层堆内存上的数组中了,Enumerator只扮演一个索引游标的角色,完全可以活在栈上,随用随收。

搞清楚这里的细微差别,我们自然也可以判断出遍历表格的所有行数据都已经固定,也是不需要挂起能力的,我们可以对PlayerRow手写一个结构体版本的Enumerator

    public int CountColumns() => 2;

    public string ColumnText(int index) => index switch
    {
        0 => Id,
        1 => Name,
        _ => string.Empty
    };

    public Enumerator GetEnumerator() => new(this);

    public struct Enumerator : IEnumerator<string>
    {
        private readonly PlayerRow _row;
        private int _index;

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        internal Enumerator(PlayerRow row)
        {
            _row = row;
            _index = -1;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public bool MoveNext()
        {
            int index = _index + 1;
            if (index < _row.CountColumns())
            {
                _index = index;
                return true;
            }

            return false;
        }

        public readonly string Current
        {
            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            get => _row.ColumnText(_index);
        }

        /// <inheritdoc />
        readonly string IEnumerator<string>.Current => Current;

        /// <inheritdoc />
        readonly object IEnumerator.Current => Current!;

        /// <inheritdoc />
        void IEnumerator.Reset() => _index = -1;

        /// <inheritdoc />
        readonly void IDisposable.Dispose() { }
    }

上层的循环可以直接写成:

    foreach (var row in Rows)
    {
        foreach (var cellText in Row)
        {
            // Do something...
            writer.Write(cellText);
        }
    }

使用了结构体版本的迭代器后,我们又进一步抹除了堆内存空间复杂度和遍历行数的关系,实现了Allocation Free。

语法进化带来设计的飞跃 #

结构体迭代器固然美好,但在实际应用中还是有个问题:如果以后加一个 OrderRow、一个 ProductRow,难道要把迭代器的样板代码复制粘贴N遍吗?这维护起来简直是噩梦,为了性能牺牲可读性,这笔账划不来。

源生成器(Source Generator) #

一个方案是编写和使用源生成器。源生成器是现代C#编译器提供的一个非常强大的特性,可以根据编译期的语法语义自定义进一步生成的代码。比如我们定义一个TableColumnAttribute:

    [AttributeUsage(AttributeTargets.Property)]
    public sealed class TableColumnAttribute(int index, string name) : Attribute
    {
        public int Index => index;

        public string Name => name;
    }

PlayerRow调整为:

public sealed partial class PlayerRow: ITableRow
{
    [TableColumnAttribute(0, "ID")]
    public required string Id { get; init; }

    [TableColumnAttribute(1, "姓名")]
    public required string Name { get; init; }
}

public interface ITableRow
{
    int CountColumns();

    string ColumnText(int index);
}

我们编写一个源生成器:先从语法语义树中筛选出当前项目中所有继承ITableRow接口的TRow类,再获取所有携带TableColumnAttribute特性的公开属性,根据特性上设置的值生成上述结构体版本的Enumerator。生成代码的过程都是在编译期完成的,生成的代码结果和手写无异,相对传统的反射等方案确保了最佳的运行时性能。

源生成器带来的便利性是毋庸置疑的:后续新增OrderRow等类型时,只需要让它继承接口,为公开属性添加特性,重新编译即可。当然,此方案也不是毫无缺点:

  1. 编写和维护源生成器的门槛还是较高的,需要开发者熟悉语法语义的概念;
  2. 因为迭代器写在类的内部,生成的代码对TRow类有一定侵入性。

共享迭代器及ref struct #

写源生成器往往会让人嫌麻烦,有没有更直观的方案呢?

有的。但我要先向读者抛出一个问题:什么情况下,我们可以使用foreach语法?

相信不少读者和我以前一样,觉得要使用foreach,对象必须是一个继承IEnumerable接口的集合。

但事实上,foreach其实并不关心你的类型是否实现了IEnumerable,它只关心一件事:你有没有GetEnumerator()方法?

换言之,foreach使用的是编译期鸭子类型(Duck Typing),C#编译器在编译时只检查模式(Pattern)是否存在,而不是检查契约(Contract)是否被继承。

典型的案例是Span<T>ReadOnlySpan<T>。查看ReadOnlySpan<T>的源代码,可以看到它被定义成public readonly ref struct ReadOnlySpan<T>,并没有继承IEnumerable<T>。但是我们可以正常使用foreach来遍历它代表的连续内存,因为它定义了迭代器和GetEnumerator()方法:

    /// <summary>Gets an enumerator for this span.</summary>
    public Enumerator GetEnumerator() => new Enumerator(this);

    /// <summary>Enumerates the elements of a <see cref="ReadOnlySpan{T}"/>.</summary>
    public ref struct Enumerator : IEnumerator<T>
    {
        /// <summary>The span being enumerated.</summary>
        private readonly ReadOnlySpan<T> _span;
        /// <summary>The next index to yield.</summary>
        private int _index;

        /// <summary>Initialize the enumerator.</summary>
        /// <param name="span">The span to enumerate.</param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        internal Enumerator(ReadOnlySpan<T> span)
        {
            _span = span;
            _index = -1;
        }

        /// <summary>Advances the enumerator to the next element of the span.</summary>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public bool MoveNext()
        {
            int index = _index + 1;
            if (index < _span.Length)
            {
                _index = index;
                return true;
            }

            return false;
        }

        /// <summary>Gets the element at the current position of the enumerator.</summary>
        public ref readonly T Current
        {
            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            get => ref _span[_index];
        }

        /// <inheritdoc />
        T IEnumerator<T>.Current => Current;

        /// <inheritdoc />
        object IEnumerator.Current => Current!;

        /// <inheritdoc />
        void IEnumerator.Reset() => _index = -1;

        /// <inheritdoc />
        void IDisposable.Dispose() { }
    }

这就启发了我们的思路,基于泛型写一个ref struct共享迭代器:

    public readonly ref struct EnumerableColumns<TRow>(TRow row) where TRow : ITableRow
    {
        public Enumerator GetEnumerator() => new(row);

        /// <summary>
        /// 参考<see cref="ReadOnlySpan{T}.Enumerator"/>。
        /// </summary>
        public ref struct Enumerator : IEnumerator<string>
        {
            private readonly TRow _row;
            private int _index;

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            internal Enumerator(TRow row)
            {
                _row = row;
                _index = -1;
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public bool MoveNext()
            {
                int index = _index + 1;
                if (index < _row.CountColumns())
                {
                    _index = index;
                    return true;
                }

                return false;
            }

            public readonly string Current
            {
                [MethodImpl(MethodImplOptions.AggressiveInlining)]
                get => _row.ColumnText(_index);
            }

            /// <inheritdoc />
            readonly string IEnumerator<string>.Current => Current;

            /// <inheritdoc />
            readonly object IEnumerator.Current => Current!;

            /// <inheritdoc />
            void IEnumerator.Reset() => _index = -1;

            /// <inheritdoc />
            readonly void IDisposable.Dispose() { }
        }
    }

上层的循环可以写成:

    foreach (var row in Rows)
    {
        var e = new EnumerableColumns<PlayerRow>(row);
        foreach (var cellText in e)
        {
            // Do something...
            writer.Write(cellText);
        }
    }

如此设计下,PlayerRow 只需要提供最基础的“读数据”的能力(一个接口,两个方法),而复杂的迭代逻辑被封装在了泛型结构体迭代器EnumerableColumns<TRow>中。

有读者可能会疑问:为什么使用ref struct呢?

因为我们这里只需要用在foreach上,不需要也不希望出现IEnumerator<string> e = row.GetEnumerator()的用法;我们也不需要挂起能力,迭代器的生命周期完全限定在foreach循环块内;内存分配和回收都在栈上自动完成,正适用于高频调用场景。

微基准测试结果 #

源代码如下:

using System.Collections;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

[SimpleJob(RuntimeMoniker.Net10_0)]
[MemoryDiagnoser]
public class EnumerableTest
{
    private static readonly List<PlayerRow> Rows = [.. Enumerable.Range(1,10000)
        .Select(i => new PlayerRow()
        {
            Id = i.ToString(),
            Name = (10000 - i).ToString(),
        })];

    [Benchmark(Baseline = true)]
    public int Array()
    {
        var length = 0;
        foreach (var r in Rows)
        {
            foreach (var s in r.Array())
            {
                length += s.Length;
            }
        }
        return length;
    }

    [Benchmark]
    public int Yield()
    {
        var length = 0;
        foreach (var r in Rows)
        {
            foreach (var s in r.Yield())
            {
                length += s.Length;
            }
        }
        return length;
    }

    [Benchmark]
    public int EnumeratorInner()
    {
        var length = 0;
        foreach (var r in Rows)
        {
            foreach (var s in r)
            {
                length += s.Length;
            }
        }
        return length;
    }

    [Benchmark]
    public int EnumeratorShared()
    {
        var length = 0;
        foreach (var r in Rows)
        {
            var e = new EnumerableColumns<PlayerRow>(r);
            foreach (var s in e)
            {
                length += s.Length;
            }
        }
        return length;
    }

    public sealed class PlayerRow : ITableRow
    {
        public required string Id { get; init; }

        public required string Name { get; init; }

        public IEnumerable<string> Array() => [Id, Name];

        public IEnumerable<string> Yield()
        {
            yield return Id;
            yield return Name;
        }

        public int CountColumns() => 2;

        public string ColumnText(int index) => index switch
        {
            0 => Id,
            1 => Name,
            _ => string.Empty
        };

        public Enumerator GetEnumerator() => new(this);

        /// <summary>
        /// 参考<see cref="ReadOnlySpan{T}.Enumerator"/>。
        /// </summary>
        public struct Enumerator : IEnumerator<string>
        {
            private readonly PlayerRow _row;
            private int _index;

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            internal Enumerator(PlayerRow row)
            {
                _row = row;
                _index = -1;
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public bool MoveNext()
            {
                int index = _index + 1;
                if (index < _row.CountColumns())
                {
                    _index = index;
                    return true;
                }

                return false;
            }

            public readonly string Current
            {
                [MethodImpl(MethodImplOptions.AggressiveInlining)]
                get => _row.ColumnText(_index);
            }

            /// <inheritdoc />
            readonly string IEnumerator<string>.Current => Current;

            /// <inheritdoc />
            readonly object IEnumerator.Current => Current!;

            /// <inheritdoc />
            void IEnumerator.Reset() => _index = -1;

            /// <inheritdoc />
            readonly void IDisposable.Dispose() { }
        }
    }

    public readonly ref struct EnumerableColumns<TRow>(TRow row) where TRow : ITableRow
    {
        public Enumerator GetEnumerator() => new(row);

        /// <summary>
        /// 参考<see cref="ReadOnlySpan{T}.Enumerator"/>。
        /// </summary>
        public ref struct Enumerator : IEnumerator<string>
        {
            private readonly TRow _row;
            private int _index;

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            internal Enumerator(TRow row)
            {
                _row = row;
                _index = -1;
            }

            [MethodImpl(MethodImplOptions.AggressiveInlining)]
            public bool MoveNext()
            {
                int index = _index + 1;
                if (index < _row.CountColumns())
                {
                    _index = index;
                    return true;
                }

                return false;
            }

            public readonly string Current
            {
                [MethodImpl(MethodImplOptions.AggressiveInlining)]
                get => _row.ColumnText(_index);
            }

            /// <inheritdoc />
            readonly string IEnumerator<string>.Current => Current;

            /// <inheritdoc />
            readonly object IEnumerator.Current => Current!;

            /// <inheritdoc />
            void IEnumerator.Reset() => _index = -1;

            /// <inheritdoc />
            readonly void IDisposable.Dispose() { }
        }
    }

    public interface ITableRow
    {
        int CountColumns();

        string ColumnText(int index);
    }
}

笔者的测试结果如下:


BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8737/25H2/2025Update/HudsonValley2)
AMD Ryzen 7 5700X 3.40GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.301
  [Host]    : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3
  .NET 10.0 : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=.NET 10.0  Runtime=.NET 10.0  
MethodMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
Array193.39 μs2.887 μs2.559 μs1.000.0257.3730960000 B1.00
Yield152.47 μs1.273 μs1.191 μs0.790.0123.6816400000 B0.42
EnumeratorInner14.71 μs0.222 μs0.208 μs0.080.00--0.00
EnumeratorShared15.05 μs0.286 μs0.318 μs0.080.00--0.00

结果显示我们将堆内存的分配抹除干净的同时,遍历的性能还提升了10倍!

结语 #

C#早已不再局限于“提升开发效率”的舒适区,而是通过一系列底层能力的持续增强,不断抬升性能的上限。从被迫接受GC开销,到主动掌控内存布局,语言的演进让我们获得了更强的内存控制力,得以用高级语言的优雅语法,写出媲美手动优化般的极致性能;也让.NET在游戏服务器、高频交易、大数据处理等对性能极度敏感的领域拥有了更广阔的想象空间。

希望读者能够借助本文,看到现代.NET的进化轨迹,不被“C#增加的只是语法糖”之类的肤浅认知所误导。在C#的世界里,优雅的代码与极致的性能从来不是互斥的选项,而是可以兼得的现实。