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【这里的CPU指核心】、内存、磁盘IO 等)。一个进程可以拥有多个线程。操作系统是抢占式的,因此每个进程内的线程都需要抢占核心运行,当然,运行一定时间后也会被操作系统停止,让出核心给其他线程执行。
线程(内核级线程)
线程是核心调度和分配的基本单位。一个进程下的线程共享该进程的资源和程序代码。我们把线程准确称为内核级线程。
协程(用户级线程)
由用户程序实现的线程,内核执行权切换是由用户态程序自己协商切换的,不需要内核的干涉。我们把协程准确称为用户级线程。
线程和协程的区别
线程的概念刚开始出现,但操作系统厂商可不能直接就去修改操作系统的内核,因为对他们来说,稳定性是最重要的。
贸然把未经验证的东西加入内核,出问题了怎么办?所以想要验证线程的可用性,得另想办法。
研究人员就编写了一个关于线程的函数库,用函数库来实现线程。
把创建线程、终止线程等功能放在了这个线程库内,用户就可以通过调用这些函数来实现所需要的功能。
刚刚我们说的线程库,是位于用户空间的,操作系统内核对这个库一无所知,对我们通过线程库创建的线程也一无所知。
也就是说操作系统眼里还是只有这一个进程,相当于我们在进程里嵌套了一个操作系统,用来建立我们自己的用户级线程。
那我用线程库写的一个多线程进程,多个线程也就都只能在一个物理CPU核心上运行,无法运行在多物理核心上。
这其实是用户级线程的一个缺点,这些线程只能占用一个核,所以做不到并行加速,而且由于用户线程的透明性,操作系统是不能主动切换线程的。
简单理解为:用户态线程是寄生在某个内核线程或者说寄生在某个进程上的,对于操作系统来说,它依然只是一个线程,当然只能运行在一个核心上
Skynet相关
可以把Skynet理解为一个简单的操作系统,它可以用来调度数千个Lua虚拟机,让它们并行工作。每个Lua虚拟机都可以接收处理其它虚拟机发送过来的消息,以及对其它虚拟机发送消息。每个Lua虚拟机,可以看成Skynet这个操作系统下的独立进程,你可以在Skynet工作时启动新的进程、销毁不再使用的进程。Skynet同时掌控了外部的网络数据输入和定时器的管理,Skynet会把这些转换为一致的(类似进程间的消息)消息输入给这些进程。
在Skynet框架下(Skynet操作系统),一个Lua虚拟机就是一个进程,Lua虚拟机也称为一个服务,那么一个服务对于Skynet来说就是一个进程。理所当然的,进程里面就会有线程,因此我们可以把Lua虚拟机中的协程看作是线程。和操作系统下的线程概念对比,不同的是:这些线程永远不会真正的并行,它们只是在轮流工作,永远只有一个协程在运行(因为协程的本质还是用户级线程,只能限定在一个核心上运行)
总结
线程(内核级线程),能独立占用物理核心,能被操作系统切换到不同的物理核心中去执行,多核心情况下是能够并行执行的。
对应的就是Skynet中的线程能同时运行不同的服务,因为这些线程运行在不同的核心上。
协程(用户级线程),永远只能在一个核心上执行,不能被操作系统切换到不同的物理核心中去执行,需要别的程序让出执行权才能运行。
对应的就是skynet.fork出的线程,Lua中的协程,这些线程或者说任务,同一时间永远只有一个在运行。
对线程,协程的应用在Skynet中的来说是非常重要的。线程对应着服务,协程对应着服务中的任务。线程的调度关系着服务的执行,协程的切换关系着
同一个服务内的任务的执行。如果设计的好,利用多核心多线程,任务协程能极大提高性能。
可以查阅我的知乎文章:
简单理解:CPU物理数,核心数,线程数,进程,线程,协程,并发,并行的概念 - 知乎 (zhihu.com)
多说一句
我们一定要紧紧抓住这个知识点:核心,进程,线程,协程。这是最最基本的计算机知识,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线程处理消息。
但如果一条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的特点等。