http服务器的实现
本文使用上一篇博文实现的epoll+reactor百万并发的服务器实现了一个使用http协议和WebSocket协议的WebServer。
完整代码请看我的github项目
1. 水平触发(Level Trigger)与边沿触发(Edge Trigger)
1.1 水平触发
水平触发是一种状态驱动机制。当文件描述符(如套接字)处于可读或可写状态时,内核会持续通知应用程序,直到应用程序处理完所有数据或资源。
优点:
-
容易编写,通常可以简单处理,因为内核会持续通知应用程序事件。
-
不容易丢失事件通知。
缺点:
- 对于高并发场景,水平触发可能会造成不必要的系统调用。因为即使数据或资源已经读取过,内核还是会通知文件描述符仍然处于可读/可写状态。
使用场景:
- 典型的阻塞式 I/O 使用水平触发较为合适。
- 适用于那些可以容忍一定的事件重复通知的应用程序。
1.2 边沿触发
边沿触发是一种状态变化驱动机制。只有当文件描述符的状态从不可读/不可写到可读/可写时,内核才会通知应用程序。ET 只在状态变化的那一刻通知,不会持续通知。
优点:
- 触发次数更少,减少了系统调用开销,适合高性能、高并发场景。
缺点:
- 容易出现遗漏事件的情况。应用程序需要一次性读取或写入尽可能多的数据,以确保没有遗漏。
- 实现更为复杂。
使用场景:
- 非阻塞IO通常配合边沿触发使用,以避免阻塞和提高性能。
- 边沿触发适用于高并发、追求性能的场景。
- 如果数据包大小变化较大,适合使用边沿触发。
2. httpserver
2.1 调整内核tcp缓冲区大小
如果文件块太大,而用户层buffer太小或者内核tcp缓冲区太小,会导致需要多次发送,从而导致发送速度变慢。
可以尝试扩大TCP缓冲区,在/etc/sysctl.conf
中设置
net.ipv4.tcp_wmem = 8192 8192 16384
net.ipv4.tcp_rmem = 8192 8192 16384
2.2 IO层和协议层
IO层包含负责管理IO事件的epoll和进行事件处理的reactor。
协议层就是实现http请求处理和发送http响应的函数。
2.3 使用状态机保存连接状态信息
可以在连接中保存一个status字段,表示当前连接的状态,当status为0,表示还没有发送任何信息,为1表示已经发送了头部,正在发送文件块,为2表示已经全部发送完毕。
显然我们需要在status为1时,将整个文件分块发送,因此就需要保存该文件描述符的上下文信息。
2.4 分块发送大文件,保存被发送文件的上下文信息
大文件传输中显然不能一次性把整个文件读出,然后写入用户缓冲区,再写入内核缓冲区。我们需要把文件分块,利用水平触发分多次写入,这样就绪要在connection中保存当前文件描述符,在status为0时打开文件,在status为2时关闭文件。
2.5 可选择使用sendfile函数减少内存复制
senfile函数可以在两个文件描述符之间直接传输数据,数据流不需要经过用户空间。它利用mmap指令直接将文件内容读取到系统缓冲区,因此性能更好。
缺点是,由于不经过用户空间,无法对文件分块发送,在阻塞IO模式下发送大文件可能长时间陷入阻塞。在非阻塞IO模式下,尽管不会陷入阻塞,但会可能导致其他连接饥饿。
2.6 性能测试qps
wrk
是一款针对 Http 协议的基准测试工具,它能够在单机多核 CPU 的条件下,使用系统自带的高性能 I/O 机制,如 epoll,kqueue 等,通过多线程和事件模式,对目标机器产生大量的负载。
下载wrk。
这篇文章详细介绍了如何安装和使用wrk进行性能测试。
特点:
- 轻量级,简单易用
- 只用于单机压测
测试结果:
- 对于每个http请求都返回一个738KB大小的图片,测试结果如下:
(base) fyli@a431:~/programs/sockets/course1 network_programs$ wrk -t12 -c400 -d30s http://localhost:2000
Running 30s test @ http://localhost:2000
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 17.69ms 14.40ms 1.68s 99.92%
Req/Sec 143.45 153.46 600.00 83.31%
25494 requests in 30.10s, 17.80GB read
Socket errors: connect 0, read 25499, write 0, timeout 1
Requests/sec: 847.08
Transfer/sec: 605.61MB
可以看到qps是847.08
- 对于每个http请求都返回一个600+字节的html文件,测试结果如下:
(base) fyli@a431:~/programs/sockets/course1 network_programs$ wrk -c400 -t12 -d30 http://localhost:2000
Running 30s test @ http://localhost:2000
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 2.72ms 33.82ms 1.79s 99.38%
Req/Sec 1.62k 1.02k 6.07k 72.66%
461290 requests in 30.09s, 318.94MB read
Socket errors: connect 0, read 461294, write 0, timeout 21
Requests/sec: 15327.85
Transfer/sec: 10.60MB
可以看到因为数据传输量变少,qps上升到了15327
2.7 代码实现
这里只展现了协议层和业务层的代码,IO层和事件回调的底层代码请看完整项目reactor.c。
webserver.h
#pragma once
#include <stdio.h>
#define BUFFER_LENGTH 819200
#define CONNECTION_LENGTH 256
#define READY_LENFTH 1024
#define PORT_NUM 2
typedef int (*RCallBack)(int fd);
struct Conn
{
int fd;
char rbuffer[BUFFER_LENGTH];
char wbuffer[BUFFER_LENGTH];
int rlength;
int wlength;
RCallBack send_callback;
RCallBack recv_callback;
int status;
int file_fd;
};
int http_request(struct Conn *);
int http_response(struct Conn *);
int set_event(int fd, int event, int flag);
void error_handling(const char *message);
void log_error(const char *message);
webserver.c
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "webserver.h"
int http_request(struct Conn *conn)
{
set_event(conn->fd, EPOLLOUT, EPOLL_CTL_MOD);
conn->status = 0;
conn->wlength = 0;
return 0;
}
int http_response(struct Conn *conn)
{
const char *file = "pic.png";
int file_fd;
if (conn->status == 0)
{
file_fd = open(file, O_RDONLY);
if (file_fd == -1)
{
log_error("open() fails");
return 1;
}
conn->file_fd = file_fd;
}
else
{
file_fd = conn->file_fd;
}
if (conn->status == 0)
{
struct stat filestat = {0};
fstat(file_fd, &filestat);
int sended = snprintf(conn->wbuffer, BUFFER_LENGTH,
"HTTP/1.1 200 OK\r\n"
"Content-Type: image/png\r\n"
"Accept-Ranges: bytes\r\n"
"Content-Length: %ld\r\n\r\n",
filestat.st_size);
conn->wlength = sended;
conn->status = 1;
}
else if (conn->status == 1)
{
ssize_t recved = read(file_fd, conn->wbuffer, BUFFER_LENGTH);
if (recved == 0)
{
close(file_fd);
conn->status = 2;
}
if (recved < 0)
{
close(file_fd);
log_error("read() fails");
conn->status = 2;
}
conn->wlength = recved;
}
return 0;
}
3. 可能出现的问题及解决
-
connection reset
recv()函数可能由于对端reset连接而返回-1,这是正常现象,关闭对应的fd即可。
-
服务器程序在客户端关闭后直接退出
可能是由于服务器程序向已经被关闭的socket写数据时会接收到一个SIGPIPE,默认情况下没有设置该信号的处理函数的话,就会导致该进程直接被kill。
- 可以设置忽略该信号。
signal(SIGPIPE, SIG_IGN);
- 也可以自定义信号处理函数
struct sigaction sa; sa.sa_handler = handle_sigpipe; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGPIPE, &sa, NULL); // 设置信号处理程序
- 也可以在send函数参数中设置不发出信号
send(fd, buffer, length, MSG_NOSIGNAL);
学习参考
学习更多请前往零声github。