Pipelines - .NET中的新IO API指引(三) 边看边记

Pipelines - .NET中的新IO API指引 作者 marcgravell  原文

此系列前两篇网上已有的译文

Pipelines - .NET中的新IO API指引(一)

Pipelines - .NET中的新IO API指引(二)

关于System.IO.Pipelines的一篇说明

System.IO.Pipelines: .NET高性能IO

本篇不是翻译,边看边译边记而已。

System.IO.Pipelines 是对IO的统一抽象,文件、com口、网络等等,重点在于让调用者注意力集中在读、写缓冲区上,典型的就是 IDuplexPipe中的Input Output。

可以理解为将IO类抽象为读、写两个缓冲区。

目前官方实现还处于preview状态,作者使用Socket和NetworkStream 实现了一个 Pipelines.Sockets.Unofficial

作者在前两篇中提到使用System.IO.Pipelines 改造StackExchange.Redis,在本篇中作者采用了改造现有的SimplSockets库来说明System.IO.Pipelines的使用。

文章中的代码SimplPipelines,KestrelServer

## SimplSockets说明

+ 可以单纯的发送(Send),也可以完成请求/响应处理(SendRecieve)
+ 同步Api
+ 提供简单的帧协议封装消息数据
+ 使用byte[]
+ 服务端可以向所有客户端广播消息
+ 有心跳检测等等

属于非常典型的传统Socket库。

## 作者的改造说明

### 对缓冲区数据进行处理的一些方案及选型

1. 使用byte[]拷贝出来,作为独立的数据副本使用,简单易用但成本高(分配和复制)
   2. 使用 ReadOnlySequence<byte> ,零拷贝,快速但有限制。一旦在管道上执行Advance操作,数据将被回收。在有严格控制的服务端处理场景(数据不会逃离请求上下文)下可以使用,言下之意使用要求比较高。
   3. 作为2的扩展方案,将数据载荷的解析处理代码移至类库中(处理ReadOnlySequence<byte>),只需将解构完成的数据发布出来,也许需要一些自定义的structs 映射(map)一下。这里说的应该是直接将内存映射为Struct?
   4. 通过返回Memory<byte> 获取一份数据拷贝,也许需要从ArrayPool<byte>.Share 池中返回一个大数组;但是这样对调用者要求较高,需要返回池。并且从Memory<T> 获取一个T[]属于高级和不安全的操作。不安全,有风险。( not all Memory<T> is based on T[])
   5. 一个妥协方案,返回一个提供Memory<T>(Span<T>)的东西,并且使用一些明确的显而易见的Api给用户,这样用户就知道应该如何处理返回结果。比如IDisposable/using这种,在Dispose()被调用时将资源归还给池。
  
   作者认为,设计一个通用的消息传递Api时,方案5更为合理,调用方可以保存一段时间的数据并且不会干扰到管道的正常工作,也可以很好的利用ArrayPool。如果调用者没有使用using也不会有什么大麻烦,只是效率会降低一些,就像使用方案1一样。
     但是方案的选择需要充分考虑你的实际场景,比如在StackExchange.Redis 客户端中使用的是方案3;在不允许数据离开请求上下文时使用方案2.。
  一旦选定方案,以后基本不可能再更改。

针对效率最高的方案2,作者提出的专业建议是 **使用ref struct** 。

此处选择的是方案5,与方案4的区别就是对Memory<T> 的处理,作者使用 System.Buffers.IMemoryOwner<T>接口

 public interface IMemoryOwner<T> : IDisposable
 {
  Memory<T> Memory { get; }
 }

以下为实现代码,Dispose时归还借出的数组,并考虑线程安全,避免多次归还(very bad)。

private sealed class ArrayPoolOwner<T>:IMemoryOwner<T>{
 private readonly int _length;
 private T[] _oversized;
 internal ArrayPoolOwner(T[] oversized,int length){
  _length=length;
  _oversized=oversized;
 }
 public Memory<T> Memory=>new Memory<T>(GetArray(),0,_length);
 private T[] GetArray()=>Interlocked.CompareExchange(ref _oversized,null,null)
  ?? throw new ObjectDisposedException(ToString());
 public void Dispose(){
  var arr=Interlocked.Exchange(ref _oversized,null);
  if(arr!=null) ArrayPool<T>.Shared.Return(arr);
 }
}

Dispose后如果再次调用Memory将会失败,即 使用时 using,不要再次使用。

**对ArrayPool的一些说明**
+ 从ArrayPool借出的数组比你需要的要大,你给定的大小在ArrayPool看来属于下限(不可小于你给定的大小),见:ArrayPool<T>.Shared.Rent(int minimumLength);
+ 归还时数组默认不清空,因此你借出的数组内可能会有垃圾数据;如果需要清空,在归还时使用 ArrayPool<T>.Shared.Return(arr,true) ;

作者对ArrayPool的一些建议: 
增加 IMemoryOwner<T> RentOwned(int length),T[] Rent(int minimumLength) 及借出时清空数组,归还时清空数组的选项。

这里的想法是通过IMemoryOwner<T>实现一种所有权的转移,典型调用方法如下

 void DoSomething(IMemoryOwner<byte> data){
  using(data){
       // ... other things here ...
                DoTheThing(data.Memory);
  }
  // ... more things here ...
 }

通过ArrayPool的借、还机制避免频繁分配。

 **作者的警告:**
 + 不要把data.Memory 单独取出乱用,using完了就不要再操作它了(这种错误比较基础)
 + 有人会用MemoryMarshal搞出数组使用,作者认为可以实现一个 MemoryManager<T>(ArrayPoolOwner<T> : MemoryManager<T>, since MemoryManager<T> : IMemoryOwner<T>)让.Span如同.Memory一样失败。
 ---- 作者也挺纠结(周道)的 :)。

使用  ReadOnlySequence<T> 填充ArrayPoolOwner(构造,实例化)

public static IMemoryOwner<T> Lease<T>(this ReadOnlySequence<T> source)
    {
        if (source.IsEmpty) return Empty<T>();
        int len = checked((int)source.Length);
        var arr = ArrayPool<T>.Shared.Rent(len);//借出
        source.CopyTo(arr);
        return new ArrayPoolOwner<T>(arr, len);//dispose时归还
    }

### 基本API

服务端和客户端虽然不同但代码有许多重叠的地方,比如都需要某种线程安全机制的写入,需要某种读循环来处理接收的数据,因此可以共用一个基类。
基类中使用IDuplexPipe(包括input,output两个管道)作为管道。

public abstract class SimplPipeline : IDisposable
    {
        private IDuplexPipe _pipe;
        protected SimplPipeline(IDuplexPipe pipe)
            => _pipe = pipe;
        public void Dispose() => Close();
        public void Close() {/* burn the pipe*/}
    }

首先,需要一种线程安全的写入机制并且不会过度阻塞调用方。在原SimplSockets(包括StackExchange.Redis v1)中使用消息队列来处理。调用方Send时同步的将消息入队,在将来的某刻,消息出队并写入到Socket中。此方式存在的问题
+ 有许多移动的部分
+ 与“pipelines”有些重复

管道本身即是队列,本身具备输出(写、发送)缓冲区,没必要再增加一个队列,直接把数据写入管道即可。取消原有队列只有一些小的影响,在StackExchange.Redis v1 中使用队列完成优先级排序处理(队列跳转),作者表示不担心这一点。

**写入Api设计**
 + 不一定时同步的
 + 调用方可以单纯的传入一段内存数据(ReadOnlyMember<byte>),或者是一个(IMemoryOwner<byte>)由Api写入后进行清理。
  + 先假设读、写分开(暂不考虑响应)

protected async ValueTask WriteAsync(IMemoryOwner<byte> payload, int messageId)//调用方不再使用payload,需要我们清理
    {
        using (payload)
  {
   await WriteAsync(payload.Memory, messageId);
  }
 }
protected ValueTask WriteAsync(ReadOnlyMemory<byte> payload, int messageId);//调用方自己清理

messageId标识一条消息,写入消息头部, 用于之后处理响应回复信息。
   返回值使用ValueTask因为写入管道通常是同步的,只有管道执行Flush时才可能是异步的(大多数情况下也是同步的,除非在管道被备份时)。

### 写入与错误

首先需要保证单次写操作,lock在此不合适,因为它不能与异步操作很好的协同。考虑flush有可能是异步的,导致后续(continuation )部分可能会在另外的线程上。这里使用与异步兼容的SemaphoreSlim。
下面是一条指南:**一般来说, 应用程序代码应针对可读性进行优化;库代码应针对性能进行优化。**
以下为机翻原文
> 您可能同意也可能不同意这一点, 但这是我编写代码的一般指南。我的意思是,类库代码往往有一个单一的重点目的, 往往由一个人的经验可能是 "深刻的, 但不一定是    广泛的" 维护;你的大脑专注于那个领域, 用奇怪的长度来优化代码是可以的。相反,应用程序代码往往涉及更多不同概念的管道-"宽但不一定深" (深度隐藏在各种库      中)。应用程序代码通常具有更复杂和不可预知的交互, 因此重点应放在可维护和 "明显正确" 上。
  基本上, 我在这里的观点是, 我倾向于把很多注意力集中在通常不会放入应用程序代码中的优化上, 因为我从经验和广泛的基准测试中知道它们真的很重要。所以。。。我要做一些看起来很奇怪的事情, 我希望你和我一起踏上这段旅程。

“明显正确”的代码

private readonly SemaphoreSlim _singleWriter= new SemaphoreSlim(1);
protected async ValueTask WriteAsync(ReadOnlyMemory<byte> payload, int messageId)
{
    await _singleWriter.WaitAsync();
    try
    {
        WriteFrameHeader(writer, payload.Length, messageId);
        await writer.WriteAsync(payload);
    }
    finally
    {
        _singleWriter.Release();
    }
}

这段代码没有任何问题,但是即便所有部分都是同步完成的,就会产生多余的状态机-------大概是 不是所有地方都需要异步处理 的意思。
通过两个问题进行重构
- 单次写入是否没有竞争?(无人争用)
- Flush是否为同步

重构,将原WriteAsync 更名为 WriteAsyncSlowPath,增加新的WriteAsync

作者的“一些看起来很奇怪的” 实现

protected ValueTask WriteAsync(ReadOnlyMemory<byte> payload, int messageId)
{
    // try to get the conch; if not, switch to async
//writer已经被占用,异步
    if (!_singleWriter.Wait(0))
        return WriteAsyncSlowPath(payload, messageId);
    bool release = true;
    try
    {
        WriteFrameHeader(writer, payload.Length, messageId);
        var write = writer.WriteAsync(payload);
        if (write.IsCompletedSuccessfully) return default;
        release = false;
        return AwaitFlushAndRelease(write);
    }
    finally
    {
        if (release) _singleWriter.Release();
    }
}
async ValueTask AwaitFlushAndRelease(ValueTask<FlushResult> flush)
{
    try { await flush; }
    finally { _singleWriter.Release(); }
}

三个地方
1. _singleWriter.Wait(0) 意味着writer处于空闲状态,没有其他人在调用
2. write.IsCompletedSuccessfully 意味着writer同步flush
3. 辅助方法 AwaitFlushAndRelease 处理异步flush情况
-------------------------------------------------------------------------------------

### 协议头处理

协议头由两个int组成,小端,第一个是长度,第二个是messageId,共8字节。

void WriteFrameHeader(PipeWriter writer, int length, int messageId)
{
    var span = writer.GetSpan(8);
    BinaryPrimitives.WriteInt32LittleEndian(
        span, length);
    BinaryPrimitives.WriteInt32LittleEndian(
        span.Slice(4), messageId);
    writer.Advance(8);
}

 

### 管道客户端实现 发送

public class SimplPipelineClient : SimplPipeline
{
    public async Task<IMemoryOwner<byte>> SendReceiveAsync(ReadOnlyMemory<byte> message)
    {
        var tcs = new TaskCompletionSource<IMemoryOwner<byte>>();
        int messageId;
        lock (_awaitingResponses)
        {
            messageId = ++_nextMessageId;
            if (messageId == 0) messageId = 1;
            _awaitingResponses.Add(messageId, tcs);
        }
        await WriteAsync(message, messageId);
        return await tcs.Task;
    }
    public async Task<IMemoryOwner<byte>> SendReceiveAsync(IMemoryOwner<byte> message)
    {
        using (message)
        {
            return await SendReceiveAsync(message.Memory);
        }
    }
}

- _awaitingResponses 是个字典,保存已经发送的消息,用于将来处理对某条(messageId)消息的回复。

### 接收循环

protected async Task StartReceiveLoopAsync(CancellationToken cancellationToken = default)
{
   try
   {
       while (!cancellationToken.IsCancellationRequested)
       {
           var readResult = await reader.ReadAsync(cancellationToken);
           if (readResult.IsCanceled) break;
           var buffer = readResult.Buffer;
           var makingProgress = false;
           while (TryParseFrame(ref buffer, out var payload, out var messageId))
           {
               makingProgress = true;
               await OnReceiveAsync(payload, messageId);
           }
           reader.AdvanceTo(buffer.Start, buffer.End);
           if (!makingProgress && readResult.IsCompleted) break;
       }
       try { reader.Complete(); } catch { }
   }
   catch (Exception ex)
   {
       try { reader.Complete(ex); } catch { }
   }
}
protected abstract ValueTask OnReceiveAsync(ReadOnlySequence<byte> payload, int messageId);

接收缓冲区里什么时间会有什么东西由发送方和系统环境决定,因此延迟是必然的,所以这里全部按异步处理就好。
- TryParseFrame 读出缓冲区数据,根据帧格式解析出实际数据、id等
- OnRecieveAsync 处理数据,比如对于回复/响应的处理等
- reader中的数据读出后尽快Advance一下,通知管道你的读取进度;

解析帧

private bool TryParseFrame(
    ref ReadOnlySequence<byte> input,
    out ReadOnlySequence<byte> payload, out int messageId)
{
    if (input.Length < 8)
    {   // not enough data for the header
        payload = default;
        messageId = default;
        return false;
    }
    int length;
    if (input.First.Length >= 8)
    {   // already 8 bytes in the first segment
        length = ParseFrameHeader(
            input.First.Span, out messageId);
    }
    else
    {   // copy 8 bytes into a local span
        Span<byte> local = stackalloc byte[8];
        input.Slice(0, 8).CopyTo(local);
        length = ParseFrameHeader(
            local, out messageId);
    }
    // do we have the "length" bytes?
    if (input.Length < length + 8)
    {
        payload = default;
        return false;
    }
    // success!
    payload = input.Slice(8, length);
    input = input.Slice(payload.End);
    return true;
}

缓冲区是不连续的,一段一段的,像链表一样,第一段就是input.First。
代码很简单,主要演示一些用法;
辅助方法

static int ParseFrameHeader(
    ReadOnlySpan<byte> input, out int messageId)
{
    var length = BinaryPrimitives
            .ReadInt32LittleEndian(input);
    messageId = BinaryPrimitives
            .ReadInt32LittleEndian(input.Slice(4));
    return length;
}

OnReceiveAsync

protected override ValueTask OnReceiveAsync(
    ReadOnlySequence<byte> payload, int messageId)
{
    if (messageId != 0)
    {   // request/response
        TaskCompletionSource<IMemoryOwner<byte>> tcs;
        lock (_awaitingResponses)
        {
            if (_awaitingResponses.TryGetValue(messageId, out tcs))
            {
                _awaitingResponses.Remove(messageId);
            }
        }
        tcs?.TrySetResult(payload.Lease());
    }
    else
    {   // unsolicited
        MessageReceived?.Invoke(payload.Lease());
    }
    return default;
}

到此为止,其余部分主要是一些服务端和其他功能实现及benchmark。。。

原文地址:https://www.cnblogs.com/cerl/p/9925879.html

时间: 2024-11-05 22:37:17

Pipelines - .NET中的新IO API指引(三) 边看边记的相关文章

Pipelines - .NET中的新IO API指引(一)

https://zhuanlan.zhihu.com/p/39223648 原文:Pipelines - a guided tour of the new IO API in .NET, part 1 作者:marcgravell 大约两年前,我发表了一篇关于.NET中即将到来的体验性新IO API的博文--在那时它被叫做"Channels":在2018年的五月末,它终于在System.IO.Pipelines命名空间中落地,我对这系列API巨感兴趣,而在几个星期前,我被分配去用&qu

Java新IO

Java从1.4开始引进了对于输入输出的改进,相关类位于java.nio包中.新IO主要有以下几个特性: (1)字符集编码器和解码器 (2)非阻塞的IO (3)内存映射文件 1. 字符集编码器和解码器 Charset类表示不同的字符集,可以使用Charset.forName方法获得指定名称的字符集对象,与Charset相关的类在java.nio.charset包中. (1)编码 将Unicode编码的字符串编码成指定编码的字节序列 Charset cset = Charset.forName("

【Servlet】Javaweb中,利用新浪api接口,获取IP地址,并获取相应的IP归属地

这里之所以调用新浪api接口,主要是可以避免我们在本地存放一个ip归属地库, 同时,我们在本地要存放用户的ip,仅仅存放其ip就可以了,无须存放其归属地,节省一个字段. 如下图,写一个带有获取客户端IP地址的网页: 首先,在Eclipse的目录结构如下: 里面除了servlet的支持包之外,就一个.jsp与一个.java. 其中这里用到Servlet3.0,因此web.xml没有任何东西: <?xml version="1.0" encoding="UTF-8"

ArcGIS API For Javascript新版本3.11中的新特性

ArcGIS API For Javascript新版本3.11中的新特性: 更简短的引用URL:如果你正在将用以前的版本的程序更新到新版本的话,这是很重要的. To update your code for version 3.11 references, replace the following URLs accordingly: /3.10/js/dojo/ should now read /3.11/ (note the dropped "/js/dojo") 将你的源码更新

Java日期时间API系列7-----Jdk8中java.time包中的新的日期时间API类的特点

1.不变性 新的日期/时间API中,所有的类都是不可变的,这对多线程环境有好处. 比如:LocalDateTime 2.关注点分离 新的API将人可读的日期时间和机器时间(unix timestamp)明确分离,它为日期(Date).时间(Time).日期时间(DateTime).时间戳(unix timestamp)以及时区定义了不同的类. 不同时间分解成了各个类,比如:LocalDate, LocalTime, LocalDateTime, Instant,Year,Month,YearMo

Java日期时间API系列8-----Jdk8中java.time包中的新的日期时间API类的LocalDate源码分析

目录 0.前言 1.TemporalAccessor源码 2.Temporal源码 3.TemporalAdjuster源码 4.ChronoLocalDate源码 5.LocalDate源码 6.总结 0.前言 通过前面Java日期时间API系列6-----Jdk8中java.time包中的新的日期时间API类中主要的类关系简图如下: 可以看出主要的LocalDate, LocalTime, LocalDateTime, Instant都是实现相同的接口,这里以LocalDate为例分析jav

Java日期时间API系列11-----Jdk8中java.time包中的新的日期时间API类,使用java8日期时间API重写农历LunarDate

通过Java日期时间API系列7-----Jdk8中java.time包中的新的日期时间API类的优点,java8具有很多优点,现在网上查到的农历转换工具类都是基于jdk7及以前的类写的,下面使用java新的日期时间API重写农历LunarDate. package com.xkzhangsan.time; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import ja

Java日期时间API系列13-----Jdk8中java.time包中的新的日期时间API类,时间类转换,Date转LocalDateTime,LocalDateTime转Date

从前面的系列博客中可以看出Jdk8中java.time包中的新的日期时间API类设计的很好,但Date由于使用仍非常广泛,这就涉及到Date转LocalDateTime,LocalDateTime转Date.下面是时间类互相转换大全,包含Instant.LocalDate.LocalDateTime.LocalTime和Date的相互转换,下面是一个工具类,仅供参考: package com.xkzhangsan.time.converter; import java.time.Instant;

Java日期时间API系列17-----Jdk8中java.time包中的新的日期时间API类,java日期计算4,2个日期对比,获取相差年月日部分属性和相差总的天时分秒毫秒纳秒等

通过Java日期时间API系列9-----Jdk8中java.time包中的新的日期时间API类的Period和Duration的区别 ,可以看出java8设计非常好,新增了Period和Duration类,专用于对比2个时间场景: Period,可以获取2个时间相差的年月日的属性. Duration,可以获取2个时间相差总的天时分秒毫秒纳秒. 下面应用: /** * 获取2个日期的相差年月天的年数部分 * @param startInclusive * @param endExclusive