本文介绍了服务器程序性能优化的一般性方法,以及部分常见服务器程序的性能优化步骤。服务器程序指的是接收客户端程序请求,执行对应操作,并将结果返回给客户端的程序,如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 inputQueue = receiveFromAllClients(maxQueueSize, maxWaitTime);
Queue outputQueue = new Queue<>();
for (Request request: inputQueue) {
Response response = process(request);
outputQueue.put(response);
}
sendAllResponse(outputQueue);
}
}
}
public class MultiThreadAsyncQueue {
AsyncQueue inputQueue = new AsyncQueue<>();
AsyncQueue 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 参考资料
- 《事务处理:概念与技术》
- 在Linux下做性能分析1:基本模型 https://zhuanlan.zhihu.com/p/22124514