Skynet框架分解(1)

JavenLaw

Skynet框架分解

这部分不关涉到代码,但依然希望在进行Skynet框架学习之前,希望你能先阅读以下资料,你会有个大概的认知。

​ Skynet的WIKI:Home · cloudwu/skynet Wiki (github.com)

​ Skynet的入门:GettingStarted · cloudwu/skynet Wiki (github.com)

​ Skynet的设计:云风的 BLOG: Skynet 设计综述 (codingnow.com)


基础知识

Skynet最重要的特点就是把不同的业务分为相互隔离的独立服务,服务之间再通过消息相互合作,最后加上单进程多线程的模式。因此要完全释放Skynet能力的关键就在于:如何设计服务和线程的分配关系,即如何充分利用CPU核心来并行运作数千个相互独立的业务。事实上Skynet很多的优化都是围绕着这点在做。例如有4种不同的线程,其中Woker线程的数量一般要求比实际的CPU核心多一点;每个Woker线程根据权重选择不同数量的消息处理,以提高效率;各种锁的选用也是考虑服务的实际情况……

如何利用有限数量的线程,满足成千上万的服务,这就是Skynet核心解决的问题。只要我们设计的好,那我们才算是充分利用好了Skynet。因此,在开始前我们需要了解计算机基础的知识:核心、进程、线程、协程。了解这些基础知识后,我们才知道如何去组织设计我们的服务,如何编写我们的代码最高效,以及如何优化现有的代码。

核心

核心为一个物理CPU拥有的运算单元数量,例如8核,即一个CPU有8个核心,拥有8个独立的运算单元。一个运算单元有一套寄存器,可以独立执行一个线程。所以一个核心同一时刻只能运行一个线程,不论这个线程是哪个进程的。每隔一定时间,大概几十毫秒,就会切换线程,即切换任务。(可能是同一个进程的线程,也可能是另外一个进程的线程)

进程

操作系统分配资源的最小单位(包括CPU核心、内存、磁盘IO 等)。一个进程可以拥有多个线程。操作系统是抢占式的,因此每个进程内的线程都需要抢占核心运行。当然,运行一定时间后也会被操作系统停止,让出核心给其他线程执行。

线程(内核级线程)

线程是核心调度和分配的基本单位。一个进程下的线程共享该进程的资源和程序代码。我们把线程准确称为内核级线程。

协程(用户级线程)

由用户程序实现的线程,内核执行权切换是由用户态程序自己协商切换的,不需要内核的干涉。我们把协程准确称为用户级线程。

线程和协程的区别

线程的概念刚开始出现,但操作系统厂商可不能直接就去修改操作系统的内核,因为对他们来说,稳定性是最重要的。

贸然把未经验证的东西加入内核,出问题了怎么办?所以想要验证线程的可用性,得另想办法。

研究人员就编写了一个关于线程的函数库,用函数库来实现线程。

把创建线程、终止线程等功能放在了这个线程库内,用户就可以通过调用这些函数来实现所需要的功能。

刚刚我们说的线程库,是位于用户空间的,操作系统内核对这个库一无所知,对我们通过线程库创建的线程也一无所知。

也就是说操作系统眼里还是只有这一个进程,相当于我们在进程里嵌套了一个操作系统,用来建立我们自己的用户级线程。

那我用线程库写的一个多线程进程,多个线程也就都只能在一个物理CPU核心上运行,无法运行在多物理核心上。

这其实是用户级线程的一个缺点,这些线程只能占用一个核,所以做不到并行加速,而且由于用户线程的透明性,操作系统是不能主动切换线程的。

简单理解为:用户态线程是寄生在某个内核线程或者说寄生在某个进程上的,对于操作系统来说,它依然只是一个线程,当然只能运行在一个核心上

并行和并发的区别

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这说明你不支持并发也不支持并行。

并发:你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

并行:你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。

并行的关键是你有同时处理多个任务的能力。

它们最关键的点就是:是否是『同时』。

可以查阅我的知乎文章:

简单理解:CPU物理数,核心数,线程数,进程,线程,协程,并发,并行的概念 - 知乎 (zhihu.com)

Skynet相关

可以把Skynet理解为一个简单的操作系统,它可以用来调度数千个Lua虚拟机,让它们并行工作。每个Lua虚拟机都可以接收处理其它虚拟机发送过来的消息,以及对其它虚拟机发送消息。每个Lua虚拟机,可以看成Skynet这个操作系统下的独立进程,你可以在Skynet工作时启动新的进程、销毁不再使用的进程。Skynet同时掌控了外部的网络数据输入和定时器的管理,Skynet会把这些转换为一致的(类似进程间的消息)消息输入给这些进程。

我们先看看操作系统的工作:操作系统拥有很多CPU核心,通过算法调度,把不同进程(实质就是线程)安排给不同的CPU核心去执行,从而完成一个个任务。这里的CPU核心是分开独立的,因此多个线程可以并行执行。而协程是存在某个线程中的,但单个线程只能在某个核心下运行,因此多个协程只能并发执行。

再来对比Skynet框架的工作:Skynet进程有很多工作线程,通过算法调度,把不同服务(就是Lua虚拟机)安排给对应的线程去执行,从而完成一个个任务。这里的工作线程是分开独立的,因此多个服务可以并行执行。而协程是存在某个服务中的,因此Lua虚拟机中的协程只能并发执行。这些协程永远不会真正的并行,它们只是在轮流工作,永远只有一个协程在运行(因为协程的本质还是用户级线程,只能限定在一个核心上运行)

Skynet框架中的线程,相当于CPU核心;Skynet框架中不同的服务(就是Lua虚拟机),相当于进程(实质就是线程);Skynet服务中的任务,相当于协程。Skynet线程不断地获取服务和执行服务,相当于CPU核心不断地获取进程和执行进程;Skynet服务切换不同任务,相当于获得运行权的线程切换不同协程。

特别注意:操作系统的算法调度是抢占式的,因此操作系统会根据时间片的轮转来切换线程;而Skynet的算法调度是通过获取全局消息队列中某个服务的次级消息队列来确定执行那个服务的,并且不会主动让出线程。而Skynet也通过全局消息队列和次级消息队列,保证了一个服务永远只能被一个线程获取和执行,保证了线程安全。

总结

线程(内核级线程),能独立占用物理核心,能被操作系统切换到不同的物理核心中去执行,多核心情况下是能够并行执行的。

对应的就是Skynet中的线程能同时运行不同的服务,因为这些线程运行在不同的核心上。

协程(用户级线程),永远只能在一个核心上执行,不能被操作系统切换到不同的物理核心中去执行,需要别的程序让出执行权才能运行。

对应的就是skynet.fork出的线程,Lua中的协程,这些线程或者说任务,同一时间永远只有一个在运行。

对线程,协程的应用在Skynet中的来说是非常重要的。线程对应着服务,协程对应着服务中的任务。线程的调度关系着服务的执行,协程的切换关系着

同一个服务内的任务的执行。如果设计的好,利用多核心多线程,任务协程能极大提高性能。

多说一句

我们一定要紧紧抓住这个知识点:核心,进程,线程,协程。这是最最基本的计算机知识,Skynet都是围绕这些知识点在进行设计和优化。Skynet做的事情就是简单的一件:充分利用现代CPU的多核模式,将不同的业务放在独立的执行环境中处理,协同工作。这也就是为什么云风建议服务尽可能放在同一进程中实现,不同进程尽可能放在同一机器中,硬件机器不够用时最优的办法首先是增加核心数量最后才是加机器,加机器也尽可能放在同一机房中。Skynet的设计是把多核性能发挥到极致,所以我们设计服务器架构,写代码需要考虑这一点,而这也是为什么我们要深入了解Skynet机制的原因,只有了解它,才能更好地使用它。

Go语言的机制:GO语言基础进阶教程:Go语言的并发模型 - 知乎 (zhihu.com)


Skynet特点

1,Skynet采用的是单进程多线程模式。

​ 简单点说就是:尽量在一个进程中完成所有的任务。注意:这里并不是说Skynet只能有一个独立的进程

​ 为什么要这么设计?现代的CPU通常拥有非常多的核心,我们希望能充分利用多核心多线程的优势,独立并行地去完成任务

​ 尽可能把多的任务,相关联的任务放到同一个进程中,这能减少任务间的通讯成本,提高效率

2,Skynet用服务来代表某项具体的业务,服务包括了处理业务的逻辑以及关联的数据状态。

​ 某个服务会被某个线程拿去执行,多线程就能同时执行多个任务,从而达到独立并行执行的目的

​ 这里Skynet保证了每个服务同一时刻永远只能被一个线程获取,保证了线程安全

3,Skynet使用Lua虚拟机来运行并隔绝各个服务,多个服务可以存在同一个进程中,互不影响。

4,Skynet采用Actor模型,服务间需要通过消息的传递来互相合作,消息驱动服务的进行。

5,Skynet是一个简单的操作系统。

​ 每个Lua虚拟机,即每个服务,都可以看成Skynet这个操作系统下的独立进程

​ 多线程,可以看成是多个CPU核心

​ 服务会被Skynet(操作系统)分给不同的核心(线程)执行


Skynet线程

Skynet 的线程分为几类:

​ 1,Timer线程

​ 2,Socket线程

​ 3,Monitor线程

​ 4,Worker线程

Timer 线程就是Skynet中负责定时器工作的线程。

定时器在游戏框架中是个比较重要的部分,关系到很多功能的协调和执行,所以独立一个线程来处理。

Socket 线程就是Skynet中负责网络工作的线程。

网络的数据收发是非常重要的模块,性能要求高,比较复杂,也用独立的线程来处理。

Monitor 线程就是Skynet中用来监测的线程。

服务运行中线程可能出现死循环等状况,同时需要监测系统的运行状态,由独立的监控线程负责。

Worker 线程就是Skynet中的工作线程。

用来负责执行业务逻辑,要充分利用多核优势,所以创建的工作线程数量也最多。


Actor模型

Skynet利用Lua虚拟机使每个服务隔离,但又让这些服务存在于同一个进程中,以便充分利用现在计算机中的多核优势。那么Skynet如何使这些服务进行通信呢?做法就是让每个服务都有自己的一个邮箱Mail_Box,用来接收别的服务发送给自己的消息,同时自己也可以向别的服务的邮箱Mail_Box发送请求。每当服务自己的邮箱Mail_Box有新消息时,就会被Skynet的线程安排执行,从而驱动业务的进行。

可以说,Skynet的服务是被消息驱动执行的,这也就是Actor模型。每个Actor,也就是每个Skynet服务,会接收消息,然后基于消息来做某些计算。那些没有消息的服务,也就是没有任务的服务,不会被线程执行到,不会占用CPU任何资源。

其实Skynet维护了一个全局消息队列,每个服务再各自维护了一个私有消息队列。Skynet线程从全局消息队列中取到某个服务的私有消息队列,再从私有消息队列中执行某个请求消息。其中Skynet保证私有消息队列的线程安全,也就是同个私有消息队列只会被一个线程获取,在这个线程处理完此服务的任务前,不会再放入全局消息队列,也就是此服务绝对不会被别的线程获取并执行。


Skynet举例

在游戏中,我们可以为每个在线用户创建一个Lua虚拟机(Skynet称之为Lua服务),姑且把它称为Agent。Agent在玩家上线时,从数据库加载关联于它的所有数据到Lua虚拟机中,因此这个Lua服务就包括了处理业务的逻辑以及玩家关联的数据,这个Agent就是玩家的实例化。

一个进程中,可以创建非常多的Lua虚拟机,也就是能同时在线非常多的玩家。玩家在不和其它玩家交互而仅仅自娱自乐时,Agent完全可以满足所有游戏要求。但同时也可以对别的玩家的网络请求做出反应,这就需要网络通信。(这部分后面再详细说)

如何执行服务中的逻辑以完成任务?前面所说,同个进程中的服务,可以看作是Skynet下每个独立的进程;线程可以看作是Skynet下的独立核心。因此每个线程会根据条件去选择不同的服务来执行。线程A,选择服务a;线程B,选择服务b,线程C,选择服务c……当某个线程完成了某个服务的任务,会再次去选择执行另外的服务,以此循环,直至完成所有服务的执行需求。(Skynet线程是如何调度的,后面有具体的说明)

举个例子:银行有8个窗口,每个窗口就是独立的一个线程,能完成一些工作任务。当工作人员开始工作时,就从排队人群中,叫一个号到窗口处理。等到这个人的事项办完(可能帮这个人办几个事项,也可能只办一个事项,或者全部办完,之后即使这个人还有事项未办理,也得重新去后面排队等待叫号),再从排队人群中再取一个号,继续工作,以此循环。排队的人,就是Skynet中的一个个等待被执行的服务。


Skynet服务

前面了解了一些计算机基础的知识,其中线程,协程,服务3个概念非常重要。服务对应着操作系统中的进程,而操作系统的任务就是如何高效地分配核心给不同的进程使用,是管理所有进程的核心。而Skynet则是管理服务的核心,是一个简易的操作系统。因此需要好好了解服务的特性以及Skynet是如何管理服务的。


服务流程

1,服务加载阶段

当服务的源文件被加载时,就会按Lua的运行规则被执行到。这个阶段不可以调用任何有可能阻塞住该服务的Skynet API。因为,在这个阶段中和服务配套的 Skynet设置并没有初始化完毕。

2,服务初始化阶段

由skynet.start这个API注册的初始化函数执行。这个初始化函数理论上可以调用任何Skynet API了,但启动该服务的skynet.newservice这个API会一直等待到初始化函数结束才会返回。

3,服务工作阶段

在初始化阶段注册了消息处理函数的话,只要有消息输入,就会触发注册的消息处理函数。这些消息都是Skynet内部消息,外部的网络数据,定时器也会通过内部消息的形式表达出来。


存在问题

1,线程占用

每个服务不会独占Worker线程,在处理一定数量消息后会主动让出线程,Worker线程就可以处理别的服务的消息。

但如果某个服务永远不调用阻塞API让出控制权,那么它将永远占据系统Worker线程,Skynet并不是一个抢占式调度器,没有时间片的设计,不会因为一个Worker线程工作时间过长而强制挂起它,所以需要格外注意不能让服务陷入死循环。(Skynet提供一个检测机制,后面再详细说)

2,协程执行

在同一服务内还可以有多个用户线程,这些线程可以用skynet.fork传入一个函数启动,也可以利用Skynet的定时器的回调函数启动。

上面提到的消息处理函数其实也是一条独立的用户线程(可以理解为:响应任何一个请求,都启动了一条新的独立用户线程,其实就是协程)。这些并不像真正操作系统的线程那样,可以利用多个核心并行运行。同一服务内的不同用户线程永远是轮流获得执行权的,每个用户线程都会需要一个阻塞操作而挂起让出控制权,也会在其它用户线程让出控制权后再延续运行。(这会导致数据同步问题,看后面)

3,内存隐患

在服务处理新消息时,是通过创建新协程来处理,虽然协程会被重复利用,但在极端情况下依然会占用较多内存。

例如:如果服务a不断给服务b发消息,但服务b的处理过程存在长时间挂起,这样,对于服务a发来的消息,服务b会不断创建协程去处理,就导致内存被大量占用的情况出现。

4,数据同步

一个Skynet服务在某个业务流程被挂起后,即使回应消息尚未收到,它还是可以处理其他的消息的。因此同一个Skynet服务可以同时拥有多条业务执行线。所以,你尽可以让同一个Skynet服务处理很多消息,它们会看起来并行。和真正分拆到不同的服务中处理的区别是,这些处理流程永远不会真正的并行,它们只是在轮流工作。一段业务会一直运行到下一个IO阻塞点,然后切换到下一段逻辑(就是前面说的协程执行)。你可以利用这一点,让多条业务线在处理时共享同一组数据,这些数据在同一个Lua虚拟机下时,读写起来都比通过消息交换要廉价的多。

带来的问题是:一旦你当前业务处理线挂起,等回应到来继续运行时,内部状态很可能被同期其它业务处理逻辑所改变,这就是数据同步问题。


服务总结

Skynet框架分解的大体部分已经讲完。从Skynet主要的特点:单进程多线程,服务代表业务,虚拟机隔离,简易操作系统开始;结合Actor模型,进程,线程,协程这几个重要的概念,简单分析了Skynet的工作流程:线程调度,消息处理,服务特点,存在问题等各方面。另外我认为,只有了解Skynet底层源码的实现,才能深刻领悟一些关于Skynet的描述,明白其中的设计原理,最后才能完全使用好Skynet框架。后面我将通过阅读源码,一步步抽丝剥茧,搞清楚Skynet到底为什么要这么设计?所说的Skynet的特点是如何通过代码实现的?Skynet优点和缺点在哪里?在实际应用中,应该如何设计编码以符合Skynet的特点等。