本文介绍了服务器程序性能优化的一般性方法,以及部分常见服务器程序的性能优化步骤。服务器程序指的是接收客户端程序请求,执行对应操作,并将结果返回给客户端的程序,如Nginx、Tomcat、SQLite、Berkeley DB等。

1 优化方法

服务器性能优化是为了提高服务器性能而进行的一系列操作,本文关注的是程序(包括操作系统)层面的优化,因此不涉及诸如增加硬件、升级硬件或升级固件版本等方法。本文提到的性能优化,指的是通过调整程序参数或程序代码,提高程序性能的行为。本文主要关注工程方面的优化,不涉及算法优化等技术。

2 优化目标

本文关注于服务器程序,因此采用吞吐量(throughput)和时延(latency)作为性能度量指标。其他的性能度量指标,比如网络流量和耗电量等,不在考虑范围之内。

吞吐量是单位时间内服务器处理的请求数量平均值。时延是客户端从发送请求到接收应答所经历的时间平均值。在本文中,性能优化的目标是提高吞吐量,降低时延。

3 计算机模型

计算机分为处理器、存储器和通信线路。处理器负责执行指令,进行运算。存储器负责存储数据,数据以字节为单位。存储器分为顺序存储器和随机存储器。顺序存储器只能按顺序存取字节,随机存储器没有这样的限制。通信线路有两个端点,一个连接到处理器,另外一个连接到存储器或处理器。通信线路负责将数据在两个端点之间传递。通信线路上传递的数据也叫做消息。由多个通信线路连接在一起的一组处理器和存储器组成网络。

下面的Java代码展示了这些模型的接口:

public interface Processor {
        void get_next_instrument();
        void execute_instrument();
};

public interface SequentialStorage {
        long getBlockSize();
        void rewind();
        // Block 是固定长度的字节数组,比如byte[512]。
        Block read();
        void write(Block data);
}

public interface RandomAccessStorage extends SequentialStorage {
        long getSize();
        void moveTo(long position);
}

public interface CommunicationLine {
        // End可以是处理器或存储器,但不允许两个End都是存储器。
        void establish(End end1, End end2);
        void sendToEnd1(byte[] data);
        void sendToEnd2(byte[] data);
};

度量处理器性能的指标是每秒执行的指令数(MIPS)。存储器的性能指标是访问时间和容量。对于随机存储器,访问数据操作包含寻找数据位置和传输数据两个操作,因此访问时间是这两个步骤耗时之和。对于顺序存储器,我们可以将moveTo操作定义为

void moveTo(long position) {
        rewind();
        int skipBlockCount = position / BLOCK_COUNT;
        while (skipBlockCount-- > 0) {
                read();
        }
}

这样就可以基于顺序存储器构建一个随机存储器。因此“访问时间=寻址时间+传输时间”这一公式也适用于顺序存储器。度量通信线路性能的指标是带宽和时延。带宽是单位时间内通信线路可以传递的比特数,以bps为单位。时延时从开始发送消息到接收第一个字节所经历的时间。时延通常由通信线路的长度所决定。

如果可以保证数据的接收顺序和发送顺序一致,那么通信线路看起来很像是一个顺序存储器。但二者存在两点重要区别:一是从存储器中读取数据后,数据仍然保存在存储器上,可以再次读取。而从通信线路中接收消息后,消息从从通信线路中移除。二是从存储器中存取单位数据时,无论成功或失败,操作时间存在一个上限。而通信线路无法满足这样的条件,因为某个端点可能长时间不发送消息。

4 性能优化模型

4.1 基础模型

客户端程序(简称客户端)是一个处理器,服务器程序(简称服务器)由若干个处理器、存储器和通信线路组成。客户端和服务器以通信线路连接。客户端发送消息给服务器,服务器程序执行相应的操作,然后将处理结果返回给客户端。客户端发送的消息叫做请求,服务器返回的对应请求的处理结果叫做应答。

这里提到的处理器、存储器和通信线路都是逻辑上的模型,并非特指CPU、硬盘和以太网。比如CPU、线程、进程都可以是处理器,L1缓存、内存、磁盘、磁带都可以是存储器,TCP连接、消息队列、数据总线都可以是通信线路。

客户端从发送请求到接收应答的过程可以分为三个阶段:客户端将请求发送到服务器、服务器处理请求、服务器将应答发送给客户端。假设这三个阶段分别耗时t1、t2和t3,并假各客户端按顺序依次发送请求,那么服务器的时延是t1+t2+t3,吞吐量是1/(t1+t2+t3)。

4.2 队列模型

假设有两个客户端向服务器发送请求,在基础模型下,服务器的处理过程如下:

接收请求1。
处理请求1。
发送应答1。
接收请求2。
处理请求2。
发送应答2。

显然“发送应答1”和“接收请求2”两个任务之间没有依赖关系,因此可以并行处理,以提高系统吞吐量。这就是队列模型。 在队列模型中,服务器将收到请求保存到请求队列,处理器循环从请求队列中读取请求并进行处理。这个处理过程和从其他客户端接收消息的操作是并行或并发的。类似的,应答的发送和业务逻辑处理也是并行或并发的。假设请求队列的长度是l,那么第二阶段的耗时变为

t2' = 请求在队列中等待调度的时间 + 实际处理时间 = (l - 1)t2 + t2 = lt2

因此服务器的时延变为t1+l*t2+t3,吞吐量变为1/t2。队列机制会增加吞吐量,代价是时延也随之增加。队列模型是基本模型一般化推广,当队列长度为1时,队列模型和基本模型非常相似。

按照前面的计算机模型,当系统中存在n个客户端时,请求队列由一个处理器(叫做队列处理器)和n+1个消息线路组成。队列处理器和每个客户端之间都存在一个通信线路,最后一个通信线路连接到服务器的业务逻辑处理器上。队列处理器从每个客户端接收消息,将消息发送到最后一个通信线路上,传递给业务逻辑处理器。应答队列也是类似的,只是消息传递的顺序相反。请求队列和应答队列也可以叫做输入队列和输出队列。

在服务器中通常包含多个模块,每个模块都可以看成是由一个业务逻辑处理器、一个输入队列、一个输出队列组成的网络。假设模块i的处理时间是t[i],输入队列长度是len[i],那么这个模块的时延就是len[i]*t[i],服务器的时延就是这些模块时延的和。

此外,t1和t3的任务是传输数据,t2的任务是执行业务逻辑。这是两类不同类型的任务。如果没有队列,CPU和程序需要在这两类任务之间频繁切换,一方面使程序变得复杂,容易出错;另一方面,不利于充分发挥CPU性能,也不方便进行针对性优化。

队列有两种常见的实现方式,一种是单线程批处理方式,一种是多线程异步队列方式。下面的代码展示了这两种方式。

public class SingleThreadBatch {
        public void processLoop() {
                while (notQuit) {
                        Queue<Request> inputQueue = receiveFromAllClients(maxQueueSize, maxWaitTime);

                        Queue<Response> outputQueue = new Queue<>();
                        for (Request request: inputQueue) {
                                Response response = process(request);
                                outputQueue.put(response);
                        }

                        sendAllResponse(outputQueue);
                }
        }
}

public class MultiThreadAsyncQueue {
        AsyncQueue<Request> inputQueue = new AsyncQueue<>();
        AsyncQueue<Request> outputQueue = new AsyncQueue<>();

        private receiveThread = new Thread() {
                        @Override
                        public void run() {
                                while (notQuit) {
                                        for (Client client: allClients) {
                                                Request request = client.receiveNoWait();
                                                if (request != null) {
                                                        inputQueue.enqueue(request);
                                                }
                                        }
                                }
                        }
                };

        private sendThread = new Thread() {
                        @Override
                        public void run() {
                                while (notQuit) {
                                        Response response = outputQueue.dequeue();
                                        send(response);
                                }
                        }
                };

         public void processLoop() {
                while (notQuit) {
                        Request request = inputQueue.dequeue();
                        Response response = process(request);
                        outputQueue.enqueue(response);
                }
        }
}

5 性能优化思路

为了提高吞吐量,必须充分利用CPU资源,让CPU满载。CPU满载后,请求不断堆积在队列中。为了避免时延过长,服务器需要进行控制队列长度。这个操作叫做流控。因此性能优化分为两步:提高CPU使用率、然后进行流控。

5.1 提高CPU使用率

CPU使用率低的原因有三点:一是过早流控,引发处理线程饥饿;二是处理线程在等待IO;三是线程调度不充分,没有充分利用多核的优势。

过早流控是因为队列长队设置得过小,通过观察队列丢包情况可以判断这一点。确认后适当增加队列长度,就可以提高CPU使用率。对于等待IO的情况,可以使用异步调用或多线程同时处理IO,降低CPU等待IO的时间。线程调度不充分的表现为部分CPU核心使用率非常高,其余核心使用率非常低,这时可以通过调整处理线程数量来进行优化。

CPU满载并非表示这个阶段的优化完成,必须保证CPU时间都用在处理业务逻辑上。要确认这一点需要对程序进行跟踪。通常CPU满载却没有用于处理业务逻辑的原因在于同步和线程调用。

5.2 流控

流控要保证在CPU满载的同时,尽量缩短队列长度。流控通常在接收客户端请求的队列进行。如果在中间队列进行,会浪费CPU处理时间和队列空间。

6 参考资料

11-24 00:16