本章主要介绍NS-3当中的事件与调度器。NS-3本质上是一个离散事件的调度器。其执行过程是按事件的发生时间来推进的。NS-3的事件与调度API提供了添加取消调度事件的功能。同时,NS-3提供了多种不同的调度器实现。
1. NS3离散事件调度简介
NS-3是一个离散事件网络仿真器。其本质是按预定的时间执行一些列的事件(函数)。编程的时候必须调度各种用以推进时间的各种事件,仿真才能得以继续。当所有的事件调度完成之后(即事件列表已经为空),则仿真结束。其中,离散的意思是,事件调度的时间可以是不连续的。而时间推进也不会以连续的方式进行。例如:刚刚执行的一个事件是调度在100秒发生,如果下一个最近的事件是调度在120秒,那么仿真器会直接跳转到120秒开始执行下一个事件,而不会按101、102、……、120这样的调度顺序进行。
NS-3需要使用一些特殊的类来完成离散事件的调度,这些类主要有:Simulator、Scheduler、Time与EventId。
2. 事件(Event)
在NS-3当中,事件用一个Event结构体来表示。这个结构体定义在Scheduler类当中,其中主要有两个成员:EventKey和EventImpl *:
1 | struct Event |
其中EventKey主要存储了事件的该发生的时间信息等:
1 | struct EventKey |
而EventImpl *则指向了一个EventImpl对象。EventImpl对象是实际要执行的代码的调用接口,其中主要包含一些实用的方法:
1 | class EventImpl : public SimpleRefCount<EventImpl> |
从类的申明当中我们可以注意到,EventImpl实际上是一个抽象类,不能直接实例化,因此,系统中实际调度的其实都是EventImpl的子类。而实际上这些子类都是对函数指针的一个简单封装,类似于回调。
从根本上说,事件实际上就是安排在某个特定时候调用的一些函数而已。只是这些函数上绑定了特定的调用时间,并且有一个特定的Id来表示一次调用。同一个函数可以被(在不同或者相同的时间)预调用多次,每一次调用都对应一个唯一的(事件)Id。
当用户调度一个事件(一次函数调用)成功的时候,系统不会将Scheduler::Event内部类直接返回给调用者。而是返回一个包装对象EventId:
1 | class EventId { |
从代码中可以看出,EventId类实际上就是将EventKey和EventImpl进行了融合,并且提供了一些实用的方法(操作符重载)。用户可以方便地取消一个事件,获取事件的详细信息,或者直接比较两个事件发生的先后顺序。
3. 仿真器(Simulator)调度事件
通过上一节的介绍,我们已经掌握了NS-3当中事件调度的基本原理。虽然我们可以自己创建Event并将其加入事件队列,但是更好的方式还是使用NS-3提供给我们的Simulator的API来添加事件。因为EventImpl类并不能直接创建对象,我们需要去自己继承其子类是相对来说比较麻烦的。NS-3采用了内部类的方式来实现,在不同的调度方法当中实现不同的内部子类,因为NS-3当中存在多种不同的仿真器和调度器实现,他们事件的内部实现是完全不同的,在类的内部隐藏实现细节也可以降低用户使用的复杂度。这些细节对用户来说没有意义,因此,我们只介绍Simulator调度事件的API接口。
抛开底层复杂的实现系列不管,要通过Simulator调度(预安排)一个事件还是非常简单的,只需要调用Simulator类的Schedule()方法,提供一个时间延迟何一个要调用的函数即可。
在所有事件都调度好之后,只要调用Simulator::Run()方法,仿真器即可从最早的事件开始依次执行。我们通过一个简单的例子来演示如何调度事件:
1 |
|
程序当中,我们创建了一个函数,其中输出了一段信息。在主函数当中,调度这个函数在第1.5秒被调用。需要强调的是,Schedule方法所指定的时间是时延,而不是绝对时间。这里之所以是1.5秒的时间,是因为在仿真还未开始时,当前时间为0。为了展示执行时间,我们打开了日志的时间前缀(参见日志一章)。在调用Run方法之后,程序将从第1.5秒开始调用事件。执行程序之后,得到如下输出:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可见,这个信息是在第1.5秒输出的。这里需要注意的是,这个1.5秒指的是仿真时间,是一个虚拟的时间概念。这和我们说的真实的物理时间是不同的。我们通过下面的例子来进行说明。例如,我们需要对一些数进行从小到大的排序。网络上曾流传睡觉排序法:写一个线程,传入一个需要排序的数,然后线程开始睡眠,睡眠时间刚好等于需要排序的这个数,然后线程醒来后输出这个数。主程序只需要创建多个线程,将需要排序的数依次传入即可。这里提供一个Java版的实现:
1 | public class SleepSort { |
当然,这种排序算是恶搞的成分多一些,因为如果需要排序的数当中存在一个非常大的数,那么排序完成的时间将会非常的久。例如需要排序的数不是1,2,3,4,5,6,7,8,9这么简单,而是100, 400, 700, 300, 800, 900, 200, 600, 500。我们可以使用NS-3的事件调度来实现它。首先有一个问题,如何在调度的时候传入需要排序的数?我们查看一下Simulator的API当中关于事件调度的函数:
1 | //=======调度对象的成员函数 |
可以看出,基本的调度可以分为两组:对对象成员变量的调度和对普通函数的调度,我们上面的例子就是最简单的对普通函数的调度。但是这个调度方法不能所调用的函数不能有参数。同时NS-3对普通函数(或者成员方法)的调度方法提供了7种重载,分别可以对应0到6个参数。
1 |
|
程序当中,我们将每个数都调度到和这个数相同的时间才输出,最后统计了整个程序运行所花的真实时间。运行程序后得到如下输出结果:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可以看出,数字的确是安从小到大的顺序排列了,但是所消耗的时间并不是所有最大的调度时间900秒,而只用了6309微秒。这说明仿真时间和真实时间之间是没有绝对的联系的。除此之外,从这个程序还可以看出事件调度可以从某种角度看成在单线程框架下的多线程支持,当然,这种多线程并不存在物理时间上的并行性,而仅存在仿真时间上的并行性。
Simulator还提供了另外一个方法Simulator::ScheduleNow(),这和上面的其他调度方法没有本质上的区别,但是可以不用指定时间延迟。而是直接使用当前时间。其实这等同于Schedule(Seconds(0), &func),只是写起来比较方便而已。这个方法存在的意义就是在同一个(仿真)时间,再“并行”执行另外一个事件。如果ScheduleNow()方法在Run()方法被调用之前就调用,那么这个事件将会在第0秒执行。否则就是按当前时间执行。下面通过一个例子来进行说明:
1 |
|
程序当中,我们在仿真开始之前,使用ScheduleNow调度了一个事件doAThing,又使用Schedule在1.5秒调度了一个事件doSomething。在1.5秒的事件当中,又使用ScheduleNow调度了另外一个事件doAnotherThing。运行程序之后,得到如下输出:
1 | Waf: Entering directory `/home/rainsia/Applications/ns-allinone-3.29/ns-3.29/build' |
可见,在仿真开始之前通过ScheduleNow调度的事件将在第0秒执行。再仿真开始之后通过ScheduleNow调度的事件将在和调度时相同的时间执行。
除此之外,Simulator还提供了另外一个系列的事件调度接口:Simulator::ScheduleWithContext()。这个方法和前面介绍的Simulator::Schedule()方法类似,唯一的区别是多了第一个参数uint32_t context。表示调度事件时的上下文。这个上下文调度的时候,用户可以任意指定一个意义。但是在NS-3当中,这个参数的意义一般被认为是Node的id。因此,我们才能够在程序中分辨哪个事件是在哪个节点中调用的。而我们在做日志的时候,也可以开启节点前缀(参见日志一章)。当一个事件没有与任何一个节点相关联的时候,其上下文参数为0xffffffff(最大值)。
4. NS-3当中的时间
我们在上面进行调度的时候Schedule()方法,需要传入一个延迟时间,其类型是Time,而我们在调用的使用使用的是Seconds()函数来创建时间。当然,如果英文具有初中水平的话,也能知道Seconds()函数创建的时间单位为秒。我们也可以传入浮点数,例如Seconds(0.2),那么将创建一个时间为0.2秒,也就是200毫秒。我们不禁想问:Time类究竟是如何存储时间数据的?Time类能表示的时间精度究竟是多少?能支持高速网络当中的时间精度吗?
4.1. Time的时间精度
首先,我们来分析,Time类储存的时候究竟使用的什么精度。直接看代码:
1 | class Time |
首先从这段代码可以看出,NS-3支持的最小的时间精度是飞秒(femtosecond)。$1\text{fs} = 10^{-15}\text{s}$。这个时间精度对于任何仿真都足够(多余)了,这几乎已经接近物理极限了。任何网络上的事件几乎都不可能在这个精度下发生。其次,实际存储时间数据的变量是m_data,它的类型是一个64位的整数。因此,一旦确定了时间精度,那么NS-3的Time类所能表示时间绝对值的最小值和最大值也就确定了。例如,如果时间单位选定是飞秒,那么最小时间精度就是1飞秒,最大时间跨度就是$2^{64}-1$飞秒,也就是18446秒,约307分钟。当然,这种时间跨度,一个规模稍稍大些的仿真都能超过这个时间。如果选定的时间单位为纳秒(ns)。$1\text{ns} = 10^{-9}\text{s}$。那么最小时间精度为1纳秒,最大时间跨度为$2^{64}-1$纳秒,也就是约7116个月。一般的仿真几乎都不会超过这个时间,除非进行整个Internet的长时间仿真,但这是不可能通过仿真来完成的工作。
我们继续看Time类的代码:
1 |
|
从代码当中可以看出,Time类使用Resolution结构体来表示时间的精度(分辨率)。使用PeekResolution()方法来查看当前使用的分辨率。而该方法当中定义了一个静态变量(这个变量在类的生命周期当中只会创建一次,并由所有实例共享)resolution,并将变量通过SetDefaultNsResolution()方法来初始化。而SetDefaultNsResolution()方法返回的默认精度为纳秒。至此,我们已经清楚了Time类使用的默认时间精度为ns,最大时间跨度为:7116个月,约593年。从各个方面来看,这都是一个比较合理的默认精度选择。
如果觉得时间精度不合理,Time类提供了一个静态方法SetResolution()来设置时间精度:
1 | static void SetResolution (enum Unit unit, struct Resolution *resolution, |
这个方法最好在任何仿真开始之前调用。当然,如果在仿真开始之后调用也没有太大问题,在调整精度之前创建的所有Time对象都会被更新为新的时间精度。然而,这种更新在整个仿真期间只能发生一次。第二次更新时间精度,在更新之前创建的时间对象都不会再被更新。
4.2. 时间类的使用
时间类实现了如下操作符和函数的重载:
- 算术运算符:+, -, +=, -=;
- 关系运算符:==, !=, <, >, <=, >=
- 算术函数:Abs(Time), Max(Time,Time), Min(Time,Time)
除此之外,NS-3提供了大量的方法用于快速创建时间到当前精度表示下,并完成了单位之间的自动转换:
1 | inline Time Years (double value); |
其中就包含了,我们曾用过的Seconds()函数。这些函数的意义应该都是一目了然的,这里就不再对其用法再做赘述。
除此之外,Time类也提供了相应的方法,将时间转换成任意单位:
1 | inline double GetYears (void) const; |
同样,这些方法的名字也足以说明其功能,无需更多解释。
NS-3同样也给时间类定义了相应的属性值类型:TimeValue,属性访问器:MakeTimeAccessor(),属性检查器:MakeTimeChecker()。因此,Time同样也可以当作类的属性,被加入属性框架当中。时间被创建为属性值类型,比较实用的一点可能就是可以直接使用字符串值类型来创建时间,例如:
1 | StringValue("100ns"); |
这都得益于Time类的一个构造函数:
1 | /** |
它可以将一个合法的时间字符串转换为一个时间类型。使用的时候需要注意,时间的值和单位之间不能有空格。任何不合法的字符串都将引起程序错误。
因此我们可以将第一个程序使用这种方式进行重写:
1 |
|
运行之后,也能得到同样的效果。
5. 调度器(Scheduler)
Simulator所调度的事件实际是通过调度器Scheduler来进行调度的,调度器的主要功能就是对添加的事件进行排序,并维护事件列表,然后依次调用这些事件。在NS-3当中存在着多种不同的调度器,这些调度器代表着不同的调度性能。在非分布式仿真当中,调度器的性能实际上主要就体现于对事件时间的排序组织性能。
NS-3提供的调度器类主要有:
- CalendarScheduler:这是一个理论上应该性能很好的调度器,然而作者在实现完之后评测性能并不好。
- HeapScheduler:主要机遇堆排序的思想设计的调度器。
- ListScheduler:使用简单的List列表实现的调度器。
- MapScheduler:NS-3的默认调度器,性能均衡。
如果读者记性比较好应该还记得在配置路径一章,提到过NS-3有几个全局属性,其中一个就是用来配置NS-3使用的调度器的。这个属性就是SchedulerType,其默认值为ns3::MapScheduler。如果需要使用其他的调度器,可以在仿真开始之前直接修改这个属性的值为其他几个类。