首页 > 代码库 > 一个简易的web服务器:Tinywebserver

一个简易的web服务器:Tinywebserver

这是学习网络编程后写的一个练手的小程序,可以帮助复习I/O模型,epoll使用,线程池,HTTP协议等内容。

程序代码是基于《Linux高性能服务器编程》一书编写的。

首先回顾程序中的核心内容和主要问题,最后给出相关代码。

0. 功能和I/O模型

实现简易的HTTP服务端,现仅支持GET方法,通过浏览器访问可以返回相应内容。

I/O模型采用Reactor(I/O复用 + 非阻塞I/O) + 线程池。 使用epoll事件循环用作事件通知,如果listenfd上可读,则调用accept,把新建的fd加入epoll中;

是已连接sockfd,将其加入到线程池中由工作线程竞争执行任务。

 

1. 线程池怎么实现?

程序采用c++编写,要自己封装一个简易的线程池类。大致思路是创建固定数目的线程(如跟核数相同),然后类内部维护一个生产者—消费者队列。

提供相应的添加任务(生产者)和执行任务接口(消费者)。按照操作系统书中典型的生产者—消费者模型维护增减队列任务(使用mutex和semaphore)。

mutex用于互斥,保证任意时刻只有一个线程读写队列,semaphore用于同步,保证执行顺序(队列为空时不要读,队列满了不要写)。

 

2. epoll用条件触发(LT)还是边缘触发(ET)?

考虑这样的情况,一个工作线程在读一个fd,但没有读完。如果采用LT,则下一次事件循环到来的时候,又会触发该fd可读,此时线程池很有可能将该fd分配给其他的线程处理数据。

这显然不是我们想要看到的,而ET则不会在下一次epoll_wait的时候返回,除非读完以后又有新数据才返回。所以这里应该使用ET。

当然ET用法在《Tinychatserver: 一个简易的命令行群聊程序》也有总结过。用法的模式是固定的,把fd设为nonblocking,如果返回某fd可读,循环read直到EAGAIN。

 

3. 继续上面的问题,如果某个线程在处理fd的同时,又有新的一批数据发来(不是老数据没读完,是来新数据了),即使使用了ET模式,因为新数据的到来,仍然会触发该fd可读,所以仍然存在将该fd分给其他线程处理的情况。

这里就用到了EPOLLONESHOT事件。对于注册了EPOLLONESHOT事件的文件描述符,操作系统最大触发其上注册的一个可读、可写或者异常事件,且只触发一次。

除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程处理某个socket时,其他线程是不可能有机会操作该socket的,

即可解决该问题。但同时也要注意,如果注册了EPOLLONESHOT的socket一旦被某个线程处理完毕,则应该立即重置这个socket上的EPOLLONESHOT事件,

以确保下一次可读时,其EPOLLIN事件能够触发。

 

4. HTTP协议解析怎么做?数据读到一半怎么办?

首先理解这个问题。HTTP协议并未提供头部字段的长度,判断头部结束依据是遇到一个空行,该空行只包含一对回车换行符(<CR><LF>)。同时,如果一次读操作没有读入整个HTTP请求

的头部,我们必须等待用户继续写数据再次读入(比如读到 GET /index.html HTT就结束了,必须维护这个状态,下一次必须继续读‘P’)。

即我们需要判定当前解析的这一行是什么(请求行?请求头?消息体?),还需要判断解析一行是否结束?

解决上述问题,可以采取有限状态机。

参考【1】中设计方式,设计主从两个状态机(主状态机解决前半部分问题,从状态机解决后半部分问题)。

先分析从状态机,从状态机用于处理一行信息(即parse_line函数)。其中包括三个状态:LINE_OPEN, LINE_OK,LINE_BAD,转移过程如下所示:

技术分享

当从状态机parse_line读到完整的一行,就可以将改行内容递交给process_read函数中的主状态机处理。

 

主状态机也有三种状态表示正在分析请求行(CHECK_STATE_REQUESTINE),正在分析头部字段(CHECK_STATE_HEADER),和正在分析内容(CHECK_CONTENT)。

主状态机使用checkstate变量来记录当前的状态。

如果当前的状态是CHECK_STATE_REQUESTLINE,则表示parse_line函数解析出的行是请求行,于是主状态机调用parse_requestline来分析请求行;

如果当前的状态是CHECK_STATE_HEADER,则表示parse_line函数解析出来的是头部字段,于是主状态机调用parse_header来分析头部字段。

如果当前状态是CHECK_CONTENT,则表示parse_line函数解析出来的是消息体,我们调用parse_content来分析消息体(实际上实现时候并没有分析,只是判断是否完整读入)

checkstate变量的初始值是CHECK_STATE_REQUESTLINE,调用相应的函数(parse_requestline,parse_header)后更新checkstate实现状态转移。

与主状态机有关的核心函数如下:

http_conn::HTTP_CODE http_conn::process_read()//完整的HTTP解析{
    LINE_STATUS line_status = LINE_OK;
    HTTP_CODE ret = NO_REQUEST;    char* text = 0;    while ( ( ( m_check_state == CHECK_STATE_CONTENT ) && ( line_status == LINE_OK  ) )                || ( ( line_status = parse_line() ) == LINE_OK ) ){//满足条件:正在进行HTTP解析、读取一个完整行
        text = get_line();//从读缓冲区(HTTP请求数据)获取一行数据
        m_start_line = m_checked_idx;//行的起始位置等于正在每行解析的第一个字节
        printf( "got 1 http line: %s", text );

        switch ( m_check_state )//HTTP解析状态跳转        {            case CHECK_STATE_REQUESTLINE://正在分析请求行            {
                ret = parse_request_line( text );//分析请求行
                if ( ret == BAD_REQUEST )
                {                    return BAD_REQUEST;
                }                break;
            }            case CHECK_STATE_HEADER://正在分析请求头部            {
                ret = parse_headers( text );//分析头部
                if ( ret == BAD_REQUEST )
                {                    return BAD_REQUEST;
                }                else if ( ret == GET_REQUEST )
                {                    return do_request();//当获得一个完整的连接请求则调用do_request分析处理资源页文件                }                break;
            }            case CHECK_STATE_CONTENT:// 解析消息体            {
                ret = parse_content( text );                if ( ret == GET_REQUEST )
                {                    return do_request();
                }
                line_status = LINE_OPEN;                break;
            }            default:
            {                return INTERNAL_ERROR;//内部错误            }
        }
    }    return NO_REQUEST;
}

 

5. HTTP响应怎么做?怎么发送效率高一些?

首先介绍readv和writev函数。其功能可以简单概括为对数据进行整合传输及发送,即所谓分散读,集中写

也就是说,writev函数可以把分散保存在多个缓冲中的数据一并发送,通过readv函数可以由多个缓冲分别接收。因此适当采用这两个函数可以减少I/O次数。

例如这里要做的HTTP响应。其包含一个状态行,多个头部字段,一个空行和文档的内容。前三者可能被web服务器放置在一块内存中,

而文档的内容则通常被读入到另外一块单独的内存中(通过read函数或mmap函数)。这里可以采用writev函数将他们一并发出。

相关接口如下:

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

其中第二个参数为如下结构体的数组struct iovec {    void  *iov_base;    /* Starting address */
    size_t iov_len;     /* Number of bytes to transfer */};
第三个参数为第二个参数的传递的数组的长度。

这里还可以再学习一下mmap与munmap函数。但是这里关于mmap与read效率的比较,应该没有那么简单的答案。mmap可以减少系统调用和内存拷贝,但是其引发的pagefault也是开销。效率的比较取决于不同系统对于这两个效率实现的不同,所以这里就简单谈一谈用法。

#include <sys/mman.h>/**addr参数允许用户使用某个特定的地址作为这段内存的起始地址,设置为NULL则自动分配地址。
*length参数指定内存段的长度.
*prot参数用来设置内*存段的访问权限,比如PROT_READ可读, PROT_WRITE可写。
*flags控制内存段内容被修改后程序的行为。如MAP_PRIVATE指内存段为调用进程所私有,对该内存段的修改不会反映到被映射的文件中。*/void *mmap(void *addr, size_t length, int prot, int flags,           int fd, off_t offset);int munmap(void *addr, size_t length);

所以根据不同情况(200,404)填充HTTP的程序如下:

技术分享 填充HTTP应答

技术分享 将应答发送给客户端

 

6.忽略SIGPIPE

这是一个看似很小,但是如果不注意会直接引发bug的地方。如果往一个读端关闭的管道或者socket中写数据,会引发SIGPIPE,程序收到SIGPIPE信号后默认的操作时终止进程。

这也就是说,如果客户端意外关闭,那么服务器可能也就跟着直接挂了,这显然不是我们想要的。所以网络程序中服务端一般会忽略SIGPIPE信号

 

7. 程序代码

程序中有比较详细的注释,虽然主干在上面问题中分析过了,但是诸如如何解析一行数据之类的操作,还是很烦的...可以直接参考代码

技术分享 threadpool.h

技术分享 http_conn.h

技术分享 http_conn.cpp

技术分享 locker.h

技术分享 main.cpp


一个简易的web服务器:Tinywebserver