[一起读源码]走进C#并发队列ConcurrentQueue的内部世界 — .NET Core篇

在上一篇《走进C#并发队列ConcurrentQueue的内部世界》中解析了Framework下的ConcurrentQueue实现原理,经过抛砖引玉,得到了一众大佬的指点,找到了.NET Core版本下的ConcurrentQueue源码,位于以下地址:

  • https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/Collections/Concurrent/ConcurrentQueue.cs

  • https://github.com/dotnet/runtime/blob/master/src/libraries/System.Private.CoreLib/src/System/Collections/Concurrent/ConcurrentQueueSegment.cs

我大致看了一下,虽然两者的实现有不少相似的地方,不过在细节上新增了许多有意思的东西,还是觉得要单独拉出来说一下。画外音:谁叫我上篇立了flag,现在跪着也要写完。。????

必须要吐糟的是,代码中ConcurrentQueue类明明是包含在System.Collections.Concurrent命名空间下,但是源码结构中的文件却放在System.Private.CoreLib目录中,这是闹哪出~

存储结构

从上面给出的源码地址可以猜测出整个结构依然是Segment+Queue的组合,通过一个Segment链表实现了Queue结构,但实际上内部又加了新的设计。抛去Queue先不看的话,Segment本身就是一个实现了多生产者多消费者的线程安全集合,甚至可以直接拿它当一个固定容量的线程安全队列使用,这点与之前Framework中差别很大。如果结合Queue整体来看,Segment不再是固定容量,而是可以由Queue来控制每个Segment的容量大小(最小是32,上限是1024 * 1024)。

在Framework中,队列会给每个Segment分配一个索引,虽然这个索引是long类型的,但理论上说队列容量还是存在上限。在Core中就不一样了,它取消了这个索引,真正实现了一个无边界(unbounded)队列。

我猜测的原因是,在Framework中由于每个Segment是固定大小的,维护一个索引可以很方便的计算队列里的元素数量,但是Core中的Segment大小不是固定的,使用索引并不能加快计算速度,使得这个索引不再有意义,这也意味着计算元素数量变得非常复杂。

一张图看清它的真实面目,这里继续沿用上一篇的结构图稍作修改:

从图中可以看到,整体结构上基本一致,核心改动就是Segment中增加了Slot(槽)的概念,这是真正存储数据的地方,同时有一个序列号与之对应。

从代码来看一下Segment的核心定义:

internal sealed class ConcurrentQueueSegment<T>
{//存放数据的容器internal readonly Slot[] _slots;//这个mask用来计算槽点,可以防止查找越界internal readonly int _slotsMask;//首尾位置指针internal PaddedHeadAndTail _headAndTail;//观察保留标记,表示当前段在出队时能否删除数据internal bool _preservedForObservation;//标记当前段是否被锁住internal bool _frozenForEnqueues;//下一段的指针internal ConcurrentQueueSegment<T>? _nextSegment;
}

其中_preservedForObservation_frozenForEnqueues会比较难理解,后面再详细介绍。

再看一下队列的核心定义:

public class ConcurrentQueue<T> : IProducerConsumerCollection<T>, IReadOnlyCollection<T>
{//每一段的初始化长度,也是最小长度private const int InitialSegmentLength = 32;//每一段的最大长度private const int MaxSegmentLength = 1024 * 1024;//操作多个段时的锁对象private readonly object _crossSegmentLock;//尾段指针private volatile ConcurrentQueueSegment<T> _tail;//首段指针private volatile ConcurrentQueueSegment<T> _head;
}

常规操作

还是按上一篇的套路为主线循序渐进。

创建实例

ConcurrentQueue依然提供了2个构造函数,分别可以创建一个空队列和指定数据集的队列。

/// <summary>
/// Initializes a new instance of the <see cref="ConcurrentQueue{T}"/> class.
/// </summary>
public ConcurrentQueue()
{_crossSegmentLock = new object();_tail = _head = new ConcurrentQueueSegment<T>(InitialSegmentLength);
}

还是熟悉的操作,创建了一个长度是32的Segment并把队列的首尾指针都指向它,同时创建了锁对象实例,仅此而已。
进一步看看Segment是怎么创建的:

internal ConcurrentQueueSegment(int boundedLength)
{//这里验证了长度不能小于2并且必须是2的N次幂Debug.Assert(boundedLength >= 2, $"Must be >= 2, got {boundedLength}");Debug.Assert((boundedLength & (boundedLength - 1)) == 0, $"Must be a power of 2, got {boundedLength}");_slots = new Slot[boundedLength];//这个mask的作用就是用来计算数组索引的防止越界,可以用`& _slotsMask`取代`% _slots.Length`_slotsMask = boundedLength - 1;//设置初始序列号for (int i = 0; i < _slots.Length; i++){_slots[i].SequenceNumber = i;}
}internal struct Slot
{[AllowNull, MaybeNull] public T Item;public int SequenceNumber;
}

再看看怎么用集合初始化队列,这个过程稍微麻烦点,但是很有意思:

public ConcurrentQueue(IEnumerable<T> collection)
{if (collection == null){ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);}_crossSegmentLock = new object();//计算得到第一段的长度int length = InitialSegmentLength;if (collection is ICollection<T> c){int count = c.Count;if (count > length){length = Math.Min(ConcurrentQueueSegment<T>.RoundUpToPowerOf2(count), MaxSegmentLength);}}//根据前面计算出来的长度创建一个Segment,再把数据依次入队_tail = _head = new ConcurrentQueueSegment<T>(length);foreach (T item in collection){Enqueue(item);}
}

可以看到,第一段的大小是根据初始集合的大小确定的,如果集合大小count大于32就对count进行向上取2的N次幂(RoundUpToPowerOf2)得到实际大小(但是不能超过最大值),否则就按默认值32来初始化。

向上取2的N次幂到底是啥意思??例如count是5,那得到的结果就是8(2×2×2);如果count是9,那结果就是16(2×2×2×2);如果刚好count是8那结果就是8(2×2×2),具体算法是通过位运算实现的很有意思。至于为什么一定要是2的N次幂,中间的玄机我也没搞明白。。

顺藤摸瓜,再看看进队操作如何实现。

元素进队

/// <summary>在队尾追加一个元素</summary>
public void Enqueue(T item)
{// 先尝试在尾段插入一个元素if (!_tail.TryEnqueue(item)){// 如果插入失败,就意味着尾段已经填满,需要往后扩容EnqueueSlow(item);}
}private void EnqueueSlow(T item)
{while (true){ConcurrentQueueSegment<T> tail = _tail;// 先尝试再队尾插入元素,如果扩容完成了就会成功if (tail.TryEnqueue(item)){return;}// 获得一把锁,避免多个线程同时进行扩容lock (_crossSegmentLock){//检查是否扩容过了if (tail == _tail){// 尾段冻结tail.EnsureFrozenForEnqueues();// 计算下一段的长度int nextSize = tail._preservedForObservation ? InitialSegmentLength : Math.Min(tail.Capacity * 2, MaxSegmentLength);var newTail = new ConcurrentQueueSegment<T>(nextSize);// 改变队尾指向tail._nextSegment = newTail;// 指针交换_tail = newTail;}}}
}

从以上流程可以看到,扩容的主动权不再由Segment去控制,而是交给了队列。正因为如此,所以在跨段操作时要先加锁,在Framework版本中是在原子操作获得指针后进行的扩容所以不会有这个问题,后面的出队操作也是一样的道理。扩容过程中有两个细节需要重点关注,那就是SegmentFrozen和下一段的长度计算。
从前面Segment的定义中我们看到它维护了一个_frozenForEnqueues标记字段,表示当前段是否被冻结锁定,在被锁住的情况下会让其他入队操作失败,看一下实现过程:

// must only be called while queue's segment lock is held
internal void EnsureFrozenForEnqueues()
{// flag used to ensure we don't increase the Tail more than once if frozen more than onceif (!_frozenForEnqueues){_frozenForEnqueues = true;Interlocked.Add(ref _headAndTail.Tail, FreezeOffset);}
}

首先判断当前冻结状态,然后把它设置为true,再使用原子操作把尾指针增加了2倍段长的偏移量,这个尾指针才是真正限制当前段不可新增元素的关键点,后面讲段的元素追加再关联起来详细介绍。而为什么要指定2倍段长这么一个特殊值呢,目的是为了把尾指针和mask做运算后落在同一个slot上,也就是说虽然两个指针位置不一样但是都指向的是同一个槽。

再说说下一段长度的计算问题,它主要是受_preservedForObservation这个字段影响,正常情况下一段的长度是尾段的2倍,但如果尾段正好被标记为观察保留(类似于上一篇的截取快照),那么下一段的长度依然是初始值32,原作者认为入队操作不是很频繁,这样做主要是为了避免浪费空间。

接着是重头戏,看一下如何给段追加元素:

public bool TryEnqueue(T item)
{Slot[] slots = _slots;// 如果发生竞争就自旋等待SpinWait spinner = default;while (true){// 获取当前段的尾指针int currentTail = Volatile.Read(ref _headAndTail.Tail);// 计算槽点int slotsIndex = currentTail & _slotsMask;// 读取对应槽的序列号int sequenceNumber = Volatile.Read(ref slots[slotsIndex].SequenceNumber);// 判断槽点序列号和指针是否匹配int diff = sequenceNumber - currentTail;if (diff == 0){// 通过原子操作比较交换,保证了只有一个入队者获得可用空间if (Interlocked.CompareExchange(ref _headAndTail.Tail, currentTail + 1, currentTail) == currentTail){// 把数据存入对应的槽点,以及更新序列号slots[slotsIndex].Item = item;Volatile.Write(ref slots[slotsIndex].SequenceNumber, currentTail + 1);return true;}}else if (diff < 0){// 序列号小于指针就说明该段已经装满了,直接返回falsereturn false;}// 这次竞争失败了,只好等下去spinner.SpinOnce(sleep1Threshold: -1);}
}

整个流程的核心就是借助槽点序列号和尾指针的匹配情况判断是否有可用空间,因为在初始化的时候序列号是从0递增,正常情况下尾指针和序列号肯定是匹配的,只有在整个段被装满时尾指针才会大于序列号,因为前面的冻结操作会给尾指针追加2倍段长的偏移量。要重点提出的是,只有在数据被写入并且序列号更新完成后才表示整个位置的元素有效,才能有出队的机会,在Framework是通过维护一个状态位来实现这个功能。整个设计很有意思,要慢慢品。

这里我们可以总结一下序列号的核心作用:假设一个槽点N,对应序列号是Q,它能允许入队的必要条件之一就是N==Q,由于入队操作把位置N的序列号修改成N+1,那么可以猜测出在出队时的必要条件之一就是满足Q==N+1

代码中的CompareExchange在上一篇中有介绍,这里不再重复。另外关于Volatile相关的稍微提一下,它的核心作用是避免内存与CPU之间的高速缓存带来的数据不一致问题,告诉编译器直接读写原始数据,有兴趣的可以找资料了解,限于篇幅不过多介绍。

元素出队

可以猜测到,入队的时候要根据容量大小进行扩容,那么与之对应的,出队的时候就需要对它进行压缩,也就是丢弃没有数据的段。

/// <summary>从队首移除一个元素</summary>
public bool TryDequeue([MaybeNullWhen(false)] out T result) =>_head.TryDequeue(out result) ||TryDequeueSlow(out result);private bool TryDequeueSlow([MaybeNullWhen(false)] out T item)
{// 不断循环尝试出队,直到成功或失败为止while (true){ConcurrentQueueSegment<T> head = _head;// 尝试从队首移除,如果成功就直接返回了if (head.TryDequeue(out item)){return true;}// 如果首段为空并且没有下一段了,则说明整个队列都没有数据了,返回失败if (head._nextSegment == null){item = default!;return false;}// 既然下一段不为空,那就再次确认本段是否还能出队成功,否则就要把它给移除了,等待下次循环从下一段出队if (head.TryDequeue(out item)){return true;}// 首段指针要往后移动,表示当前首段已丢弃,跨段操作要先加锁lock (_crossSegmentLock){if (head == _head){_head = head._nextSegment;}}}
}

整体流程基本和入队一样,外层通过一个死循环不断尝试操作,直到出队成功或者队列为空返回失败为止。释放空间的操作也从Segment转移到队列上,所以要加锁保证线程安全。这一步我在代码注释中写的很详细就不多解释了,再看一下核心操作Segment是如何移除元素的:

public bool TryDequeue([MaybeNullWhen(false)] out T item)
{Slot[] slots = _slots;// 遇到竞争时自旋等待SpinWait spinner = default;while (true){// 获取头指针地址int currentHead = Volatile.Read(ref _headAndTail.Head);// 计算槽点int slotsIndex = currentHead & _slotsMask;// 获取槽点对应的序列号int sequenceNumber = Volatile.Read(ref slots[slotsIndex].SequenceNumber);// 比较序列号是否和期望值一样,为什么要加1的原因前面入队时说过int diff = sequenceNumber - (currentHead + 1);if (diff == 0){// 通过原子操作比较交换得到可以出队的槽点,并把头指针往后移动一位if (Interlocked.CompareExchange(ref _headAndTail.Head, currentHead + 1, currentHead) == currentHead){// 取出数据item = slots[slotsIndex].Item!;// 此时如果该段没有被标记观察保护,要把这个槽点的数据清空if (!Volatile.Read(ref _preservedForObservation)){slots[slotsIndex].Item = default;Volatile.Write(ref slots[slotsIndex].SequenceNumber, currentHead + slots.Length);}return true;}}else if (diff < 0){// 这种情况说明该段已经没有有效数据了,直接返回失败。bool frozen = _frozenForEnqueues;int currentTail = Volatile.Read(ref _headAndTail.Tail);if (currentTail - currentHead <= 0 || (frozen && (currentTail - FreezeOffset - currentHead <= 0))){item = default!;return false;}}// 竞争失败进入下一轮等待spinner.SpinOnce(sleep1Threshold: -1);}
}

流程和追加元素类似,大部分都写在备注里面了,这里只额外提一下为空的情况。Segment为空只有一种情况,那就是头尾指针落在了同一个槽点,但这是会出现两种可能性:

  • 第一种是都落在了非最后一个槽点,意味着该段没有被装满,拿首尾指针相减即可判断。

  • 第二种是都落在了最后一个槽点,意味着该段已经被装满了,如果此时正在进行扩容(frozen),那么必须要在尾指针的基础上减去FreezeOffset再去和头指针判断,原因前面有说过;

是不是感觉环环相扣、相辅相成、如胶似漆、balabala.....????

统计元素数量

前面也预告过,因为队列不再维护段索引,这样会导致计算元素数量变得非常复杂,复杂到我都不想说这一部分了????。简单描述一下就跳过了:核心思路就是一段一段来遍历,然后计算出每段的大小最后把结果累加,如果涉及多个段还得加锁,具体到段内部就要根据首尾指针计算槽点得出实际数量等等等等,代码很长就不贴出来了。

这里也严重提醒一句,非必要情况下不要调用Count不要调用Count不要调用Count。

接下来重点说一下队列的IsEmpty。由于Segment不再维护IsEmpty信息,所以实现方式就有点曲线救国了,通过尝试能否从队首位置获取一个元素来判断是否队列为空,也就是常说的TryPeek操作,但细节上稍有不同。

/// <summary>
/// 判断队列是否为空,千万不要使用Count==0来判断,也不要直接TryPeek
/// </summary>
public bool IsEmpty => !TryPeek(out _, resultUsed: false);private bool TryPeek([MaybeNullWhen(false)] out T result, bool resultUsed)
{ConcurrentQueueSegment<T> s = _head;while (true){ConcurrentQueueSegment<T>? next = Volatile.Read(ref s._nextSegment);// 从首段中获取头部元素,成功的话直接返回true,获取失败就意味着首段为空了if (s.TryPeek(out result, resultUsed)){return true;}// 如果下一段不为空那就再尝试从下一段重新获取if (next != null){s = next;}//如果下一段为空就说明整个队列为空,跳出循环直接返回false了else if (Volatile.Read(ref s._nextSegment) == null){break;}}result = default!;return false;
}

上面的代码可以看到有一个特殊的参数resultUsed,它具体会有什么影响呢,那就得看看Segment是如何peek的:

public bool TryPeek([MaybeNullWhen(false)] out T result, bool resultUsed)
{// 实际上队列的TryPeek是一个观察保护操作,这时resultUsed会标记成true,如果是IsEmpty操作的话就为false,因为并不关心这个元素是否被释放了if (resultUsed){_preservedForObservation = true;Interlocked.MemoryBarrier();}Slot[] slots = _slots;SpinWait spinner = default;while (true){int currentHead = Volatile.Read(ref _headAndTail.Head);int slotsIndex = currentHead & _slotsMask;int sequenceNumber = Volatile.Read(ref slots[slotsIndex].SequenceNumber);int diff = sequenceNumber - (currentHead + 1);if (diff == 0){result = resultUsed ? slots[slotsIndex].Item! : default!;return true;}else if (diff < 0){bool frozen = _frozenForEnqueues;int currentTail = Volatile.Read(ref _headAndTail.Tail);if (currentTail - currentHead <= 0 || (frozen && (currentTail - FreezeOffset - currentHead <= 0))){result = default!;return false;}}spinner.SpinOnce(sleep1Threshold: -1);}
}

除了最开始的resultUsed判断,其他的基本和出队的逻辑一致,前面说的很详细,这里不多介绍了。

枚举转换数据

前面反复的提到观察保护,这究竟是个啥意思??为什么要有这个操作??

其实看过上一篇文章的话就比较好理解一点,这里稍微回顾一下方便对比。在Framework中会有截取快照的操作,也就是类似ToArray\ToList\GetEnumerator这种要做数据迭代,它是通过原子操作维护一个m_numSnapshotTakers字段来实现对数据的保护,目的是为了告诉其他出队的线程我正在遍历数据,你们执行出队的时候不要把数据给删了我要用的。在Core中也是为了实现同样的功能才引入了观察保护的概念,换了一种实现方式而已。

那么就以ToArray为例是怎么和其他操作交互的:

public T[] ToArray()
{// 这一步可以理解为保护现场SnapForObservation(out ConcurrentQueueSegment<T> head, out int headHead, out ConcurrentQueueSegment<T> tail, out int tailTail);// 计算队列长度,这也是要返回的数组大小long count = GetCount(head, headHead, tail, tailTail);T[] arr = new T[count];// 开始迭代数据塞到目标数组中using (IEnumerator<T> e = Enumerate(head, headHead, tail, tailTail)){int i = 0;while (e.MoveNext()){arr[i++] = e.Current;}Debug.Assert(count == i);}return arr;
}

上面的代码中,有一次获取队列长度的操作,还有一次获取迭代数据的操作,这两步逻辑比较相似都是对整个队列进行遍历,所以做一次数据转换的开销非常非常大,使用的时候一定要谨慎。别的不多说,重点介绍一下如何实现保护现场的过程:

private void SnapForObservation(out ConcurrentQueueSegment<T> head, out int headHead, out ConcurrentQueueSegment<T> tail, out int tailTail)
{// 要保护现场肯定要先来一把锁lock (_crossSegmentLock){head = _head;tail = _tail;// 一段一段进行遍历for (ConcurrentQueueSegment<T> s = head; ; s = s._nextSegment!){// 把每一段的观察保护标记设置成trues._preservedForObservation = true;// 遍历到最后一段了就结束if (s == tail) break;}// 尾段冻结,这样就不能新增元素tail.EnsureFrozenForEnqueues();// 返回两个指针地址用来对每一个元素进行遍历headHead = Volatile.Read(ref head._headAndTail.Head);tailTail = Volatile.Read(ref tail._headAndTail.Tail);}
}

可以看到上来就是一把锁,如果此时正在进行扩容或者收容的操作会直接阻塞掉,运气好没有阻塞的话你也不能有新元素入队了,因为尾段已经冻结锁死只能自旋等待,而出队也不能释放空间了。原话是:

At this point, any dequeues from any segment won't overwrite the value, and none of the existing segments can have new items enqueued.

有人就要问,这里把尾段锁死那等ToArray()完成后岂不是也不能有新元素入队了?不用担心,前面入队逻辑提到过如果该段被锁住队列会新创建一个段然后再尝试入队,这样就能成功了。但是问题又来了,假如前面的段还有很多空位,那岂不是有浪费空间的嫌疑?我们知道没有观察保护的时候每段会以2倍长度递增,这样的话空间浪费率还是挺高的。带着疑问提了个Issue问一下:
https://github.com/dotnet/runtime/issues/35094

到这里就基本把.NET Core ConcurrentQueue说完了。

总结

对比Framework下的并发队列,Core里面的改动还是不小的,尽管保留了SpinWaitInterlocked相关操作,但是也加入了lock,逻辑上也复杂了很多,我一步步分析和写文章搞了好几天。

至于性能对比,我找到一个官方给出的测试结果,有兴趣的可以看看:

https://github.com/dotnet/runtime/issues/27458#issuecomment-423964046

最后强行打个广告,基于.NET Core平台的开源分布式任务调度系统ScheduleMaster有兴趣的star支持一下,2.0版本即将上线:

  • https://github.com/hey-hoho/ScheduleMasterCore

  • https://gitee.com/hey-hoho/ScheduleMasterCore(只从github同步)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/310432.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java语法基础50题训练(上)

题目1: 有两只老虎&#xff0c;一只体重为180kg&#xff0c;一只体重为200kg&#xff0c;请用程序实现判断两只老虎的体重是否相同。 代码如下: public class OperatorTest {public static void main (String[] args) {int w1 180;int w2 200;boolean ans w1 w2?true:f…

EFCore.Sharding(EFCore开源分表框架)

简介本框架旨在为EF Core提供Sharding(即读写分离分库分表)支持,不仅提供了一套强大的普通数据操作接口,并且降低了分表难度,支持按时间自动分表扩容,提供的操作接口简洁统一.源码地址:EFCore.SHarding引言读写分离分库分表一直是数据库领域中的重难点,当数据规模达到单库极限的…

分布式事务 -- seata框架AT模式实现原理

Seata AT 模式 上一节中我们提到AT模式是基于XA事务模型演变过来的&#xff0c;所以他的整体机制也是一个改进版本的两阶段提交协议。 第一阶段&#xff1a;业务数据和回滚日志记录在同一个本地事务中提交&#xff0c;释放本地锁和链接资源第二阶段&#xff1a;提交异步化&…

[Java基础]数据输入

Scanner使用的基本步骤: 1.导包: import java.util.Scanner;2.创建对象: Scanner sc new Scanner(System.in);3.接收数据: int i sc.nextInt();代码如下: import java.util.Scanner;public class OperatorTest {public static void main (String[] args) {//创建对象Scan…

k8s中流量分离以及资源隔离实战

源宝导读&#xff1a;明源云客的终端用户越来越多&#xff0c;也涌现出线上流量活动的场景&#xff0c;大量的访问和接口请求导致服务器出现较高负载。本文将介绍云客团队为了缓解服务器压力&#xff0c;通过K8S进行分流与资源隔离的实践过程。一、背景PaaS和B2C的主要客户云客…

怎样实现WPF Prism Module的国际化和本地化?

English | 简体中文上一篇有简单介绍主工程的国际化&#xff0c;使用的资源字典(XAML)实现的。这几天我添加了几个Prism模块(Module)&#xff0c;发现子模块使用资源字典的方式实现国际化和本地化不好做&#xff0c;没有找到比较好的参考文章&#xff0c;所以换了一种方式&…

dotNET Core 3.X 使用 Jwt 实现接口认证

在前后端分离的架构中&#xff0c;前端需要通过 API 接口的方式获取数据&#xff0c;但 API 是无状态的&#xff0c;没有办法知道每次请求的身份&#xff0c;也就没有办法做权限的控制。如果不做控制&#xff0c;API 就对任何人敞开了大门&#xff0c;只要拿到了接口地址就可以…

数据结构与算法--代码鲁棒性案例分析

代码鲁棒性 鲁棒是robust的音译&#xff0c;就是健壮性。指程序能够判断输入是否符合规范&#xff0c;对不合要求的输入能够给出合理的结果。容错性是鲁棒的一个重要体现。不鲁棒的代码发生异常的时候&#xff0c;会出现不可预测的异常&#xff0c;或者程序奔溃。由于鲁棒性非…

【半译】两个gRPC的C#库:grpc-dotnet vs Grpc.Core

grpc-dotnet 是在2019年随着 .NET Core 3.0 一起发布的一个gPRC官方库。在ASP.NET Core 的 gRPC项目模板里面就使用了这个库。.NET Core 3.0之前难道不可以使用gRPC吗&#xff1f;目前&#xff0c;gRPC 在.NET上有两种官方实现&#xff1a;Grpc.Core&#xff1a;这个是原来的gR…

[Java基础]String对象的特点(易错点)

String对象的特点: 1.通过new创建的字符串对象&#xff0c;每一次new都会申请一个内存空间&#xff0c;虽然内容相同&#xff0c;但是地址值不同。 2.以""方式给出的字符串&#xff0c;只要字符串相同(顺序和大小写)&#xff0c;无论在程序代码中出现几次&#xff0…

数据结构与算法--解决问题的方法- 二叉树的的镜像

解决问题的思路 工作中遇到的问题可能用到的数据结构由很多&#xff0c;并且各种数据结构都不简单&#xff0c;我们不可能光凭借想象就能得到问题的解法&#xff0c;因此画图是在家具问题过程中用来帮助自己分析&#xff0c;推理的常用手段。很多问题比较抽象&#xff0c;不容…

使用dnSpy调试asp.net core源码

环境&#xff1a;window 10vs2019 16.5.1dnspy v6.1.4.netcore3.1参考&#xff1a;.Net反编译技术详解及4个反编译工具介绍一、关于dnSpydnSpy是近几年的新秀&#xff0c;功能远比ILSpy强大&#xff0c;甩.net Reflector几条街&#xff0c;被汉化、破解、逆向方面的人才奉为神器…

数据结构与算法--解决问题的方法-顺时针打印矩阵

顺时针打印矩阵 题目输入一个矩阵&#xff0c;按照从外向里顺时针的顺序依次打印每一个数字。例如下案例&#xff1a; 如上图矩阵&#xff0c;顺时针打印&#xff1a;1,2,3,4,8,12,16,15,14,13,9,5,6,7,1,10 以上问题看起来比较复杂&#xff0c;但是又没有涉及到复杂的数据结…

.NET与鲲鹏共展翅,昇腾九万里(二)

在上一篇文章 .NET与鲲鹏共展翅&#xff0c;昇腾九万里&#xff08;一&#xff09;中&#xff0c;我们通过在鲲鹏架构的Euler系统上跑Docker的方式把dotnet core 跑起来了&#xff0c;有读者反馈说“还是走docker喽&#xff0c;你这个标题应该改成鲲鹏和docker两条鲸鱼的故事”…

[Java基础]final和static修饰符

final: final修饰局部变量时: static&#xff1a; static访问特点:

优化委托的 DynamicInvoke

优化委托的 DynamicInvokeIntro委托方法里有一个 DynamicInvoke 的方法&#xff0c;可以在不清楚委托实际类型的情况下执行委托方法&#xff0c;但是用 DynamicInvoke 去执行的话会比直接用 Invoke 的方法会慢上很多&#xff0c;差了两个数量级&#xff0c;所以在知道委托类型的…

数据结构与算法-- 广度优先打印二叉树

广度优先打印二叉树 题目&#xff1a;从上往下打印出二叉树的每一个节点&#xff0c;同一层节点按照从左到右顺序打印&#xff0c;例如下图中二叉树&#xff0c;依次打印出是8,6,10,5,7,9,11 如上题中二叉树的节点定义我们用之前文章 二叉树实现原理中定义的节点结构。此处提议…