1.5 回头看操作系统
服务端编程很注重程序运行效率,所以我们要知其然,也要知其所以然。
1.5.1 多进程为什么能提升性能
回过头想想1.4节的分布式程序,将多个程序部署在多台物理机上显然可以提升性能,那么将它们部署在同一台物理机上是否也能提高性能?
早期的计算机只能够执行单个任务(如图1-17所示),程序由代码段和数据段组成,如果计算机只需执行一个任务,内存的逻辑结构可以很简单。图1-17中内存的语句1~5代表代码段,变量1代表数据段,PC代表当前程序执行到哪条语句了。
图1-17 单任务计算机的结构示意图
有些语句会阻塞程序执行,按照1.1.2节的游戏流程,服务端需要在玩家登录时加载数据、登出时保存数据(如代码1-8所示),由于硬盘速度很慢,因此类似于readFileSync的语句就有可能阻塞程序,因为要等待读取数据后才能再往下执行。
代码1-8 添加数据保存功能的服务端(Node.js)
var server = net.createServer(function(socket){ //新连接//... //断开连接 socket.on('close',function(){ }); });
CPU的执行时序如图1-18所示,状态R代表执行中(Runing),状态S代表休眠(Sleeping)。CPU只有在R状态下才会忙碌,S状态下CPU无事可做。
图1-18 代码1-8中CPU的执行时序
既然CPU可能会进入“无事可做”的状态,一种充分利用CPU资源的方法就此产生,即让物理机同时运行多个互不相干的程序(进程)。如图1-19所示,每个进程的代码段和数据段相互独立,且它们都会记录各自执行到哪条语句了(即图中的PC)。操作系统会分配CPU,当进程1无事可做时,就让CPU执行进程2的语句,反之亦然。
图1-19 多进程的内存结构
所以,为什么开启多个程序可以提高执行效率?是因为单个程序中可能会存在一些阻塞语句让CPU空闲,开启多个程序可以填补CPU的空闲时间。
按照以上分析,如果程序中不包含阻塞语句,且运行在单核CPU下,同台物理机下部署多个程序是不能提升性能的。不过当代大多是多核CPU,可以同时执行多个程序,因此在非阻塞程序中,开启与CPU核心数相当的进程可以充分利用CPU。在图1-20中,core1执行着进程1、core2执行着进程2、core3和core4无事可做。
图1-20 四核CPU示意图
1.5.2 阻塞为什么不占CPU
除了读取文件,如果用C、C++、C#开发网络程序,还会用到一些阻塞函数,比如等待客户端连接的accept函数、接收数据的recv函数等。那阻塞为什么不会占用CPU资源呢?
为了便于说明,先看看代码1-9,程序每隔0.1秒打印一句“count is XXX”,代码中的sleep函数可以使程序休眠一段时间。
代码1-9 阻塞(C语言)
void Block() { int count = 0; while(true) { count++; printf("count is %d", count); sleep(100);//0.1秒 } }
操作系统为了支持多任务,实现了进程调度的功能,它会把进程分为“运行”和“休眠”等几种状态。运行状态是进程获得CPU使用权,正在执行代码的状态;休眠状态是阻塞状态,比如代码1-9运行到sleep时,程序会从运行状态变为等待状态,过0.1秒后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是在同时执行多个任务。
图1-21中的计算机运行着A、B、C三个进程,其中进程A执行着代码1-9中的程序,一开始,这三个进程都被操作系统的工作队列引用,它们处于运行状态,会分时执行。
图1-21 操作系统工作队列示意图
当程序执行到sleep语句时,操作系统会将进程A从工作队列移到等待队列中(如图1-22所示)。这样一来,工作队列中就只剩下进程B和进程C了。依据进程调度规则,CPU会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用CPU资源。等到条件成立(比如等待一段时间),操作系统会重新将进程A放入工作队列中,继续执行。
图1-22 操作系统等待队列示意图
1.5.3 线程会占用多少资源
1.4节用“多进程”的方案提高了服务端的承载量,事实上,使用“多线程”方案亦可。一般的程序(进程)包含一个代码段和一个数据段,多线程程序则包含了多个代码段。如图1-23所示,进程1包含了线程1和线程2这两个线程,每个线程都有它们自己的代码和PC(记录运行到哪个语句),它们共享数据(变量1)。
图1-23 多线程程序示意图
线程会占用多少资源呢?
·Linux系统默认会给线程分配8MB的栈空间。虽然它承诺给线程8MB的内存,但要等到用到时才会分配。就像某网盘标榜给每个用户2TB大小的空间,实际并没有即刻分配那么多。但占用的实际内存至少会是一“页”,即4KB。
·CPU切换线程需要做很多工作,它执行一条语句大概需要几纳秒,完成一次线程切换大概需要几微秒,花销较大。开启的线程数越多,CPU就需要做更多的切换工作,这会使响应变慢。
可见,在普通的计算机中,虽然操作系统理论上可以支持(近乎)无限的线程数,但实际上运行几百个性能就很不好了(请记住这里的“几百个”,后面章节会再次提起)。
知识拓展:1.2.3节介绍的网络模块的底层实现有如下两种方法:
1)每当有新的客户端连接时,开启新线程处理该客户端。
2)使用多路复用技术,所谓“多路”,指的是服务端可以阻塞(如使用epoll_wait)等待多个客户端的连接,有任何一个收到数据即返回。
Web服务器可以用这两种方法,但游戏服务端大多只会用第2种方法。这是因为Web服务器都是短连接,发送消息后即断开,同时在线的客户端很少;游戏服务端大多是长连接,同时在线的玩家很多,方法1只能支持数百名玩家。