基于线程池的简单Web和CGI服务器的实现


前言

本文用于自我复习简单Web服务器的实现过程,以及其中涉及的Linux系统编程和网络编程中的知识点。
项目地址: https://github.com/Liu-huaian/Simple-HTTP-Web-Server
参考文献:《Linux高性能服务器编程》 游双


一、项目简介

项目描述: 本项目采用C++语言基于Ubuntu操作系统,实现了一个轻量级的HTTP服务器,支持通过GET请求获取静态资源和通过POST请求访问CGI程序。

主要工作:

  1. 采用Proactor事件处理模式,使用Epoll和线程池对HTTP请求进行高效并发处理;
  2. 采用有限状态机对HTTP请求进行解析;
  3. 采用内存映射加快静态资源的传输;
  4. 使用多进程和管道,实现对基于Python的CGI脚本调用的实现。

关键词:Linux, HTTP Server, 多路I/O复用,多线程,多进程,进程间通信

二、CGI设计与实现

CGI(Common Gateway interface)公共网关接口,是外部扩展应用程序与Web服务器交互的一个标准接口。许多静态的HTML网页无法实现的功能,可以通过CGI实现,比如表单的处理、数据库的访问等。

本文将通过多进程来对CGI进行实现,整体思路如下图所示:
CGI程序执行示意图

  1. 在完成HTTP请求的解析后,确认该请求是否为有效的CGI请求(POST方法且请求CGI名称存在且可获取);
  2. 如果有效,则当前工作线程创建2个pipe管道,用于父子进程间的通信;
  3. 工作线程调用fork函数,创建CGI子进程;
  4. CGI子进程和父进程分别关闭两个pipe的读端和写端,形成两个单向的通信管道;
  5. CGI子进程重定向读写管道到标准输入输出,便于后序CGI程序的调用,并设置环境变量;
  6. CGI子进程使用execl函数,替换现有的程序段等数据,开始执行CGI程序;
  7. CGI程序通过环境变量、标准输入获取HTTP请求的参数信息,并执行相关的处理,将最终结果写入标准输出中;
  8. 父进程获取CGI子进程的输出数据,将其写入发送缓冲区;
  9. 父进程等待CGI子进程结束,回收相关资源并关闭管道;

CGI接口调用实现虽然简单,但涉及了多进程中常见的知识点,下面对其进行复习。

1、进程间通信

Linux下的进程间通信多多种:

  • pipe(管道):用于父子进程等亲缘进程之间,也被称为匿名管道。在管道中数据以单向字节流的方式传输;
  • fifo(有名管道):用于无亲缘关系的进程间通信,使用双方在使用前通过mkfifo创建管道;
  • 消息队列:消息队列由操作系统提供,相比于管道的数据流格式,消息队列更像是数据报,消息之间相互隔开,可以用于多个进程间同时通信;
  • socket(套接字):socket需要绑定ip+port(网络地址+端口号),通过套接字传输时需要经过网卡设备,但现有的硬件设备水平基本保证了网卡不会是传输的瓶颈(参见陈硕的mudo系列教学视频)。而且socket可以跨设备通信,对于分布式设备而言非常有利。
  • 本地套接字:与socket异曲同工,但是使用unix套接字进行通信,仅限于本地。
  • 共享内存:将某个文件映射到内核空间,多个进程可以指向该映射地址,进行读/写。这是进程间通信最快的方法,但需要配合信号量等同步机制来实现。

在本项目中,我们通过工作线程fork出一个CGI子线程,因此使用pipe管道最为方便。pipe的函数调用如下:

int pipe(int pipefd[2]);
// pipefd:传出参数,pipe[0]标识管道的读端,pipe[1]标识管道的写端
// 成功返回0 失败返回1

2、多进程的创建

		// 执行fork函数会触发系统的异常处理程序。
		pid_t fork(void);
		// 调用成功时,父进程返回子进程的PID(>0),子进程返回0
		// 调用失败时,返回-1,并设置errno

子进程fork了父进程哪些资源?

通过 " man fork " 命令可以看到manpage对于fork操作有着详细的解释。

子进程和父进程拥有各自独立的地址空间,在执行fork时,子进程将精细的拷贝父进程地址空间中所有的内容(事实上,为了提升fork的效率,往往采用的是“读时共享,写时复制”策略)。这表明fork后的子进程有着和父进程近乎一致的资源,除了以下:

  • 子进程拥有自己的PID;
  • 子进程的父进程PID等于执行fork操作的进程PID;
  • 子进程没有继承父进程的memory locks;
  • 子进程的资源使用和CPU运行时间计数被置为0;
  • 子进程的未决信号集被置为空;
  • 子进程未继承父进程的定时器;

值得特别指出的是,子进程继承了父进程的文件描述符集合,这是我们CGI调用的基础之一。

对于多线程的进程,fork操作是否存在风险?

是存在的。fork创建的子进程只会创建一个线程。 考虑一个场景,父进程是一个多线程程序,其中有一个全局的互斥锁m,其中某个线程持有该锁,而另一个线程执行了fork操作。fork后的子进程会拥有m和调用fork的线程,但是没有持有锁的线程,这就导致了这把锁死锁了(因为锁的状态也会被fork)。如果子进程试图取获取这把锁,那将导致持续阻塞。

解决方法有很多,一是尽量避免在多线程中使用fork;二是使用pthread_atfork函数,在fork前和fork后分别获取和释放互斥锁,但资源较多时很难同时获取所有的锁;三是在fork后,立即执行exec调用,开始执行新的程序,这有些冒险,需要保证在fork到exec之间都是异步信号安全的。

在本项目中,我们使用fork函数创建CGI进程相对而言是安全的。在工作线程内部,我们并不涉及对锁的争用,不会因此而阻塞死锁。并且在fork之后,我们会很快执行exec调用。

在执行exec之前,我们需要进行如下操作:

  1. 关闭通信管道的读端和写端,只有单向的管道才能进行数据的读取;
  2. 重定向读/写管道到标准输入和标准输出,这样子进程对于标准输入读取和标准输出写入就等价于从管道读取数据,从管道返回数据。
  3. 通过环境变量设置 “ Content-Length",exec后的子进程可以通过环境变量获取 " Content-Length" 变量,从而确认待传入的参数长度。

3、execl函数

int execl(const char *pathname, const char *arg, .../* (char  *) NULL */);
	// pathname:可执行文件路径
	// arg: 参数列表(arg0, arg1, arg2...)
	// 只有当错误发生时才会有返回值

指的指明的是,execl函数的arg参数列表,对于arg0往往是文件名称本身,并且参数列表的最后需要以NULL结尾。

fork + exec是一对经典搭档。fork函数创建一个子进程,其地址空间与父进程一致,因此它将执行与父进程一样的程序操作。exec函数使用可执行文件替换当前进程的地址空间,使得当前进程开始执行可执行文件中的内容。

4、CGI程序

在本项目中,我们使用Python编写我们的CGI程序,一个简单的日期CGI文件date.cgi如下所示:

#!/usr/bin/python3
#coding:utf-8
import datetime
time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(time)

我们在开头指定该程序的解释器路径。同时,我们需要增加该文件的可执行权限。

	chmod a+x ./date.cgi

5、子进程回收

 pid_t waitpid(pid_t pid, int *wstatus, int options);
 // pid: 待回收的子进程PID(>0) 或者 同组进程(=0) 或者 任意进程(-1)
 // wstatus: 子进程结束后的退出状态
 // options: 回收方式

子进程结束后需要父进程回收,否则可能出现以下几种状态:

僵尸进程

僵尸进程指的是子进程结束且消亡,而父进程未结束且未回收子进程资源,此时子进程的进程控制块依然存留在内核中,造成资源的浪费,此时该子进程被称为僵尸进程。

孤儿进程

孤儿进程指的是子进程正在运行,而父进程已经结束并且消亡,此时该子进程将交由init进程进行管理,该子进程也被称为孤儿进程。

如何处理僵尸进程

  • 使用wait函数进行资源回收。子进程退出时将发送SIGCHLD信号,多线程可以设置一个信号处理函数,当触发SIGCHLD信号时对子进程进行回收。

在本项目中,我们简化了设计,使用工作线程阻塞回收CGI子线程,并且关闭通信管道,释放资源。

整体的CGI代码实现如下:

/*
    已确定cgi文件存在,现在执行cgi程序
*/
http_conn::HTTP_CODE http_conn::execute_cgi()
{
    // 01 pipe与fork
    int cgi_out[2]; // cgi程序读取管道
    int cgi_in[2];  // cgi程序输入管道
    pid_t pid; // 进程号

    // 创建管道
    if(pipe(cgi_in) < 0){
        return INTERNAL_ERROR; // 服务器内部错误
    }
    if(pipe(cgi_out) < 0){
        return INTERNAL_ERROR; // 服务器内部错误
    }
    if(DEBUG==1){
        printf("thread: %ld, call: execute_cgi, msg: pipe create successful!\n", pthread_self());
    }
    if((pid = fork()) == 0){/* 该部分为子进程 */
        close(cgi_out[0]); // pipe为单向通信
        close(cgi_in[1]);

        dup2(cgi_out[1], 1); // dup2 stdin
        dup2(cgi_in[0], 0); // dup2 stdout  
        
        // 通过环境变量设置Content-Length传递
        char content_env[30];
        sprintf(content_env, "CONTENT_LENGTH=%d", m_content_length);
        putenv(content_env);
        execl(m_real_file, m_real_file, NULL);

        if(DEBUG==1){
            printf("thread: %ld, call: execute_cgi, msg: execute cgi failed!\n", pthread_self());
        }
        exit(1); // 执行成功时不应该到这

    }else{/* 该部分为父进程 */
        close(cgi_out[1]);
        close(cgi_in[0]);

        // 向cgi发送content内容,因为pipe是带缓存的,所以可以直接写入
        if(DEBUG==1){
            printf("thread: %ld, call: execute_cgi, msg: main process close pipe!\n", pthread_self());
            printf("write data : %ld :  %s!\n", strlen(m_content_data), m_content_data);
        }
        int ret = writePipe(cgi_in[1], m_content_data, strlen(m_content_data));
        if(DEBUG==1){
            if(ret < 0){
                printf("error : %s\n", strerror(errno));
            }
            //printf("thread: %ld, call: execute_cgi, msg: write data %d bytes!\n", pthread_self(), ret);
        }
        while (readPipe(cgi_out[0], m_cgi_buf + strlen(m_cgi_buf), WRITE_BUFFER_SIZE - strlen(m_cgi_buf) - 1) > 0)
        {
            // 不断读取管道中数据并存入缓存区中
            if(DEBUG==1){
                printf("thread: %ld, call: execute_cgi, msg: recv cgi data : %s", pthread_self(), m_cgi_buf);
            }
        }
        close(cgi_in[1]);
        close(cgi_out[0]);
        int status = 0;
        waitpid(pid, &status, 0);
        if(status > 0){
            return INTERNAL_ERROR;
        }
    }   
    if(DEBUG==1){
        printf("cgi exec successful!\n");
    }
    return CGI_REQUEST;
}

本章小结

整个CGI调用过程比较简单,是一个较为典型的多进程编程实例。然而,每次调用CGI往往需要经历多次的系统调用(管道创建、子进程创建、消息读写等),在整个CGI处理过程中,整个工作线程处于阻塞状态,直到CGI程序执行完毕。这样而言,整个Web服务器的处理效率较低,可以考虑:
(1)将常用的业务逻辑直接写入HTTP服务器程序中,减少CGI的调用;
(2)对于CGI调用,可以参考Golang的协程设计,当前线程(M)因为系统调用而阻塞时,应当将其与协程(G)共同摘离出处理器环境(P),使得P可以寻找的新的M和G进行程序运行;
(3)可以设置专门的工作线程,用于读取管道信息和子进程回收。


总结

提示:这里对文章进行总结:

liugnix是一个 cgi模式 web服务器。 支持语言 因使用的为CGI 模式,支持所有cgi模式运行的程序。 php python go perl 如何运行 cd mywebsite liugnix_ENV liuginx --help 2013/05/07 01:10:43 liuginx Usage of liuginx: -path=".": Setting website room path. Default '.' -port=":8080": Set server port! -route_all=true: All file to route_index -route_index="": Route url to index! set index.php or index.py ! 安装方法? 已经有编译好的程序, 可以根据自己的需要下载 windows 32位系统 : https://github.com/ablegao/liugnix/liugnix_win32.exe windows 64位系统: https://github.com/ablegao/liugnix/liugnix_win64.exe linux 64位: https://github.com/ablegao/liugnix/liuginx_linux64 Mac 10.8.3 https://github.com/ablegao/liugnix/liuginx_mac 自己编译 确保自己的GOLang环境可以运行。 go get github.com/ablegao/liuginx go install github.com/ablegao/liuginx 如何配置语言解析? 你需要一个名为liuginx.conf文件 , 这个文件需要和liuginx在同一个目录下。 (源码中有附带。 ) 配置文件内容: { "server":{ "process":10240 }, "file":{ ".php":{ "script":"/usr/local/bin/php-cgi", "argv":["$file"], "env":[] }, ".phps":{ "script":"/usr/local/bin/php-cgi", "argv":["-s","$file"], "env":[] }, ".go":{ "script":"/usr/local/go11/bin/go", "argv":["run" , "$file"], "env":[ "GOPATH=~/code/mygo", "GOROOT=/usr/local/go11" ] }, ".py":{ "script":"/opt/python/py27/bin/python", "argv":["-u", "$file"], "env":[] } } } 配置文件结构 server:{ process: 最大进程数, 如果配置的很高, 能带来很高的并发。 } file下的配置: 如.php , 则是配置php文件的解析。 其中, script 用来指定php解析器。 这里使用的 php-cgi ,windows 下是php-cgi.exe argv 中$file 为一个必填选项。 实际执行时相当于 /usr/local/bin/php-cgi $file 看.phps的解析 , 就是再 php-cgi 基础上加入了一个 -s 参数, 同时, 可以查看php-cgi的其他命令参数, 加入到argv中。 env 用于扩展其他命令集参数。 如果我的使用的是thinkphp cakephp等, 怎么隐藏index.php文件, 实现路由功能? liuginx.exe --route_index=index.php 有的文件, 已经存在, 我并不想经过路由怎么办? liuginx.exe --route_index=index.php --route_all=false 标签:LiuGinx
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值