Nginx源码思考项目改进
架构模式
- 事件驱动架构(EDA)用于处理大量并发连接和IO操作
- 优点:高效处理大量并发请求,减少线程切换和阻塞调用
- 技术实现:直接使用EPOLL,参考Node.js的http服务器
- 网络通信
- 协议:HTTP2
- 本身是改进的HTTP1.1,保持与HTTP的高兼容性(gRPC虽然可以提供更高效的传输效率,但是与项目的主要功能不符,所以暂时不用)
- HTTP2,在快速加载静态和动态库的内容中适用,可以提高速度
- 主要特性
- 多路复用:在同一连接中并行处理多个请求和响应,减少了延迟。
- 服务器推送:允许服务器未经请求即推送资源,提高页面加载速度。
- 头部压缩:通过HPACK压缩协议减少了请求和响应头的大小。
- 尝试使用nghttp2的库来支持HTTP2
- gRPC:基于HTTP/2的高效RPC协议,适用于服务间通信。
- WebSocket:用于实时通信的全双工协议。
- 并发处理
- 事件驱动模型
- Reactor模式:主线程负责监听事件并分发给工作线程处理。适用于高并发、低延迟场景。(大量短请求)
- Proactor模式:主线程负责完成事件处理,工作线程处理业务逻辑。适用于需要高并发和高吞吐量的场景。
- 线程池
- 使用线程池处理并发请求,避免频繁创建和销毁线程的开销。
- 配置合理的线程池大小,避免过多或过少的线程影响性能。
- 协程
- 使用协程实现轻量级并发处理,提高系统资源利用率。协程相比于线程,开销更小,切换更快。
- 资源管理
- 内存管理
- 使用高效的内存分配器,如tcmalloc、jemalloc,减少内存碎片和分配开销。
- 避免内存泄漏,使用智能指针(如C++的
std::shared_ptr
、std::unique_ptr
)管理内存。- 文件描述符管理
- 合理分配和管理文件描述符,避免资源泄漏。
- 限流
- 实现限流策略,防止高并发请求压垮系统。可以使用漏桶算法或令牌桶算法。
- 缓存
- 本地缓存
- 在服务器内存中缓存频繁访问的数据,减少数据库访问次数。
- 分布式缓存
- 对于需要高一致性的场景,使用一致性哈希算法管理缓存节点
HTTP请求模块阅读后思考
主要目标是在云服务器上设计一个高性能HTTP服务器,从而实现HTTP请求的高效处理。
改进方向
- 非阻塞IO和事件驱动模型
- 使用EPOLL以及reactor去完成高效的I多路复用机制
- 尝试改进方向
- EPOLL来处理并发连接
- 使用非阻塞的IO操作,避免单个连接阻塞整个服务器
- 内存池管理
- 使用内存池进行内存管理,减少频繁内存分配和释放内存操作,提高内存利用率,减少内存碎片
- 改进方向
- 实现简单的内存管理器,预分配大内存,然后从中分配小内存
- 请求结束后统一释放内存
- 请求处理和解析
- 优化存储的数据结构
- 反向代理和负载均衡
- 将请求转发到后端服务器,同时将响应返回给客户端。
- 改进方向
- 使用功能HTTP客户端库或者服务器完成HTTP请求转发
- 尝试使用简单的轮询、加权轮询或者哈希算法实现负载均衡
- 压缩和缓存
- 对HTTP响应进行gzip压缩,减少传输数据量。实现缓存功能,缓存后端服务器的响应,减少后端服务器负载,提高响应速度。
- 改进方向
- 使用zlib库进行gzip压缩。
- 使用哈希表或LRU缓存算法实现缓存。
- 限流和访问控制
- 限制客户端的请求速率,防止恶意请求或流量攻击。通过IP地址或其他条件进行访问控制,限制特定客户端的访问权限
- 改进方向
- 使用令牌桶或漏桶算法实现限流。
- 使用IP白名单或黑名单进行访问控制
修改服务器架构
EPOLL事件驱动在HTTP服务器架构下,HTTP请求和HTTP响应分别对应EPOLL的什么状态。
EPOLLIN (Readable): 表示有新的数据可读。对于一个HTTP服务器,当一个新的HTTP请求到达时,socket变为可读状态,触发EPOLLIN事件。你需要监听这个事件来读取客户端发送的HTTP请求数据。
EPOLLOUT (Writable): 表示可以向socket写入数据。当你需要向客户端发送HTTP响应时,通常会监听这个事件。
EPOLLET (Edge Triggered): EPOLL的边缘触发模式。这个模式下,事件只在状态变化时通知,所以需要非阻塞I/O和循环读取/写入数据,直到数据全部处理完毕。对于高性能服务器,这种模式更高效。
事件类型和socket之间的关系分析
ADD (EPOLL_CTL_ADD): 当你第一次监听一个socket时,使用EPOLL_CTL_ADD事件类型,将socket添加到EPOLL实例中,并指定你要监听的事件类型(通常是EPOLLIN)。
MOD (EPOLL_CTL_MOD): 当你已经在监听一个socket,但想修改其监听的事件类型时,使用EPOLL_CTL_MOD事件类型。例如,当你读取完HTTP请求数据后,想监听EPOLLOUT事件以便发送响应,就可以使用EPOLL_CTL_MOD修改监听事件类型。
DEL (EPOLL_CTL_DEL): 当你不再需要监听某个socket时,使用EPOLL_CTL_DEL事件类型将其从EPOLL实例中删除。
HTTP请求后,EPOLL具体实现步骤分析
- 添加socket到EPOLL实例中 : 使用
epoll_ctl
函数和EPOLL_CTL_ADD事件类型,将监听的socket添加到EPOLL实例中,并指定要监听EPOLLIN事件。- 读取请求数据: 当EPOLL检测到EPOLLIN事件时,读取HTTP请求数据。
- 修改监听事件类型: 如果需要发送响应,可以使用EPOLL_CTL_MOD修改监听事件类型为EPOLLOUT。
- 发送响应数据: 当EPOLL检测到EPOLLOUT事件时,发送HTTP响应数据。
cpp
//简化EPOLL模型
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#define MAX_EVENTS 10
#define READ_BUFFER_SIZE 1024
void handle_connection(int client_fd) {
char buffer[READ_BUFFER_SIZE];
int bytes_read = read(client_fd, buffer, sizeof(buffer) - 1);
if (bytes_read > 0) {
buffer[bytes_read] = '\0'; // Null-terminate the string
printf("Received request:\n%s\n", buffer);
// Process the HTTP request and generate a response here.
// For simplicity, we'll just send a basic HTTP response.
const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, World!";
write(client_fd, response, strlen(response));
} else if (bytes_read == 0) {
// Client closed the connection
close(client_fd);
} else {
// Read error
perror("read");
close(client_fd);
}
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // Create a listening socket
// Bind and listen steps are omitted for brevity...
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
struct epoll_event event;
struct epoll_event events[MAX_EVENTS];
event.data.fd = listen_fd;
event.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
perror("epoll_ctl: listen_fd");
exit(EXIT_FAILURE);
}
while (1) {
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
if (events[i].data.fd == listen_fd) {
// Handle new connection
int client_fd = accept(listen_fd, NULL, NULL);
if (client_fd == -1) {
perror("accept");
continue;
}
// Add new client socket to EPOLL instance
event.data.fd = client_fd;
event.events = EPOLLIN;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event) == -1) {
perror("epoll_ctl: client_fd");
close(client_fd);
}
} else {
// Handle data from a connected client
handle_connection(events[i].data.fd);
// Optionally, modify the socket to listen for EPOLLOUT if needed
}
}
}
}
close(listen_fd);
close(epoll_fd);
return 0;
}
项目设计分析与部分改进
TcpServer.hpp
getinstance()
- 作用:实现单例模式的方法,用于确保TcpServer类的实例在程序生命周期内只被创建一次
- 单例模式的目的:一个类只有一个实例,并提供一个全局访问点。
- 静态互斥锁:线程锁的Lock确保它只可以被初始化一次,所以声明为静态,并在所有的getinstance的调用中共享。
- 双重检查是否上锁:外部检查避免了在单例实例已经创建获取锁。内部检查确保了线程安全。
- 类外初始化:类外初始化静态成员svr
- 私有化构造函数:构造函数私有化,防止直接实例化类
改进思路
使用C++11的静态局部变量初始化特性,简化代码的同时保证线程安全
std::once_flag
和**std::call_once
** : 使用std::once_flag
和std::call_once
确保TcpServer
实例只被初始化一次,并且是线程安全的。- 静态局部变量 :
svr
作为静态局部变量,只会被初始化一次,确保单例的唯一性。- 构造函数私有化: 将构造函数设为私有,防止类外部直接创建实例。
- 禁止拷贝和赋值: 删除拷贝构造函数和赋值操作符,防止复制单例实例。
cpp
class TcpServer
{
private:
int port;
int listen_sock;
// static TcpServer*svr;
static std::unique_ptr<TcpServer> svr;
private:
TcpServer(int _port):port(_port),listen_sock(-1)
{}
TcpServer(const TcpServer&s) = delete;
TcpServer&operator = (const TcpServer&) = delete;
public:
//单例模式
static TcpServer*getinstance(int port)
{
// static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// if(nullptr == svr)
// {
// pthread_mutex_lock(&lock);
// if(nullptr == svr)
// {
// svr = new TcpServer(port);
// svr->InitServer();
// }
// pthread_mutex_unlock(&lock);
// }
// return svr;
static std::once_flag initFlag;
std::call_once(initFlag,[&]()
{
svr.reset(new TcpServer(port));
svr->InitServer();
});
return svr.get();
}
void InitServer()
{
......
}
};
// TcpServer*TcpServer::svr = nullptr;
std::unique_ptr<TcpServer> TcpServer::svr = nullptr;
HttpServer.hpp
InitServer()
- 忽略SIGPIPE信号,防止写入到已经关闭的套接字时导致服务器崩溃
- unix系统编程中,SIGPIPE信号通常与管道和套接字的写操作有关,当进程试图向一个已经关闭的管道或者套接字写入数据的时候,系统会向进程发送SIGPIPE信号。如果进程没有处理这个信号,默认行为则是终止进程。所以为了避免这种情况的发生则忽略SIGPIPE信号
Loop()
- accept接受新连接,tsvr->sock返回服务器监听的套接字描述符,peer用于保存客户端的地址信息
- 创建任务并加入线程池
- Loop函数是服务器的核心,它不断监听客户端连接请求,当接收到新连接的时候,将其封装成任务并提交给线程池处理。通过线程池来提高服务器的并发处理能力,同事避免频繁创建和销毁线程的开销
整体代码实现
cpp
#pragma once
#include<iostream>
#include<pthread.h>
#include<signal.h>
#include"TcpServer.hpp"
#include"log.hpp"
#include"Task.hpp"
#include"ThreadPoll.hpp"
#define PORT 8888
class HttpServer
{
private:
int port;
bool stop; //标记服务状态
public:
HttpServer(int _port=PORT)
:port(_port),stop(false)
{}
void InitServer()
{
//避免写入时,server崩溃,忽略SIGPIPE信号
signal(SIGPIPE,SIG_IGN);
LOG(INFO,"server initialized");
}
//启动服务
void Loop()
{
TcpServer*tsvr = TcpServer::getinstance(port);
LOG(INFO,"Loop begin")
while(!stop)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(tsvr->Sock(),(struct sockaddr*)&peer,&len);
if(sock<0)
{
continue;
}
LOG(INFO,"Get a new link");
//构建任务
Task task(sock);
//向线程池中传入任务z
ThreadPoll::getinstance()->PushTask(task);
}
}
~HttpServer()
{}
};
ThreadPoll.hpp
成员变量分析
num
: 线程池中线程的数量。stop
: 标记线程池是否停止。task_queue
: 存储待处理任务的队列。lock
: 保护任务队列的互斥量。cond
: 用于线程间同步的条件变量。单例模式设计
- single_instance:静态指针,指向唯一的线程池实例
- getinstance():使用双重检查锁定模式确保线程池实例在多线程环境下安全初始化
任务管理模块
- pushTask:将任务添加到任务队列,并唤醒一个线程
- popTask:从任务队列中取出任务
InitThreadPoll()
线程池初始化,创建num个线程,并启动它们执行ThreadRoutine函数。线程池层创建多个工作线程,每个线程在后台等待并处理任务队列中的任务。通过线程池提高服务器的并发处理能力,避免了频繁创建和销毁线程的开销。
ThreadRoutine(void* args)
每个线程在任务队列为空时等待任务,一旦有任务则取出并进行处理。无限循环的从任务队列中获取任务进行处理,通过加锁确保访问任务队列是线程安全的,while循环检查任务队列是否为空,等待任务队列的到来,防止虚假唤醒。
改进思路
- **添加停止机制:**添加stopAll方法,安全的停止所有线程
- **条件变量广播:**使用pthread_cond_broadcast唤醒所有线程,以便在停止线程池的时候所有线程都能够及时退出
- 改进线程池的单例模式: 使用C++11中std::call_once和std::once_flag确保线程池实例的线程安全初始化。同时使用std::unique_ptr管理单例实例的生命周期
- **线程安全:**std::call_once和std::once_flag确保初始化操作线程安全
- **自动资源管理:**使用std::unique_ptr管理单例实例,确保资源在程序结束的时候自动释放,避免内存泄漏
ThreadPoll单例模式实现过程分析
- std::call_once(initInstanceFlag, { ... })
;
确保 lambda 表达式只会被调用一次。single_instance.reset(new ThreadPoll());
创建一个新的ThreadPoll
实例,并将其指针管理权交给single_instance
。single_instance->InitThreadPoll();
初始化线程池。return single_instance.get();
返回ThreadPoll
实例的指针。当其他线程同时调用getinstance的时候,由于std::call_once的保证,lambda表达式只会在第一个线程调用时执行一次,后续的调用都不会重复执行对象的创建和初始化,所有的线程都会返回同一个ThreadPoll实例
std::call_once函数
- C++11引入的一个标准库函数,用于确保某个操作只被执行一次,即使有多个线程同时进行该操作。与std::once_flag配合使用,能够方便的实现线程安全的初始化。
- 参数分析
flag
: 一个std::once_flag
对象,用来保证所调用的函数只执行一次。f
: 要执行的函数,可以是函数指针、函数对象或 lambda 表达式。args...
: 要传递给函数f
的参数。single_instance.reset(new ThreadPoll())
- sigle_instance 是一个unique_ptr对象,而reset是unique_ptr的成员函数
- **reset:**成员函数用于重置智能指针,释放其当前持有的对象,并让其持有新的对象
线程池的优缺点
优点
- 提高并发性能
- 减少线程创建和销毁的内存开销:线程池中的线程是在初始化的时候创建的,而不是等待每次任务到来的时候才创建的,从而减少频繁创建和销毁线程所带来的系统开销。
- 提高资源利用效率:通过限制最大线程数量,从而避免系统资源被过度消耗,从而防止因为创建过度线程导致的性能下降。
- 简化编程
- 代码逻辑清晰:任务处理逻辑与线程管理逻辑分离,从而让代码更便于理解和维护。
- 任务提交简单:利用线程池技术,只需要将任务提交给线程池,线程池就会自己调度和执行任务,不需要程序员管理线程的声明周期。
- 线程复用
- 提高线程利用效率:线程池中的线程是可以重复进行使用,提高线程的使用效率以及系统的响应速度。
- 负载均衡
- 均衡的分配任务:线程池可以自动均匀的将任务分配给线程,从而实现负载均衡,避免个别线程过载
- 减少上下文切换
缺点
- 实现复杂,不方便进行调优
- 资源竞争
- 同步开销:多线程环境下,多个线程竞争访问共享资源从而带来同步开销,从而影响性能
- 死锁风险:如果对同步和锁不当的管理,会造成死锁
- 增加内存占用
- 提前创建线程会增加内存开销:如果处理的任务量过多,线程的创建会占用更多的内存。
- 响应时间不确定
单例模式改进后代码
cpp
#pragma once
#include<iostream>
#include<queue>
#include<pthread.h>
#include<functional>
#include<memory>
#include<mutex>
#include<unistd.h>
#include"Task.hpp"
#define NUM 6
class ThreadPoll
{
private:
int num;//线程池中线程个数
bool stop;//错误处理标志
std::queue<Task> task_queue;//任务队列
pthread_mutex_t lock;//互斥量
pthread_cond_t cond;//条件变量
ThreadPoll(int _num = NUM):num(_num),stop(false)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&cond,nullptr);
}
~ThreadPoll()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
//改进1
// ThreadPoll(const ThreadPoll&){};
ThreadPoll(const ThreadPoll&) = delete;
ThreadPoll&operator = (const ThreadPoll&) = delete;
static std::unique_ptr<ThreadPoll>single_instance;
static std::once_flag initInstanceFlag;
public:
//线程池单例设计
static ThreadPoll*getinstance()
{
// static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
// if(single_instance == nullptr)
// {
// pthread_mutex_lock(&_mutex);
// if(single_instance == nullptr)
// {
// //类内创建线程池并完成初始化
// single_instance = new ThreadPoll();
// single_instance->InitThreadPoll();
// }
// pthread_mutex_unlock(&_mutex);
// }
// return single_instance;
//改进3,使用C++11对单例模式进行升级
std::call_once(initInstanceFlag,[](){
single_instance.reset(new ThreadPoll());
single_instance->InitThreadPoll();
});
return single_instance.get();
}
bool InitThreadPoll()
{
for(int i =0;i<num;i++)
{
pthread_t tid;
//线程从线程池中拿取任务执行,需要通过this指针
if(pthread_create(&tid,nullptr,ThreadRoutine,this)!=0)
{
LOG(FATAL,"create thread pool error ");
return false;
}
}
LOG(INFO,"create thread pool success ");
return true;
}
void PushTask(const Task&task)
{
Lock();
task_queue.push(task);//将任务放到任务队列中
Unlock();
ThreadWakeup();
}
//改进2
void StopAll()
{
Lock();
stop = true;
//唤醒所有线程
pthread_cond_broadcast(&cond);
Unlock();
}
//判断线程是否退出
bool IsStop()
{
return stop;
}
bool TaskQueueIsEmpty()
{
return task_queue.size()==0?true:false;
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void ThreadWait()
{
//使用条件变量,条件变量满足时唤醒线程继续执行任务
pthread_cond_wait(&cond,&lock);
}
void ThreadWakeup()
{
pthread_cond_signal(&cond);
}
static void *ThreadRoutine(void*args)
{
ThreadPoll*tp = (ThreadPoll*)args;
while(true)
{
Task t;
tp->Lock();
//如果任务队列中没有任务,则让线程进行休眠;防止伪唤醒的出现,使用while循环
while (tp->TaskQueueIsEmpty() && !tp->IsStop())
{
tp->ThreadWait();
}
if(tp->IsStop())
{
tp->Unlock();
break;
}
tp->PopTask(t);
tp->Unlock();
t.ProcessOn();
}
return nullptr;
}
void PopTask(Task&task)
{
task = task_queue.front();
task_queue.pop();
}
};
std::unique_ptr<ThreadPoll> ThreadPoll::single_instance;
std::once_flag ThreadPoll::initInstanceFlag;
当前线程池的替换方案
- 异步编程
- C++的boost::asio 和 libuv
- boost.Asio是Boost库的一部分,主要提供了异步IO的操作,同时支持TCP、UDP等协议
- libuv 是一个多平台的支持异步 I/O 的 C 库,最初用于 Node.js。它支持事件循环和异步 I/O 操作,使得构建高性能的网络应用程序变得更加容易
- 优点:高效处理IO操作,减少线程切换带来的开销
- 缺点:编程模型复杂,需要管理异步操作的生命周期
- 协程
- 协程是轻量化的用户态线程,可以在单个线程中实现类似于多线程的并发操作
- 优点:简化异步编程模型,代码可读性高,性能开销小
- 缺点:需要现代编译器的支持
- Reactor/Proactor模型
- 事件驱动的并发模型,boost::asio就是基于Reactor模型实现的
- 优点:高效处理并发IO请求
- 缺点:需要理解和实现事件驱动机制,编程模型复杂
- Actor模型
- 并发编程模型,通过消息传递进行通信,常见的包括Erlang和Akka
- 优点:支持并发和分布式,模型清晰
- 缺点:代码改动大
- IO密集型应用 适合异步编程或者Reactor模型;CPU密集型任务 则使用线程池;携程和Actor模型在高并发或者分布系统中表现出色。
总结:主要是修改思路的一些总结,后面代码的具体改进也是基于此处的一些探索,非最终版本。