高效的服务器程序

更一般的来说,是对每秒要处理大量请求程序的一种性能讨论。优化性能方法不可能说尽,也要具体问题具体分析,只是总结下常用的几点。这篇博客提出了好多方法,但是感觉分类上有点不妥,大概从三方面来分的一是节约cpu,二是如何使用内存,三是减少磁盘的I/O,虽然分的很清楚,但是很容易造成一点误解,让人感觉内存和cpu的使用没什么关系。这篇文章指出了影响性能的四大“罪魁祸首”,可惜是英文的,读起来似懂非懂,简单总结下能理解得了的方案和方法:

  1. 模型选择上,服务器架构还是选择多路复用的形式,性能好的模型为epoll。在要求高速的情况下选择ET模式(默认为LT模式),但要防止数据的停滞。大框架是很重要的,选错了以后优化肯定会遇到瓶颈。

  2. 减少data copy(四大罪魁祸首第一),这个问题要分几个方面来谈:

  1. 减少Context switch 上下文的切换(四大罪魁祸首第二),发生上下文切换的条件有三个,进程(线程)切换,中断,内核空间和用户空间的切换(这个分操作系统,有的操作系统会发生context switch有的不会,但是如果有系统调用一般会发生用户空间到内核空间的切换,所以也应该尽量减少系统调用的次数)。减少进程(线程)切换要求进程(线程)的数目尽量少(还可以减小线程同步锁的管理)一般和核的数目相差不多就可以,否则没发充分的利用CPU。
  2. 减少Memory Allocation内存申请(管理)(四大罪魁祸首第三)。不应该频繁地去动态申请和释放内存。原因很简单一,避免内存泄露。二,避免碎片过多。三,影响效率。一般来说,都是一次申请一大块内存,然后自己写内存分配算法,可以利用内存池(与线程池思想类似)的方法管理内存。
  3. 减少Lock Contention(四大罪魁祸首第四),操作系统维护锁是要消耗性能的,要尽量减少锁的数目和锁的竞争,细化锁的颗粒度(编码可能复杂,要在两者之间取得平衡)。
  4. 还可以采用负载均衡(平均分配请求到服务器)和集群的方式来提高服务器的性能。

几个涉及到的问题顺便总结下:context switch 、sendfile和splice and tee、writev/readv、cache line

context switch

这个东西和中断类似进程切换时候保存寄存器什么的,当这个进程又获得cpu时间片的时候恢复寄存器,下面是wiki上的引用,很简单明白

A context switch is the computing process of storing and restoring the state (context) of a CPU so that execution can be resumed from the same point at a later time. This enables multiple processes to share a single CPU. The context switch is an essential feature of a multitasking operating system. Context switch: steps In a switch, the state of the first process must be saved somehow, so that, when the scheduler gets back to the execution of the first process, it can restore this state and continue. The state of the process includes all the registers that the process may be using, especially the program counter, plus any other operating system specific data that may be necessary. This data is usually stored in a data structure called a process control block (PCB), or switchframe. In order to switch processes, the PCB for the first process must be created and saved. The PCBs are sometimes stored upon a per-process stack in kernel memory (as opposed to the user-mode call stack), or there may be some specific operating system defined data structure for this information. Since the operating system has effectively suspended the execution of the first process, it can now load the PCB and context of the second process. In doing so, the program counter from the PCB is loaded, and thus execution can continue in the new process. New processes are chosen from a queue or queues. Process and thread priority can influence which process continues execution, with processes of the highest priority checked first for ready threads to execute.

sendfile & splice and tee

sendfile: 通过这个系统调用来实现“零拷贝”,直接从存储器中传送到网卡的缓存中,从而减少系统调用次数。 read(file, tmp_buf, len);
write(socket, tmp_buf, len); 如果这样复制文件,则有: 1、调用read函数,文件数据被copy到内核缓冲区 2、read函数返回,文件数据从内核缓冲区copy到用户缓冲区 3、write函数调用,将文件数据从用户缓冲区copy到内核与socket相关的缓冲区。 4、数据从socket缓冲区copy到相关协议引擎。 数据经由的为:硬盘—>内核buf—>用户buf—>socket相关缓冲区—>协议引擎 而sendfile系统调用则提供了一种减少以上多次copy,提升文件传输性能的方法。Sendfile系统调用是在2.1版本内核时引进的: sendfile(socket, file, len); 1、sendfile系统调用,文件数据被copy至内核缓冲区 2、再从内核缓冲区copy至内核中socket相关的缓冲区(2.4以后的版本,仅仅将记录数据位置和长度相关的数据保存到 socket相关的缓存) 3、最后再socket相关的缓冲区copy到协议引擎 相较传统read/write方式,2.1版本内核引进的sendfile已经减少了内核缓冲区到user缓冲区,再由user缓冲区到socket相关 缓冲区的文件copy,而在内核版本2.4之后,文件描述符结果被改变,sendfile实现了更简单的方式,系统调用方式仍然一样,细节与2.1版本的 不同之处在于,当文件数据被复制到内核缓冲区时,不再将所有数据copy到socket相关的缓冲区,而是仅仅将记录数据位置和长度相关的数据保存到 socket相关的缓存,而实际数据将由DMA模块直接发送到协议引擎,再次减少了一次copy操作。

splice and tee:

splice - splice data to/from a pipe long splice(int fd_in, off_t *off_in, int fd_out,off_t *off_out, size_t len, unsigned int flags); splice() moves data between two file descriptors without copying between kernel address space and user address space. It transfers up to len bytes of data from the file descriptor fd_in to the file descriptor fd_out, where one of the descriptors must refer to a pipe. tee - duplicating pipe content long tee(int fd_in, int fd_out, size_t len, unsigned int flags); tee() duplicates up to len bytes of data from the pipe referred to by the file descriptor fd_in to the pipe referred to by the file descriptor fd_out. It does not consume the data that is duplicated from fd_in; therefore, that data can be copied by a subsequent splice(2).

为什么要两个系统调用组合使用呢?我理解,tee只能从管道描述符到管道描述符的复制数据,如果想看发送的内容则需要splice,而splice可以从管道描述符,到一个普通文件复制或者move数据,所以必须两个系统调用组合的使用。下面是一实现系统命令tee的代码,linux man上面的,可以增加对这两个函数的理解。

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <errno.h>
#include <limits.h>

int
main (int argc, char *argv[])
{
  int fd;
  int len, slen;

  assert (argc == 2);

  fd = open (argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644);
  if (fd == -1)
    {
      perror ("open");
      exit (EXIT_FAILURE);
    }

  do
    {
      /*
       * tee stdin to stdout.
       */
      len = tee (STDIN_FILENO, STDOUT_FILENO, INT_MAX, SPLICE_F_NONBLOCK);

      if (len < 0)
        {
          if (errno == EAGAIN)
            continue;
          perror ("tee");
          exit (EXIT_FAILURE);
        }
      else if (len == 0)
        break;

      /*
       * Consume stdin by splicing it to a file.
       */
      while (len > 0)
        {
          slen = splice (STDIN_FILENO, NULL, fd, NULL, len, SPLICE_F_MOVE);
          if (slen < 0)
            {
              perror ("splice");
              break;
            }
          len -= slen;
        }
    }
  while (1);

  close (fd);
  exit (EXIT_SUCCESS);
}

它可以使得数据可以直接从一个soket到另一个soket,不需要经用户和内核空间的切换。splice背后的真正概念是暴露给用户空间的“随机内核缓冲区”的概念。“也就是说,splice和tee运行在用户控制的内核缓冲区上,在这个缓冲区中,splice将来自任意文件描述符的数据传送到缓冲区中(或从缓冲区传送到文件描述符),而tee将一个缓冲区中的数据复制到另一个缓冲区中。因此,从一个很真实(而抽象)的意义上讲,splice相当于内核缓冲区的read/write,而tee相当于从内核缓冲区到另一个内核缓冲区的memcpy”。数据可以直接从一个soket到另一个soket,不需要经用户和内核空间的切换。这是sendfile不支持的。

当两个socket之间需要互相交换(relay)数据时,不再把数据拷贝到一个临时的缓冲区(大多数情况下是用户空间缓冲区),而是修改tcp数据包的一些参数,如源、目的ip地址和端口号等,然后将修改后的数据包直接通过ip层网络接口发送出去。可见,与基于recv/send机制的数据交换相比,这个过程根本就不需要内核空间到用户空间的转换,也至少避免了两次数据拷贝:从接收缓冲区到用户缓冲区和从用户缓冲区到发送缓冲区,效率也不可同日而语[1] [2]。更加详细的内容请参看TCP粘合技术原理,实际的TCP splicing技术实现还要考虑更多的细节。

writev和readv

这两个函数用来在一次函数调用中读、写多个非连续的缓冲区,有时也将这两个函数称为散布读(scatter read,一个文件读到多个缓冲区)和聚集写(gather write,多个缓冲区写到一个文件)。

ssize_t readv(int fileds, const struct iovec *iov, int iovcnt);
ssize_t writev(int fileds, const struct iovec *iov, int iovcnt);
struct iovec{
  void *iov_base;
  size_t iov_len;
}

其中iovec为一个数组结构的一个元素,*iov_base为缓冲区地址,iov_len为该缓冲区的长度(正则表达式的返回结构也是这样类似)。iovcnt表示数组大小。变通的解决方案有(假设为两个缓冲区写)1)调用write两次,一次一个缓冲区,这样系统调用就要两次,要content swich和cpu。2)分配一个足以容下两个缓冲区的大缓冲区,可以把这两个缓冲区复制到里面然后调用一次write操作。如果少量数据,那么这样的方法可能比writev好些,因为writev有固定的开销,但是这样的话会增加程序的复杂度并不值得。如果有大量的分散的数据,那么用writev这种方法会很好的提升性能。

cache line

在高性能服务器的代码中经常会看到类似这样的代码:

typedef union
{
  erts_smp_rwmtx_t rwmtx;
  byte cache_line_align_[ERTS_ALC_CACHE_LINE_ALIGN_SIZE(sizeof(erts_smp_rwmtx_t))];
}erts_meta_main_tab_lock_t;
erts_meta_main_tab_lock_t main_tab_lock[16];

其中用来填充的cache_line_align的作用是?

在做多线程程序的时候,为了避免使用锁,我们通常会采用这样的数据结构:根据线程的数目,安排一个数组, 每个线程一个项,互相不冲突. 从逻辑上看这样的设计无懈可击,但是实践的过程我们会发现这样并没有提高速度. 问题在于cpu的cache line. 我们在读主存的时候,数据同时被读到L1,L2中去,而且在L1中是以cache line(通常64)字节为单位的. 每个Core都有自己的L1,L2,所以每个线程在读取自己的项的时候, 也把别人的项读进去, 所以在更新的时候,为了保持数据的一致性, core之间cache要进行同步, 这个会导致严重的性能问题. 这就是所谓的False sharing问题,这就是为什么在高性能服务器中到处看到cache_line_align, 号称是避免cache的trash.

In symmetric multiprocessor (SMP) systems, each processor has a local cache. The memory system must guarantee cache coherence. False sharing occurs when threads on different processors modify variables that reside on the same cache line. This invalidates the cache line and forces an update, which hurts performance. This article covers methods to detect and correct false sharing. False sharing is a well-known performance issue on SMP systems, where each processor has a local cache. It occurs when threads on different processors modify variables that reside on the same cache line, as illustrated in Figure 1. This circumstance is called false sharing because each thread is not actually sharing access to the same variable. Access to the same variable, or true sharing, would require programmatic synchronization constructs to ensure ordered data access.

图中可以看到thread 0和thread 1使用一个数组中不同的项,但可能运行thread 0的现成的核读入整个数组,把thread 1所用的项也读入核的cache中,这样两个核之间就需要同步了,就会大大降低性能。而这个时候,用一些trash把数组中的每一项都填充到cache line大小,这个时候就会只读入自己需要的项就可以了。

2012/10/4补充又看到论坛上对此问题的讨论,补充点,建模分层的框架

分层,分很多层, 1.代理层 2.负载均衡1 3.负载均衡2 4.前端JSP,PHP等 5.后端CGI(C,C++简单封装,实际功能在6实现) 6.后端接口实现(针对读写操作在不同机器上实现) 7.数据库缓存(memcache,这里又分为读缓存,和写缓存) 8.实际数据库(读和写分开,防止锁,有一定的同步算法) 9.在核心的步骤上要做到双机热备。

参考:

http://en.wikipedia.org/wiki/Context_switch
http://www.cnblogs.com/ssliao/archive/2010/09/06/1819408.html
http://blog.chinaunix.net/space.php?uid=317451&do=blog&id=92492
http://pl.atyp.us/content/tech/servers.html
http://amyz.itpub.net/post/34151/419608
http://blog.sina.com.cn/s/blog_466c66400100bi2n.html~type=v5_one&label=rela_prevarticle
http://blog.csdn.net/lovekatherine/article/details/1540291
http://machael.blog.51cto.com/829462/479931
http://blog.yufeng.info/archives/783
http://software.intel.com/en-us/articles/avoiding-and-identifying-false-sharing-among-threads/
http://www.cnblogs.com/fll/archive/2008/05/17/1201540.html

Table of Contents