前言

在过去的一些文章里面,我们聊了一些.NET平台上高性能编程的技巧,今天带大家了解一下AlterNats这个库是如何做到远超同类SDK性能的。

NATS:NATS是一个开源、轻量级、高性能的分布式消息中间件,实现了高可伸缩性和优雅的Publish/Subscribe模型。NATS的开发哲学认为高质量的QoS应该在客户端构建,故只建立了Request-Reply,不提供 1.持久化 2.事务处理 3.增强的交付模式 4.企业级队列等功能,所以它的性能可以非常好。

NATS.NET:NATS.NET是NATS官方实现的C#语言客户端,它的架构和Go版本保持一致,导致没有使用一些高性能的API和新的语法,性能整体较弱,不过它支持.NET4.6+和.NETStandard1.6+几乎兼容主流的.NET版本。

AlterNats:因为官方实现的NATS.NET性能较弱,所以大佬又实现使用了C#和.NET新特性和API编写了这个高性能NATS客户端,它的发布订阅性能比StackExchange.Redis和官方的Nats.Net快三倍以上。

上图是8byte数据发布订阅性能对比,可以看到AlterNats遥遥领先,比官方的实现快了很多。下面就带大家了解一下如何使用AlterNats和为什么它能实现这么高的性能。

使用

AlterNats的API完全采用async/await并保持C#原生风格。

// 创建一个链接
await using var conn = new NatsConnection(); // 订阅消息
var subscription = await conn.SubscribeAsync<Person>("foo", x =>
{
Console.WriteLine($"Received {x}");
}); // 发布消息
await conn.PublishAsync("foo", new Person(30, "bar")); // 退订
subscription.Dipose(); // --- public record Person(int Age, string Name);

NatsOptions/ConnectOptions是不可变的记录,它可以使用C#的new和with语法,非常的方便。

// 可选配置项可以通过`with`关键字
var options = NatsOptions.Default with
{
Url = "nats://127.0.0.1:9999",
LoggerFactory = new MinimumConsoleLoggerFactory(LogLevel.Information),
Serializer = new MessagePackNatsSerializer(),
ConnectOptions = ConnectOptions.Default with
{
Echo = true,
Username = "foo",
Password = "bar",
}
}; await using var conn = new NatsConnection(options);

它还提供了用于接收结果的标准协议。可以将其用作服务器之间的简单RPC,在某些情况下可能很有用。

// 服务端
await conn.SubscribeRequestAsync("foobar", (int x) => $"Hello {x}"); // 客户端
var response = await conn.RequestAsync<int, string>("foobar", 100);

如何做到高性能的?

在之前的文章中,和大家聊过,高性能就是在相同的资源的情况下,能处理更多的数据。我们需要降低单次数据处理所耗费的资源(CPU、内存、磁盘等等),下面就带大家了解AlterNats做了什么,来节省这些资源。

高性能Socket编程

在C#中,最底层的网络处理类是Socket,如果你想要异步、高性能的处理网络请求,你需要重用带回调的SocketAsyncEventArgs

然而,现在我们有更简单的方式使用async/await方法,不需要复杂的SocketAsyncEventArgs,不过它有许多使用异步的方法,需要你选择正确的一个去使用。最简单选择的方法就是,用返回值为ValueTask的API就好了。

// 使用这些
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken) public ValueTask<int> ReceiveAsync(Memory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken) public ValueTask<int> SendAsync(ReadOnlyMemory<byte> buffer, SocketFlags socketFlags, CancellationToken cancellationToken)) // 不要使用这些
public Task ConnectAsync(string host, int port) public Task<int> ReceiveAsync(ArraySegment<byte> buffer, SocketFlags socketFlags) public Task<int> SendAsync(ArraySegment<byte> buffer, SocketFlags socketFlags)

返回ValueTask的API内部使用的AwaitableSocketAsyncEventArgs,它非常的高效,另外由于ValueTask是结构体类型,无需像Task一样在堆上分配,还能简单的享受到异步带来的性能提升。与SocketAsyncEventArgs相比,这是一个非常大的改进,SocketAsyncEventArgs非常难用,我强烈推荐上面提到的ValueTask API.

另外要注意的是,同步API可以使用Span<T>类型,但异步的API只能使用Memroy<T>(因为数据需要存在在堆上),这个点不仅限于Socket网络编程,其它API也是一样,如果整个系统在设计的时候就没有考虑这些,那么无法使用Span<T>类型可能为成为障碍。但是你必须保证你可以随心所欲的使用Memory<T>

使用二进制解析文本协议

NATS的协议是基于文本的协议,和Redis等协议类似,它可以简单通过字符串函数来拆分和处理。可以使用StreamReader很容易的实现这个协议,因为你所需要就是ReadLine来读取数据就好。然而,在网络上传输的是UTF-8格式的二进制数据,将其作为字符串来处理开销较大,如果我们需要高性能,那么必须将其作为二进制数据来处理。

NATS的协议可以通过前导字符串(INFO、MSG、PINT、+OK、-ERR等等)来确定消息的类型。虽然可以很容易的使用if(msg == "INFO")这样的代码来分割字符串处理,但是出于性能原因,这样的开销是不可接受的。

那么我们使用什么样的方法来提升性能呢?举一个例子,字符串INFO的UTF-8编码是[73, 78, 70, 79]

我们可以直接使用ReadOnlySpan<byte>.SequenceEqual()函数来处理(这个函数优化非常好,它会使用SIMD来提高性能)。

不过在我们的场景里,因为NATS的前导字符串都在4byte以内,所以我们可以将INFO转换为一个int类型来处理,比如将字符串INFO的UTF-8编码转换为int就是1330007625.

所以在AlterNats里面的代码就是这样处理INFO的。

var msg = new byte[] {73,78,70,79};
if (Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference<byte>(msg)) == 1330007625) // INFO
{
"Command is INFO".Dump();
}



这应该是在理论上最快的判断方式了,3个字符的指令后面总是紧跟着空格或者换行符,所以可以使用下面这些常量来判断其它的类型。


internal static class ServerOpCodes
{
public const int Info = 1330007625; // Encoding.ASCII.GetBytes("INFO") |> MemoryMarshal.Read<int>
public const int Msg = 541545293; // Encoding.ASCII.GetBytes("MSG ") |> MemoryMarshal.Read<int>
public const int Ping = 1196312912; // Encoding.ASCII.GetBytes("PING") |> MemoryMarshal.Read<int>
public const int Pong = 1196314448; // Encoding.ASCII.GetBytes("PONG") |> MemoryMarshal.Read<int>
public const int Ok = 223039275; // Encoding.ASCII.GetBytes("+OK\r") |> MemoryMarshal.Read<int>
public const int Error = 1381123373; // Encoding.ASCII.GetBytes("-ERR") |> MemoryMarshal.Read<int>
}

使用栈上分配

在请求发送中,有很多小的字符串和byte[]对象,这些小对象会比较频繁产生从而影响GC标记时间,在AlterNats中,比较多的使用了stackalloc byte[10]将这些小的对象分配在栈上,当方法结束时,对象就自动释放了,无需GC再参与,有利于降低内存占用率和GC的暂停时间。

public void WritePublish<T>(in NatsKey subject, ReadOnlyMemory<byte> inboxPrefix, int id, T? value, INatsSerializer serializer)
{
// 栈上分配小对象
Span<byte> idBytes = stackalloc byte[10];
if (Utf8Formatter.TryFormat(id, idBytes, out var written))
{
idBytes = idBytes.Slice(0, written);
} var offset = 0;
var maxLengthWithoutPayload = CommandConstants.PubWithPadding.Length
+ subject.LengthWithSpacePadding
+ (inboxPrefix.Length + idBytes.Length + 1) // with space
+ MaxIntStringLength
+ NewLineLength;
}

自动管道批处理

在NATS协议中,所有的写入和读取操作都是流水线的(批处理)。这很容易用Redis的流水线来解释。比如,如果你同一时间发送3个消息,每次发送一个,然后等待响应,那么多次往返的发送和接收会成为性能瓶颈。

在发送消息中,AlterNats自动将它们组织成流水线:使用System.Threading.Channels,消息被打包进入队列,然后由一个写循环检索它们,并将它们通过网络成批的发送出去。一旦网络传输完成,写循环的方法又会将等待网络传输时累积的消息再次进行批处理。

这不仅能节省往返的时间(在NATS中,发布和订阅都是独立的,所以不需要等待响应),另外它也能减少连续的系统调用。.NET最快的日志记录组件ZLogger也采用了相同的方法。

将许多功能整合到单个对象中

为了实现这样的PublishAsync方法,我们需要将数据放入队列的Channel中,并且将其固定在堆上。我们还需要一个异步方法的Task,以便我们可以用await等待它写入完成。

await connection.PublishAsync(value);

为了高效地实现这样一个API,避免多余的分配,我们把所有的功能都在一个消息对象(内部名称叫Command)里面,这样的话只有它会被分配内存。

class AsyncPublishCommand<T> :
ICommand,
IValueTaskSource,
IThreadPoolWorkItem,
IObjectPoolNode<AsyncPublishCommand<T>> internal interface ICommand
{
void Write(ProtocolWriter writer);
} internal interface IObjectPoolNode<T>
{
ref T? NextNode { get; }
}

这个对象(AsyncPublicCommand)本身就有用于保存T类型数据和将其二进制数据写入Socket的角色(ICommand)。

此外,通过实现IValueTaskSource接口,该对象本身也变成了ValueTask。

然后,await后面的回调需要交给线程池处理,以避免阻塞写循环。使用传统的ThreadPool.QueueUserWorkItem(callback)会有额外的内存分配,因为它会在内部创建一个ThreadPoolWorkItem并将其塞入线程池队列中。在.NET Core 3.0以后我们可以通过实现IThreadPoolWorkItem来避免内部ThreadPoolWorkItem对象的内存分配。

最后,我们只有一个对象需要分配,另外我们还可以池化这个对象,使其达到零分配(zero allocated)。可以使用ConcurrentQueue<T>或者类似的轻松实现对象池,上面的类中,通过实现IObjectPoolNode<T>接口,使它自己成为栈中的节点,避免分配数组。堆栈也可以提供一个无效的实现,为这种缓存的使用进行优化。

零拷贝架构

需要发布、订阅的数据通常是序列化的C#类型,比如Json、MessagePack等。在这种情况下,它们不可避免的会使用bytes[]交换数据,例如,StackExchange.Redis中的RedisValue内容实际上就是bytes[],无论是发送还是接收,我们都需要创建和保存bytes[]

为了避免这种情况,通常会使用ArrayPool来实现零分配,但是这仍然会产生复制的成本。当然,零分配是我们的目标,但是我们也要朝着zero-copy去努力。

AlterNats序列化要求使用IBufferWriter<byte>写入,使用ReadOnlySequence<byte>来读取。

public interface INatsSerializer
{
int Serialize<T>(ICountableBufferWriter bufferWriter, T? value);
T? Deserialize<T>(in ReadOnlySequence<byte> buffer);
} public interface ICountableBufferWriter : IBufferWriter<byte>
{
int WrittenCount { get; }
} // --- // 举个例子,使用MessagePack来实现序列化和反序列化 public class MessagePackNatsSerializer : INatsSerializer
{
public int Serialize<T>(ICountableBufferWriter bufferWriter, T? value)
{
var before = bufferWriter.WrittenCount;
MessagePackSerializer.Serialize(bufferWriter, value);
return bufferWriter.WrittenCount - before;
} public T? Deserialize<T>(in ReadOnlySequence<byte> buffer)
{
return MessagePackSerializer.Deserialize<T>(buffer);
}
}

C#的System.Text.Json或MessagePack有接收IBufferWriter<byte>参数的序列化重载方法。序列化器通过IBufferWriter直接读取和写入Socket提供的缓冲区,从而消除了Socket和序列化器之间的bytes[]复制。

在读取时,ReadOnlySequence<byte>是必须的,因为从Socket接收的数据通常是分段的。

一种常见的设计模式就使用System.IO.Pipelines的PipeReader来读取和处理数据,它目的是一个简单使用的高性能I/O库。但是AlterNats没有使用Pipelines,而是使用了自己的读取机制和ReadOnlySequence<byte>

System.Text.Json和MessagePack for C#的序列化方法提供了一个接受IBufferWriter<byte>参数的重载,反序列化方法接受ReadOnlySequence<byte>。换句话说,现代序列化器必须支持IBufferWriter<byte>ReadOnlySequence<byte>

总结

本文内容70%来自AlterNats作者的博客文章,这是一篇不可多得的好文章,详细的说明了AlterNats是如何做到高性能的,让我们在回顾一下。

  • 使用最新的Socket ValueTask API
  • 将所有的功能放到单个对象中,降低SDK的内存分配
  • 池化SDK使用类,栈上分配数据,做到堆上零分配
  • 使用二进制方式解析NATS协议
  • 对读取和写入自动进行批处理
  • 使用IBufferWriter<byte>ReadOnlySequence<byte>,对网络数据处理做到zero-copy

附录

AlterNats项目地址: https://github.com/Cysharp/AlterNats

AlterNats是如何做到高性能的发布订阅的?的更多相关文章

  1. Kafka是分布式发布-订阅消息系统

    Kafka是分布式发布-订阅消息系统 https://www.biaodianfu.com/kafka.html Kafka是分布式发布-订阅消息系统.它最初由LinkedIn公司开发,之后成为Apa ...

  2. Redis教程03——Redis 发布/订阅(Pub/Sub)

    Pub/Sub 订阅,取消订阅和发布实现了发布/订阅消息范式(引自wikipedia),发送者(发布者)不是计划发送消息给特定的接收者(订阅者).而是发布的消息分到不同的频道,不需要知道什么样的订阅者 ...

  3. Redis 学习(三) —— 事务、消息发布订阅

    一.Redis事务 Redis 提供的事务机制与传统的数据库事务有些不同,传统数据库事务必须维护以下特性:原子性(Atomicity), 一致性(Consistency),隔离性(Isolation) ...

  4. 发布-订阅消息系统Kafka简介

    转载请注明出处:http://www.cnblogs.com/BYRans/ Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写.Kafka是一种高吞吐量的分布式 ...

  5. redis实现消息队列&发布/订阅模式使用

    在项目中用到了redis作为缓存,再学习了ActiveMq之后想着用redis实现简单的消息队列,下面做记录.   Redis的列表类型键可以用来实现队列,并且支持阻塞式读取,可以很容易的实现一个高性 ...

  6. 【9】JMicro微服务-发布订阅消息服务

    如非授权,禁止用于商业用途,转载请注明出处作者:mynewworldyyl 1. JMicro消息服务目前实现特性 a. JMicro只支持发布订阅消息服务,不支持队列式消息服务: b. 不支持消息持 ...

  7. [翻译] C# 8.0 新特性 Redis基本使用及百亿数据量中的使用技巧分享(附视频地址及观看指南) 【由浅至深】redis 实现发布订阅的几种方式 .NET Core开发者的福音之玩转Redis的又一傻瓜式神器推荐

    [翻译] C# 8.0 新特性 2018-11-13 17:04 by Rwing, 1179 阅读, 24 评论, 收藏, 编辑 原文: Building C# 8.0[译注:原文主标题如此,但内容 ...

  8. Kafka — 高吞吐量的分布式发布订阅消息系统【转】

    1.Kafka独特设计在什么地方?2.Kafka如何搭建及创建topic.发送消息.消费消息?3.如何书写Kafka程序?4.数据传输的事务定义有哪三种?5.Kafka判断一个节点是否活着有哪两个条件 ...

  9. Linux(6)- redis发布订阅/持久化/主从复制/redis-sentinel/redis-cluster、nginx入门

    一.redis发布订阅 Redis 通过 PUBLISH .SUBSCRIBE 等命令实现了订阅与发布模式. 其实从Pub/Sub的机制来看,它更像是一个广播系统,多个Subscriber可以订阅多个 ...

随机推荐

  1. Oracle 错误表

    ORA-00001: 违反唯一约束条件 (.) ORA-00017: 请求会话以设置跟踪事件 ORA-00018: 超出最大会话数 ORA-00019: 超出最大会话许可数 ORA-00020: 超出 ...

  2. MySQL left join 引发的惨案

    当我用这个进行更改值时,type未控制order表 其他数据被更改 还好备份数据表了(这里就体现了备份的重要性) UPDATE expense_order as a left join ( SELEC ...

  3. 攻防世界-MISC:Aesop_secret

    这是攻防世界高手进阶区的的第九题,题目如下: 点击下载附件一,得到一个压缩包,解压后得到一张GIF动图,找个网站给他分解一下,得到如下图片 不知道是什么意思,所以就跑去看WP了,用010editor打 ...

  4. systemd进程管理工具实战教程

    关注「开源Linux」,选择"设为星标" 回复「学习」,有我为您特别筛选的学习资料~ 1. systemd介绍 systemd是目前Linux系统上主要的系统守护进程管理工具,由于 ...

  5. 墙裂推荐!一款 VM 大规模集群管理工具

    关注「开源Linux」,选择"设为星标" 回复「学习」,有我为您特别筛选的学习资料~ Google 发布了基础设施管理工具 VM Manager,可自动维护大型Compute En ...

  6. wsgiref模块、web框架、django框架简介

    """web框架:将前端.数据库整合到一起的基于互联网传输的python代码 web框架也可以简单的理解为是软件开发架构里面的'服务端'""" ...

  7. Vulnhub-napping

    1.信息收集 先用arp-scan探测出靶机地址 arps-scan -l 显然靶机地址 10.1.1.106,然后利用nmap进行详细信息收集 nmap -A 10.1.1.106 可以看到,目标的 ...

  8. JavaScript 模块的循环加载(循环依赖问题分析)

    简介 "循环加载"(circular dependency)指的是,a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本. 分析 使用 madge 工具进行循环加载分析 ...

  9. c# DirectoryEntry LDAPS

    参考地址:https://stackoverflow.com/questions/54987776/ldap-connection-error-the-server-is-not-operationa ...

  10. Spring Ioc源码分析系列--Ioc容器注册BeanPostProcessor后置处理器以及事件消息处理

    Spring Ioc源码分析系列--Ioc容器注册BeanPostProcessor后置处理器以及事件消息处理 前言 上一篇分析了BeanFactoryPostProcessor的作用,那么这一篇继续 ...