围棋中的模拟时间和测试事件循环

2020-05-25 18:25:16

最初我想分别写这两个主题的文章(模拟时间和测试事件循环),但在这个过程中,我意识到我想要谈论的东西太相关了:当我需要模拟时间时,通常是用它来测试一些事件循环,而当我测试事件循环时,通常也会涉及到模拟时间。

所以最后,把所有这些都整合到一篇文章里感觉更好。

不幸的是,到今天为止,go stdlib中的时间函数是不可模拟的:每当我们使用例如time.Now()或time.NewTicker等时,它们将使用实时,这使得对时间敏感的代码很难正确测试。

https://github.com/benbjohnson/clock:是一个更完整的库,它试图模拟stdlib时间包的所有功能。

在Github中也有一个关于可模仿时间支持的老问题,其中提到了上面的两个库,我确实有一些希望,嘲笑时间迟早会以某种形式包括在stdlib中,但我们要有耐心。因此,从今天起,如果我们想要拥有被嘲弄的时间,我们必须使用定制的解决方案。

在这篇文章中,我将重点介绍第二个库,benbjohnson/Clock,这是我已经使用了几年的库(的一个分支),总体来说,如果使用得当,它就能完成这项工作。因此,让我们仔细看看它。

这个库的核心只有一个名为Clock的接口,它试图模拟时间包的功能:

//Clock表示标准库Time包中函数的接口//。时钟封装中提供两种实现方式。第一个//是一个实时时钟,它简单地包装了时间包的功能。//秒是模拟时钟,只有在//以编程方式调整时才会前进。键入时钟接口{在(d时间.Duration)<;-更改时间之后。Time AfterFunc(d时间.持续时间,f函数())*Timer Now()时间。从(t时间起)至今已有一段时间。时间)时间。持续时间睡眠(d时间。持续时间)滴答(d时间。持续时间)<;-更改时间。Time Ticker(%d时间.Duration)*Ticker Timer(%d时间.Duration)*Timer}

因此,在我们的代码中,不是使用例如time.Now(),而是需要这个时钟接口的一个实例,并使用它。所以不是这样,而是:

//在初始化代码中的某个位置,创建Clock接口的实例:C:=Clock。new()://稍后在某个地方使用该实例:fmt。Println(c.。现在()。

大多数情况下,我发现自己有一个时钟实例作为某些东西的参数的一部分,如下所示:

键入foo struct{lock lock.Clock},然后键入FooParams struct{lock lock.Clock}和func NewFoo(params*FooParams)*foo{foo:=&;foo{lock:params.Clock,}//注意:我们有意不使用默认参数。按时钟计时。新建,请参阅下面的//警告部分。返回foo}。

然后,我们显然应该在foo的方法中使用时钟实例,而不是使用实时函数:

foo:=NewFoo(&;FooParams{CLOCK:CLOCK。new(),//使用实时}))//以某种方式使用foo。

const testTimeLayout=";2006年1月2日15:04:05.000";unc TestFoo(t*testing.t){//创建模拟时钟mockedClock:=时钟。NewMock()NOW,_:=时间。parse(testTimeLayout,";May 1,2020 at 00:00:00.000";)mockedClock。设置(现在)foo:=NewFoo(&;FooParams{Clock:mockedClock,})*//以某种方式测试foo,例如我们可以这样提前模拟时间:mockedClock.Add(1*time.Second)foo.PrintTime()*mockedClock.Add(1*time.Second)foo.PrintTime()}。

这一切都非常直截了当,但也不是那么有趣。当我们开始使用带计时器或报价器的Goroutine时,事情会变得更加棘手,所以让我们开始吧。

如上所述,Clock试图模仿stdlib时间包,所以它当然有计时器和报价器。要正确使用它们,并避免意外(如flkey测试),在一定程度上了解被嘲笑的计时器和自动报价器是如何工作的是很有用的。

(顺便说一句,是的,该方法的名字很奇怪,命名为Ticker,而不是stdlib中的NewTicker;Timer vs NewTimer也是如此。我不知道这是否是故意的,但无论如何,不会造成太大伤害,只是要记住一些事情)。

因此,如果c实例表示实际(而不是模拟)时间,那么正如您所期望的那样,Ticker方法只是委托给Time.NewTicker,除此之外不做更多事情。但是,如果c是模拟时间,则调用Ticker会在内部注册一个新的模拟自动收报机,因此每当我们稍后通过调用add将模拟时间提前时,先前创建的自动收报机将在适当的时候接收到自动收报机。

同样值得注意的是,当我们通过调用add来推进模拟时间时,所有需要触发的定时器和定时器都会在Goroutine调用add中同步触发。例如,拥有以下内容:

C:=钟。NewMock()NOW,_:=时间。parse(testTimeLayout,";20205.01,00:00:00.000";)c.。设置(NOW)://使用AfterFunc创建一些计时器,带有自定义回调c.AfterFunc(200*time.Millissec,func(){fmt.Println(";AfterFunc1 fired,time:";,c.Now())})c.AfterFunc(50*time.Millissec,func(){fmt.Println(";AfterFunc2 fired,now())})//创建一些常规计时器var mytimers[]*时钟.Timer mytimers=append(mytimers,c.Timer(1*time.Second))mytimers=append(mytimers,c.Timer(2*time.Second))mytimers=append(mytimers,c.Timer(5*time.Second))mytimers=append(mytimers,c.Timer(100*time.Millisond)。

到Add Call返回时,我们的两个AfterFunc回调已经在这个相同的Goroutine中被调用,所有这些常规计时器都收到了一条消息到它们的C通道(除了5秒的那个,因为我们只将时间提前了3秒),并且自动收报机被尝试向它的C通道发送消息6次,但是只有第一次成功,因为没有人从该通道读取数据。因此,在添加调用之后,已经打印了以下内容:

我们还可以检查mytimers切片,并从它们的C通道获取消息:

对于i,tmr:=range mytimers{var val string select{case t:=<;-tmr.c:val=fmt.Sprintf(";%s";,t)默认值:val=";尚未从fmt激发";}。Printf(";计时器#%d:%s\n";,i,val)}

定时器#0:2020-05-01 00:00:01+0000协调世界时#1:2020-05-01 00:00:02+0000协调世界时#2:尚未触发定时器#3:2020-05-01 00:00:00.1+0000协调世界时。

另一个重要的(也是恼人的)细节是,到今天为止,还没有一个很好的通用方法来告诉Go运行时:“运行所有可运行的Goroutines,直到它们阻塞为止”。但是,使用模拟时间,我们实际上确实需要这样的时间:例如,如果我们有一个从某个自动收报机的C通道读取的事件循环,并且我们提前了模拟时间,以便这个C通道可能已经接收到一条消息(因为自动收报机可能已经打勾了),我们想要确保在我们继续之前,这个Goroutine处理了该消息。

但是由于没有很好的通用方法,时钟库使用了一种糟糕的方法:每次推进模拟时间,它也只休眠1ms(我的意思是,它休眠的是“真正的”1ms,而不是模拟的1ms)。这确实意味着使用它的测试在定义上是不可靠的(因为睡眠时间长并不能保证任何Goroutine程序都会实际运行),而且如果测试将模拟时间提前了很多,测试就会显著变慢(因为睡眠“1ms”显然不能精确地睡眠1ms:通常它最终会长得多)。

因此,虽然我同意作为最通用的逻辑,默认只休眠一些任意的持续时间(如1ms)是可以的,但我相信应用程序应该有一种方法可以用一些自定义逻辑覆盖这种行为,因为我们可能有一些特定于应用程序的可靠方法来确保我们需要运行的Goroutine确实运行了(比如添加一些可模拟的回调,只要事件循环处理了某些事件就会调用这些回调)。

因此,我不得不将时钟库派生为https://github.com/dimonomid/clock并实现它;如果您感兴趣,这里是承诺:使Gosed的实现可配置。

API是以向后兼容的方式更新的:我们仍然可以只调用clock.NewMock(),并且我们将得到相同的模拟时钟,它在推进模拟时间时仅休眠1ms。但是,如果我们想要覆盖该行为,我们可以这样做:

c:=lock.NewMockOpt(lock.MockOpt{Gosked:func(){//提前模拟时间后运行的任何自定义逻辑},})。

我的那些更改的拉取请求已经打开了近两年,不幸的是,我不确定它是否会合并到上游,所以在下面的示例中,我将使用我的叉子。

我们稍后将讨论此Gosted回调的替代实现,但现在,请记住这一切,让我们继续前进。

例如,让我们考虑一个简单的组件foo,它占用一个时间间隔(如1秒)和一个int的输出通道,并在给定的时间间隔(如0、1、2等)向该通道发送一个不断递增的数字。

Package Foo和import(";time";t";github.com/dimonomid/lock&34;)类型foo struct{nextNum int out chan<;-int时钟时钟.Clock}类型FooParams struct{lock.Clock//out是向out Chan<;传递数字的通道-int//Interval是将数字传递到out间隔时间的频率。Duration}://NewFoo创建并返回foo的一个实例,还启动一个内部//goroutine,它将向提供的频道params.Out发送数字。func NewFoo(params*FooParams)*foo{if params.Clock==nil{需要时钟";)}*foo:=&;foo{lock:params.Clock,out:params.out,}如果foo.lock==nil{foo.lock=lock.New()},则使用foo.run(params.Interval)或return foo}infunc(foo*foo)run(Interval Time.Duration){//注意:在此Goroutine中创建自动收报机时出现问题,//如下所述。股票代码:=foo.lock.Ticker(间隔),{<;-ticker.C foo.out<;-foo.nextNum foo.nextNum+=1}}。

在2006年1月2日15:04:05.000";Func TestFoo(t*test.t){//创建模拟时间,初始化于2020年午夜5:04;05.000";Func TestFoo(t*Testing.T){//创建模拟时间,初始化时间为5月1日零时零分)(";Testing&34;";time";Testing";github.com/dimonomid/lock";);Const testTimeLayout=";TestLayout=";TestLayout=";mockedClock:=时钟。NewMock()NOW,_:=时间。parse(testTimeLayout,";May 1,2020 at 00:00:00.000";)mockedClock。设置(现在)//创建输出通道,我们稍后将检查它是否接收到我们//期望的数字。out:=make(chan int,1)//创建foo,它还将启动内部goroutine以//向通道发送数字。NewFoo(&;FooParams{lock:mockedClock,out:out,interval:1*time.Second,})2//断言我们收到预期的数字mockedClock.Add(1*time.Second)assertRecvInt(t,out,0)nmockedClock.Add(1*time.Second)assertRecvInt(t,out,1)*mockedClock.Add(1*time.Second。-chan int,want int){select{case get:=<;-ch:if get!=Want{t.Errorf(";want%d;,get%d";,want,get)}默认值:t.Errorf(";want%d;,get";,want)}}。

但是,如果我们运行它,我们会发现测试经常失败,结果不确定。有时可能是这样的:

-失败:TestFoo(0.01s)foo_test.Go:50:想要0,没有得到foo_test.Go:47:想要1,得到0 foo_test.Go:47:想要2,得到1。

-失败:TestFoo(0.01s)foo_test.Go:50:想要0,没有得到foo_test.Go:47:想要1,没有得到foo_test.Go:47:想要2,得到0。

也可能是别的什么。因此,很明显,在如何使用被嘲笑的时间方面存在着一场竞赛。事实上,这场竞赛是在创造一个股票代码和推进被嘲弄的时间之间进行的。您还记得,在模拟时钟实例上调用Ticker会导致它在内部注册该滴答器,这样当模拟时间稍后提前时,它就可以将滴答声传递给该模拟滴答器。但是我们上面的代码在Run中创建了股票代码,该代码在单独的Goroutine中运行:

函数(foo*foo)Run(Interval Time.Duration){ticker:=foo.lock.Ticker(Interval){/*.*/}}。

当我们在主要的测试大猩猩赛道上推进模拟时间的时候。因此,结果取决于GO运行时相对于mockedClock.Add调用调度run goroutine的时间。如果Run在我们创建之后立即运行,则测试通过,因为滚动条是在我们提前模拟时间之前创建的。但是,如果至少有一次对mockedClock.Add的调用发生在创建自动收报机之前,则测试将失败。

因此,要解决这个问题,我们需要确保在NewFoo中同步创建滚动条:也就是说,在NewFoo中创建滚动条,并将其作为参数传递以运行:

func NewFoo(params*FooParams)*foo{foo:=&;foo{lock:params.Clock,out:params.out,}股票代码:=foo.lock.Ticker(params.Interval)go foo.run(Ricker)and return foo}Func(foo*foo)run(ticker*lock.Ticker){for{/*.。相同的循环体.*/}}。

在这一变化之后,考试就不再是零碎的了,而且它们都通过了。这一更改不会对生产代码产生任何实际影响,但是当我们使用时钟时,我们必须牢记这样模拟时间的内部细节。

现在,让我们设想一下,我们需要通过添加一个方法SetInterval来改进foo组件,该方法将在运行时更新将数字发送到out的间隔。此方法将只向事件循环发送一条消息,然后事件循环将接收该消息并重新创建具有新间隔的自动收报机。

func(foo*foo)run(ticker*lock.Ticker){for{select{case<;-ticker.c:foo.out<;-foo.nextNum foo.nextNum+=1个案例间隔:=<;-foo.intervalReqCH:ticker.Stop()ticker=foo.lock.Ticker(Interval)}。

然后,我们将以下代码片段添加到TestFoo的末尾,以测试此新功能:

//延长间隔50毫秒//foo.SetInterval(1050*时间.毫秒)//确保将时间提前更新间隔后,//我们获得下一个数字。mockedClock.add(1050*时间.毫秒)assertRecvInt(t,out,3)。

因此,经过一些调试之后,我们意识到即使没有缓冲通道intervalReqCH,发送到该通道并不意味着事件循环完全处理了消息(即,用新的持续时间重新创建了自动收报机)。发生的情况是:我们调用SetInterval,它将消息发送到intervalReqCH,而该消息已经由run goroutine接收,但尚未处理;然后我们提前模拟时间,此时它也会休眠1ms以释放运行时调度goroutine(上面提到的那个恼人的细节),然后run goroutine最终被调度,因此它处理来自intervalReqCH的消息并重新创建报价器,但是我们不再提前时间,因此它永远不会计时。

我们需要添加一种方法来确保消息实际上已经由事件循环处理。例如,实现一些“可模拟的”回调,如果不是nil,则只要intervalReqCH被完全处理,就会调用该回调。如下所示:向foo结构中再添加一个未导出的字段:

//intervalApplication仅用于测试:测试代码可以将其设置为非nil//函数,然后只要处理来自intervalReqCH的//消息,就会从事件循环中调用它。间隔应用函数()。

案例间隔:=<;-foo.intervalReqCH:ticker.Stop()ticker=foo.lock.Ticker(Interval)if foo.intervalApplication!=nil{foo.intervalApplication()}。

然后,在测试代码中,在创建foo之后,我们使用一个函数填充intervalApplication回调,该函数将消息发送到另一个通道:

现在,测试通过了。然而,它也让我们意识到,我们必须为我们期望事件循环处理的每条消息添加那些可模拟的回调,这很难闻,因为它需要测试代码来了解太多关于实现的细节。在这样的小组件中,它可能是可以接受的,但是当我们开发具有较大事件循环的组件时,知道事件循环处理的内部事件的确切序列可能会变得太多。

我确实有一个更好的建议给你,但是现在,请容忍我。显然我们还有另外一个问题要先解决。

细心的读者可能会注意到,我们用于更新时间间隔的测试实际上并没有测试是否应用了新的时间间隔:我们将新的时间间隔设置为1050ms,将时间提前该持续时间,并检查我们是否从foo获得了下一个数字,但实际上,即使自动收报机没有变化,测试仍然会通过,因为我们设置的新时间间隔大于旧的时间间隔,因此将模拟时间提前1050ms意味着1000ms的自动收报机也会触发。这很容易验证:只需注释这两行:

//ticker.Stop()//ticker=foo.clock.Ticker(Interval)_=Interval//避免未使用变量的编译错误。

然后我们意识到我们真的不能轻易做到这一点,因为没有可靠的方法来做(2),也就是说,验证某些事情还没有发生。我们可以通过添加此功能来尽最大努力,以确保通道中没有消息:

Func assertNoRecvInt(t*Testing.T,ch<;-chan int){select{case get:=<;-ch:t.Errorf(";Want Nothing,Get%d";,Get)Default://All Good}}。

但是,在注释了自动收报机逻辑(即,损坏的代码)之后,测试有时还是会通过。这是因为我们在Go Scheduler这里是仁慈的:我们可能还没有收到一个项目,不是因为我们的逻辑是正确的,而是因为Go Scheduler碰巧还没有运行事件循环Goroutine,所以这是因为Go Scheduler在这里是仁慈的:我们可能还没有收到一个项目,这不是因为我们的逻辑是正确的,而是因为Go Scheduler碰巧还没有运行事件循环Goroutine。

事实上,对于这个简单的组件,我们可以通过例如将新间隔设置得更小来解决它:然后,通过将时间提前较小的持续时间,我们可以验证它尚未发生,并且此检查将可靠地工作。但是,这种解决办法并不总是可以在现实世界的代码中实现:有时,可靠地验证某些事件尚未发生实际上是可取的,所以让我们试着想一想如何做到这一点。

对于{SELECT{{case msg:=<;-foo:handleFoo(Msg)案例msg:=<;-bar:handlebar(Msg)}。

我们想要的是有一种方法来确保那些通道foo和bar(可能被缓冲)中的所有挂起消息(如果有的话)已经由该循环处理。

如果我们可以使所有的测试通道都没有缓冲,那么这就不是问题,因为我们发送消息时会对其进行处理。然而,未缓冲的通道并不总是合适的:例如,自动收报机和定时器通道C是1缓冲的,或者某些操作可能会导致多个消息被发送到一个通道,因此在我们检查它们之前,该通道应该有一个缓冲区来容纳它们。

因此,同样,我们希望有一种方法来确保所有挂起的消息,无论我们缓冲了多少消息,都已经得到了处理。

想象一下,我们有一个像cycleEventLoop这样的函数,它就是这样做的:它会阻塞,直到事件循环有更多的消息要处理。当没有更多消息时,cycleEventLoop返回。它还意味着调用cycleEventLoop是无害的,即使事件循环根本没有要处理的消息:在本例中,c。

..