Span 在网络编程中可以提供高性能的内存访问和数据处理能力

开发 前端
在 .NET 中,许多常见的数据类型,如 `string`、`array` 等,都是引用类型,它们本身并不包含实际的数据,而是在堆上分配了一块内存来存储数据,然后将其地址传递给变量。

`Span<T>` 是 .NET Core 2.1 引入的一个新类型,它提供任意内存的连续区域的类型安全和内存安全表示形式。`Span<T>` 可以与任意的值类型或引用类型进行关联,包括原始内存指针、数组、堆上对象等。通过 `Span<T>`,我们可以对这些数据结构进行高效的读取和写入操作,而无需进行拷贝或者分配额外的内存。

在 .NET 中,许多常见的数据类型,如 `string`、`array` 等,都是引用类型,它们本身并不包含实际的数据,而是在堆上分配了一块内存来存储数据,然后将其地址传递给变量。这种设计在很多情况下非常方便,但也会带来一些性能上的问题,比如频繁的内存分配和释放、GC 压力等。`Span<T>` 的出现为解决这些问题提供了一种新的方式。

使用 `Span<T>`,我们可以尽可能地避免进行内存分配和复制,从而提高代码的运行效率。同时,由于 `Span<T>` 只是一个“视图”,它并不会改变原始数据的内容或生命周期,因此也非常安全可靠。在 .NET Core 中,许多常见的 API(如网络、IO、序列化等)都已经开始支持 `Span<T>`,这为我们编写高性能、低延迟的代码提供了更多的可能性。

Span是如何实现的?

通常不需要了解他们正在使用的库是如何实现的。但是,就 Span<T> 而言,至少对其背后的细节有一个基本的了解是值得的,因为这些细节暗示了其性能和使用限制。

首先,Span<T> 是一个包含 ref 和长度的值类型,定义大致如下:

public readonly ref struct Span<T>
{
  private readonly ref T _pointer;
  private readonly int _length;
  ...
}

引用 T 字段的概念一开始可能很奇怪,事实上,实际上无法在 C# 甚至 MSIL 中声明引用 T 字段。但 Span<T> 实际上是为在运行时中使用一种特殊的内部类型而编写的,该类型被视为实时 (JIT) 内部类型,JIT 为其生成等效的 ref T 字段。考虑一个可能更熟悉的 ref 用法:

public static void AddOne(ref int value) => value += 1;
...
var values = new int[] { 42, 84, 126 };
AddOne(ref values[2]);
Assert.Equal(127, values[2]);

此代码通过引用传递数组中的插槽,这样(撇开优化不谈)堆栈上有一个 ref T。Span<T> 中的 ref T 是相同的想法,只是封装在一个结构中。直接或间接包含此类 ref 的类型称为类似 ref 的类型,C# 7.2 编译器允许通过在签名中使用 ref 结构来声明此类类似 ref 的类型。

从这个简短的描述中,应该清楚两件事:

  • Span<T> 的定义方式使操作可以像数组一样高效:索引到 span 中不需要计算来确定指针的起点及其起始偏移量,因为 ref 字段本身已经封装了两者。(相比之下,ArraySegment<T> 具有单独的偏移字段,因此索引和传递的成本更高。
  • Span<T> 作为类似 ref 类型的性质,由于其 ref T 字段而带来了一些约束。

Span使用注意:

Span<T> 是在堆栈而不是托管堆上分配的 ref 结构 。 Ref 结构类型有许多限制,以确保它们不能提升到托管堆,包括不能装箱、不能分配给 类型的Objectdynamic变量或任何接口类型,它们不能是引用类型中的字段,也不能跨 await 和 yield 边界使用。 此外,对和 两个NotSupportedException方法的 Equals(Object)GetHashCode调用会引发 。

因为它是仅堆栈类型, Span<T> 不适用于许多需要存储对堆上的缓冲区的引用的方案。 例如,进行异步方法调用的例程也是如此。 对于此类方案,可以使用互补 System.Memory<T> 和 System.ReadOnlyMemory<T> 类型。

Span的应用场景

Span<T> 可以看作是一个指向连续内存块的引用,它可以用于访问数组、堆栈、堆等数据结构中的连续元素。Span<T> 对象本身不会分配或释放任何内存,因此它非常适用于内存密集型的应用场景,例如网络编程、高性能计算等。

以下是一些 Span<T> 的常见应用场景:

  • 数组操作:Span<T> 可以用于访问和操作数组中的元素,包括读取、修改、排序等操作。与传统的数组访问方式相比,Span<T> 更加灵活和高效,可以有效地减少内存分配和拷贝的开销。
  • 文件操作:Span<T> 可以用于读取和写入文件中的二进制数据。通过使用 MemoryMappedFile 和 Span<T>,可以实现高效的文件读写操作,并且避免了不必要的内存分配和拷贝。
  • 网络编程:Span<T> 可以用于访问网络数据包中的二进制数据,例如解析 TCP/IP 数据包、HTTP 请求等。通过使用 Span<T>,可以避免数据拷贝和内存分配的开销,从而提高网络编程的性能和效率。
  • 高性能计算:Span<T> 可以用于访问和操作大型数组或矩阵中的元素。通过使用 Span<T>,可以避免不必要的内存分配和拷贝,提高计算速度和效率。

如何使用Span

在 .NET 中,可以通过以下几种方式来创建 Span<T> 对象:

直接使用原始内存指针

unsafe
{
    int[] array = { 1, 2, 3, 4 };
    fixed (int* ptr = array)
    {
        Span<int> span = new Span<int>(ptr, array.Length);
        // 对 span 进行操作
    }
}

在这个例子中,我们首先通过 fixed 关键字将 array 数组的地址固定下来,然后使用 new Span<int>(ptr, array.Length) 构造函数创建一个 Span<int> 对象,该对象引用了整个 array 数组。

使用数组

int[] array = { 1, 2, 3, 4 };
Span<int> span = new Span<int>(array);
// 对 span 进行操作

在这个例子中,我们直接使用 array 数组创建了一个 Span<int> 对象,该对象引用了整个数组。

使用数组的一部分

int[] array = { 1, 2, 3, 4 };
Span<int> span = new Span<int>(array, 1, 2);
// 对 span 进行操作

在这个例子中,我们使用 new Span<int>(array, 1, 2) 构造函数创建了一个 Span<int> 对象,该对象引用了 array 数组的第二个元素和第三个元素。

使用字符串

string str = "hello world";
Span<char> span = str.AsSpan();
// 对 span 进行操作

在这个例子中,我们使用 AsSpan 方法将一个字符串转换为 Span<char> 对象,该对象引用了字符串的所有字符。

除了上述方式外,还可以使用 Memory<T> 或者 ReadOnlyMemory<T> 类型来创建 Span<T> 对象。Memory<T> 表示一个可变的内存区域,而 ReadOnlyMemory<T> 表示一个不可变的内存区域,它们都可以用来创建 Span<T> 对象。例如:

int[] array = { 1, 2, 3, 4 };
Memory<int> memory = new Memory<int>(array);
Span<int> span = memory.Span;
// 对 span 进行操作

在这个例子中,我们首先使用 new Memory<int>(array) 构造函数创建了一个 Memory<int> 对象,然后使用 Span 属性获取了其对应的 Span<int> 对象。

使用 Span<T> 可以避免数据拷贝和内存分配的开销,从而提高网络编程的性能和效率。一般情况下,网络数据包的二进制数据往往是连续存储在内存中的,Span<T> 可以直接引用该内存块,而不需要进行额外的拷贝操作。

Span在网络编程中的应用

使用 Span<T> 解析网络数据包的一般步骤:

  • 从网络中接收到数据:使用网络编程库(如Socket)接收网络数据,将数据存储在一个字节数组或内存缓冲区中。
  • 创建 Span<T> 对象:通过将字节数组或内存缓冲区传递给 Span<T> 的构造函数,创建一个 Span<T> 对象。例如:`Span<byte> dataSpan = new Span<byte>(dataBuffer);`
  • 解析数据:利用 Span<T> 的索引和切片功能,可以方便地访问和解析二进制数据。可以通过索引获取特定位置的字节,也可以使用切片操作获取指定范围的字节。例如:`byte firstByte = dataSpan[0];` 或 `Span<byte> headerSpan = dataSpan.Slice(0, headerLength);`
  • 处理数据:根据具体的网络协议,对二进制数据进行解析和处理,提取需要的信息。可以使用 Span<T> 提供的方法或自定义的处理逻辑进行操作。

Span和网络编程的结合

`Span<T>` 在网络编程中可以提供高性能的内存访问和数据处理,从而提升网络应用程序的效率。下面是几个使用 `Span<T>` 进行网络编程的常见场景:

数据接收和解析:使用 `Socket` 接收到的字节数据可以直接转换为 `Span<byte>`,避免了额外的内存拷贝操作。然后,可以使用 `Span<T>` 提供的方法对数据进行解析,例如检查数据包的长度、提取字段值等。

byte[] buffer = new byte[1024];

int bytesRead = socket.Receive(buffer); // 从 Socket 接收数据

Span<byte> data = buffer.AsSpan(0, bytesRead);

// 解析数据包...`

数据发送:使用 `Span<T>` 可以直接将数据发送到网络中,而无需将数据复制到新的缓冲区中。这样可以避免内存拷贝的开销,提高发送数据的效率。

byte[] data = GetPacketData(); // 获取待发送的数据

socket.Send(data.AsSpan()); // 直接发送数据

数据处理和转换:在网络通信中,涉及到各种数据格式的转换和处理操作。使用 `Span<T>` 可以方便地对字节数据进行解析、转换和修改。

byte[] receivedData = ReceiveDataFromSocket(); // 从 Socket 接收数据

// 将接收到的数据转换为字符串

string message = Encoding.UTF8.GetString(receivedData.AsSpan());

// 修改数据并发送回去

receivedData.AsSpan().Reverse(); // 反转字节顺序

SendDataToSocket(receivedData);`

缓冲区池化:在高并发的网络应用程序中,使用缓冲区池化技术可以避免频繁的内存分配和释放操作,提高性能。`Span<T>` 可以与缓冲区池化技术相结合,共享和重用缓冲区,减少内存开销。

ArrayPool<byte> bufferPool = ArrayPool<byte>.Shared;

byte[] buffer = bufferPool.Rent(1024); // 从缓冲区池中租借一个缓冲区

int bytesRead = socket.Receive(buffer); // 从 Socket 接收数据

Span<byte> data = buffer.AsSpan(0, bytesRead);

// 处理接收到的数据...

bufferPool.Return(buffer); // 将缓冲区归还给缓冲区池`

通过合理地利用 `Span<T>` 的特性和方法,我们可以在网络编程中实现高效的数据处理和传输,提升网络应用程序的性能和可伸缩性。但需要注意的是,使用 `Span<T>` 时要小心悬挂指针和内存安全问题,确保操作的内存是有效的并且不会被修改。

使用Socket和Span结合示例

使用 Socket 和 Span<T> 进行网络数据包解析是一种高效、低内存消耗的方式。下面是一个简单的示例,演示如何使用这两个类型进行网络数据包解析:

// 假设已经建立了一个 TCP 连接,并且从 Socket 接收到了一段字节数据

byte[] buffer = new byte[1024]; // 接收数据的缓冲区
int bytesRead = socket.Receive(buffer); // 从 Socket 接收数据

Span<byte> data = buffer.AsSpan(0, bytesRead); // 将接收到的字节数据转换为 Span<byte>

// 解析数据包
while (data.Length > 0)
{
    // 检查数据包的长度是否足够
    if (data.Length < sizeof(int))
    {
        // 数据不完整,等待下一次接收
        break;
    }

    // 读取数据包的长度
    int packetLength = BitConverter.ToInt32(data);
    
    // 检查数据包是否完整
    if (data.Length < packetLength + sizeof(int))
    {
        // 数据不完整,等待下一次接收
        break;
    }
    
    // 提取数据包内容
    Span<byte> packetData = data.Slice(sizeof(int), packetLength);

    // 处理数据包
    ProcessPacket(packetData);

    // 调整指针,继续处理剩余数据
    data = data.Slice(sizeof(int) + packetLength);
}

在上面的示例中,我们首先从 Socket 接收数据,并将接收到的字节数据存储在一个字节数组中。然后,我们使用 AsSpan 方法将字节数组转换为 Span<byte>,以便进行高效的数据包解析。

接下来,我们使用一个循环来处理数据包。在每次循环中,我们首先检查是否有足够的数据来读取数据包的长度。如果数据不完整,我们等待下一次接收。如果有足够的数据,我们读取数据包的长度,并检查是否有足够的数据来完整解析数据包。如果数据不完整,我们等待下一次接收。

一旦我们获得了完整的数据包,我们可以使用 Slice 方法提取数据包的内容,并进行相应的处理操作(例如解析数据、验证数据、处理数据等)。

通过使用 Span<T> 和适当的循环逻辑,我们可以高效地解析网络数据包,减少内存拷贝和数据处理的开销,从而提高网络应用程序的性能和效率。

责任编辑:姜华 来源: 今日头条
相关推荐

2016-12-11 10:08:31

高性能组网能力

2020-11-06 18:51:17

LinuxTCP服务器

2023-11-01 11:51:08

Linux性能优化

2018-03-08 12:17:38

大数据HPCHadoop

2023-11-01 11:59:13

2023-12-13 10:22:04

APISpark数据

2024-03-18 13:43:20

Linux架构

2018-08-14 11:05:25

2013-11-08 10:12:07

2023-11-01 10:38:46

Linux高性能网络编程

2023-12-18 11:21:40

MongoDB数据库

2020-02-06 13:40:35

编程缓存优化

2024-04-01 10:07:47

应用程序数据数据库

2024-01-29 08:26:13

Span高性能数组数据结构

2018-10-08 15:22:36

IO模型

2010-01-21 16:12:28

NetFlow交换

2011-09-01 15:12:43

SQL ServerHadoop

2013-12-16 17:17:01

OpenMp数据处理

2013-09-03 09:14:12

2023-11-01 11:27:10

Linux协程
点赞
收藏

51CTO技术栈公众号