如何实现一个高性能服务器
# 前言
随着互联网的发展,一些网站可能每时每刻都会有千万级别的请求打到服务器上,对此我们除了增加硬件设备以外,软件架构的设计也是很重要的,只要合适的通信架构,才能做到良好的拓展以及性能的提升。
# 高并发服务器演进
# 多进程架构
Linux发展初期采用的是多进程的模式,每当有一个请求打到应用程序上,该进程就会fork一个子进程处理这个请求,也就是说每一个请求都会有一个专门的子进程处理。

这种模式也就是我们常说的process-per-connection,这个模式优点如下:
- 多进程编程实现简单。
- 进程之间相互隔离,一个子进程崩溃不会影响另一个其他进程的运行。
- 充分利用多核资源,提升服务器性能。
同样的缺点也很明显:
- 因为进程间相互独立,所以通信就需要借助进程间的通信机制。
- 因为处理一个请求而去频繁创建和销毁进程,对于操作系统的负担巨大。
# 演进-多线程架构
于是我们就有多线程的架构,相比创建进程来说,创建一个线程开销就会小很多,每当有一个用户发起请求时,进程中的线程就会来处理这个请求,这就是我们所说的thread-per-connection,这种架构相比前者优点如下:
- 一个线程因为处理IO请求阻塞时,不会影响其他线程的执行。
- 同一个进程之间的线程共享内存,所以线程间通信无需借助任务通信机制。
- 创建和销毁线程的开销远小于进程。
同样的缺点也很明显:
- 由于线程共享进程的内存,所以操作数据时需要考虑线程之间数据同步,所以需要借助同步互斥的机制确保操作线程安全。
- 一个线程崩溃可能会导致整个应用进程崩溃。
- 线程使用不当导致死锁问题可能会导致应用阻塞。
- 多线程导致线程频繁切换也会使得系统运行效率降低。
- 如果每个请求一个一个线程,对于海量的高并发请求,尽可能出现C10K问题。

# 优化-事件循环和事件驱动
# 什么是事件驱动
我们不妨思考一下多线程架构为什么会出现性能问题,其实原因也很简单,我们没有很好的利用线程,假设我们一个请求对应一个线程,那么每个请求到来时我们都会派发一个线程,然后呢?线程使用完成后闲下来怎么办?销毁吗?很显然不是的,这个线程要么拿来处理别的请求,要么阻塞监听请求的到来,试想一下,假如服务器某一时刻完全没有请求,一堆线程都在阻塞监听请求的到来,那么我们的进程就会变成这样:

因为监听是阻塞的,所以线程实际上都属于运行态,假如我们的进程还有一些别的业务要处理,这些忙碌的线程是无法抽出身来处理的,很明显多线程架构对于线程存在很严重的浪费。试想一下,解决这些性能问题的思路是什么? 很明显,为什么我们的请求一定要线程阻塞监听呢?我们能不能做到有请求来的时候,通知我们的线程处理呢?这样一来我们的线程在闲下来的时候不就可以处理进程的一些杂活吗?
这时候就不得不提一下事件驱动编程(event-based-concurrency),在该模式下,我们只需用少量的线程循环等待事件(eventLoop)的到来,然后将事件风法到对应的事件处理函数(event-handler)上即可。

于是我们就有了这样一套伪代码:
while(true){
//获取事件
List<event> eventList=getEvent();
//遍历事件查找处理器并处理该事件
eventList.forEach(e->{
handler=findHandler(e);
hander.handler(e);
});
}
2
3
4
5
6
7
8
9
那么问题来了:
- 我们如何等待事件到来,如果做到让事件主动找我们呢?
- 处理事件的函数要不要和事件循环放在一个线程中呢?
# 实现事件驱动
我们都知道Linux的设计理念是一切皆文件,所以我们的程序、socket都是通过文件描述符来进行描述的,假如我们当前有10个socket处理网络IO请求,在不使用事件驱动的情况下,它的伪代码可能是这样的:
//阻塞等待socket1结果并写到buf1中
recv(fd1,buf1);
//阻塞等待socket2结果并写到buf2中
recv(fd2,buf2);
//阻塞等待socket3结果并写到buf3中
recv(fd3,buf3);
//阻塞等待socket4结果并写到buf4中
recv(fd4,buf4);
//略
2
3
4
5
6
7
8
9
10
11
12
13
这种写法的缺点也很明显,为每一个socket设置阻塞并等待接受数据,假设socket1数据还没到来,而socket2到来了,这段代码就会因为socket1的阻塞导致socket2的任务无法及时被处理。
好在Linux为我提供epoll模型,通过epoll模型,我们只需将socket文件描述符告知操作系统,如果这些文件描述符中有事件来了通知应用程序即可,这样一来,我们的代码就变成了这样:
//创建epoll
EpollFd epollFd=createEpoll();
//将文件描述符注册到epoll上
epollCreateCtl(epollFd,socketFdList)
while(true){
//收到epoll推送过来的事件
List<event> eventList=epollWait(epollFd);
//遍历并处理事件
eventList.foreach(e->handler(e));
}
2
3
4
5
6
7
8
9
10
11
如此我们的事件就无需专门使用多线程去阻塞监听,而是通过epoll主动推送过来,这样一来,我们就可以通过事件循环机制及时处理所有需要被处理的事件了。

再来回答第2个问题,业务处理线程是否需要和事件循环放在一个线程上,答案是分两种情况:
- 如果所有的任务都是毫秒级即可处理的任务且不涉及任务IO,那么我们完全可以将事件循环和事件处理放在一个线程上。

- 假如事件处理非常耗时且涉及IO操作,为了保证事件循环不会因为事件处理而阻塞,我们建议将事件处理和事件循环放到不同的线程上,确保利用多核资源解决并发的IO请求。

条件2所说情况也就是我们现如今海量高并发场景最容易情况,而针对这种场景所设计的模式我们也给它起了给名字叫:Reactor模式。
# Reactor模式
既然聊到Reactor模式,我们就来说说它的优势和灵活性,对此我们将上述的需求进行一次升级,假如我们高并发场景下的任务既有阻塞式IO也有非阻塞式IO,那么我又该如何设计应用模式呢?
其实改动也不是很大,对于那些非阻塞的任务,我们直接放在事件循环中处理也不妨,其余的阻塞式IO还是按照原本的设计思路,将其交给其他核心线程的事件处理函数处理即可。

# 优化-事件处理函数
# 基于异步回调
高并发服务器的整体设计我们已经有了明确的方案,接下来就是对事件处理函数进行优化了,假设事件处理函数现在收到的请求涉及多个服务间的RPC调用,这些RPC调用是会阻塞当前线程的,他们的代码可能时这样的:
//获取用户信息
User user=queryUserInfo(user);
//获取用户余额
Money money=queryBalance(user);
//扣减余额,发送商品
Price price=buyGoods(money);
2
3
4
5
6
因为这些请求是同步阻塞的,这意味着当前线程必须等待RPC结果返回才能处理后续的逻辑,所以当因为RPC阻塞的时候,这些线程就只能干等着。 所以我们是否可以将RPC调用变成异步的呢?然后将后续的逻辑以回调的函数放到RPC调用函数上,这样一来我们的方法就可以立即返回,这样一来各个请求的RPC调用都以异步回调的方式丢到另外专门处理这些RPC请求的线程池中,避免了一个请求进来导致线程频繁阻塞进而无法处理更多的请求。但是这样的作法缺点也很明显,下面这段代码仅仅只有两个回调,但是可读性却变得非常差,这也可能导致后续代码维护和问题定位变得非常繁琐。
User user=queryUserInfo(user,(res)->queryBalance(res,(res2)->buyGoods(res2)));
# 基于协程
针对上面的问题,我们想到了协程,将事件交给线程上的协程处理,一旦协程发生阻塞,线程就会在用户态上将任务切换到另一个协程上,如此一个线程就能处理多个IO阻塞事件,并且协程切换的开销远远小于线程切换,服务器处理请求的效率就会显著提升。

更重要的一点,我们的代码还是保持下面这个样子,可读性还是很高。
//获取用户信息
User user=queryUserInfo(user);
//获取用户余额
Money money=queryBalance(user);
//扣减余额,发送商品
Price price=buyGoods(money);
2
3
4
5
6
# 小结
# CPU、线程、协程之间的关系
这三者我们可以从两个角度来解释,首先从所处位置来说:
- CPU处于硬件层,专门负责处理收到指令并完成运算。
- 线程处于内核态,线程的调度和切换过程对用户来说是不可控的,因为内核对于线程的调度有着自己的一套算法,内核永远会按照自己的调度算法为线程分配资源。
- 协程则处于应用层,它活跃于线程之上,多个协程对应一个线程,协程的切换是在用户态上,即用户可控的方式进行切换。

换个角度来说,协程我们完全可以理解为用户态线程,是内核线程资源的再调度,使得那些阻塞的线程更好的利用自己的CPU时间片去执行尽可能多的任务。

# Java中实现高性能服务器的手段有那些
对于上述的高性能服务器设计,除了协程Java暂不支持以外,其实Java已经为我们提供了一个不错的框架——Netty。 Netty是一种高性能、异步事件驱动的网络应用程序框架,用于快速开发可扩展的服务器和网络应用程序。它基于Java NIO(New I/O)技术,提供了一种简单、高效的方式来处理网络通信。 Netty的设计目标是提供简单、稳定、快速的开发体验,同时保持高性能和可扩展性。它的核心思想是事件驱动和异步通信模型。Netty使用事件和回调机制来处理网络操作,而不是传统的阻塞式I/O,这使得它能够处理大量并发连接而不会阻塞线程,从而提高了系统的吞吐量和响应性能。 Netty提供了丰富的功能和组件,包括对多种传输协议的支持(如TCP、UDP、HTTP、WebSocket等),高性能的编解码器、可靠的数据传输和流量控制、安全认证、心跳检测等。它还提供了方便的API和工具,简化了网络编程的复杂性,使开发人员能够更轻松地构建可靠、高性能的网络应用程序。 Netty在许多大型互联网公司和开源项目中得到广泛应用,如Elasticsearch、Kafka、RocketMQ等。它被认为是构建高性能、可扩展的网络应用程序的首选框架,尤其适用于需要处理大量并发连接和高负载的场景。
# 参考文献
计算机底层的秘密:https://book.douban.com/subject/36370606/ (opens new window)