第八章 高性能服务器程序框架(5) - Huoke/Linux-net-Programma GitHub Wiki

8.7 提高服务器性能的其他建议

性能对服务器来说是至关重要的,毕竟每个客户都期望其请求能很快地得到响应。响应服务器性能的首要因素就是系统的硬件资源,比如CPU的个数、速度、内存的大小等。不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。因此,我们需要考虑的主要问题是如何从"软环境"来提升服务器的性能。

服务器的 "软环境":

  • 一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量。
  • 另一方面是指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能,这是本节要讨论的问题。

前面我们介绍了几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式————有现状态机,它们都有助于提高服务器的整体性能。下面我们进一步分析高性能服务器需要注意的其他几个方面:池、数据复制、上下文切换和锁。

8.7.1 池

既然服务器的硬件资源 “充裕” ,那么提高服务器性能的一个很直接的方法就是以空间换时间,即 “浪费” 服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。很明显,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗费时间的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。

不过,既然池中的资源时预先静态分配的,我们就无法预期应该分配多少资源。这个问题又该如何解决呢?最简单的解决方案就是分配 “足够多”得资源,即针对每个可能的客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户量都可能远远没有达到服务器能支持的最大客户数量。好在这种资源浪费对服务器来说一般不会构成问题。还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。

根据不同的资源类型,池可分为多种,常见的由内存池、进程池、线程池、连接池。它们的含义都很明确。

  1. 内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如 5000 字节)的接收缓冲区是很合理的。当客户请求的长度超过接收缓存区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区。
  2. 进程池和线程池都是并发编程常用的 “伎俩”。当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态地调用fork 或 pthread_create 等函数来创建进程和线程。
  3. 连接池通常用于服务器或服务器集群的内部永久连接。 图8-4中,每个逻辑单元可能都需要频繁地访问本地的某个数据库。简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。很显然,这种做法的效率太低。一种解决方案是使用连接池。连接池是服务器预先和数据库建立的一组连接的集合。当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。待完成数据库的访问之后,逻辑单元再将该连接返回给连接池。

8.7.2 数据复制

高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从 socket 或者文件描述符读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。这里说的 “直接处理” 指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。比如 ftp服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读其它的权限,而绝对不会关心文件的具体内容。这样的话, ftp服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用send函数来发送,而是可以使用 “零拷贝” 函数 sendfile 来直接将其发送给客户端。

此外,用户代码内部(不访问内核)的数据复制也是应该避免的。举例来说, 当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间共享这些数据,而不是使用管道或者消息队列来传递。又比如代码清单8-3所示的解析HTTP请求的实例中,我们用指针(start_line)来指出每个行在 buffer 中的起始位置,以便随后对行内容进行访问,而不是把行的内容复制到另外一个缓冲区来使用,因为这样既浪费空间,又效率低下。

8.7.3 上下文切换和锁

并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的系统开销。即使是I/O密集型的服务器,也不应该使用过多的工作线程(或工作进程,下同),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。

因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的,图8-11所描述的半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。

并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。显然,图8-11所描述的半同步/半异步模式就比8-10所描述的半同步/半反应堆模式的效率高。如果服务器必须使用 “锁”, 则可以考虑减少锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要些这块内存时,系统才必须去锁住这块区域。