Tinyhttpd 是J. David Blackstone在1999年写的一个不到 500 行的超轻量型 Http Server,用来学习非常不错,可以帮助我们真正理解服务器程序的本质。官网:http://tinyhttpd.sourceforge.net,github地址:https://github.com/EZLippi/Tinyhttpd。
一张图来表示该SERVER的运行机制:

工作流程

 (1) 服务器启动,在指定端口或随机选取端口绑定 httpd 服务。
 (2)收到一个 HTTP 请求时(其实就是 listen 的端口 accpet 的时候),派生一个线程运行 accept_request 函数。
 (3)取出 HTTP 请求中的 method (GET 或 POST) 和 url,。对于 GET 方法,如果有携带参数,则 query_string 指针指向 url 中 ? 后面的 GET 参数。
 (4) 格式化 url 到 path 数组,表示浏览器请求的服务器文件路径,在 tinyhttpd 中服务器文件是在 htdocs 文件夹下。当 url 以 / 结尾,或 url 是个目录,则默认在 path 中加上 index.html,表示访问主页。
 (5)如果文件路径合法,对于无参数的 GET 请求,直接输出服务器文件到浏览器,即用 HTTP 格式写到套接字上,跳到(10)。其他情况(带参数 GET,POST 方式,url 为可执行文件),则调用 excute_cgi 函数执行 cgi 脚本。
(6)读取整个 HTTP 请求并丢弃,如果是 POST 则找出 Content-Length. 把 HTTP 200  状态码写到套接字。
(7) 建立两个管道,cgi_input 和 cgi_output, 并 fork 一个进程。
(8) 在子进程中,把 STDOUT 重定向到 cgi_outputt 的写入端,把 STDIN 重定向到 cgi_input 的读取端,关闭 cgi_input 的写入端 和 cgi_output 的读取端,设置 request_method 的环境变量,GET 的话设置 query_string 的环境变量,POST 的话设置 content_length 的环境变量,这些环境变量都是为了给 cgi 脚本调用,接着用 execl 运行 cgi 程序。
(9) 在父进程中,关闭 cgi_input 的读取端 和 cgi_output 的写入端,如果 POST 的话,把 POST 数据写入 cgi_input,已被重定向到 STDIN,读取 cgi_output 的管道输出到客户端,该管道输入是 STDOUT。接着关闭所有管道,等待子进程结束。
(10) 关闭与浏览器的连接,完成了一次 HTTP 请求与回应,因为 HTTP 是无连接的。

API接口列表

函数 作用
startup 初始化 httpd 服务,包括建立套接字,绑定端口,进行监听等
accept_request 处理从套接字上监听到的一个 HTTP 请求,在这里可以很大一部分地体现服务器处理请求流程
execute_cgi 运行 cgi 程序的处理,也是个主要函数
get_line 读取套接字的一行,把回车换行等情况都统一为换行符结束
sever_file 调用 cat 把服务器文件返回给浏览器
headers 把 HTTP 响应的头部写到套接字
unimplemented 返回给浏览器表明收到的 HTTP 请求所用的 method 不被支持
bad_request 返回给客户端这是个错误请求,HTTP 状态吗 400 BAD REQUEST
cat 读取服务器上某个文件写到 socket 套接字
cannot_execute 主要处理发生在执行 cgi 程序时出现的错误
error_die 把错误信息写到 perror 并退出
not_found 主要处理找不到请求的文件时的情况

源码解读

阅读顺序: main -> startup -> accept_request -> execute_cgi。

首先来看main函数,该函数主要是初始化http服务->等待客户端请求(accept) ->创建线程处理(pthread_create)

这里先对startup里面的几个函数用法做一下分析。
1. socket函数
在 Linux 下使用 <sys/socket.h> 头文件中 socket() 函数来创建套接字,原型为:

int socket(int af, int type, int protocol);

1) af 为地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。
2) type 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)。
3) protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
2. bind函数
服务器端要用 bind() 函数将套接字与特定的IP地址和端口绑定起来,原型为:

int bind(int sock, struct sockaddr *addr, socklen_t addrlen);

注意端口号和ip地址分别用htons、htonl进行了转换,我们知道网络字节序都是大端字节序,我们服务器字节序可能是大端也可能是小端,因此需要转换一下。linux中,网络字节序与主机字节序定义如下:

//将主机字节序转换为网络字节序
 unit32_t htonl (unit32_t hostlong);
 unit16_t htons (unit16_t hostshort);
 //将网络字节序转换为主机字节序
 unit32_t ntohl (unit32_t netlong);
 unit16_t ntohs (unit16_t netshort);
  1. listen函数
    listen函数把一个未连接的套接字转换为一个被动套接字,指示内核应该接受指向该套接字的连接请求,原型如下:
int listen(int sockfd, int backlog);

OK,了解了以上的函数,我们来看一下整个流程是如何运行的,一张图以蔽之:

图中backlog参数就是我们图中SYN队列和ACCEPT队列中连接数目之和的最大值。这两个队列是内核实现的,当服务器绑定、监听了某个端口后,这个端口的SYN队列和ACCEPT队列就建立好了。
客户端使用connect向服务器发起TCP连接,当图中1.1步骤客户端的SYN包到达了服务器后,内核会把这一信息放到SYN队列(即未完成握手队列)中,同时回一个SYN+ACK包给客户端。一段时间后,在较中2.1步骤中客户端再次发来了针对服务器SYN包的ACK网络分组时,内核会把连接从SYN队列中取出,再把这个连接放到ACCEPT队列(即已完成握手队列)中。而服务器在第3步调用accept时,其实就是直接从ACCEPT队列中取出已经建立成功的连接套接字而已。
试想一下如果应用程序发生阻塞(例如获取不到锁、IO阻塞等),无法及时调用accept函数将套接字从accept队列取出,就有可能造成accept队列满。如果ACCEPT队列满,则不会导致放弃连接,也不会把连接从SYN列队中移出,这会加剧SYN队列的增长。若SYN队列满,则会直接丢弃请求,即新的SYN网络分组会被丢弃。

对于accept函数,作用是等待客户请求到来,当请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字,原型为:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 

第一个参数,是一个已设为监听模式的socket的描述符。 第二个参数,是一个返回值,它指向一个struct sockaddr类型的结构体的变量,保存了发起连接的客户端得IP地址信息和端口信息。第三个参数,也是一个返回值,指向整型的变量,保存了返回的地址信息的长度。

由上面流程可以看出,三次握手跟accept没关系,accept调用发生在三次握手之后,只是纯粹的将连接好的套接字从accept队列里面取出来

下面这张图清晰地表示了tcp/ip通信的流程:

回到源码,我们看主函数的处理流程:

while (1)
 {
        client_sock = accept(server_sock,
                (struct sockaddr *)&client_name,
                &client_name_len);
        if (client_sock == -1)
            error_die("accept");
        /* accept_request(&client_sock); */
        if (pthread_create(&newthread , NULL, (void *)accept_request, (void *)(intptr_t)client_sock) != 0)
            perror("pthread_create");
 }

进行完上面的初始化后,主进程死循环来获取套接字数据,如果有数据到来,创建一个线程来处理数据,这样就可以继续响应其他客户端的请求。

由以上代码我们可以看出,其实tinyhttpd设置为阻塞的,关于阻塞非阻塞,这里有必要说明一下阻塞和非阻塞模型。
对阻塞套接字,accept行为如下图:

对非阻塞套接字,accept会有两种返回,如下图:

如果accept队列为空,那么就会返回EAGAIN。

设置socket阻塞非阻塞的方式大概有以下几种方式:
1. linux平台上可以在利用socket()函数创建socket时指定创建的socket是异步的:

int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
  1. inux平台上可以调用fcntl()或者ioctl()函数
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);  
ioctl(sockfd, FIONBIO, 1);  //1:非阻塞 0:阻塞 

下面再来看数据接收处理流程。
当接收到数据之后,我们看到main函数里面有创建新的线程来处理该请求:

if (pthread_create(&newthread , NULL, (void *)accept_request, (void *)(intptr_t)client_sock) != 0)
            perror("pthread_create");

该线程运行函数accept_request,该函数主要是对request进行处理,最后会调用execute_cgi这个函数,这个函数处理流程在最上图有给出。注意,这里有意思的是会fork一个子进程,下面先简单一下fork函数用法,fork函数原型如下:

pid_t fork();

上面是fork函数的原型,它有三个返回值
– 该进程为父进程时,返回子进程的pid
– 该进程为子进程时,返回0
– fork执行失败,返回-1
fork在执行之后,会创建出一个新的进程,这个新的进程内部的数据是原进程所有数据的一份拷贝。因此fork就相当于把某个进程的全部资源复制了一遍,然后让cs:eip指向新进程的指令部分。

fork给父进程返回子进程pid,给其拷贝出来的子进程返回0,这也是他的特点之一,一次调用,两次返回。两次返回看上去有点神秘,实质是在子进程的栈中构造好数据后,子进程从栈中获取到的返回值。

fork的实现分为以下两步
1. 复制进程资源
2. 执行该进程

复制进程的资源包括以下几步
1. 进程pcb
2. 程序体,即代码段数据段等
3. 用户栈
4. 内核栈
5. 虚拟内存池
6. 页表

我们来看一下fork在代码中的具体应用:

if ( (pid = fork()) < 0 ) {
        cannot_execute(client);
        return;
    }

if (pid == 0)  /* child: CGI script */
{
   ...
} else {    /* parent */
   ...
}

明白了fork的原理,我们来探讨一下如果在一个线程中fork出一个子进程,该子进程会如何执行呢?下面是《POSIX多线程程序设计》中的原话:“当多线程进程调用fork创造子进程时,Pthreads指定只用那个调用fork的线程在子进程中存在。”换句话说,就是fork得到的子进程只有一个执行流,仅仅执行调用fork函数的这个线程,其他的线程不会被调用。用个图来直观的表示:

但是我们上面还说了,一个进程的全部信息不仅仅包括执行流(对应堆栈),还有堆空间数据、bss段和data段等等。这些数据是多线程程序中各个进程共有的,比如一些全局变量。换句话说,就是fork会把这些数据也复制到子进程中。
那么问题就来了,比如说一个多线程程序中有一个全局互斥锁。fork会把互斥锁一起复制到子进程中,当然互斥锁的状态也是和复制时父进程中一样的。如果复制时的互斥锁是锁住的,那么程序的逻辑很可能被破坏。因为子进程中只有一个执行流,互斥锁如果不是执行fork的线程锁住的,那结果将是永远锁住,锁住互斥锁的线程在新进程中根本就不存在了。
所以POSIX对这个问题提供了一个解决方案,这就是pthread_atfork,下面来看一个简单的例子:

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <unistd.h>


pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *doit(void *arg)
{
    printf("pid = %d begin doit ...\n", static_cast<int>(getpid()));
    pthread_mutex_lock(&mutex);
    struct timespec ts = {2, 0};
    nanosleep(&ts, NULL);
    pthread_mutex_unlock(&mutex);
    printf("pid = %d end doit ...\n", static_cast<int>(getpid()));

    return NULL;
}

void prepare(void)
{
    pthread_mutex_unlock(&mutex);
}

void parent(void)
{
    pthread_mutex_lock(&mutex);
}

int main(void)
{
    pthread_atfork(prepare, parent, NULL);
    printf("pid = %d Entering main ...\n", static_cast<int>(getpid()));
    pthread_t tid;
    pthread_create(&tid, NULL, doit, NULL);
    struct timespec ts = {1, 0};
    nanosleep(&ts, NULL);
    if (fork() == 0)
    {
        doit(NULL);
    }
    pthread_join(tid, NULL);
    printf("pid = %d Exiting main ...\n", static_cast<int>(getpid()));

    return 0;
}

在执行fork() 创建子进程之前,先执行prepare(), 将子线程加锁的mutex 解锁下,然后为了与doit() 配对,在创建子进程成功后,父进程调用parent() 再次加锁,这时父进程的doit() 就可以接着解锁执行下去。而对于子进程来说,由于在fork() 创建子进程之前,mutex已经被解锁,故复制的状态也是解锁的,所以执行doit()就不会死锁了。

参考

《高性能网络编程(一)—-accept建立连接》
《服务器编程心得(四)—— 如何将socket设置为非阻塞模式》
《多线程程序中的fork调用》
《fork的原理及实现》

发表评论

电子邮件地址不会被公开。 必填项已用*标注