`

一个比nginx速度更快的HTTP服务器de设计与实现

 
阅读更多
在上次的FreeBSD和linux的nginx静态文件性能对比测试 后,我萌发了自己动手做一个简单的Web Server来搞清楚nginx高性能背后的原理的想法。最后成功实现了一个基于epoll的简单的HTTP服务器,实现了200,404,400,304响应,并且性能比nginx高了一点点。本文主要介绍这个HTTP服务器的原理和设计过程。

阅读了一些文章(见最后的参考阅读)后,我整理出了以下要点:

实现多并发的socket服务器有这样几个方法:

1. 多进程共享一个监听端口

bind之后使用fork()创建一份当前进程的拷贝,并启动子进程。子进程采用阻塞式accept、read、write,即这些操作会阻塞线程,直到操作完成才继续执行。缺点是进程之间通信速度慢,每个进程占用很多内存,所以并发数一般受限于进程数。

2. 多线程

类似多进程,只不过用线程代替了进程。主线程负责accept,为每个请求建立一个线程(或者使用线程池复用线程)。比多进程速度快,占用更少的内存,稳定性不及多进程。因为每个线程都有自己的堆栈空间,其占用的内存还是无法免除的,所以并发数一般受限于线程数。

一个阻塞式IO程序的流程示例图:

QQ截图20110923131031

3. 事件驱动的非阻塞IO(nonblocking I/O)

单线程,将socket设置为非阻塞模式(accept、read、write会立即返回。如果已经accept完了所有的连接,或读光了缓冲区的数据,或者写满了缓冲区,会返回-1,而不是进入阻塞状态)。使用select或epoll等机制,同时监听多个IO操作有无事件发生。当其中的一个或多个处于Ready状态(即:监听的socket可以accept,tcp连接可以read等)后,立即处理相应的事件,处理完后立即回到监听状态(注意这里的监听是监听IO事件,不是监听端口)。相当于阻塞式IO编程中任意一处都可能回到主循环中继续等待,并能从等待中直接回到原处继续执行;而accept、读、写都不再阻塞,阻塞全部移动到了一个多事件监听操作中。

一个非阻塞式IO程序的流程示例图:

QQ截图20110923131039

举例来说,如果在A连接的Read request的过程中,缓冲区数据读完了,而请求还没有结束,直接返回到主循环中监听其它事件。而这时如果发现另一个Send了一半的Response连接B变为了可写状态,则直接处理B连接Send Response事件,从上次B连接写了一半的地方开始,继续写入数据。这样一来,虽然是单线程,但A和B同时进行,互不干扰。

因为流程更加复杂,无法依靠线程的堆栈保存每个连接处理过程中的各种状态信息,我们需要自己维护它们,这种编程方式需要更高的技巧。比方说,原先我们可以在send_response函数中用局部变量保存发送数据的进度,而现在我们只能找一块其它的地方,为每一个连接单独保存这个值了。

nginx即使用事件驱动的非阻塞IO模式工作。

nginx支持多种事件机制:跨平台的select,Linux的poll和epoll,FreeBSD的kqueue,Solaris的/dev/poll等。在高并发的情况下,在Linux上使用epoll性能最好,或者说select的性能太差了。

事件机制分为水平触发,或译状态触发(level-triggered)和边缘触发(edge-triggered)。前者是用通过状态表示有事件发生,后者通过状态变化表示事件发生。打个比方来说,使用状态触发的时候,只要缓冲区有数据,你就能检测到事件的存在。而使用边缘触发,你必须把缓冲区的数据全部读完之后,才能进行下一次事件的检测,否则,因为状态一直处于可读状态,没有发生变化,你将永远收不到这个事件。显然,后者对编写程序的严谨性要求更高。

select和poll属于前者,epoll同时支持这两种模式。值得一提的是,我自己测试了一下,发现即使在20000并发的情况下,epoll使用这两种模式之前性能差异仍可以忽略不计。

另外需要注意的是,对于常规文件设置非阻塞是不起作用的

4. 此外还有异步IO,一般在Windows上使用,这里就不谈了。

另外nginx使用了Linux的sendfile函数。和传统的用户程序自己read和write不同,sendfile接收两个文件描述符,直接在内核中实现复制操作,相比read和write,可以减少内核态和用户态的切换次数,以及数据拷贝的次数。

接下来正式开始设计。我选择了非阻塞IO,epoll的边缘触发模式。先找了个比较完整的使用epoll的一个socket server例子作为参考,然后在它的基础上边修改边做实验:

https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/

这个例子比较简单,而且也没有体现出非阻塞IO编程。不过通过它我了解到了epoll的基本使用方法。为了实现并发通信,我们需要把程序“摊平”。

首先,分析我们的HTTP服务器通信过程用到的变量:

状态

Wait for reading

Wait for writing

次数

变量类型

非本地变量

备注

Accept

Y

N

n

local

Read request

Y

N

n

nonlocal

Read buf

Open file

N

N

n

nonlocal

文件名

Send response header

N

Y

n

nonlocal

Response header buf

Read file -> Send response content

N

Y

n*n

nonlocal

Read&write buf

Write pos

fd

Sock

读满read buf或读到EOF,再发

发送时将read buf

Close file

N

N

n

fd

Close socket

N

N

n

sock

然后,定义一个结构用于保存这些变量:

struct process {
    int sock;
    int status;
    int response_code;
    int fd;
    int read_pos;
    int write_pos;
    int total_length;
    char buf[BUF_SIZE];
};

为了简便,我直接用一个全局数组装所有的process:

static struct process processes[MAX_PORCESS];

另外定义每个连接通信过程中的三个状态:

#define STATUS_READ_REQUEST_HEADER    0
#define STATUS_SEND_RESPONSE_HEADER    1
#define STATUS_SEND_RESPONSE        2

之后,就是按部就班地实现主循环、读取request,解析header,判断文件是否存在、检查文件修改时间,发送相应的header和content了。

下面只把程序中跟epoll有关的关键部分贴出来:

main()函数:

使用epoll_create()创建一个epoll fd,注意,这里的listen_sock已经设置为nonblocking(我使用了这篇文章中的setNonblocking函数)了:

    efd = epoll_create1 ( 0 );
    if ( efd == -1 )
    {
        ...
    }

    event.data.fd = listen_sock;
    event.events = EPOLLIN | EPOLLET;
    s = epoll_ctl ( efd, EPOLL_CTL_ADD, listen_sock, &event );
    if ( s == -1 )
    {
        ...
    }

    /* Buffer where events are returned */
    events = calloc ( MAXEVENTS, sizeof event );

这里的EPOLLIN表示监听“可读”事件。

在主循环中epoll_wait():

    while ( 1 )
    {
        int n, i;

        n = epoll_wait ( efd, events, MAXEVENTS, -1 );
        if ( n == -1 )
        {
            perror ( "epoll_wait" );
        }
        for ( i = 0; i < n; i++ )
        {
            if ( ( events[i].events & EPOLLERR ) ||
                    ( events[i].events & EPOLLHUP ) )
            {
                fprintf ( stderr, "epoll error\n" );
                close ( events[i].data.fd );
                continue;
            }

            handle_request ( events[i].data.fd );

        }
    }

epoll_wait()会在发生事件后停止阻塞,继续执行,并把发生了事件的event的file descriptor放入events中,返回数组大小。注意的是,这里要循环处理所有的fd。


接下来是关键部分:

void handle_request ( int sock )
{
    if ( sock == listen_sock )
    {
        accept_sock ( sock );
    }
    else
    {
        struct process* process = find_process_by_sock ( sock );
        if ( process != 0 )
        {
            switch ( process->status )
            {
            case STATUS_READ_REQUEST_HEADER:
                read_request ( process );
                break;
            case STATUS_SEND_RESPONSE_HEADER:
                send_response_header ( process );
                break;
            case STATUS_SEND_RESPONSE:
                send_response ( process );
                break;
            default:
                break;
            }
        }
    }
}

根据epoll返回的fd,做不同处理:如果是监听的socket,则accept();否则,根据sock的fd查找相应的process结构体,从中取回状态信息,返回到之前的处理状态中。这样就能实现信春哥,死后原地复活的状态恢复机制了。

在accept中,将accept出来的连接也设置为非阻塞,然后在process数组中找一个还没使用的空位,初始化,然后把这个socket存到process结构体中:

struct process* accept_sock ( int listen_sock )
{
    int s;
    // 在ET模式下必须循环accept到返回-1为止
    while ( 1 )
    {
        struct sockaddr in_addr;
        socklen_t in_len;
        int infd;
        char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
        if ( current_total_processes >= MAX_PORCESS )
        {
            // 请求已满,accept之后直接挂断
            infd = accept ( listen_sock, &in_addr, &in_len );
            if ( infd == -1 )
            {
                if ( ( errno == EAGAIN ) ||
                        ( errno == EWOULDBLOCK ) )
                {
                    break;
                }
                else
                {
                    perror ( "accept" );
                    break;
                }
            }
            close ( infd );

            return;
        }

        in_len = sizeof in_addr;
        infd = accept ( listen_sock, &in_addr, &in_len );
        if ( infd == -1 )
        {
            if ( ( errno == EAGAIN ) ||
                    ( errno == EWOULDBLOCK ) )
            {
                break;
            }
            else
            {
                perror ( "accept" );
                break;
            }
        }

        getnameinfo ( &in_addr, in_len,
                      hbuf, sizeof hbuf,
                      sbuf, sizeof sbuf,
                      NI_NUMERICHOST | NI_NUMERICSERV );

        //设置为非阻塞
        s = setNonblocking ( infd );
        if ( s == -1 )
            abort ();
        int on = 1;
        setsockopt ( infd, SOL_TCP, TCP_CORK, &on, sizeof ( on ) );
        //添加监视sock的读取状态
        event.data.fd = infd;
        event.events = EPOLLIN | EPOLLET;
        s = epoll_ctl ( efd, EPOLL_CTL_ADD, infd, &event );
        if ( s == -1 )
        {
            perror ( "epoll_ctl" );
            abort ();
        }
        struct process* process = find_empty_process_for_sock ( infd );
        current_total_processes++;
        reset_process ( process );
        process->sock = infd;
        process->fd = NO_FILE;
        process->status = STATUS_READ_REQUEST_HEADER;
    }
}

三个不同状态对应三个不同函数进行处理,我就不全贴了,以read_request为例:

void read_request ( struct process* process )
{
    int sock = process->sock, s;
    char* buf=process->buf;
    char read_complete = 0;

    ssize_t count;

    while ( 1 )
    {
        count = read ( sock, buf + process->read_pos, BUF_SIZE - process->read_pos );
        if ( count == -1 )
        {
            if ( errno != EAGAIN )
            {
                handle_error ( process, "read request" );
                return;
            }
            else
            {
                //errno == EAGAIN表示读取完毕
                break;
            }
        }
        else if ( count == 0 )
        {
            // 被客户端关闭连接
            cleanup ( process );
            return;
        }
        else if ( count > 0 )
        {
            process->read_pos += count;
        }
    }

    int header_length = process->read_pos;
    // determine whether the request is complete
    if ( header_length > BUF_SIZE - 1 )
    {
    process->response_code = 400;
    process->status = STATUS_SEND_RESPONSE_HEADER;
    strcpy ( process->buf, header_400 );
    send_response_header ( process );
    handle_error ( processes, "bad request" );
    return;
    }
    buf[header_length]=0;
    read_complete = ( strstr ( buf, "\n\n" ) != 0 ) || ( strstr ( buf, "\r\n\r\n" ) != 0 );

    if ( read_complete )
    {
        // ...

        //解析之后,打开文件,把文件描述符存入process,然后进入发送header状态
        process->status = STATUS_SEND_RESPONSE_HEADER;
        //修改此sock的监听状态,改为监视写状态
        event.data.fd = process->sock;
        event.events = EPOLLOUT | EPOLLET;
        s = epoll_ctl ( efd, EPOLL_CTL_MOD, process->sock, &event );
        if ( s == -1 )
        {
            perror ( "epoll_ctl" );
            abort ();
        }
        //发送header
        send_response_header ( process );
    }
}

这里的注意点如下:

1. 读取的时候要一直循环读取到返回-1为止,然后检查errno,如果errno为EAGAIN,表示缓冲区已经空了,这个socket变为了“不可读”。如果不读完,边缘触发模式的epoll_wait将永远不会再触发这个socket的“可读”事件。

2. 使用epoll_ctl ( efd, EPOLL_CTL_MOD, process->sock, &event )修改epoll的状态,这里在读完后,我们要继续监听“可写”事件,因此要把epoll监听的事件改为EPOLLOUT。

接下来不断完善这个程序并进行优化,并实现了304 not modified功能之后,用ab测试性能,并和nginx对比:

Server Software:        clowwindyserver/1.0
Server Hostname:        localhost
Server Port:            8082

Document Path:          /jquery.js
Document Length:        57244 bytes

Concurrency Level:      100
Time taken for tests:   2.241 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      574420000 bytes
HTML transferred:       572440000 bytes
Requests per second:    4462.88 [#/sec] (mean)
Time per request:       22.407 [ms] (mean)
Time per request:       0.224 [ms] (mean, across all concurrent requests)
Transfer rate:          250348.23 [Kbytes/sec] received

Server Software:        nginx/0.7.67
Server Hostname:        localhost
Server Port:            80

Document Path:          /jquery.js
Document Length:        57244 bytes

Concurrency Level:      100
Time taken for tests:   2.490 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      574720000 bytes
HTML transferred:       572440000 bytes
Requests per second:    4016.54 [#/sec] (mean)
Time per request:       24.897 [ms] (mean)
Time per request:       0.249 [ms] (mean, across all concurrent requests)
Transfer rate:          225428.04 [Kbytes/sec] received

结果很令人欣慰的比nginx快了一点点,并且只用了700K内存。不过作为一个功能比nginx少了很多的程序来说这一结果是意料之中的。
然后试图测试上万并发的情况,结果too many open files了。于是修改fd数限制:

# echo 32768 > /proc/sys/fs/file-max
# ulimit -n 32768

再次测试:

Server Software:        clowwindyserver/1.0
Server Hostname:        localhost
Server Port:            8082

Document Path:          /jquery.js
Document Length:        57244 bytes

Concurrency Level:      10000
Time taken for tests:   2.249 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      574420000 bytes
HTML transferred:       572440000 bytes
Requests per second:    4445.59 [#/sec] (mean)
Time per request:       2249.420 [ms] (mean)
Time per request:       0.225 [ms] (mean, across all concurrent requests)
Transfer rate:          249378.52 [Kbytes/sec] received

nginx设置worker_connections 20480以后:

Server Software:        nginx/0.7.67
Server Hostname:        localhost
Server Port:            80

Document Path:          /jquery.js
Document Length:        57244 bytes

Concurrency Level:      10000
Time taken for tests:   2.715 seconds
Complete requests:      10000
Failed requests:        0
Write errors:           0
Total transferred:      574720000 bytes
HTML transferred:       572440000 bytes
Requests per second:    3683.83 [#/sec] (mean)
Time per request:       2714.569 [ms] (mean)
Time per request:       0.271 [ms] (mean, across all concurrent requests)
Transfer rate:          206754.74 [Kbytes/sec] received


结果性能只下降了一点点,并且依然领先nginx。不过,为了承受更多的连接调大process数组以后,进程一共吃了40M内存。而nginx仅用了5M内存。因为我们使用了极其浪费内存的用数组装连接状态和缓存的方法,所以会吃掉缓存大小(process.buf的大小)乘以process数组的大小的内存。调小缓存以后内存占用降到6M,不过这并不是根本解决之道,还是存在很大的浪费。如果改为动态内存管理,应该就会小于nginx了。

这说明事件驱动的非阻塞IO可以顶得住上万并发,并需要远小于阻塞式编程的服务器程序的内存,速度也更快。

最后把源码丢到github了,想看完整源码的同学请移步:

https://github.com/clowwindy/clowwindy_server

参考阅读:

The C10K problem (强烈推荐)

Introduction to non-blocking I/O

Non-blocking I/O with regular files

Linux Files and the Event Poll Interface

分享到:
评论

相关推荐

    一步步安装nginx搭建流媒体服务器

    一步步安装nginx搭建流媒体服务器的所有软件打包; nginx-1.8.0.tar.gz :应用服务器主程序 nginx_mod_h264_streaming-2.2.7.tar.gz :MP4流媒体支持模块。 openssl-1.0.1c.tar.gz :openssl库 pcre-7.9.tar.gz :...

    nginx-1.0.4 服务器软件下载

     作为邮件代理服务器:Nginx 同时也是一个非常优秀的邮件代理服务器(最早开发这个产品的目的之一也是作为邮件代理服务器),Last. fm 描述了成功并且美妙的使用经验。  Nginx 是一个安装非常的简单,配置文件非常...

    Nginx服务器的安装与配置.pdf

    第2章 Nginx服务器的安装与配置.pdf 第3章 Nginx的基本配置与优化.pdf 第4章 Nginx与PHP(FastCGI)的安装、配置与优化.pdf 第5章 Nginx与JSP、ASP.NET、Perl的安装与配置.pdf 第6章 Nginx HTTP负载均衡和反向代理的...

    nginx高性能web服务器详解

    《Nginx高性能Web服务器详解》全面介绍了当前Internet上流行的一款开放源代码的Web服务器——Nginx。全书一共分为四大部分,分别从入门、功能、实现和应用等四个方面对Nginx服务器的知识进行完整阐述,从而满足广大...

    nginx服务器

    Nginx是一个轻量级高性能的web服务器,它是为快速响应大量静态文件请求和高效利用系统资源而设计的。与apache使用面向进程或线程的方式处理请求不同,nginx使用异步事件驱动模型在负载下性能更突出。 虽然nginx能...

    搭建nginx点播服务器

    Nginx是一款高性能的开源Web服务器,同时也可以用作点播(On-Demand)媒体服务器。点播服务器通常用于提供音频和视频文件的分发,以支持用户随时随地访问这些媒体内容。以下是Nginx作为点播服务器的一些特点和功能:...

    Nginx服务器的安装与配置

    Nginx服务器的安装与配置Nginx服务器的安装与配置

    Nginx服务器作反向代理实现内部局域网的url转发配置

    然后k兄就提议可以在内网搭建个nginx反向代理服务器,将nginx反向代理服务器的80映射到外网IP的80,这样指向到公司外网IP的域名的HTTP请求就会发送到nginx反向代理服务器,利用nginx反向代理将不同域名的请求转发给...

    实战Nginx高性能Web服务器

    1、高性能Web服务器Nginx的配置与部署研究(1)Nginx简介及入门示例 内容:概述Nginx的背景知识和简单的入门实例。 2、高性能Web服务器Nginx的配置与部署研究(2)Nginx入门级配置与部署及“Hello World” 内容:...

    反向代理服务器 Nginx

    Nginx (engine x) 是一个轻量级的、高性能的、基于 Http 的、反向代理服务器,静态 web 服务器。 Nginx 最初是由俄罗斯人 Igor Sysoev(伊戈尔·赛索耶夫)使用 C 语言为俄罗斯访问量第 二的 Rambler.ru 站点开发的...

    决战Nginx系统卷——高性能Web服务器详解与运维

    本书第一部分首先讲述了Nginx服务器的功能、模块管理和进程管理,然后讲述Nginx如何处理请求,在这个基础之上再认识Nginx提供的服务器的名字,Nginx服务器最大的焦点在于高并发和反向代理,在不多却足够使用的模块下...

    Nginx高性能Web服务器详解

    Nginx是一个高性能的HTTP和反向代理服务器,也是一个IMAP/POP3/SMTP代理服务器。 Nginx是一款轻量级的Web服务器/反向代理服务器以及电子邮件代理服务器,并在一个BSD-like协议下发行。由俄罗斯的程序设计师lgor ...

    nginx-0.8.33.zip一个高性能的 HTTP 和 反向代理 服务器,也是一个 IMAP/POP3/SMTP 代理服务器

    1. Nginx ("engine x") 是一个高性能的 HTTP 和 反向代理 服务器,也是一个 IMAP/POP3/SMTP 代理服务器 。 Nginx 是由 Igor Sysoev 为俄罗斯访问量第二的Rambler.ru 站点开发的,它已经在该站点运行超过四年多了。...

    nginx搭建文件服务器上传文件获取文件

    基于openresty+nginx+lua实现文件服务器(包括获取文件及上传文件)

    windows服务器部署 nginx+tomcat+mysql服务器端部署 阿里云服务器部署及配置

    详细说明了windows服务器nginx+tomcat+mysql部署及配置(配置阿里云后台安全组,配置域名)很适合新手学习 附件中包含: 1.操作说明文档 2.操作录屏 3.安装所用到的软件安装包 1)Windows Server 2019 数据中心版 ...

    NGINX高性能WEB服务器详解(PDF)(2/2)

    《Nginx高性能Web服务器详解》全面介绍了当前Internet上流行的一款开放源代码的Web服务器——Nginx。全书一共分为四大部分,分别从入门、功能、实现和应用等四个方面对Nginx服务器的知识进行完整阐述,从而满足广大...

    NGINX高性能WEB服务器详解(PDF)(1/2)

    《Nginx高性能Web服务器详解》全面介绍了当前Internet上流行的一款开放源代码的Web服务器——Nginx。全书一共分为四大部分,分别从入门、功能、实现和应用等四个方面对Nginx服务器的知识进行完整阐述,从而满足广大...

    Nginx高性能Web服务器详解.pdf

    《Nginx高性能Web服务器详解》全面介绍了当前Internet上流行的一款开放源代码的Web服务器——Nginx。全书一共分为四大部分,分别从入门、功能、实现和应用等四个方面对Nginx服务器的知识进行完整阐述,从而满足广大...

    Nginx API文档 Nginx是一个高性能的HTTP和反向代理web服务器

    Nginx (engine x) 是一个高性能的HTTP和反向代理web服务器,同时也提供了IMAP/POP3/SMTP服务。Nginx是由伊戈尔·赛索耶夫为俄罗斯访问量第二的Rambler.ru站点(俄文:Рамблер)开发的,第一个公开版本0.1.0...

Global site tag (gtag.js) - Google Analytics