Disruptor源码解读
上一篇已经介绍了Disruptor是什么?简单总结了为什么这么快?下面我们直接源码搞起来,简单粗暴。
高性能队列disruptor为什么这么快?
一、核心类接口
Disruptor 提供了对RingBuffer的封装。
RingBuffer 环形队列,基于数组实现,内存被循环使用,减少了内存分配、回收扩容等操作。
EventProcessor 事件处理器,实现了Runnable,单线程批量处理BatchEventProcessor和多线程处理WorkProcessor。
Sequencer 生产者访问序列的接口,RingBuffer生产者的父接口,其直接实现有SingleProducerSequencer和MultiProducerSequencer。
EventSequencer 空接口,暂时没用,用于以后扩展。
SequenceBarrier 消费者屏障 消费者用于访问缓存的控制器。
WaitStrategy 当没有可消费的事件时,根据特定的实现进行等待。
SingleProducerSequencer 单生产者发布实现类
MultiProducerSequencer 多生产者发布实现类
笔者简单介绍下常用的类,看不懂没关系,等看完源码自然明白。
核心类源码分析已经上传了git地址:https://github.com/Sonion/disroptor
二、生产者
开局一张图,走起。
想分析disruptor,先看生产者,这是笔者整理的生产者相关类图。

单生产者发布流程:
生产者发布消息是从Disruptor的publish方法开始,
//Disruptor.java
public <A> void publishEvent(final EventTranslatorOneArg<T, A> eventTranslator, final A arg)
{
ringBuffer.publishEvent(eventTranslator, arg);
}
实际调用的RingBuffer的publishEvent,实际上也就是做两件事,一先去获取RingBuffer上的一个可用位置,第二步在可用位置上发布数据。
//RingBuffer.java
public <A> void publishEvent(EventTranslatorOneArg<E, A> translator, A arg0)
{
final long sequence = sequencer.next();
translateAndPublish(translator, sequence, arg0);
}
来看看获取RingBuffer上的一个可用位置,先看看单生产者SingleProducerSequencer.next()方法。
//SingleProducerSequencer.java
/**
* @see Sequencer#next()
*/
@Override
public long next()
{
return next(1);
} /**
* @see Sequencer#next(int)
*/
@Override
public long next(int n)
{
if (n < 1 || n > bufferSize)
{
throw new IllegalArgumentException("n must be > 0 and < bufferSize");
}
// 获取上次申请最后的序列值
long nextValue = this.nextValue;
// n=1,得到本次需要申请的序列值
long nextSequence = nextValue + n;
// 可能发生绕环的点,本次申请值 - 环形一圈长度
long wrapPoint = nextSequence - bufferSize;
// 数值最小的序列值,理解为最慢消费者
long cachedGatingSequence = this.cachedValue;
// 序列值初始值是 -1 ,只有wrapPoint 大于 cachedGatingSequence 将发生绕环行为,生产者超一圈从后方追上消费者,生产者覆盖未消费的情况。
// 没有空坑位,将进入循环等待。
if (wrapPoint > cachedGatingSequence || cachedGatingSequence > nextValue)
{
cursor.setVolatile(nextValue); // StoreLoad fence long minSequence;
// 只有当消费者消费,向前移动后,才能跳出循环
// 每次重新获取消费者序列最小值进行轮询判断
while (wrapPoint > (minSequence = Util.getMinimumSequence(gatingSequences, nextValue)))
{
LockSupport.parkNanos(1L);
}
// 当消费者消费后,更新缓存的最小序号
this.cachedValue = minSequence;
}
// 将成功申请的序号赋值给对象实例变量
this.nextValue = nextSequence; return nextSequence;
}
next获取可以写入的序列号,回到RingBuffer的publishEvent方法,执行translateAndPublish方法,进行发布操作。
//RingBuffer.java
private void translateAndPublish(EventTranslator<E> translator, long sequence)
{
try
{
translator.translateTo(get(sequence), sequence);
}
finally
{
sequencer.publish(sequence);
}
}
translator.translateTo()对EventTranslator接口的实现。将数据放置好,进行发布。
//SingleProducerSequencer.java
/**
* @see Sequencer#publish(long)
*/
@Override
public void publish(long sequence)
{
// 更新Sequencer内部游标值
cursor.set(sequence);
// 当生产者发布新事件后,将通知等待的EventProcessor,可以进行消费
waitStrategy.signalAllWhenBlocking();
} // BlockingWaitStrategy.java
@Override
public void signalAllWhenBlocking()
{
synchronized (mutex)
{
mutex.notifyAll();
}
}
到此单生产者发布流程已经讲完。还是那句话,很简单,两步操作先去获取RingBuffer上的一个可用位置,第二步在可用位置上发布数据。
多生产者发布流程:
我们简单看下,前面和单生产者发布流程一样,实现接口AbstractSequencer,还是next()方法,我们来看。
//MultiProducerSequencer.java
/**
* @see Sequencer#next()
*/
@Override
public long next()
{
return next(1);
} /**
* @see Sequencer#next(int)
*/
@Override
public long next(int n)
{
if (n < 1 || n > bufferSize)
{
throw new IllegalArgumentException("n must be > 0 and < bufferSize");
} long current;
long next; do
{
// 当前游标值,初始化时是-1
current = cursor.get();
next = current + n; long wrapPoint = next - bufferSize;
long cachedGatingSequence = gatingSequenceCache.get(); if (wrapPoint > cachedGatingSequence || cachedGatingSequence > current)
{
long gatingSequence = Util.getMinimumSequence(gatingSequences, current); if (wrapPoint > gatingSequence)
{
LockSupport.parkNanos(1); // TODO, should we spin based on the wait strategy?
continue;
} gatingSequenceCache.set(gatingSequence);
}
else if (cursor.compareAndSet(current, next))
{
break;
}
}
while (true); return next;
}
可以看到多生产者发布流程和单生产者发布流程区别不大,最后当然还是调用publish发布,publish有点区别,我们来看。
/**
* @see Sequencer#publish(long)
*/
@Override
public void publish(final long sequence)
{
//多生产者是采用availableBuffer数组设置
setAvailable(sequence); waitStrategy.signalAllWhenBlocking();
} /**
* @see Sequencer#publish(long, long)
*/
@Override
public void publish(long lo, long hi)
{
for (long l = lo; l <= hi; l++)
{
setAvailable(l);
}
waitStrategy.signalAllWhenBlocking();
}
对比SingleProducerSequencer的publish,MultiProducerSequencer的publish没有设置cursor,而是将内部使用的availableBuffer数组对应位置进行设置。availableBuffer是一个记录RingBuffer槽位状态的数组,通过对序列值sequence&bufferSize-1,获得槽位号,再通过位运算,获取序列值所在的圈数,进行设置。使用更高效的位与和右移操作。
private void setAvailable(final long sequence)
{
// calculateIndex 与&, calculateAvailabilityFlag 移位操作
setAvailableBufferValue(calculateIndex(sequence), calculateAvailabilityFlag(sequence));
} private void setAvailableBufferValue(int index, int flag)
{
// 使用Unsafe更新属性,因为是直接操作内存,所以需要计算元素位置对应的内存位置buffer地址
long bufferAddress = (index * SCALE) + BASE;
// availableBuffer是标志可用位置的int数组,初始全为-1
UNSAFE.putOrderedInt(availableBuffer, bufferAddress, flag);
} private int calculateAvailabilityFlag(final long sequence)
{
return (int) (sequence >>> indexShift);
} private int calculateIndex(final long sequence)
{
return ((int) sequence) & indexMask;
}
到此,多生产者发布流程也讲完了,是不是很easy,如果大家有问题,评论区我们一起讨论。
三、消费者
老规矩,再来一张图,这个就比较简单了。

EventProcessor是整个消费者事件处理框架,EventProcessor接口继承了Runnable接口,主要有两种实现:单线程批量处理BatchEventProcessor和多线程处理WorkProcessor。
在使用Disruptor帮助类构建消费者时,使用handleEventsWith方法传入多个EventHandler,内部使用多个BatchEventProcessor关联多个线程执行。这种情况类似JMS中的发布订阅模式,同一事件会被多个消费者并行消费。适用于同一事件触发多种操作。
而使用Disruptor的handleEventsWithWorkerPool传入多个WorkHandler时,内部使用多个WorkProcessor关联多个线程执行。这种情况类似JMS的点对点模式,同一事件会被一组消费者其中之一消费。适用于提升消费者并行处理能力。
BatchEventProcessor单线程批处理事件(理解为广播消费,重复消费)
// EventHandlerGroup.java
public EventHandlerGroup<T> then(final EventHandler<? super T>... handlers)
{
return handleEventsWith(handlers);
}
public EventHandlerGroup<T> handleEventsWith(final EventHandler<? super T>... handlers)
{
return disruptor.createEventProcessors(sequences, handlers);
}
//Disruptor.java
// barrierSequences是EventHandlerGroup实例的序列,就是上一个事件处理者组的序列
EventHandlerGroup<T> createEventProcessors(
final Sequence[] barrierSequences,
final EventHandler<? super T>[] eventHandlers)
{
checkNotStarted();
// processorSequences本次事件处理器组的序列组
final Sequence[] processorSequences = new Sequence[eventHandlers.length];
final SequenceBarrier barrier = ringBuffer.newBarrier(barrierSequences); for (int i = 0, eventHandlersLength = eventHandlers.length; i < eventHandlersLength; i++)
{
final EventHandler<? super T> eventHandler = eventHandlers[i]; final BatchEventProcessor<T> batchEventProcessor =
new BatchEventProcessor<>(ringBuffer, barrier, eventHandler); if (exceptionHandler != null)
{
batchEventProcessor.setExceptionHandler(exceptionHandler);
} consumerRepository.add(batchEventProcessor, eventHandler, barrier);
processorSequences[i] = batchEventProcessor.getSequence();
}
// 每次添加完事件处理器后,更新门控序列,用于后续调用链的添加判断。
updateGatingSequencesForNextInChain(barrierSequences, processorSequences); return new EventHandlerGroup<>(this, consumerRepository, processorSequences);
} // 门控,是指后续消费链的消费,不能超过前边。
private void updateGatingSequencesForNextInChain(final Sequence[] barrierSequences, final Sequence[] processorSequences)
{
if (processorSequences.length > 0)
{
//GatingSequences一直保存消费链末端消费者的序列组
ringBuffer.addGatingSequences(processorSequences);
for (final Sequence barrierSequence : barrierSequences)
{
ringBuffer.removeGatingSequence(barrierSequence);
}
// 取消标记上一组消费者为消费链末端
consumerRepository.unMarkEventProcessorsAsEndOfChain(barrierSequences);
}
}
BatchEventProcessor构建消费者链时的逻辑都在createEventProcessors这个方法中。
先简单说下ConsumerRepository,这个类主要保存消费者的各种关系,如通过EventHandler引用获取EventProcessorInfo信息,通过Sequence获取ConsumerInfo信息等。
因为要使用引用做key,所以数据结构使用IdentityHashMap。
IdentityHashMap使用的是==比较key的值,而HashMap使用的是equals()HashMap使用的是hashCode()查找位置,IdentityHashMap使用的是System.identityHashCode(object)IdentityHashMap理论上来说速度要比HashMap快一点另外一点呢就是
IdentityHashMap中key能重复,但需要注意一点的是key比较的方法是==,所以若要存放两个相同的key,就需要存放不同的地址。
这个createEventProcessors方法接收两个参数,barrierSequences表示当前消费者组的屏障序列数组,如果当前消费者组是第一组,则取一个空的序列数组;否则,barrierSequences就是上一组消费者组的序列数组。createEventProcessors方法的另一个参数eventHandlers,这个参数是代表事件消费逻辑的EventHandler数组。
Disruptor为每个EventHandler实现类都创建了一个对应的BatchEventProcessor,所有消费者共用一个SequenceBarrier。
在构建BatchEventProcessor时需要以下传入三个构造参数:dataProvider是数据存储结构如RingBuffer;sequenceBarrier用于跟踪生产者游标,协调数据处理;eventHandler是用户实现的事件处理器,也就是实际的消费者。
//BatchEventProcessor.java
@Override
public void run()
{
if (running.compareAndSet(IDLE, RUNNING))
{
sequenceBarrier.clearAlert(); notifyStart();
try
{
if (running.get() == RUNNING)
{
processEvents();
}
}
finally
{
notifyShutdown();
running.set(IDLE);
}
}
else
{
// This is a little bit of guess work. The running state could of changed to HALTED by
// this point. However, Java does not have compareAndExchange which is the only way
// to get it exactly correct.
if (running.get() == RUNNING)
{
throw new IllegalStateException("Thread is already running");
}
else
{
earlyExit();
}
}
} private void processEvents()
{
T event = null;
long nextSequence = sequence.get() + 1L; while (true)
{
try
{
// 当前能够使用的最大值
// 使用给定的等待策略去等待下一个序列可用
final long availableSequence = sequenceBarrier.waitFor(nextSequence);
if (batchStartAware != null)
{
batchStartAware.onBatchStart(availableSequence - nextSequence + 1);
} // 批处理
// 消费的偏移量大于上次消费记录
while (nextSequence <= availableSequence)
{
event = dataProvider.get(nextSequence);
eventHandler.onEvent(event, nextSequence, nextSequence == availableSequence);
nextSequence++;
} // eventHandler处理完毕后,更新当前序号
sequence.set(availableSequence);
}
catch (final TimeoutException e)
{
notifyTimeout(sequence.get());
}
catch (final AlertException ex)
{
if (running.get() != RUNNING)
{
break;
}
}
catch (final Throwable ex)
{
exceptionHandler.handleEventException(ex, nextSequence, event);
sequence.set(nextSequence);
nextSequence++;
}
}
}
我们再来看看SequenceBarrier实现类ProcessingSequenceBarrier的代码是如何实现waitFor方法。
final class ProcessingSequenceBarrier implements SequenceBarrier
{
/**
* 等待可用消费时,指定的等待策略
*/
private final WaitStrategy waitStrategy;
/**
* 依赖的上组消费者的序号,如果当前为第一组则为cursorSequence(即生产者发布游标序列)
* 否则使用FixedSequenceGroup封装上组消费者序列
*/
private final Sequence dependentSequence;
/**
* 当触发halt时,将标记alerted为true
*/
private volatile boolean alerted = false;
/**
* AbstractSequencer中的cursor引用,记录当前发布者发布的最新位置
*/
private final Sequence cursorSequence;
/**
* MultiProducerSequencer 或 SingleProducerSequencer
*/
private final Sequencer sequencer; ProcessingSequenceBarrier(
final Sequencer sequencer,
final WaitStrategy waitStrategy,
final Sequence cursorSequence,
final Sequence[] dependentSequences)
{
this.sequencer = sequencer;
this.waitStrategy = waitStrategy;
this.cursorSequence = cursorSequence;
// 依赖的上一组序列长度,第一次是0
if (0 == dependentSequences.length)
{
dependentSequence = cursorSequence;
}
// 将上一组序列数组复制成新数组保存,引用不变
else
{
dependentSequence = new FixedSequenceGroup(dependentSequences);
}
} @Override
public long waitFor(final long sequence)
throws AlertException, InterruptedException, TimeoutException
{
// 检查是否停止服务
checkAlert();
// 获取最大可用序号 sequence为给定序号,一般为当前序号+1,cursorSequence记录生产者最新位置,
long availableSequence = waitStrategy.waitFor(sequence, cursorSequence, dependentSequence, this); if (availableSequence < sequence)
{
return availableSequence;
}
// 返回已发布最高的序列值,将对每个序号进行校验
return sequencer.getHighestPublishedSequence(sequence, availableSequence);
}
我们再来看看等待策略WaitStrategy#waitFor
//BlockingWaitStrategy.java
public final class BlockingWaitStrategy implements WaitStrategy
{
private final Object mutex = new Object(); @Override
public long waitFor(long sequence, Sequence cursorSequence, Sequence dependentSequence, SequenceBarrier barrier)
throws AlertException, InterruptedException
{
long availableSequence;
// 当前游标小于给定序号,也就是无可用事件
if (cursorSequence.get() < sequence)
{
//也就是只有等待策略才会用锁,其他使用CAS,这就是前文提到的高效原因
synchronized (mutex)
{
// 当给定的序号大于生产者游标序号时,进行等待
while (cursorSequence.get() < sequence)
// 循环等待,在Sequencer中publish进行唤醒;等待消费时也会在循环中定时唤醒。
{
barrier.checkAlert();
mutex.wait();
}
}
}
while ((availableSequence = dependentSequence.get()) < sequence)
{
barrier.checkAlert();
ThreadHints.onSpinWait();
} return availableSequence;
}
WorkProcessor多线程处理事件(理解为集群消费)
WorkProcessor的原理和BatchEventProcessor类似,只是多了workSequence用来保存同组共用的处理序列。在更新workSequence时,涉及多线程操作,所以使用CAS进行更新。
//WorkProcessor.java
@Override
public void run()
{
if (!running.compareAndSet(false, true))
{
throw new IllegalStateException("Thread is already running");
}
sequenceBarrier.clearAlert(); notifyStart(); boolean processedSequence = true;
long cachedAvailableSequence = Long.MIN_VALUE;
long nextSequence = sequence.get();
T event = null;
while (true)
{
try
{
// if previous sequence was processed - fetch the next sequence and set
// that we have successfully processed the previous sequence
// typically, this will be true
// this prevents the sequence getting too far forward if an exception
// is thrown from the WorkHandler
// 表示nextSequence序号的处理情况(不区分正常或是异常处理)。只有处理过,才能申请下一个序号。
if (processedSequence)
{
processedSequence = false;
do
{
// 同组中多个消费线程有可能会争抢一个序号,使用CAS避免使用锁。
// 同一组使用一个workSequence,WorkProcessor不断申请下一个可用序号,对workSequence设置成功才会实际消费。
nextSequence = workSequence.get() + 1L;
sequence.set(nextSequence - 1L);
}
while (!workSequence.compareAndSet(nextSequence - 1L, nextSequence));
}
// 缓存的可用序号比要处理的序号大,才能进行处理
if (cachedAvailableSequence >= nextSequence)
{
event = ringBuffer.get(nextSequence);
workHandler.onEvent(event);
processedSequence = true;
}
// 更新缓存的可用序列。这个cachedAvailableSequence只用在WorkProcessor实例内,不同实例的缓存可能是不一样
else
{
// 和单线程模式类似,返回的也是最大可用序号
cachedAvailableSequence = sequenceBarrier.waitFor(nextSequence);
}
}
catch (final TimeoutException e)
{
notifyTimeout(sequence.get());
}
catch (final AlertException ex)
{
if (!running.get())
{
break;
}
}
catch (final Throwable ex)
{
// handle, mark as processed, unless the exception handler threw an exception
exceptionHandler.handleEventException(ex, nextSequence, event);
processedSequence = true;
}
} notifyShutdown(); running.set(false);
}
总结一波:
BatchEventProcessor主要用于处理单线程并行任务,同一消费者组的不同消费者会接收相同的事件,并在所有事件处理完毕后进入下一消费者组进行处理(是不是类似JUC里的Phaser、CyclicBarrier或CountDownLatch呢)。WorkProcessor通过WorkerPool管理多个WorkProcessor,达到多线程处理事件的目的,同一消费者组的多个WorkProcessor不会处理同一个事件。通过选择不同的WaitStragegy实现,可以控制消费者在没有可用事件处理时的等待策略。
应用场景总结
BatchEventProcessor
- 对Event的处理顺序有需求
- 单个Event的处理非常快(因为单线程)
WorkProcessor
- 对Event的处理顺序没有要求,虽然是顺序的消费,但是最终的消费先后取决于线程的调度,没有办法保证
- 单个Event的处理速度相对较慢(因为多线程)
具体的核心类分析,可以参考 https://github.com/Sonion/disroptor 里面有分析和注释,写个demo,打个断点,再看看核心类和方法,Disruptor 源码还是简单的,主要是环形队列,循环写入,不用GC回收,还有一些缓存行优化,无锁等处理是值得学习和思考的。
参考资料:
1、参考demo可以看这里
https://www.cnblogs.com/xiangnanl/p/9955714.html#4114850
2、简单资料
https://blog.csdn.net/changong28/article/details/43637679
http://brokendreams.iteye.com/category/349033
Disruptor源码解读的更多相关文章
- SDWebImage源码解读之SDWebImageDownloaderOperation
第七篇 前言 本篇文章主要讲解下载操作的相关知识,SDWebImageDownloaderOperation的主要任务是把一张图片从服务器下载到内存中.下载数据并不难,如何对下载这一系列的任务进行设计 ...
- SDWebImage源码解读 之 NSData+ImageContentType
第一篇 前言 从今天开始,我将开启一段源码解读的旅途了.在这里先暂时不透露具体解读的源码到底是哪些?因为也可能随着解读的进行会更改计划.但能够肯定的是,这一系列之中肯定会有Swift版本的代码. 说说 ...
- SDWebImage源码解读 之 UIImage+GIF
第二篇 前言 本篇是和GIF相关的一个UIImage的分类.主要提供了三个方法: + (UIImage *)sd_animatedGIFNamed:(NSString *)name ----- 根据名 ...
- SDWebImage源码解读 之 SDWebImageCompat
第三篇 前言 本篇主要解读SDWebImage的配置文件.正如compat的定义,该配置文件主要是兼容Apple的其他设备.也许我们真实的开发平台只有一个,但考虑各个平台的兼容性,对于框架有着很重要的 ...
- SDWebImage源码解读_之SDWebImageDecoder
第四篇 前言 首先,我们要弄明白一个问题? 为什么要对UIImage进行解码呢?难道不能直接使用吗? 其实不解码也是可以使用的,假如说我们通过imageNamed:来加载image,系统默认会在主线程 ...
- SDWebImage源码解读之SDWebImageCache(上)
第五篇 前言 本篇主要讲解图片缓存类的知识,虽然只涉及了图片方面的缓存的设计,但思想同样适用于别的方面的设计.在架构上来说,缓存算是存储设计的一部分.我们把各种不同的存储内容按照功能进行切割后,图片缓 ...
- SDWebImage源码解读之SDWebImageCache(下)
第六篇 前言 我们在SDWebImageCache(上)中了解了这个缓存类大概的功能是什么?那么接下来就要看看这些功能是如何实现的? 再次强调,不管是图片的缓存还是其他各种不同形式的缓存,在原理上都极 ...
- AFNetworking 3.0 源码解读 总结(干货)(下)
承接上一篇AFNetworking 3.0 源码解读 总结(干货)(上) 21.网络服务类型NSURLRequestNetworkServiceType 示例代码: typedef NS_ENUM(N ...
- AFNetworking 3.0 源码解读 总结(干货)(上)
养成记笔记的习惯,对于一个软件工程师来说,我觉得很重要.记得在知乎上看到过一个问题,说是人类最大的缺点是什么?我个人觉得记忆算是一个缺点.它就像时间一样,会自己消散. 前言 终于写完了 AFNetwo ...
随机推荐
- 洛谷 P4136 谁能赢呢?
P4136 谁能赢呢? 题目描述 小明和小红经常玩一个博弈游戏.给定一个n×n的棋盘,一个石头被放在棋盘的左上角.他们轮流移动石头.每一回合,选手只能把石头向上,下,左,右四个方向移动一格,并且要求移 ...
- easyui north 穿透
穿透layout的north 原理 穿透下面的层只要使该层的position置于fix即可 如果该层还有下一级,则下一层级的position置于absolute即可 示例代码 #menu.active ...
- 在虚拟机搭建JStrom
原文:http://blog.csdn.net/u014134180/article/details/51810311 一 安装步骤 二 搭建Zookeeper集群 1 ZooKeeper 单机安装与 ...
- PyMySQL - Python3 MySQL数据库连接 - 基本操作
一.Python连接MySQL数据库 1.导入模块 #导入模块 import pymysql 2.打开数据库连接 #打开数据库连接 #注意:这里已经假定存在数据库testdb,db指定了连接的 ...
- docker: 通过docker方式安装jenkins
1. docker pull jenkins 2. docker run -d -p 49001:8080 -v $PWD/jenkins:/var/jenkins_home:z -t jenkins ...
- C# .NET如何定义图片按钮
先添加一个按钮,然后修改Image或者BackgroundImage显然不好,我想要按钮透明,就不要放在按钮的方框里,而且鼠标滑过和鼠标点击最好都是我自定义. 我们拉一个PictureBox,然后 ...
- oracle事务处理及实例演示jdbc操作批量删除
事务 作为逻辑处理的基本单位,对于数据库操作来说由一条或者多条sql语句来构成.当然还有针对非数据库操作的,如在计算机中设置的还原点即是一个非常好的应用. 对于事务的基本性质在还有一篇中有所叙述:SQ ...
- 稀疏表示 Sparse Representation
稀疏表示_百度百科 https://baike.baidu.com/item/%E7%A8%80%E7%96%8F%E8%A1%A8%E7%A4%BA/16530498 信号稀疏表示是过去近20年来信 ...
- YTU 2516: 剪刀石头布
2516: 剪刀石头布 时间限制: 1 Sec 内存限制: 128 MB 提交: 193 解决: 123 题目描述 小慧小时候很喜欢和她的小伙伴们一起玩剪刀(Scissors).石头(Rock). ...
- XAML实例教程系列 - 标记扩展(Markup Extensions) 六
XAML实例教程系列 - 标记扩展(Markup Extensions) 分类: Windows 8 Silverlight2012-06-21 13:00 1139人阅读 评论(0) 收藏 举报 扩 ...