百万在线:大型游戏服务端开发
上QQ阅读APP看书,第一时间看更新

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){
    //新连接
    var data = fs.readFileSync('save.txt');
    //...
    //断开连接
    socket.on('close',function(){
        fs.writeFileSync('save.txt', data)
    });
});

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只能支持数百名玩家。