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响应数据。
//简化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 1024void 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 stringprintf("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 connectionclose(client_fd);} else {// Read errorperror("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 connectionint client_fd = accept(listen_fd, NULL, NULL);if (client_fd == -1) {perror("accept");continue;}// Add new client socket to EPOLL instanceevent.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 clienthandle_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
作为静态局部变量,只会被初始化一次,确保单例的唯一性。- 构造函数私有化: 将构造函数设为私有,防止类外部直接创建实例。
- 禁止拷贝和赋值: 删除拷贝构造函数和赋值操作符,防止复制单例实例。
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函数是服务器的核心,它不断监听客户端连接请求,当接收到新连接的时候,将其封装成任务并提交给线程池处理。通过线程池来提高服务器的并发处理能力,同事避免频繁创建和销毁线程的开销
整体代码实现
#pragma once #include<iostream>
#include<pthread.h>
#include<signal.h>
#include"TcpServer.hpp"
#include"log.hpp"
#include"Task.hpp"
#include"ThreadPoll.hpp"#define PORT 8888class 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);//向线程池中传入任务zThreadPoll::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:成员函数用于重置智能指针,释放其当前持有的对象,并让其持有新的对象
线程池的优缺点
优点
- 提高并发性能
- 减少线程创建和销毁的内存开销:线程池中的线程是在初始化的时候创建的,而不是等待每次任务到来的时候才创建的,从而减少频繁创建和销毁线程所带来的系统开销。
- 提高资源利用效率:通过限制最大线程数量,从而避免系统资源被过度消耗,从而防止因为创建过度线程导致的性能下降。
- 简化编程
- 代码逻辑清晰:任务处理逻辑与线程管理逻辑分离,从而让代码更便于理解和维护。
- 任务提交简单:利用线程池技术,只需要将任务提交给线程池,线程池就会自己调度和执行任务,不需要程序员管理线程的声明周期。
- 线程复用
- 提高线程利用效率:线程池中的线程是可以重复进行使用,提高线程的使用效率以及系统的响应速度。
- 负载均衡
- 均衡的分配任务:线程池可以自动均匀的将任务分配给线程,从而实现负载均衡,避免个别线程过载
- 减少上下文切换
缺点
- 实现复杂,不方便进行调优
- 资源竞争
- 同步开销:多线程环境下,多个线程竞争访问共享资源从而带来同步开销,从而影响性能
- 死锁风险:如果对同步和锁不当的管理,会造成死锁
- 增加内存占用
- 提前创建线程会增加内存开销:如果处理的任务量过多,线程的创建会占用更多的内存。
- 响应时间不确定
单例模式改进后代码
#pragma once #include<iostream>
#include<queue>
#include<pthread.h>
#include<functional>
#include<memory>
#include<mutex>
#include<unistd.h>
#include"Task.hpp"#define NUM 6class 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();}//改进2void 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模型在高并发或者分布系统中表现出色。
总结:主要是修改思路的一些总结,后面代码的具体改进也是基于此处的一些探索,非最终版本。