metaclass、type以及python中关于class的那些事

type

在python中,type()可以查看一个对象的类型,也可以创建一个新的类型。
我们都知道,python作为动态语言相比于静态语言的一个显著区别就是类和函数的定义是运行时动态创建的(而非编译时定义好)。
我们可以这样定义一个类

1
2
class A(object):
pass

也可以这样定义

1
A = type('A', (object,), {})

上述两种方式构造的A类是等价的。

class的type

先看一段代码

1
a = A()

上述代码里我们创建了A类的实例(instance),命名为a。很显然a是一个对象,并且由于在python中万物皆对象,实际上A也是一个对象。如果我们尝试对a和A用type查看他们的类型,会发现一些有趣的事情。

1
2
type(a)  # <class '__main__.A'>
type(A) # <type 'type'>

事实上如果你尝试对别的类用type,你会发现它们的输出都是<type 'type'>

metaclass

承接上文中我们说的,类(class)本质上也是一个对象。在python里,内存里的一个对象一定是被创建出来的。
在上文的代码中我们发现A类可以被type直接创建出来,除此之外,还可以使用元类(metaclass)。
这里引用一段我认为非常经典的关于元类与类关系的描述

1
2
当我们定义了类以后,就可以根据这个类创建出实例,所以:先定义类,然后创建实例。
但是如果我们想创建出类呢?那就必须根据metaclass创建出类,所以:先定义metaclass,然后创建类。

所以,metaclass允许你创建类或者修改类。换句话说,你可以把类看成是metaclass创建出来的“实例”。
在下面的示例中,MyClass和MySubclass都是MetaClass的实例:

1
2
3
4
5
6
7
8
9
class MetaClass(type):
pass

class MyClass(object):
__metaclass__ = MetaClass
pass

class MySubclass(MyClass):
pass

这里有一点绕的地方在于,所有metaclass的父类都是type或者源自type,type可以认为在python中是奇点般的存在,由它生出万物。

__init____new____call__

稍微熟悉python的人都知道,__init____new____call__这三个方法在类的实例创建中有重要作用。

1
2
a = MyClass()
a()

当我们通过上述代码的方式来构造MyClass类的一个实例时,实际上会先调用MyClass__new__方法(如果没有就会去父类上面找),然后再调用__init__方法。
另外我们知道对象通过提供__call__(slef, *args ,**kwargs)方法可以模拟函数的行为,如果一个对象提供了该方法,就可以像函数一样使用它。也就是说a(arg1, arg2...) 等同于调用a.__call__(self, arg1, arg2) ,而a()实际上就是调用MyClass__call__方法,即调用了对象a的类型上的__call__方法。

在弄清上面的原理后,我们再看向a = MyClass()这句代码。通过上面的介绍,我们知道MyClass也是一个对象,而它的类型就是它的metaclass,即我们上面定义的MetaClass类。所以,MyClass()实际上会调用MyClass的类型,也即是MetaClass__call__方法。而这,也正是很多代码中通过重写自身metaclass中的__call__方法从而达到控制自身以及子类的初始化逻辑。

浅谈微服务及其在游戏里的应用

一般来说,微服务(Microservices)的概念被认为最早是2011年在威尼斯的一场会议上面讨论并提出的最初只是一些通用的设计原则和系统架构。在随后的几年内,随着诸如Fred George和Martin Fowler等人的演讲和文章的出现,微服务架构逐渐开始流行,越来越多的公司和项目开始借鉴和使用微服务架构相关的技术。时至今日,虽然微服务架构并没有一个官方的定义,但是相比于传统的SOA(Service-oriented architecture)架构,微服务有以下几个典型的特点:

  • 细颗粒度的服务划分
  • 每个服务可以单独部署
  • 服务之间使用语言无关的协议通讯,如http,tcp等

这也意味着在一个微服务的系统里面,可能你用的数据库操作是用java写的,http服务是用node.js写的,一些具体的业务服务又是用python写的。没有任何强制的规定用哪种语言来实现,换句话说就是什么最合适就用什么。

微服务架构的演变

微服务本身的架构也不是一成不变的,Phil Calçado有一篇文章《Pattern: Service Mesh》非常详细的介绍了从微服务诞生前到现在的系统架构演变历史。大概可以总结为几个阶段:

1
2
3
4
5
6
7
8
9
10
11
数据传输逻辑和业务逻辑写在一起
|
出现TCP/IP协议负责数据传输
|
服务发现等功能被抽成单独的库
|
独立处理相关问题的层
|
边车模式 "Sidecar Pattern"
|
服务网格 "Service Mesh"

可以看到总的发展趋势其实一直就是在朝一个方向发展,即业务逻辑和底层架构分开。服务网格(Service Mesh,一种较新的微服务架构)这个词的发明者William Morgan说过这么一段话:

“A service mesh is a dedicated infrastructure layer for handling service-to-service communication. It’s responsible for the reliable delivery of requests through the complex topology of services that comprise a modern, cloud native application. In practice, the service mesh is typically implemented as an array of lightweight network proxies that are deployed alongside application code, without the application needing to be aware”

从infrastructure layer这一词可以看出,Service Mesh在现代的分布式架构的系统中充当的角色和TCP/IP协议在传统网络环境中扮演的角色很像,一个解决的end to end之间通信的可靠性问题,一个是屏蔽了分布式系统带来的复杂性问题,总之都是实现了一种对应用层透明的基础传输设施。

微服务在游戏中应用

通常来说,比较适合做成微服务的业务逻辑都是无状态的,而绝大部分游戏却又都是典型的强状态业务。但是随着游戏用户的日益增加,游戏服务器的规模也日益庞大。尤其是一些喜欢做成开放大世界的游戏,其游戏服务器本身就是一个复杂的分布式系统。在这种情况下,把游戏中一些可以抽象出来的无状态业务从核心模块里面剥离出来做成微服务,既能够减少承载核心业务机器的负载,又可以减少非核心业务发生故障对玩家游戏体验的影响。通常来说,一些可以抽出来做微服务的有诸如邮件系统,好友系统,匹配系统,聊天系统等,而一些和玩家状态强相关的比如属性,战斗之类的则没有办法。一个比较简单的衡量业务是否适合做成微服务的标准就是:这个微服务是否可以随时重启,重启带来的影响越小则越适合。

接下来我们以游戏中的聊天系统为例子,来分析一下在游戏中引入微服务的优缺点。今天,大部分游戏的聊天功能的需求早已不仅仅是满足与一局游戏内部的聊天而已了,一张地图/一个服务器内的聊天,玩家与玩家之间的私聊,群聊,离线消息,全服广播,乃至不同种类设备之间的聊天(比如使用手机助手的玩家和游戏内的玩家之间的聊天)等等需求。与其说这是一个游戏内的功能,不如说这是一个简易版本的IM应用了。而从实现角度来看,聊天系统是可以完全和游戏玩法解耦开的,其本身又是一个弱状态的业务,因此,使用微服务来实现是完全可行的。

进一步分析,对于服务调用方,通常一个聊天系统只需要实现发送,拉取,订阅,推送四个基本的功能就可以满足绝大部分业务需求。而对于微服务自己内部,一个现有的非常成熟的技术可以非常轻松的实现这些需求,那就是消息队列。把一个聊天的业务抽象起来看就是某个客户端往某个指定的频道发送了一些消息,而这个频道可能有两个订阅者(私聊)或者很多个订阅者(群聊),而这些订阅者有一个统一的目的,就是在有人往这个频道发送消息的时候能够尽快得到通知。一般在消息队列中,我们称之为“发布–订阅”模型。一方面微服务上可以根据聊天的类型,生成不同的频道号来区分客户端发送过来的消息类型从而转发到消息队列不同的话题里。另一方面,当消息队列某个话题有新内容推送过来时,微服务这里只需要解析一下这个话题,然后向关注这个话题的客户端推送这些消息。

与直接嵌入在游戏服务器内部的聊天系统实现相比,这种使用微服务的实有几个显而易见的优势:

  • 1.扩展性,聊天系统的负载较高时,单独对微服务进行扩容就可以了,无需操作游戏服务器
  • 2.独立性,没有和游戏服务器的耦合关系,可以单独开发,单独升级,如果线上出现问题,还可以及时熔断进行降级,防止“雪崩”效应
  • 3.可靠性,不再受限于游戏服务器内部实现的限制,可以尽情使用最新的技术架构来保证稳定可靠

与之对应的,引入微服务也带来了一些额外的开销:

  • 1.额外的复杂性,虽然我们上面分析的时候非常简单,但是实际实践下来还是有很多坑的存在,尤其是在引入了第三方的消息队列以后
  • 2.额外的性能开销,与做在游戏服内部的实现相比,消息链路有了明显的加长,序列化/反序列化,网络通信都是额外的开销
  • 3.额外的运维监控,本身已经是一个需要监控的独立的业务系统(当然,前提是你确实需要一个这样强大的业务系统)

不要为了微服务而微服务

前面吹了这么多微服务的好处,可能大部分人都会认为此篇文章是来鼓吹和推广微服务的。但是,我的本意恰恰相反,我强烈不建议盲目引入微服务到现有的架构中。是否需要引入微服务绝对是一个要多方权衡的东西,且不说微服务本身通常会带来的额外延时开销,开发团队的能力、企业的组织结构、业务本身的特点等等都是需要纳入考虑的点。所幸的是网络游戏发展到今天,已经演化很多通用的基础功能,比如聊天,好友,邮件,匹配等等。以现在的中大型网络游戏的用户规模而言,这些系统还是非常适合做成微服务的。但即使如此,在开发的前期,在业务逻辑和系统边界还不是那么清晰的时候,还是要谨慎考虑,千万不要陷入拿着解决方案找问题的“陷阱”之中。

时区问题杂谈

在今天,大部分手游制作团队的眼光都不再局限于中国国内市场,出海已经成了大部分公司手游的标配。但是在出海发行的时候,我们的产品难免会有一些“水土不服”的情况发生。时区,就是这其中的一个。

GMT,UTC以及UnixEpoch

  • GMT:格林尼治标准时间
  • UTC:协调世界时
  • UnixEpoch:自UTC时间1970年1月1日0点0分0秒以来至今所经过的时间,通常用秒来表示

考虑到地球是个球,人们又把地球划分成24个时区。北京位于东8区,而我们一般说的北京时间可以表达为GMT+8或者UTC+8。因为通常来说,在不那么精确的情况下我们是可以认为GMT等于UTC的。

至于UnixEpoch或者说unix时间,很明显,这是一种对程序更为友好的时间表达方式。在python代码里面,直接调用time.time()得到的就是它的值,并且UnixEpoch与时区无关。同一时刻,在任何时区调用time.time()得到的值都应该相等。

夏令时(Daylight Saving Time)

海发除了要注意时区以外,还有可能遇到的一个问题就是夏令时。美国和大部分欧洲国家目前仍在使用夏令时。
引用python 2.7版本的官方doc里的一句话“DST rules are magic (determined by local law)”,在程序的角度换句话说就是,夏令时的开始与结束很难用统一的规则去控制(所以很多项目采用停服维护的方式去应对当地法律规定的夏令时开始与结束的情况)。
关于夏令时的介绍这里不再赘述,感兴趣的人可以自行上网搜索。我们只需要知道它生效时会发生什么就行了,下面就是一个例子。

1
2
3
洛杉矶在当地时间 2018年03月11日,02:00:00 时钟向前调整 1 小时 变为 2018年03月11日,03:00:00,开始夏令时

洛杉矶在当地时间 2018年11月04日,02:00:00 时钟向后调整 1 小时 变为 2018年11月04日,01:00:00,结束夏令时

夏令时的规则看似简单,但是实际使用中稍不注意就会产生问题。举个列子,策划想要配个活动,活动在洛杉矶当地时间2018年3月11日1点举行,3点结束。如果没有考虑夏令时的情况直接在代码里面这么判断开始时间和结束时间

1
2
3
4
5
6
7
8
9
10
11
import time

t1 = time.strptime("2018/03/11 01:00:00",'%Y/%m/%d %H:%M:%S')
start_time = time.mktime(t1)

t2 = time.strptime("2018/03/11 03:00:00",'%Y/%m/%d %H:%M:%S')
end_time = time.mktime(t2)

#do some work with start_time and end_time...

#这里start_time和end_time的差值是7200秒,但实际上当地时间1点和3点之间只差3600秒

从功能角度讲,策划的本意是设计一个当地时间1点到3点持续2个小时的活动,程序代码里实现的也是一个持续2个小时的活动。只不过当地的玩家会发现在当地使用了夏令时的时钟上活动一直持续到了当地时间4点才结束,难免会产生一些歧义与误解。

如何没有歧义的表达一个时间

日常使用中我们比较能接受的表达方式一般是这样的“Year-Month-Day Hour:Minute:Second”,如我现在写这篇博客的时间就可以表示为“2019-07-14 16:20:28”。这种表达方式有一个问题,就是只能在同一个时区使用,而地球是被划分成多个时区的。如果不带上时区以及是否使用夏令时的信息,这种写法是没有办法准确描述一个时间的(这里需要注意的是UnixEpoch时间,从来就没有任何歧义)。

几种常见的应用场景

在游戏中策划往往会设计需要多和时间相关的玩法或者活动,以下是我见过的比较常见的几种形式:

  • 指定时间以后开始某活动,如某年某月某日几点整开服
  • 每天,每周,每月,每年的固定时间段开始某活动,如每周固定时间段服务器维护
  • 活动未开始时显示距某个时间段的时间,活动开始后显示距离结束剩余的时间
  • 拥有固定cd的功能,比如领取奖励的cd是8小时

一般这些活动的时间都是策划配置的当地时间(至于为什么是当地时间,很简单,你总不能让玩家大半夜起来参加某个活动吧)。程序中一般的常见套路是将这个人类可读的时间转化成一个具体的可以比较大小的数(比如UnixEpoch),然后在请求到来需要条件判断的时候直接用当前时间比较就可以了。但是在判断一些比较复杂的条件时,会有一些坑。举个例子,有个活动是在每周五举行,那么自然程序这边需要判断一下当前时间是不是处于周五,于是有了以下错误代码:

1
2
3
4
5
6
import time
cur_time = time.time()
#1970.1.1 是周四
weekday = (cur_time // 86400 + 3) % 7
if weekday == 4: # weekday为0表示周一,1表示周二,2表示周三...
...

为什么说这一代码是错的呢?我们假设当前time.time()=0,则此时如果你在0时区,那当前的时刻应该恰好是1970年1月1日0点。但是如果你在北京(东8区),当前的时刻其实是1970年1月1日上午8点。假设又经过了17个小时,也就是17*3600=61200秒,此时time.time()=61200。在0时区当前时刻是1970年1月1日17点,但是在东8区却已经是1970年1月2日1点。对东8区而言,此时已经是周五了,但用上述代码运行得到weekday还是3(即周四),这明显是错误的。因此,正确代码其实应该是带上时区偏移来计算才对,如下。

1
2
3
4
5
6
import time
cur_time = time.time() + time_zone_offset # 东时区为正,西时区为负,东8区则为8*3600
#1970.1.1 是周四
weekday = (cur_time // 86400 + 3) % 7
if weekday == 4: # weekday为0表示周一,1表示周二,2表示周三...
...

一点总结

最后是我的一些在实际业务中总结出来的经验:

1.time.time()得到的就是UnixEpoch,没有时区概念,任何时区在同一时刻调用该方法得到的值都相同。

2.两个不同地方的时间之间如果要比较先后关系必须带有时区信息。换句话说就是:不同时区的时间进行比较时,最简单的方式是转换成对应时刻的0时区的时间。

3.“2019-7-15 12:00:00”这个时间,需要有时区和是否使用夏令时信息才有准确意义

4.计算一个当地的时间(今天的秒数,这周周五的时间戳,今天0点的时间戳等)时,需要用time.time()加上当地时区的偏移来计算。

5.夏令时是真的傻X

面试官喜欢问的GC那些事

最近在找工作,因为之前的主力语言是C#,所以每次在面试的时候无一例外的都被问到了.Net的GC相关的问题.同样的问题我几乎每次面试的时候都要回答一遍,也是让人哭笑不得(这里就不得不提一下在微软面试的时候,就没有问这些热门问题).但是换一个角度想一想,基本上带runtime的语言,其GC的实现都是一个比较核心的技术点,并且直接影响到这门语言的性能表现,如果面试者完全不了解或者丝毫不关心这一块的东西,那毫无疑问是不合格的.今天正好就借这个机会,把我所了解的.Net的GC的相关技术细节做一个比较全面的总结.

为什么我们需要一个gc

dotnet官方文档认为gc主要有以下几个优点

  • 让开发者无需手动释放内存
  • 高效的分配对象到托管堆上
  • 自动回收对象,整理内存碎片
  • 内存安全

总的来说,gc最主要的作用或者说gc被设计出来的主要目的就是解决程序长时间运行的内存使用问题.

gc的工作原理

一般来说,gc的主要工作流程就是在程序的堆上对象比较多的时候遍历一遍,找出那些没有引用或者说程序无法访问到的对象,并将其回收,释放掉其占用的内存.如何确定一个对象没有被引用,最简单的方法就是引用计数.即使在每个对象上加一个计数器,每当有别的地方引用到了它,计数器值加1,当计数器值为0的时候就表示没有引用了,可以释放.但是这种实现有一个明显的缺点,那就是没有办法解决循环引用问题.举个例子,现在有两个对象A和B,A中有指向B的引用,B中有指向A的引用,但是程序中再没有别的地方有指向这两个对象的任何引用,这时候单纯的引用计数是没有办法检测出A,B其实都是可以回收的对象的.所以现在的虚拟机语言如C#,Java等其实用的是另一种实现,通过定义一些root的对象,然后在gc时尝试让每个对象生成到root对象的可达图,如果没有可达路径,则表示这个对象是无法访问的,可以回收.通常这些root对象可以是一个栈,线程对象或者全局对象等.

1
2
3
4
5
6
7
8
9
10
11
Root            Root
---------------------
|
A
|\
B C E
| |\
D F-G


这种情况下虽然E,F,G三个对象都有被引用,但是他们和任何一个root对象都没有任何关系了,所以程序集中其余地方也不能访问到,所以是可以回收的

gen0, gen1和gen2

前面说的只是gc工作的基本原理,dotnet的实际实现远远比这要复杂.在CLR中,托管堆分为3代:gen0,gen1和gen2.其中gen0一般用于存储生命周期较短的对象比如临时变量,同时也是gc发生最频繁的地方.一般较小的新对象都是分配在gen0堆上,如果对象比较大,会直接分配到gen2堆上去.每次gc过后,gen0堆中剩余的没被释放的对象就会被移动到gen1堆中,gen1堆中没被释放的则会移动到gen2堆中.一般来说gen2堆中的主要就是一些生命周期比较长的对象诸如全局对象.

gc时发生了什么

一次典型的gc工作流应该是这样:

  • 标记可回收的对象
  • 更新指向堆上对象的引用
  • 回收死掉对象的占用的空间,将原来分配在这段内存之后的对象移动过来

第一步很简单,辨别一下敌我,弄清楚哪些要回收,哪些可以继续保留.第二步开始重新整理对象之间的引用关系.第三步会重新整理堆上的内存分配.

Large Object Heap

上面说到的gc工作流的第三步可能会涉及大量的内存拷贝,比如移动的对象本身比较大.为此,dotnet针对大于85000byte的大对象,采用了专门的处理方式(至于为什么是85000这个值,dotnet官方说这是一个经验值).其实就是不轻易移动大对象,较大对象放在专门的LOH(Large Object Heap)上,只有在达到LOH的阈值/手动调用GC.Collect方法/OS内存不足的时候才会去尝试整理大对象.可以参考这篇文章,里面详细介绍了在windows平台下dotnet framework和dotnet core针对较大对象的gc优化策略.

workstation和server

在dotnet中,提供了两种区别较大的gc模式:workstation和server,大体上可以认为是分别给开发环境和实际生产环境使用的两种gc模式.workstation mode下同时支持concurrent gc与non-concurrent gc,而server mode考虑到系统吞吐和稳定性需求,只有non-concurrent gc和background gc(在dotnet framework 4以后,background gc已经取代了concurrent gc).而这些模式都可以在配置文件中修改,
当开启server mode时,CLR会根据cpu的核心数量开启较多的gc线程,开启concurrent gc时会开启一些单独做gc的线程,在较新版本的dotnet中还会同时开启了background gc,不过background gc仅仅只会对gen2堆上的对象进行回收,并且backgaround gc的工作线程优先级和普通的用户线程是一样的.

gc真的是程序性能瓶颈吗

当我们开发应用的时候,gc往往是性能下降的第一背锅顺位.我无数次见到某些”有经验”程序员指着较为别人的代码说一些诸如”你这个地方会引发gc/会导致性能问题*&%#@”之类的话,仿佛随便写写代码就会让gc成为性能瓶颈.但事实真的如此吗?恐怕未必.想真正弄清程序的bottle neck在何处,还是要具体环境具体分析.windows下用visual studio,linux下用perf等工具都是可以很容易的看出某一段时刻程序执行真正耗时的地方在哪里.

记一次失败的微软onsite经历

前段时间在领英上接到一位微软HR的邀请,投了一发微软office 365的开发岗,地点在苏州.从job description上看工作内容我所做的有很多共同点,并且requires也很接近,于是乎就抱着试试看的心态想去尝试一下,就当做是对自身实力水平的一种检验了.

微软的面试流程比较长,先是HR的电话面试,会和你聊一聊一些工作上的事情,如果可以的话,是比较推荐用英文的.接着是会有1-2轮的网上面试,要求使用Microsoft Teams这个软件(这里不得不吐槽一下Teams的质量,我是开了VPN才勉强能够连接上去,并且每次启动的时候都奇慢无比).网上面试的话是技术官来面,会先和你聊一些你简历上写的东西,最后会做一道比较简单的算法题目.和之前一样,也是推荐全程使用英文,当然,如果面试官是中国人并且你对自己的英文不是特别自信的话,你可以提出使用中文.经过网上面试之后如果你过了的话,会收到去线下onsite的邀请(微软报销往返酒店机票哦).

所谓onsite,基本就是算法题目.微软的onsite大概有3轮,每轮是一个小时,这里和国内其他的公司的线下面试不同的是,onsite基本不怎么聊天了,在很简短的一些自我介绍和交流之后,面试官会开始出题,你需要在规定的时间内在一块白板上写出完整的代码,语言随便使用,并且需要bug free.在3轮onsite面试中大概率会有一位外国面试官,所以这里你必须至少得准备一些英文的自我介绍以及熟悉一些常见的英文交流词汇.onsite的题目难度一般不会很简单,至少是leetcode上中等难度的.我这次onsite的表现很不好,有一轮没有写出最优解,有一轮没有做出来,最后就理所当然的挂了.归其原因,我想大概有两点,一是准备不够充分,在微软这种难度的onsite面前,还是需要刷一定数量的题目的,尤其是你之前如果不是从事算法相关岗位的.我在leetcode上找带微软的tag的题目大概刷了快100道,但是还是远远不够.其二是缺乏应对这种面试形式的经验,临场的紧张感会影响你的思路.在某乎上面各种”牛人”分享的国内大厂面试经验全部都是类似于问答形式的,面试官会不停的问你各种技术细节,但是却很少有直接白板撸代码的,从这点上看国内的IT企业和国外的还是有很大区别的.

在短短的几轮面试之后,我就灰溜溜的回来了,从苏州回来的火车上我就在想一些问题.站在微软的角度看,其实他们要的,不是那些简历上写的很华丽的,或者title很牛逼的人,而是真真正正的”聪明人”.所谓”聪明人”,智商情商高,解决问题能力强,之前做什么项目,用什么技术栈其实都不重要(反正微软内部技术栈也和外面大不一样…).那么这个面试的目的就很简单了,怎样在最快的时间内筛选出微软认为的”聪明人”就是唯一的目标.

1
Talk is cheap, show me the code --Linus Torvalds

毫无疑问,现场手撸代码是最好也是最方便的检验方式之一.因为它不仅仅考验了面试者的算法基本功,更是对其在高压情况下能否集中注意力解决问题的一种考验,而这,往往是很多人并不具备的一种能力.

1
失败本身并没有任何积极的意义,只有分析失败并从中吸取教训的过程才有 -- SaltySailor

在这次去微软线下之前,我在之前的公司从事着熟悉领域的工作,日复一日的工作提升了我的熟练度的同时也把自己包裹进了一个安全区.如果没有这次面试,我可能还会一直安于现状.如果你没有看到更高的山,你可能不会对外面的世界有太多的想法,一旦你看到了,有想法的人就再也忘不了自己面对那座大山时的感觉了.很多时候我们所处的环境会遮挡我们的视野,如果你自己不主动翻出去看看,你永远不会知道自己在这个世界上真实的实力水平,这大概就是我这次失败的onsite经历中给我的最大的收获吧.

HashedWheelTimer

为什么我们需要Timer?

经常写网络层代码的人肯定不会对定时器(Timer)陌生,因为有很多应用场景会需要用到定时器或者与之相似的东西,比如:

  • delay一段时间再执行某个方法
  • 等待某个事件直到超过一定的时间上限
  • 以一定的间隔循环执行某些方法

而在 .net中(广义的 .net包括 .net framework, .net core, .net standard, xamarin等),一共有四种定时器(Timer)类型,分别是:

  • System.Windows.Forms.Timer(.NET Framework only):windows窗体程序使用的定时器,只能在单线程中使用

  • System.Web.UI.Timer(.NET Framework only):ASP.NET使用的定时器

  • System.Threading.Timer:可以在指定的时间间隔执行方法,也可通过传入时间参数控制其行为,但是此类无法继承并且传入的方法一旦确定就无法修改,几乎所有的控制权都由 .net runtime掌控

  • System.Timers.Timer:主要用于在程序中生成定时的事件,不是所有的 .net版本都支持

考虑到适用性,一般在 .net core中使用的都是System.Threading.Timer

.net的Timer存在的一些问题

一般来说,我们会这么使用System.Threading.Timer

1
2
3
4
var timer = new Timer((object)=>{
//do something
}),null, 1000, 250);.
timer.Change(0, 500);

上面代码先是new了一个System.Threading.Timer的实例,在构造这个实例的时候,需要传入你希望执行的回调函数,执行回调的所需要的存储的状态(如果不需要则传入null即可),第一次执行回调的delay的时间,以及之后每次执行回调的时间间隔(如果这个值设置为Timeout.Infinite则不会重复执行回调).但是实际使用的时候会发现,如果够早了大量的Timer的话,会导致函数执行回调的时间精度有显著地下降.同时Timer本身还存在一个问题,就是回调函数的执行时间会有一些误差,因为Timer的实现依赖底层的操作系统.在Windows上,一般这个误差是在15ms左右,详细信息可以查看MSDN上的文档.

浏览System.Threading.Timer的实现,不难发现问题的原因.因为在当前版本的.net的实现里面,new一个Timer对象的时候,实际上做的事情相当于是把传进来的回调插入到一个在线程池中工作的队列中,而唤醒这个回调函数的时候则需要去这个队列里面查找时间到了的回调函数,随着Timer数量的增加,队列的长度也会明显的增加,这时候再插入或者唤醒就会有显著的开销.

HashWheelTimer

这时候就轮到我们的HashWheelTimer登场了,考虑一下,什么样的数据结构插入的时间复杂度最低?不难发现是HashTable,因为其插入的时间复杂度是o(1),那么接下来就要考虑一下如何将这个特点应用到Timer的查找中.如果我们对时间精度要求不是非常的高(一般大部分应用需要的时间精度都不会小于1ms),那么我们可以构建这样一张HashTable,这张表中每个槽位表示一个最小时间精度的间隔比如10ms,槽位用于存放回调函数.这样在这张表里第一个槽位存放的就是10ms后需要执行的回调函数,第二个槽位里面存放的就是20ms后要执行的回调函数,以此类推…

进一步分析,可以将原来的HashTable改为一个ring buffer,这样就可以循环使用,不过这样也限制了最大delay的时间(虽然实际使用中我们很少会去delay很长一段时间再去执行某个操作).同时我们需要保留有一个指向当前槽位的记录,这样这个定时器的工作流程可以概括为:每次await一定的时间(最小时间精度),然后将指向当前槽位的索引记录+1,取出这个槽位里的回调函数,依次执行,最后再继续await直到下一次循环开始.

不过需要注意的一点是,既然在 .net core中,await Task.Delay()这一行为本身是会产生一些误差的,那么长时间执行以后难免会有较大的累积误差.如何解决这个累积误差也很简单,引入一个执行次数的计数,暂且称之为execCount和我们HashWheelTimer的执行总时间.每次循环开始时用执行总时间除以最小时间精度得到值和execCount相比较,如果大于说明执行的次数不够,则这次循环执行完回调函数后不再等待,直接进行下一次循环,这样就可以从总体上避免累积误差的产生.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Task.Run(async () =>
{
//初始化总执行时间和执行次数计数
sw.Start();
var execCount = 0;
//HashWheelTimer主循环
while (true)
{
if (( sw.ElapsedMilliseconds / tickDuration ) > execCount)
{
//执行delay的回调函数
doCallBack();
execCount++;
}
else
{
//await一个最小时间精度
await Task.Delay(tickDuration);
}
}
});

总结

经过上述步骤,我们就可以得到一个初步可用的HashWheelTimer,它拥有o(1)时间复杂度的插入操作,同时也避免了累积误差的出现.
但同时它也有两个缺点:

  • 1.受限于最小时间精度
  • 2.最大delay时间有上限

所以,是否使用它还是得结合项目的实际情况去看.

PS:在.net core 2.1中,我看到Timer已经引入一些优化(虽然并没有解决根本性的问题),比如根据Environment.ProcessorCount来决定Timer工作队列的数量,每次会把新构造的TimerCallBack分配到不同的队列中等等,也许在不久的未来,我们甚至可以看到标准库中引入HashWheelTimer.

关于System.IO.Pipelines的简介

Pipeline是什么

System.IO.Pipelines是一个随 .net core 2.1一起发布的新的IO相关的基础库,它没有随 .net core 2.1一起发布,而是需要采用NuGet Package的方式独立安装。很明显,Pipelines库中使用了大量 .net standard 2.0及以后的基础类型(诸如Span, Memory等),相关的API也是全新风格,因此如果想在之前的项目中使用则需要升级 .net版本。

Pipeline能解决什么问题

在Pipeline出来之前,我们在C#中一般会使用Stream来实现相关的IO操作,比如使用NetworkStream从一个Socket中读写网络数据。比如下面这段代码就是一个简单的例子:

1
2
3
4
5
6
7
8
9
async Task Read(NetworkStream stream)
{
var buffer = new byte[1024];
await stream.ReadAsync(buffer, 0, buffer.Length);

//buffer handler
//...
}

每次尝试从网络中读取数据的时候,需要预先申请一块内存当buffer,然后再尝试从socket中读取数据写入到这块buffer中。通常来说我们需要做各种优化,比如buffer的循环使用,并且实际应用时,我们通常可能需要在另一个线程去使用这些从socket中来的数据。但是这样以来,又会牵扯到另一个问题–线程安全,并且与此同时还要保证性能,我们需要非常小心且聪明的编写我们的代码才能避免出现各bug。而Pipeline的出现,极大程度上帮我们减小了解决这个问题的负担。

如何使用Pipeline

在Pipeline库中,最基础的类型是Pipe,每个Pipe拥有Reader和Writer两个成员。顾名思义,Pipe.Reader负责从Pipe中读取数据,Pipe.Writer负责往Pipe中写入数据,就好比一根水管的两端,你可以从一段往里面塞东西,也可以从另一端取东西。并且Pipe底层的实现已经帮我们保证了Reader和Writer的方法的线程安全性。但是需要注意的是,由于目前 .net core相关文档的缺失,实际使用中我还是发现了一些坑。以代码为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private void readLoop()
{
Task.Run(async () =>
{
Exception error = null;
while (!Cancel.IsCancellationRequested)
{
try
{
var buffer = pipe.Writer.GetMemory(1024);
int bytesRecv = await tcpclient.Client.ReceiveAsync(buffer, SocketFlags.None);
if (bytesRecv == 0)
{
break;
}

// Tell the PipeWriter how much was read from the Socket
pipe.Writer.Advance(bytesRecv);
}
catch (Exception e)
{
error = e;
break;
}

var result = await pipe.Writer.FlushAsync(Cancel.Token);
if (result.IsCompleted || result.IsCanceled)
{
break;
}
}

pipe.Writer.Complete(error);
Close(PipelineCompleteException.Instance);
});
}

这是一段使用Pipeline从socket中读取数据的代码,在一个循环中我们首先从Pipe中申请一块内存,然后往这块内存中写入从socket中读取的数据。然后需要显式的调用Pipe.Writer.Advance()去告诉Pipe的另一端也就是Pipe.Reader我们写入了这么多数据,最后再调用Pipe.Writer.FlushAsync()。这样,我们就可以在别的线程使用Pipe.Reader.ReadAsync()来读取数据了。

1
2
3
4
5
6
7
var result = await pipe.Reader.ReadAsync(cancel.Token);
if (result.IsCompleted)
{
break;
}
//需要手动通知Writer我们已经读了多少,这些内存已经可以回收了
pipe.Reader.AdvanceTo(result.Buffer.End);

这是一段从Pipeline中读取数据的代码,需要注意的是在每次使用Reader.ReadAsync()之后必须要调用一次Reader.AdvanceTo(),不然是没有办法调用下次ReadAsync的。之所以这样做原因也很简单,Pipeline底层是有一块自己管理的buffer的,用户需要写的时候会从里面申请内存,用完了以后会放回去,这样才能做到循环使用避免造成过多的gc或者memory leak。另外因为AdvanceTo的语义是告诉Pipe.Writer我已经从Pipe中消费了多少数据,这部分数据的内存已经可以回收了或者说可以往里面写入了。如果同时有多个线程同时调用ReadAsync,那么每个线程读取到的数据指向的一块内存地址是可能会有重叠的部分,而当其中任意一个线程调用AdvanceTo的时候这部分内存地址就会被回收,可能会被写入新的内容,而其他的线程如果这时候还在尝试读取这块数据,就会出现各种奇怪的问题。另一个就是result的值是需要判断的,比如result.IsCompleted,这时候表示Writer已经完成所有数据往Pipe里的写入了,没有新的数据会被写入进Pipe里。这时候我们需要判断,并且决定接下来的行为(是继续等待还是结束)。

通过上面的例子我们不能看出使用Pipeline编写的代码无需我们手动添加任何lock或者手动管理内存,并且更重要的是它是原本较为复杂的代码结构变得非常的简洁和清晰。因为在我的经验中,任何涉及多线程的代码中,保持结构的简洁和清晰是非常重要的,一段各种循环/lock嵌套,同步异步方法混用的代码绝对是埋下的炸弹,往往只有出现bug的时候你才能发现原来这种当时没有考虑到(事实上你往往很难想清楚所有的情况)的情况会引发诸如xxx的问题。

更多的参考

如今自 .net core 2.1出来也已经过了一段时间,关于 .net core中的新内容,已经有很多人在尝试并且研究,其中不乏关于Pipeline的内容。下面是我了解并且推荐的的一些比较优质的博客:

  • David Fowler的这篇博客用一个简单的应用场景介绍了如何使用Pipeline以及和老的API相比它又有什么优势

  • Marc Gravell的Pipe Dreams系列博客,非常详细的介绍了Pipeline的用途以及他关于如何使用Pipeline的更深层次的思考

断线重连与重登陆

背景

先设想以下几种常见的会导致移动设备断线的场景:

  • 手机4G切换到了Wi-Fi
  • 使用手机时由于移动切换了基站
  • 内网路由器的NAT映射表刷新了

诸如此类的情景,其实本质上都可以归纳为一个问题:你正在使用的设备的外网IP地址突然变了。对TCP协议而言,其标识连接的方式是通过一个包含源IP地址,源IP端口,目的IP地址,目的IP端口的4元组来实现的。如果我们在应用程序中只根据TCP的连接来区分用户,那么在出现上述情况时必然会产生的一个问题就是来自同一个用户新老连接的会产生冲突。如果放任不处理,用户就会出现明明网络是好的,但是自己却与服务器断开了连接的情况。这无疑是用户体验极差的,甚至会产生一种这样的印象:这破应用服务器怎么这么差。比较好的做法是自动帮同一个用户把老的连接关掉,数据传输移植到新的连接上,用户可能会感受到轻微的延迟/卡顿,但是不会出现直接断线的情况。这个问题Google称之为Connection Migration即连接迁移。

进一步分析

解决这个问题首先需要了解一下IP地址的改变会产生什么影响,前面提到过TCP是以IP地址+端口号为标识来区分不同连接的数据的。更深入一点分析,当准备建立一条TCP连接的时候,本质上是客户端发送一个特殊的包给服务端,然后服务端根据这个特殊的包的信息可以知道是来自xx地址xx端口的一个客户端请求建立连接,一旦接受,在服务端这里就会生成一个专门为这个客户端服务的一条连接,这之后双方就可以通过这条连接来通讯了,绝大部分使用TCP的应用都会把这条网络连接进行层层的封装来达到给上层调用进行收发数据的目的。当一方的IP地址发生改变,显而易见,这条TCP连接就失效了,服务端无法再接收到任何消息,客户端发送出去的消息也杳无音讯。不难想到如果不使用这种IP地址+端口的四元组作为连接的唯一标识不是就可以无视IP地址的变动了吗?答案确实如此,如果你使用的是TCP协议,那么你可以参考MPTCP( https://en.wikipedia.org/wiki/Multipath_TCP )的实现,不过需要注意的是MPTCP没有办法完全解决上述问题(如手机仅有4G且切换了基站),MPTCP本质上是提前建立多条平行的连接,然后利用多条连接来最大程度减小某种渠道连接失败带来的影响。但是如果你使用的是UDP协议,可操作性就很多了。由于UDP本身是没有连接概念的,大部分使用UDP协议的应用都会封装出一套类似TCP连接相关接口的,并且通过读UDP包头的一些数据或者干脆还是用源的IP地址+端口来区分来源,所以完全可以在包头添加一个自己管理的16/32/64位的ID作为连接的唯一标识,这样就完全不依赖IP地址来区分数据来源了,自然也就不会被IP地址变动所影响。

更具体的一些细节

说起来容易做起来难,真正尝试在UDP网路库里面加这个特性的时候,倒也遇到了一些问题和产生了一些思考。

  • IP变动的感知:尽管我们不再依赖IP地址+端口来区分来源了,但是我们还是需要知晓同一个connection id的IP变动这一事情的,所以对于数据包的来源IP地址和端口还是要记录一下,并且最好有显式的通知给上层。
  • 旧连接状态的迁移:使用UDP构建网络库的时候由于服务端需要自己在底层分包,所以即使IP切换了,也不会对数据的接受产生影响,在服务端的角度看上去就是收到了一个数据包,它的唯一标识connection id是一个已知的,只不过对应的IP地址改变了。那么在服务端只需要将这个数据包继续抛给上层对应的connection id的连接处理就行了。而在客户端,IP地址切换可能导致你调用的socket相关API报错(也可能没有,视你使用的底层库而定)这时候处理好报错,恢复正常发送流程也就可以了。这里需要注意一点,抽象分层做的好的话,UDP网络库里面一些有状态的层如可靠重传层,加密层等这些应该是和底层真正用来发送数据的socket层相互分开的,所以本质上IP地址的变化不会对连接状态产生太大影响。
    如果使用的是TCP协议,则需要提前记录下来两端的收发数据状态,重连建立新的连接的时候有一个重新握手的流程,让两端交换一下旧连接没有收发完的数据。
  • 连接唯一标识(ID)的管理:理论上来说为了防止碰撞最好让ID尽可能的长(如64位),但是如果实在不放心客户端自动随机生成的话,可以考虑使用服务端生成。因为这样的话就可以保证唯一,但是坏处就是建立连接的步骤和耗时会变多。对于一些并发量比较高比较看中延时的且以短连接为主网页应用,可能这样不是非常适合。但是以长连接为主的游戏应用,其实不是特别在意建立连接时多耗一些时间。
  • 预防恶意攻击:这点也是绝大部分网络库最头疼的一点,或者说在网络库里我觉得其实根本没有最好的处理办法。以最无脑的DDoS攻击为例,在你网络库还没有开始调用系统调用读取数据的时候,其实已经就被攻击了,因为人家攻击的根本就是不是你这个维度的东西。分布式的DDos完全可以在带宽,CPU资源这层对服务端进行攻击。那么再退一步说,对于一些伪装包、篡改包的攻击,还是可以进行防御的。数据包中加上签名就可以在绝大部分情况下防止中间人篡改数据。防止中间人读取数据的话也可以加上加密。对于那种强行获取到了别人的connection id,然后开始伪装成别人的发包,可以再建立连接的时候使用DH秘钥交换之类的算法,这样可以得到一个只有双方知道的秘钥,再用这个秘钥加密就可以保证中间人无法伪装成别人发包。如果客户端被完全破解了,或者整个网路库的代码被破解了,攻击者可以完全按照合法的客户端登录流程一步步发送合法的数据包给服务端这种情况,目前我也没有想到特别好的办法。。。但是其实换个角度想,这种其实对于网络层来说已经是无法辨别真伪的数据包了,只有在业务逻辑层加上限制,才能检测到是否非法(如果这个伪装的客户端没有触发任何限制呢?那就只能当做正常用户了)。

有感知&无感知的断线重连

一般来说,我们通常会把应用层和网络传输层分开,中间用接口的方式实现解耦。因此上层应用只需要看到一些send/recv/connect的接口就可以了,对于底下网络层是怎样实现数据传输的并则不需要关心。如果我们在网络层内部实现一些自动重连的机制,就可以对上层不暴露断线这件事情。当底层的断线发生时,上层应用仍然可以正常的使用下层提供的send/recv接口,只是可能会感受到微弱的延时而已(当然这取决于你重连的耗时),这种情况下由于应用层是无感知的,所以我们可以称之为无感知(对上层应用而言)的断线重连。

但是网络传输层也不是万能的,如果遇到了底层的重连无法恢复的情况,这时候就只能把断线这件事情告诉上层应用了。一般来说,这时候上层应用可以返回登录界面让用户重新登录。如果想做的用户体验好一点,重新登陆以后可以自动帮用户恢复到上次断线前的状态,诸如重连回到上次的房间,位置,进行中的游戏等。

实现一个底层的断线重连

虽然上层应用处理断线重连时可能会根据业务的不同而有不用的变化,但是在底下的网络传输层内部,需要做的事情缺十分相似,建立好新的连接,将双方未送达的数据恢复。考虑到无论是使用TCP协议还是自己定义的可靠UDP类协议,都存在断线的可能性,因此,我们可以考虑在连接之上再封装一层session层,session层可以感知到下层持有连接的断线情况(通过心跳等方式实现感知),并且在断线发生时立即重新建立连接。此外,对上层应用暴露出来的send/recv接口并不是直接与内核的socket相关api通讯,而是在session内部稍稍缓存。一来这样在session尝试重连的时候,上层应用仍然可以继续调用send/recv接口。二来为了保证数据的完整性,在session内部也可以记录收发过的数据包,这样重新建立好最底层的连接之后可以继续收发之前被截断的数据。

一个简单的session实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Session
{
private Connection conn; //真正的底层连接,可被替换
private SendRecvState state; //记录收发数据状态的类
private Task keepAliveLoop; //检测连接状态的task

public void Connect()
{
//do connect
}

public void Send(byte[] data)
{
//do send
}

public byte[] Recv()
{
//do recv
}

private bool reconnect()
{
//do reconnect
}
}

一点总结

断线重连的解决方案,说白了就是用新的连接替换已经失效的老的连接。在这个替换过程中,对原来连接的一些读写的状态需要恢复,从而保证上层应用的数据的完整性。另外在使用一些网络协议的时候,如何检测到断线也是一个很重要的问题,以TCP为例(RFC里及其难用的TCP-Keep—Alive就算了),如果用户不主动收发数据,是不知道这条连接是否还健在的,所以一个定时的心跳检测也是必须的。所以一个完整的断线重连流程总结起来就是:侦测断线–>重新连接–>恢复状态

关于QUIC的一点研究

QUIC是什么

QUIC全称Quick UDP Internet Connections,是Google于2013年推出的一种新的网络传输协议,旨在解决传统http协议中存在的一系列问题。需要注意的是QUIC从一开始针对的应用场景就很明确,就是为了减少web latency。在Google的首发演讲中对QUIC的描述是“TCP + TLS + SPDY over UDP”(现在应该换成HTTP2了),所以QUIC很看重一些web比较关心的指标(比如0-RTT的连接建立,以及一条连接多路复用)。

QUIC要做什么

以TCP协议为例子,第一版的TCP于1981年在RFC-793中公布。距今已经20年有余,现在的网络状况早已经和当年大不一样。虽然TCP协议一直在更新,一直有着新特性在加入,但是有个矛盾的问题一直没有办法解决。那就是TCP做了太多的事情在传输层,并且都是在操作系统的内部实现的。这样做操作系统上的应用开发者就无需关心底层传输实现,可以直接调用系统的API来实现网络传输,否则开发一个应用就要开发一套传输算法,会造出来无数的轮子。但是同时也有一个问题,如果想要使用更新TCP协议版本,则需要操作系统的厂商来推进更新内核的TCP协议实现,而目前看操作系统的厂商的积极性很低。同时,仅仅你个人的操作系统更新了并没有用,还需要网络中大量的网络设备一起更新,这显然是不现实的。因此,只能把一些网络传输特性实现在更上层的应用中,尽管一个APP造一个轮子显然显得有些蠢,但是在浏览器内浏览网页却是个不错的应用场景。从目前看,操作系统内核的UDP协议实现 + 应用层自定义的可靠传输算法 + 实现一些更上面应用层的标准是这类轮子的标配。当然,也包括QUIC。

QUIC扮演的角色

1
2
3
4
5
6
7
8
9
10
11
12
+++++++++++           ++++++++++++++        
| HTTP/2 | | HTTP/2 API |
| | ++++++++++++++
+++++++++++ | |
| TLS 1.2 | | QUIC |
| | | |
+++++++++++ | |
| TCP | ++++++++++++++
| | | UDP |
++++++++++++++++++++++++++++++++++++
| IP |
++++++++++++++++++++++++++++++++++++

QUIC的优势

  • Connection establishment latency:
    相比于TCP+TLS建立连接需要3-RTT以上的时间,QUIC大部分情况下建立握手都可以做到0-RTT和1-RTT。
  • Improved congestion control:目前Google版本的QUIC使用的拥塞控制算法其实就是把TCP的Cubic再实现了一遍,在一些边角如ack做了一些优化。
  • Multiplexing without head-of-line blocking:在http2.0的一个特性是多路复用(Multiplexing),即某个客户端对同一个域名的多次请求只会使用同一条tcp连接。这样可以显著的减少建立tcp连接带来的开销,但是由于在tcp中是一个数据包丢失了会导致其之后的数据包都被卡住,而http2.0中使用又是同一条连接,这样就会导致“多路”同时卡住。QUIC则是可以同时支持多个独立的stream,stream之间的数据包在实际传输的过程中是穿插的,但是每个stream的数据却一定是严格FIFO的,这样某个包丢失了只会影响自己那一条stream。其实这点主要原因还是因为建立一条tcp连接的代价太高了,尤其在使用浏览器这种情景下人们本能的是想要复用同一条连接减少开销,只不过这时候tcp的“可靠性”却成了一种负担。
  • Forward error correction:向前纠错能力,这点简单的来说就是比如你要发10个包,QUIC实际上给你发的是12个包,有2个包是由FEC算法根据前10个包的内容计算出来。这样这10个包中只要有2个以下的包丢了,接受方根据同样的FEC算法可以还原出那几个丢失的包。这本质上是一种冗余策略,虽然会显著的提高数据量但是在已知网络情况较差的时候会显著降低延迟(重传带来的延迟更大)。类似的算法在KCP和很多其余的网络库中也可以看到。
  • Connection migration:TCP的协议中标记一条连接的唯一标识是一个来源IP地址+来源端口+目标IP地址+目标端口的4元组构成的。如果移动设备一旦切换了基站或者4G切到了Wi-Fi又或者某个NAT路由器的映射表动态刷新了等行为都会直接导致TCP断线。QUIC使用的是一个64位的唯一ID来标识的,这样即使切换了网络,应用层也是无感的。不过QUIC中这个64位的唯一ID是客户端自己生成的,这里可能有人会问为什么不是服务器来生成?答案其实就在上面,是因为QUIC要做到0-RTT的握手,如果是服务器生成,那么建立连接的步骤就会多一步服务器发送生成好的唯一ID给客户端并且等待客户端的确认回包。但是这里有一个无法避免的问题,那就是connection id collision。不过好在有人算过两个64位的随机数作id,在一个100,000并发的服务器上碰撞的概率大概是3*10^-10。考虑到QUIC应用场景,似乎可以理解为什么会这样做。

QUIC的一些问题

目前QUIC协议在IETF工作组中还处于频繁修改的状态,IETF工作组也给出了一些协议实现建议,github上也有人在造一些轮子(比如这个golang版本的实现 https://github.com/lucas-clemente/quic-go )。在众多选择和没有统一标准的面前的是数不清的坑(是使用Google QUIC的实现还是IETF QUIC第三方实现?)真正能够做出稳定上线产品的还是凤毛麟角(这里有一篇关于微博的应用笔记 http://www.infoq.com/cn/news/2018/03/weibo-quic )。同时,在健康的网络状况下QUIC和TCP相比并没有优势,在弱网情况下是有的。如果坚持使用QUIC作为线上产品的网络传输解决方案的话,可以备用一套TCP的方案,在测试到一些网络封闭了UDP包这种情况时可以随时回滚。

最后,期待2019年能形成QUIC的RFC技术规范。

构建联机网络库

为什么需要一个网络库或者说网络库是干什么的

对任何一个联网游戏而言,其联机部分的业务逻辑都需要一个网络库来支持。一般而言,这个网络库需要实现以下的需求:

1
2
3
4
1.提供简单的Send/Recv/Accept/Listen接口
2.至少提供可靠的信道
3.方便做出符合游戏特性的优化
4.内存分配优化

如何实现

从传输协议选择来看,几乎绝大部分游戏在联机相关业务逻辑上使用的都是UDP协议,由于TCP协议本身的一些特点,导致其在游戏等实时性要求较高的领域上表现并不佳,相关的讨论网上有很多。但是使用UDP协议的时候必须要解决一个问题,那就是传输可靠性。由于网络本身的不稳定因素,UDP协议并不保证传输内容是否能够准确到达目的地,这就需要你设计一套算法来保证传输可靠性。

除此之外,我们在传输数据时经常会做一些压缩,加密的操作,这些也是可以直接在网络库中直接实现的。因此,简单总结来说我们需要做的是一个“UDP协议 + 可靠传输算法 + 上层额外功能”的网络库

可靠传输算法

对于网络传输过程中遇到的丢包问题,所有的解决方法归结到最后就是两个字–重传。但是何时重传,重传哪些数据就有很大的操作空间了。绝大部分的网络库实现可靠传输算法使用的都是类似于TCP协议的send-ack模型。这种模型简单来说就是在发送的时候给每个数据包打上一个递增的序列号,发送出去后不会丢弃掉这个数据包,而是放在自己的队列中。当接收端收到这个序列号的TCP数据包时,就会往发送端返回一个ack包确认自己已经收到这个序列号的包。接收端如果收到了这个ack,就可以确认这个数据包已经被对方接收到了,可以从队列中移除它了。如果经过一段时间发送方没有收到队列中数据包的对应的ack,就会重新发送这个数据包,直到收到ack。
对于这种模型而言,在编写代码时有一些小技巧。

  • 序列号循环使用,每个数据包的都会有一个序列号,并且我们希望它尽可能的小,能使用int16就尽量不要使用int32,但是当发送的时间长了,序列号超过int16上限怎么办?答案是循环使用。不过这样可能会有一个问题:接收端收到一大一小两个序列号的包,到底谁先谁后呢?其实也很简单,计算两者的差的绝对值,如果小于1/2个int16.maxValue,就可以认为较小的值先。如果大于,那么就认为是已经循环了一轮序列号了,较大的值才是先发的包。(这里认为不会出现某个包在序列号已经用了一轮的情况下才终于发送到接收方的情况)
    数据的合包,用户发送的数据是长度不一的,有的可能很大,有的可能很小。频繁的调用底层接口发送小包显得很浪费,而一次性发送某个很大的包时会被底层拆分成一个个小于mtu(最大传输单元,以太网一般为1500 byte)的数据包依次发送。而对于udp而言,只要这批次中有一个包丢了,整个数据大包就会认为全部丢失了且需要重传,这无疑是极大的浪费。所以如果我们可以把用户的数据整合一下,小包合并,大包拆分。最终以略小于mtu的大小依次发送出去,这样就会有效的提高传输效率。当然,在合包和拆包的过程中难免会有额外的流量开销,但是由于小的数据包被合并了,压缩就会有比较好的效果了,这样实际上会节省流量。

  • 重传策略,一般而言在发送完某个数据包之后,会启动一个定时器,当定时器时间到了以后如果还没有收到ack,就会触发定时器重新发送数据包。但是,当数据包数量上去了以后,启动如此多的定时器显然是一笔巨大开销。在TCP中有个叫做滑动窗口的结构,可以较好的解决这个问题。我们简单的把发送出去的数据包全部缓存在一个优先重传队列中并记录时间戳。然后单独启动一个定时器定期检测这个队列中数据包是否收到ack了,检测周期理论上应该是2倍的rtt值,但是可以适当放宽到3~4倍的rtt值。需要注意的是这个队列应设置上限,如果发送数据包完后发现这个队列满了,就放到另一个更大的缓冲区域内,这个缓冲区没有上限,但是不会定期检查ack情况,只有优先重传队列不满的情况下才会从这个缓冲区中取包塞进去。如果频繁出现优先重传队列满了的情况,可以考虑放大队列的长度。关于ack包,一般而言ack是不重传的,不过可以做很多优化,比如一个ack包中冗余多个确认的序列号。这样可以减少因为ack包丢包导致产生的重传。

  • 加密,比较常见的做法是先使用diffie-hellman秘钥交换算法得到一把双方都有的公钥,然后再基于这个公钥使用其余的加密算法,rc4/sha128/256等。当然,为了保证数据的完整性,我们还应该在数据包中加上签名,比如加上一个md5签名在包头。

设计适合游戏特性的接口

绝大部分游戏都有帧这一概念,因为绝大部分游戏的主逻辑都是在一个死循环内定期update。而往往网络层的收发包,都会在update里面执行。所以很明显我们必须提供给游戏用的是非阻塞的接口,因为如果阻塞了游戏的主循环就直接卡住了。
前面提到的合包策略中有一点其实也是很适合游戏的,那就是合包算法需要一个合适的时间把缓冲区中的包全部发送出去,而游戏定期update的这个特点正好满足。每次update的最后flush一下缓冲区中的数据,正好避免缓冲区因为等待太久导致数据没有办法及时送达。
还有就是绝大部分游戏的连接需要能够检测到自己什么时候与服务器断开了连接,所以可以在传输层做一个类似keep-alive的功能,这样应用层可以随时知道自己的状态。

一些值得借鉴的其余网络库

KCP

kcp在国内算是比较有名的开源可靠网络库,由于其kcp是一个纯算法层面的可靠传输层实现,故很容易被port到别的语言上(目前已经有C#,Golang,),同时网上也有比较多的分析这里就不赘述了。

GameNetworking Sockets

GameNetworking Sockets是valve在今年开源的自家的使用C++写的网络库,也是基于UDP协议。被广泛应用于v社自家的项目中,如CS:GO,Dota2等。根据github上的描述,提供了类似于TCP的面向连接的API,但是又是基于消息的数据传输方式(而不是流),同时支持可靠不可靠的信道,内部自带合包/拆包,在可靠性上面也是参考了巨佬Glenn Fiedler的文章以及Google QUIC实现的一套基于send-ack的算法并且也自带了网络模拟的工具链(已经是开源网络库的标配)。但是由于其本身是从valve庞大的祖传代码库中抽出来的一部,故代码中会有很多奇怪的命名(比如无数个steamxxx,steamworkxxxx),并且负责开源的员工似乎只会C++,并没有提供比较好的C接口供别人封装成dll来让别的语言调用(参见其项目中排名第一但是至今还没有close的关于C#的wrapper的issue)。所以除非你的项目是C/C++的并且没有时间自己开发一套网络库,否则都不建议使用该库。

Netcode.IO

Netcode.IO是一个开源的基于UDP的面向连接的网络库。原版是游戏网络传输方面的巨佬Glenn Fiedler用C写的,非常干净利索,现在已经被port到C#,Golang,Rust,并且有专用的适合Unity,UE4的版本。这里我以.net版本的实现为例进行研究。
.net版本的实现结构大概分2层,Public层和Core层。

  • Public层:由于是为了游戏而专门设计的,所以在这层为上层应用封装了Client和Server两个类。

    • Client:对外暴露了简单的Connect/Send接口,内部主要靠Tick(即内部有一个循环定时发送自己缓冲区的数据,读取远端发送过来的数据并处理)来驱动。提供了个一些重要事件的回调(onStateChanged/onMessageReceived等)内部实现了加密,keep-alive
    • Server:提供了ClientConnect/DisConnect,MessageReceive之类的回调,在内部定义了一种RemoteClient类来表示连接上来的客户端便于管理。同时在构造Server时就需要指定能容纳的最大的Client数目,比较适合做那种有人数上限的房间制的游戏,内部也是靠Tick驱动。
  • Core层:定义内部用的网络包格式,也是比较常见的前若干个字节分配个PacketHead,然后PacketHead里面会有一些描述信息,重复包的检测也依赖于包头的数据。
    加密也是在这里实现的(加密的实现较low,客户端和服务器共用一个私钥,然后利用这把私钥对第一个Connect包加密,客户端在发送这个Connect包时会为其添加两个key,一个用来给发出去的包加密,一个用来给收到的包解密,客户端和服务器共用这两个key,由于非法的Connect包会被直接忽视掉,由此达到其声称的抗DDos效果…)

  • 一些工具类:
    自己实现了诸如LockFreeQueue,BufferPool,DateTimeExChange,KeyGenerate之类的工具类,造了一些轮子,主要是因为作者写的时候按照的标准是 .net 3.5,并且他和我一样希望做到pure managed implementation and use zero native DLLs or wrappers

底层传输基本是调用的.net提供的socket API(自己稍微封装了一下原生的API,定义了一个ISocketContext接口,这样方便做测试)。

总的来说.net版本的实现中规中矩,自定义了一个ISocketContext接口方便之后写一些模拟的Socket层来模拟丢包之类的测试算是一个亮点,并且代码也比较简洁清爽(强烈吐槽C版本动辄8000行+的单个大文件)。

Reliable.IO

上面的Netcode.IO并不能保证数据的可靠性,一般来说需要在其之上实现一套重传算法来保证消息的可靠性。上面C#版本的作者基于原版的Reliable.IO也实现了一个pure managed C# socket agnostic reliability layer。ReliableNetcode里面定义了3种信道:reliable、unreliable、unreliableOrderd。其中unreliableOrderd信道非常适合做游戏中诸如状态,位置等数据的传输,因为很多时候这些属性只需要最新的数据就行了。

其中reliable信道的实现是传统的send-ack方式,主要依赖两个队列,一个叫做sendBuffer,一个叫做messageQueue。上层应用调用send接口后并不会立即将消息发出,而是尝试往sendBuffer里面放,如果满了则放到messageQueue中,并且在存放数据的时候都会打上一个唯一的序列号来标记这个数据包。然后内部会定期的调用update函数,在update内部会尝试将sendBuffer中缓存的数据包重组成一个个指定大小的包(一般为mtu左右)然后再发出去。并且实际上发送时调用的是外部传递进来的回调函数,这样就比较好的达到了重传算法和底下传输层实现的解耦。接收方接收到了这个ack包之后会根据数据包中包含的唯一的序列号发送一个ack给发送方,发送方根据ack中包含的序列号从sendBuffer中移除相应数据包并更新相关数据,中间丢失的包或者没有收到ack的包会被重传直到收到ack。