在上一篇《走进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同步)

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

  1. 我是怎么读源码的,授之以渔

    点击上方"视学算法",选择"设为星标" 做积极的人,而不是积极废人 作者 :youzhibing 链接 :https://www.cnblogs.com/you ...

  2. myisam怎么读_耗时半年,我成功“逆袭”,拿下美团offer(刷面试题+读源码+项目准备)...

    欢迎关注专栏[以架构赢天下]--每天持续分享Java相关知识点 以架构赢天下​zhuanlan.zhihu.com 以架构赢天下--持续分享Java相关知识点 每篇文章首发此专栏 欢迎各路Java程序 ...

  3. LiteOS内核源码分析:消息队列Queue

    本文分享自华为云社区<LiteOS内核源码分析系列十 消息队列Queue>,原文作者:zhushy . 队列(Queue)是一种常用于任务间通信的数据结构.任务能够从队列里面读取消息,当队 ...

  4. 学会读源码,很重要!

    刚参加工作那会,没想过去读源码,更没想过去改框架的源码:总想着别人的框架应该是完美的.万能的,应该不需要改:另外即使我改了源码,怎么样让我的改动生效了?项目中引用的不还是没改的jar包吗.回想起来觉得 ...

  5. 杂谈篇之我是怎么读源码的,授之以渔

    前言 开心一刻 今天上课不小心睡着了,结果被老师叫起来回答问题,这是背景.无奈之下看向同桌寻求帮助,同桌小声说到选C,结果周围的人都说选C,向同桌投去一个感激的眼神后大声说道选C.刚说完教室就笑开了, ...

  6. 这样读源码,不牛X也难

    程序员在工作过程中,会遇到很多需要阅读源码的场景,比如技术预研.选择技术框架.接手以前的项目.review他人的代码.维护老产品等等.可以说,阅读源代码是程序员的基本功,这项基本功是否扎实,会在很大程 ...

  7. 微信读书vscode插件_跟我一起读源码 – 如何阅读开源代码

    阅读是最好的老师 在学习和提升编程技术的时候,通过阅读高质量的源码,来学习专家写的高质量的代码,是一种非常有效的提升自我的方式.程序员群体是一群乐于分享的群体,因此在互联网上有大量的高质量开源项目,阅 ...

  8. 读源码,对程序员重要吗?

    来源: CSDN(ID:CSDNnews) 嘿,朋友们!本文我将分享一些关于主动阅读和研究源码的一些想法.在我看来,阅读源码能够帮你成为一名更专业的开发人员.毫无疑问的是,阅读源码提高了我的软件开发水 ...

  9. 夜读源码,带你探究 Go 语言的iota

    Go 语言的 iota 怎么说呢,感觉像枚举,又有点不像枚举,它的底层是什么样的,用哪个姿势使用才算正规,今天转载一篇「Go夜读」社区上分享的文章,咱们一起学习下.Go 夜读,带你每页读源码~!  这 ...

最新文章

  1. bae java_有人在BAE开发过java-tomcat应用吗?数据库老是链接失败
  2. [leetcode]Multiply Strings @ Python
  3. windows 远程桌面 启动方式
  4. Java反编译工具-JD-GUI解决直接打开.class文件卡顿问题
  5. Spring AOP编程-传统AOP开发切点表达式写法介绍
  6. 前端笔试能查吗_老码农的字节跳动前端面试总结
  7. 循环队列的java结构_Java数据结构——循环队列
  8. 3d打印 路径规划_“光博会+工博会”双展来袭!这个9月,知象光电3D视觉工业应用方案,重磅亮相!...
  9. 新天龙八部服务器维护了吗,《新天龙八部》3月11日全服更新维护公告
  10. 编写基于事件的CQRS读取模型
  11. Qt中绑定信号槽之后,信号槽无效
  12. 抽奖送福利|8大福利任性送,快来抽它
  13. MySQL启动参数(三) —— table_open_cache
  14. StackExchange.Redis 使用LuaScript脚本模糊查询hash
  15. Pulseaudio之nemo(二十二)
  16. 网站架构优化之css+div设计对SEO的影响
  17. ES dynamic mapping(动态映射)
  18. 学习记录:Monkey测试
  19. wkhtmltopdf 照片不显示的问题
  20. X-Scan-v3.3 使用说明

热门文章

  1. Java原来如此-随机数
  2. 高级SQL注入拿shell,一般黑客不知道。哈客
  3. ACPI知识学习笔记
  4. Silverlight专题(10)- WatermarkedTextBox使用
  5. [原]让链接点击过后无虚线
  6. 学习kaneboy的 sps区域列表的管理入口程序
  7. infoseccrypto_java下载_關於php接ICBC的支付接口的解決方案
  8. chromebook刷机_如何查看Chromebook的停产日期
  9. LeetCode 366. Find Leaves of Binary Tree
  10. PropertiesUtil 获取文件属性值