前言
项目地址
项目详细介绍
本文章适合刚学习完C++基础知识并尝试实现一个网络编程项目的同学,其中包含了该项目的代码逐行注释和解析以及许多刚学习网络编程中会遇到的疑问。
项目简介:
Linux下C++轻量级Web服务器,助力初学者快速实践网络编程,搭建属于自己的服务器.
- 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
- 使用状态机解析HTTP请求报文,支持解析GET和POST请求
- 访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
- 实现同步/异步日志系统,记录服务器运行状态
- 经Webbench压力测试可以实现上万的并发连接数据交换
在这个项目中,每个设计选择都针对Web服务器的实际需求,确保其能够在真实环境中高效、稳定地运行。让我们逐点来看并了解一些基础概念:
1. 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
实际运用与意义:
-
线程池:通过预先创建一组线程,避免了高并发下频繁创建和销毁线程的开销。线程池可以高效地处理多个客户端请求,提升服务器的响应速度和资源利用率。
现实意义:这样设计的服务器可以在面对大量并发请求时依然保持高效稳定,不会因为频繁的线程操作而降低性能。
-
非阻塞socket:允许服务器在处理客户端请求时,不会因为某个操作(比如等待数据)而卡住整个程序。非阻塞IO能够让服务器同时处理多个连接,从而提高吞吐量。
现实意义:这种设计确保了服务器在等待某些事件(如数据到达)时,不会停下来,而是可以继续处理其他请求,使得服务器能够高效处理多个并发连接。
-
epoll(ET和LT):epoll是Linux下非常高效的I/O多路复用技术。用我的理解简单来说就是占用一个或几个专用的线程来实现I/O操作的多路复用(即同时监听成百上千个客户端连接的请求和响应,而无需为每个连接创建一个线程。),然后把复杂的I/O操作交给线程池里的其他线程。ET(边缘触发)和LT(水平触发)是两种事件通知模式。ET模式减少了重复通知的开销,提高了性能;LT模式则更为安全,适合稳定运行。
现实意义:通过同时实现ET和LT,程序可以灵活应对不同的需求。ET模式可以在高负载下减少系统调用次数,LT模式则确保事件不遗漏,特别是在复杂场景下更为可靠。
-
Reactor和模拟Proactor模型:这篇博客讲的比较清楚 Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」
简单列表对比Reactor 和 Proactor 模型
特性 | Reactor 模型 | Proactor 模型 |
---|---|---|
发起 I/O 操作 | 应用程序 | 应用程序 |
处理 I/O 操作 | 应用程序 | 操作系统 |
I/O 操作是否同步 | 同步非阻塞 | 异步 |
通知时机 | I/O 准备就绪 | I/O 操作完成 |
应用场景 | 多数 Unix 系统、Java NIO | Windows IOCP、Boost.Asio |
难易程度 | 相对较简单 | 实现复杂,需要依赖异步 I/O 支持 |
性能表现 | 中等(需要应用程序主动参与 I/O 操作) | 高(I/O 操作由操作系统负责) |
现实意义:通过实现这两种模型,程序可以根据不同的场景选择最合适的并发处理方式。Reactor适合处理事件驱动的应用,模拟Proactor则更能体现服务器在处理完成时再执行相关操作的特点。
2. 使用状态机解析HTTP请求报文,支持解析GET和POST请求
实际运用与意义:
-
状态机解析HTTP请求:状态机是一种将复杂任务分解为不同状态并根据状态转移来处理的模型。通过状态机,服务器可以精准、有效地解析HTTP请求,不管是简单的GET请求还是复杂的POST请求,都能正确解析处理。
现实意义:使用状态机可以让服务器的请求解析过程更加清晰和高效,减少错误处理,提高响应速度。对于Web服务器来说,这种设计非常重要,因为它能确保服务器在处理不同类型请求时都能准确执行。
3. 访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
实际运用与意义:
-
数据库交互:服务器通过与数据库交互,实现用户的注册和登录功能。这是Web应用中最常见的功能之一,也是用户与服务器交互的核心。
现实意义:这不仅展示了服务器的基本功能,还使项目更加实用,能够处理实际Web服务需求,如用户管理和数据存储。能够提供图片和视频文件的请求处理展示了服务器处理静态资源的能力。
4. 实现同步/异步日志系统,记录服务器运行状态
实际运用与意义:
-
日志系统:日志系统是服务器的重要组成部分,用于记录运行时的各种信息,如错误、请求、状态等。同步日志可以实时记录,而异步日志则可以在不影响主线程的情况下批量处理日志。
现实意义:通过日志系统,开发者可以监控服务器的运行状态,发现和定位问题,提高调试效率。异步日志在高并发场景下尤为重要,能够确保记录日志的同时,不会阻塞主要的请求处理线程。
5. 经Webbench压力测试可以实现上万的并发连接数据交换
实际运用与意义:
-
Webbench压力测试:Webbench是一种常用的压力测试工具,用于测试Web服务器的性能。通过该工具,项目展示了服务器能够在高并发环境下处理上万连接的能力。
现实意义:这一点直接证明了该服务器的高效性和可靠性。能够在高负载下依然保持良好的性能,体现了这个项目在实际应用中的价值。
接下来便开始正式的源码分析:
源码详细分析
项目路径如下:
1.webserver.cpp
完整注释版:
#include "webserver.h"
//构造函数
WebServer::WebServer()
{
//创建客户端连接数组
users = new http_conn[MAX_FD];
//创建root文件夹路径
char server_path[200];
getcwd(server_path, 200);
char root[6] = "/root";
m_root = (char *)malloc(strlen(server_path) + strlen(root) + 1);
strcpy(m_root, server_path);
strcat(m_root, root);
//创建定时器
users_timer = new client_data[MAX_FD];
}
//析构函数
WebServer::~WebServer()
{
//释放epoll文件描述符
close(m_epollfd);
//释放监听文件描述符
close(m_listenfd);
//释放管道读端
close(m_pipefd[1]);
//释放管道写端
close(m_pipefd[0]);
//删除客户端连接及定时器数组
delete[] users;
delete[] users_timer;
//删除线程池
delete m_pool;
}
//初始化函数(服务器相关参数)
void WebServer::init(int port, string user, string passWord, string databaseName, int log_write,
int opt_linger, int trigmode, int sql_num, int thread_num, int close_log, int actor_model)
{
//初始化端口
m_port = port;
//数据库用户名
m_user = user;
//数据库密码
m_passWord = passWord;
//数据库名
m_databaseName = databaseName;
//数据库连接池数量
m_sql_num = sql_num;
//线程池的数量
m_thread_num = thread_num;
//日志写入的方式 同步or异步
m_log_write = log_write;
//用于控制连接关闭的变量
m_OPT_LINGER = opt_linger;
//控制监听和连接采用ET or LT触发模式
m_TRIGMode = trigmode;
//日志的开关 0为开
m_close_log = close_log;
//控制事件处理的模型为Reactor还是Proactor
m_actormodel = actor_model;
}
//通过m_TRIGMode选择监听和连接的触发模式 LT: Level Triggered ET:Edge Triggered
void WebServer::trig_mode()
{
//LT + LT
if (0 == m_TRIGMode)
{
m_LISTENTrigmode = 0;
m_CONNTrigmode = 0;
}
//LT + ET
else if (1 == m_TRIGMode)
{
m_LISTENTrigmode = 0;
m_CONNTrigmode = 1;
}
//ET + LT
else if (2 == m_TRIGMode)
{
m_LISTENTrigmode = 1;
m_CONNTrigmode = 0;
}
//ET + ET
else if (3 == m_TRIGMode)
{
m_LISTENTrigmode = 1;
m_CONNTrigmode = 1;
}
}
//初始化日志
void WebServer::log_write()
{
if (0 == m_close_log)
{
//m_log_write控制异步还是同步
if (1 == m_log_write)
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 800);
else
//每写一次刷新一次log
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 0);
}
}
//初始化数据库池
void WebServer::sql_pool()
{
//初始化数据库连接池
m_connPool = connection_pool::GetInstance();
m_connPool->init("localhost", m_user, m_passWord, m_databaseName, 3306, m_sql_num, m_close_log);
//初始化数据库读取表
users->initmysql_result(m_connPool);
}
//初始化线程池
void WebServer::thread_pool()
{
//(事件相应模型,数据库连接池,线程池中的线程数量)
m_pool = new threadpool<http_conn>(m_actormodel, m_connPool, m_thread_num);
}
//配置监听套接字和管道
void WebServer::eventListen()
{
//创建套接字 PF_INET=IPV4 SOCK_STREAM=TCP
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
//检查是否创建成功
assert(m_listenfd >= 0);
//优雅关闭连接
if (0 == m_OPT_LINGER)
{
struct linger tmp = {0, 1};
//setsockopt函数设置套接字的相关属性
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
else if (1 == m_OPT_LINGER)
{
struct linger tmp = {1, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
//用于捕获和检查绑定和监听是否成功
int ret = 0;
//该结构体定义在头文件 <netinet/in.h> 中 用于存放IP地址和端口号
struct sockaddr_in address;
//清除内存中的垃圾,即结构体中的非0随机值
bzero(&address, sizeof(address));
//表示使用的是IPV4地址
address.sin_family = AF_INET;
//存放IPV4地址具体的值 这里的htonl(INADDR_ANY)绑定了所有本地IP地址
address.sin_addr.s_addr = htonl(INADDR_ANY);
//存放端口号 htons(m_port) 即Host TO Network Short 设置监听的端口号的过程中将主机字节序的端口号转换为网络字节序。
address.sin_port = htons(m_port);
//1即启用SO_REUSEEADDR允许重用本地地址 使得在重启服务器后快速重新绑定原来的端口
int flag = 1;
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
//绑定套接字到本地IP地址
ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
//是否绑定成功
assert(ret >= 0);
//设置套接字为监听模式,最多5个连接请求排队
ret = listen(m_listenfd, 5);
//检测是否成功
assert(ret >= 0);
//初始化定时器 设置最小超时单位
utils.init(TIMESLOT);
//存储事件信息结构体的数组
epoll_event events[MAX_EVENT_NUMBER];
//epoll创建内核事件表 管理着所有的文件描述符及其事件
m_epollfd = epoll_create(5);
//检查是否创建成功
assert(m_epollfd != -1);
//添加监听描述符到epoll事件表
//这里的这个布尔值决定是否启用 EPOLLONESHOT 模式。
//如果为 true,则在这个文件描述符上触发一个事件后,epoll 不会再次监视这个文件描述符,直到你手动重置它。这在多线程环境中非常有用,避免同一个文件描述符的事件被多个线程处理。
//如果为 false,则 epoll 在每次该文件描述符上有事件发生时都会通知你。
utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
//设置客户端连接的epoll
http_conn::m_epollfd = m_epollfd;
//创建用于信号处理的套接字(管道) 这两个套接字 sv[0] 和 sv[1] 之间可以进行双向通信。socketpair 通常用于进程间通信
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
assert(ret != -1);
//将管道的写段设置为非阻塞 避免写操作阻塞进程:如果 m_pipefd[1] 是阻塞的,当你尝试写入数据时,如果管道已满(即 m_pipefd[0] 没有被读取),写操作将阻塞进程,直到有足够的空间可用。这可能导致程序挂起,等待管道缓冲区变得可写。通过将 m_pipefd[1] 设置为非阻塞模式,如果管道已满,写操作将立即返回失败(通常返回 EAGAIN 或 EWOULDBLOCK),程序可以处理这个情况而不被阻塞。
utils.setnonblocking(m_pipefd[1]);
//将管道的读端添加到epoll 使其可以通过信号驱动
utils.addfd(m_epollfd, m_pipefd[0], false, 0);
//设置对关闭信号的处理(ignore)
utils.addsig(SIGPIPE, SIG_IGN);
//设置对定时器触发信号的处理()
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);
//设置定时器 每隔TIMESLOT秒发送一次SIGALRM用于检查超时
alarm(TIMESLOT);
//工具类,信号和描述符基础操作
Utils::u_pipefd = m_pipefd;
Utils::u_epollfd = m_epollfd;
}
//定时器相关函数 参数为(连接描述符,客户端的网络信息结构体)
void WebServer::timer(int connfd, struct sockaddr_in client_address)
{
//初始客户端相关信息
users[connfd].init(connfd, client_address, m_root, m_CONNTrigmode, m_close_log, m_user, m_passWord, m_databaseName);
//初始化client_data数据
users_timer[connfd].address = client_address;
users_timer[connfd].sockfd = connfd;
//创建定时器
util_timer *timer = new util_timer;
//绑定用户数据
timer->user_data = &users_timer[connfd];
//设置回调函数,即定时器超时用cb_func来处理
timer->cb_func = cb_func;
//获取当前时间
time_t cur = time(NULL);
//超时时间为当前时间+3*TIMESLOT
timer->expire = cur + 3 * TIMESLOT;
//将定时器与用户数据关联
users_timer[connfd].timer = timer;
//将定时器添加到定时器链表中,以便定时器可以参与时间轮机制或其他定时器管理机制。
utils.m_timer_lst.add_timer(timer);
}
//调整定时器
void WebServer::adjust_timer(util_timer *timer)
{
//若有数据传输,则将定时器往后延迟3个单位
time_t cur = time(NULL);
timer->expire = cur + 3 * TIMESLOT;
//并对新的定时器在链表上的位置进行调整
utils.m_timer_lst.adjust_timer(timer);
//写入log
LOG_INFO("%s", "adjust timer once");
}
//回调函数的调用(定时器,客户端的已有连接描述符)
void WebServer::deal_timer(util_timer *timer, int sockfd)
{
//调用回调函数
timer->cb_func(&users_timer[sockfd]);
//避免删除一个空指针
if (timer)
{
utils.m_timer_lst.del_timer(timer);
}
//写入log
LOG_INFO("close fd %d", users_timer[sockfd].sockfd);
}
//根据监听模式处理客户端连接
bool WebServer::dealclientdata()
{
struct sockaddr_in client_address;
//知道客户端地址结构体大小,便于使用accept()
socklen_t client_addrlength = sizeof(client_address);
//LT触发模式下的accept
if (0 == m_LISTENTrigmode)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
//记录出错的log
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
return false;
}
//当前排队的客户端连接已到峰值
if (http_conn::m_user_count >= MAX_FD)
{
//向客户端发送busy信息
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
return false;
}
//为这个新连接设置一个定时器
timer(connfd, client_address);
}
//ET模式处理监听事件
else
{
//一次性接收所有客户端连接
while (1)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
break;
}
if (http_conn::m_user_count >= MAX_FD)
{
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
break;
}
timer(connfd, client_address);
}
return false;
}
return true;
}
//信号处理函数(服务器操作系统层面上的通知)
bool WebServer::dealwithsignal(bool &timeout, bool &stop_server)
{
int ret = 0;
int sig;
char signals[1024];
//从管道中读取信号 将其存储在singals数组中
ret = recv(m_pipefd[0], signals, sizeof(signals), 0);
//出错
if (ret == -1)
{
return false;
}
//读完
else if (ret == 0)
{
return false;
}
//
else
{
for (int i = 0; i < ret; ++i)
{
//逐个处理信号
switch (signals[i])
{
case SIGALRM:
{
timeout = true;
break;
}
case SIGTERM:
{
stop_server = true;
break;
}
}
}
}
return true;
}
//处理客户端读事件
void WebServer::dealwithread(int sockfd)
{
//获取当前客户端的定时器
util_timer *timer = users_timer[sockfd].timer;
//reactor模式 以事件驱动
if (1 == m_actormodel)
{
//调整定时器时间
if (timer)
{
adjust_timer(timer);
}
//若监测到读事件,将该事件放入请求队列
m_pool->append(users + sockfd, 0);
//不断循环询问客户端该事件是否结束
while (true)
{
//improv为1代表事件处理完成
if (1 == users[sockfd].improv)
{
//timer_fkag代表定时器事件触发 需要deal_timer处理如超时或客户端关闭连接
if (1 == users[sockfd].timer_flag)
{
//调用回调函数
deal_timer(timer, sockfd);
users[sockfd].timer_flag = 0;
}
users[sockfd].improv = 0;
break;
}
}
}
else
//proactor模式
{
//服务器让操作系统来完成读取验证 而不是让应用程序自己去做读取
if (users[sockfd].read_once())
{
LOG_INFO("deal with the client(%s)", inet_ntoa(users[sockfd].get_address()->sin_addr));
//将该事件放入请求队列
m_pool->append_p(users + sockfd);
//同Reactor
if (timer)
{
adjust_timer(timer);
}
}
else
{
//服务器读不出来就调用回调函数结束该事件
deal_timer(timer, sockfd);
}
}
}
//处理客户断写事件 同上
void WebServer::dealwithwrite(int sockfd)
{
util_timer *timer = users_timer[sockfd].timer;
//reactor
if (1 == m_actormodel)
{
if (timer)
{
adjust_timer(timer);
}
m_pool->append(users + sockfd, 1);
while (true)
{
if (1 == users[sockfd].improv)
{
if (1 == users[sockfd].timer_flag)
{
deal_timer(timer, sockfd);
users[sockfd].timer_flag = 0;
}
users[sockfd].improv = 0;
break;
}
}
}
else
{
//proactor
if (users[sockfd].write())
{
LOG_INFO("send data to the client(%s)", inet_ntoa(users[sockfd].get_address()->sin_addr));
if (timer)
{
adjust_timer(timer);
}
}
else
{
deal_timer(timer, sockfd);
}
}
}
//服务器主事件循环
void WebServer::eventLoop()
{
//控制超时事件
bool timeout = false;
//主循环的开关
bool stop_server = false;
while (!stop_server)
{
//epoll_wait阻塞(监听)已注册的套接字 (epoll套接字 epoll内核表,最大事件数量,-1代表无限等待)
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
//检测epoll_wait是否出错
{
LOG_ERROR("%s", "epoll failure");
break;
}
for (int i = 0; i < number; i++)
{
//哪个套接字发生了事件
int sockfd = events[i].data.fd;
//如果是监听套接字,则处理新的客户端连接
if (sockfd == m_listenfd)
{
//判断处理是否成功 调用处理客户端连接函数
bool flag = dealclientdata();
if (false == flag)
continue;
}
//客户端有无异常断开、挂起、错误
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//服务器端关闭连接,移除对应的定时器
util_timer *timer = users_timer[sockfd].timer;
deal_timer(timer, sockfd);
}
//当前事件是否来自管道中的信号
else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
{
//处理信号 超时或关闭
bool flag = dealwithsignal(timeout, stop_server);
//处理失败时的log
if (false == flag)
LOG_ERROR("%s", "dealclientdata failure");
}
//当前事件是否为读事件
else if (events[i].events & EPOLLIN)
{
dealwithread(sockfd);
}
//当前事件是否为写事件
else if (events[i].events & EPOLLOUT)
{
dealwithwrite(sockfd);
}
}
//超时事件
if (timeout)
{
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
代码详细解析版:
1.1 头文件和构造函数
#include "webserver.h"
WebServer::WebServer()
{
// 创建http_conn类对象处理客户端连接
users = new http_conn[MAX_FD];
// 生成root文件夹路径
char server_path[200];
getcwd(server_path, 200); // 获取当前工作目录
char root[6] = "/root";
m_root = (char *)malloc(strlen(server_path) + strlen(root) + 1);
strcpy(m_root, server_path);
strcat(m_root, root);
// 定时器
users_timer = new client_data[MAX_FD];
}
#include "webserver.h"
:包含WebServer类的声明。WebServer::WebServer()
:初始化WebServer对象。users = new http_conn[MAX_FD];
:创建一个http_conn
对象数组,用于存储客户端连接。getcwd(server_path, 200);
:获取当前工作目录。- 设置root文件夹路径:将当前工作目录和
"/root"
拼接成新的字符串,存储在m_root
中。使用strcpy(m_root, server_path)将当前工作目录的路径复制到m_root中。使用strcat(m_root, root)将/root附加到当前工作目录路径的末尾。 users_timer = new client_data[MAX_FD];
:创建一个client_data
对象数组,用于管理客户端定时器。
1.2 析构函数
WebServer::~WebServer()
{
close(m_epollfd);
close(m_listenfd);
close(m_pipefd[1]);
close(m_pipefd[0]);
delete[] users;
delete[] users_timer;
delete m_pool;
}
- 析构函数
WebServer::~WebServer()
:释放资源。- 关闭epoll文件描述符、监听文件描述符、管道文件描述符。
- 删除动态分配的
users
和users_timer
数组。 - 删除线程池对象
m_pool
。
tips:在析构函数中,close(m_pipefd[1]);
和 close(m_pipefd[0]);
的作用是关闭管道的写端和读端。这是清理资源的步骤,以确保在对象销毁时释放操作系统的文件描述符资源,避免文件描述符泄漏。
关于管道的具体分析如下:
1.2.1 管道(Pipe)的作用
- 管道是一种进程间通信机制,通常用于父子进程或线程之间传递数据。在这段服务器代码中,管道主要用于信号处理(如通过管道传递信号事件)。
- 管道有两个端点:一个是写端(
m_pipefd[1]
),另一个是读端(m_pipefd[0]
)。服务器中的信号处理通常是在信号处理程序中将信号信息写入管道,然后主循环通过epoll
监控管道的读端,来处理这些信号。
之所以需要管道进行信号处理是因为信号处理程序是异步执行的,只能做简单的操作,而主循环通常被epoll_wait
阻塞,无法及时响应信号;通过管道,信号处理程序可以安全地通知主循环有信号事件发生,epoll
可以监控管道的读端,当有数据可读时立即返回并处理,从而实现安全、高效的信号与主循环通信。
具体来说,epoll_wait
是一种用于监听多个文件描述符上事件的系统调用,它会阻塞程序的执行,直到至少有一个被监听的文件描述符上有事件发生。阻塞意味着程序在调用epoll_wait
时会暂停执行,直到有事件发生或超时。这种设计有利于提高效率,因为CPU不需要空转等待事件发生。
当程序在epoll_wait
中阻塞时,如果没有发生任何预期的IO事件(如网络数据到达),程序就不会继续执行其他代码。这时,如果有信号到来,信号处理程序虽然会被触发执行,但因为它只能做一些简单的工作,不能直接处理复杂的逻辑。
为了让主循环知道信号已经到来并及时响应(如关闭服务器、处理特殊任务),我们需要打破epoll_wait
的阻塞,让程序能够恢复执行。在信号处理程序中写入管道,可以让管道上产生一个可读事件,epoll_wait
会检测到这个事件,立即返回,从而让主循环有机会处理信号。
因此,管道用于信号处理的目的是为了在epoll_wait
阻塞时,让主循环能够及时响应信号事件。
1.2.2 为什么需要显式关闭?
- 管道本质上也是一种文件描述符,操作系统会分配有限数量的文件描述符给每个进程。如果不及时关闭,文件描述符的数量会耗尽,从而导致程序无法再创建新的文件描述符(例如,无法接受新的网络连接)。
- 在析构函数中,显式关闭管道可以确保在对象销毁时,管道资源被正确地释放,避免潜在的资源泄露问题。
1.3 初始化函数
void WebServer::init(int port, string user, string passWord, string databaseName, int log_write,
int opt_linger, int trigmode, int sql_num, int thread_num, int close_log, int actor_model)
{
m_port = port;
m_user = user;
m_passWord = passWord;
m_databaseName = databaseName;
m_sql_num = sql_num;
m_thread_num = thread_num;
m_log_write = log_write;
m_OPT_LINGER = opt_linger;
m_TRIGMode = trigmode;
m_close_log = close_log;
m_actormodel = actor_model;
}
- 初始化函数
WebServer::init
:初始化服务器的各项参数。- 设置端口号、数据库用户名和密码、数据库名、日志写入方式、关闭连接选项、触发模式、数据库连接池大小、线程池大小、日志关闭选项、事件模型等。
1.4 触发模式函数
void WebServer::trig_mode()
{
// LT + LT
if (0 == m_TRIGMode)
{
m_LISTENTrigmode = 0;
m_CONNTrigmode = 0;
}
// LT + ET
else if (1 == m_TRIGMode)
{
m_LISTENTrigmode = 0;
m_CONNTrigmode = 1;
}
// ET + LT
else if (2 == m_TRIGMode)
{
m_LISTENTrigmode = 1;
m_CONNTrigmode = 0;
}
// ET + ET
else if (3 == m_TRIGMode)
{
m_LISTENTrigmode = 1;
m_CONNTrigmode = 1;
}
}
- 触发模式函数
WebServer::trig_mode
:设置监听和连接的触发模式。- LT(水平触发):0
- ET(边缘触发):1
- 根据
m_TRIGMode
的值来设置监听和连接的触发模式。
WebServer::trig_mode
函数用于设置服务器的监听(m_LISTENTrigmode
)和连接(m_CONNTrigmode
)的触发模式。触发模式有两种:LT(水平触发,Level-Triggered)和ET(边缘触发,Edge-Triggered),分别用0
和1
表示。
根据m_TRIGMode
的值,trig_mode
函数将决定监听和连接操作的触发模式:
m_TRIGMode == 0
: 监听和连接均采用LT(水平触发)。m_TRIGMode == 1
: 监听采用LT,连接采用ET(边缘触发)。m_TRIGMode == 2
: 监听采用ET,连接采用LT。m_TRIGMode == 3
: 监听和连接均采用ET。
1.4.1 水平触发(LT)和边缘触发(ET)的区别:
这两种触发模式是针对I/O事件的不同处理方式,通常用于 epoll
或者 select
/poll
等 I/O 多路复用机制。
-
LT(Level-Triggered,水平触发):
- 工作方式:在水平触发模式下,只要文件描述符上还有数据未处理,
epoll
会反复通知应用程序。因此,只要某个事件没有被处理,下一次调用epoll_wait
时,仍会返回该事件。 - 特点:
- 容易编程,适合大部分场景。
- 可能导致重复处理同一事件。
- 场景:适用于要求及时处理事件的场景,编程简单,但效率相对较低。
- 工作方式:在水平触发模式下,只要文件描述符上还有数据未处理,
-
ET(Edge-Triggered,边缘触发):
- 工作方式:在边缘触发模式下,
epoll
只会在文件描述符状态发生变化时通知应用程序,且只通知一次。如果应用程序没有在第一次通知时处理完所有数据,后续epoll_wait
不会再通知该事件,除非状态再次发生变化。 - 特点:
- 更高效,减少了系统调用次数。
- 编程复杂,需要确保一次性处理所有数据,否则可能会错过事件。
- 场景:适用于高性能、高并发服务器,需要精确控制I/O操作。
- 工作方式:在边缘触发模式下,
1.5 日志写入函数
void WebServer::log_write()
{
if (0 == m_close_log)
{
// 初始化日志
if (1 == m_log_write)
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 800);
else
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 0);
}
}
- 日志写入函数
WebServer::log_write
:初始化日志系统。- 根据
m_log_write
的值来选择不同的日志初始化方式。
- 根据
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 800);
- 如果
m_log_write
等于1,调用日志系统的init
函数来初始化日志文件。这里使用的是异步日志模式,参数说明如下:"./ServerLog"
:日志文件的路径,日志将写入到当前目录下的ServerLog
文件中。m_close_log
:传递日志开关变量,这里是0,表示日志功能开启。2000
:日志队列最大长度,代表日志的最大条目数。800000
:日志文件的最大大小,单位是字节。日志文件达到此大小后,可能会进行滚动或创建新日志文件。800
:表示日志的刷新频率,通常用于异步日志模式下的刷新间隔(即多长时间刷新一次日志到文件)。
else
Log::get_instance()->init("./ServerLog", m_close_log, 2000, 800000, 0);
- 如果
m_log_write
不等于1,执行这个else
分支,调用init
函数初始化日志文件。这时的最后一个参数是0
,这表示同步日志模式,即每次写入日志都会立即刷新到文件,而不是等待一段时间后批量刷新。
1.6 数据库连接池初始化
void WebServer::sql_pool()
{
// 初始化数据库连接池
m_connPool = connection_pool::GetInstance();
m_connPool->init("localhost", m_user, m_passWord, m_databaseName, 3306, m_sql_num, m_close_log);
// 初始化数据库读取表
users->initmysql_result(m_connPool);
}
- 数据库连接池初始化函数
WebServer::sql_pool
:初始化数据库连接池并读取数据库表。- 获取数据库连接池实例,并进行初始化。
- 调用
http_conn
对象的initmysql_result
方法,初始化数据库读取表。
tips:在我看来 连接池这个概念的作用就类似于缓存,可以理解为,连接池和缓存都旨在提高系统的性能和效率,但它们处理的对象和应用场景不同。
1.7 线程池初始化
void WebServer::thread_pool()
{
// 线程池
m_pool = new threadpool<http_conn>(m_actormodel, m_connPool, m_thread_num);
}
- 线程池初始化函数
WebServer::thread_pool
:创建并初始化线程池对象m_pool
。
在这段代码中,threadpool<http_conn> 使用了模板类 threadpool,并且将 http_conn 作为模板参数传递给它。
T 是一个模板参数,可以是任何类型。在这段代码中,T 被替换为 http_conn,表示线程池中的任务将处理 http_conn 类型的对象。
1.7 配置监听套接字和信号传输管道
void WebServer::eventListen()
{
// 网络编程基础步骤
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(m_listenfd >= 0);
// 优雅关闭连接
if (0 == m_OPT_LINGER)
{
struct linger tmp = {0, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
else if (1 == m_OPT_LINGER)
{
struct linger tmp = {1, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(m_port);
int flag = 1;
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret >= 0);
ret = listen(m_listenfd, 5);
assert(ret >= 0);
utils.init(TIMESLOT);
// epoll创建内核事件表
epoll_event events[MAX_EVENT_NUMBER];
m_epollfd = epoll_create(5);
assert(m_epollfd != -1);
utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
http_conn::m_epollfd = m_epollfd;
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
assert(ret != -1);
utils.setnonblocking(m_pipefd[1]);
utils.addfd(m_epollfd, m_pipefd[0], false, 0);
utils.addsig(SIGPIPE, SIG_IGN);
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);
alarm(TIMESLOT);
// 工具类,信号和描述符基础操作
Utils::u_pipefd = m_pipefd;
Utils::u_epollfd = m_epollfd;
}
这段代码是一个Web服务器的事件监听函数 eventListen()
,用于设置网络通信的基础环境,创建监听套接字、设置连接选项、初始化 epoll
事件表,并设置必要的信号处理。下面逐行解释这个函数的工作原理:
1.7.1 创建监听套接字
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(m_listenfd >= 0);
socket(PF_INET, SOCK_STREAM, 0);
:创建一个TCP套接字。PF_INET
表示使用IPv4协议。SOCK_STREAM
表示使用面向连接的TCP协议。0
表示协议选择默认的传输协议(TCP)。
assert(m_listenfd >= 0);
:检查套接字创建是否成功。如果m_listenfd
小于0,表示创建失败,程序会在这里终止。
1.7.2 设置优雅关闭连接
if (0 == m_OPT_LINGER)
{
struct linger tmp = {0, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
else if (1 == m_OPT_LINGER)
{
struct linger tmp = {1, 1};
setsockopt(m_listenfd, SOL_SOCKET, SO_LINGER, &tmp, sizeof(tmp));
}
- 这里的
SO_LINGER
选项用于控制关闭套接字时的行为。m_OPT_LINGER
是一个标志变量,控制SO_LINGER
的行为。
struct linger tmp = {0, 1};
:如果m_OPT_LINGER
为0,SO_LINGER
的延时关闭行为被禁用。struct linger tmp = {1, 1};
:如果m_OPT_LINGER
为1,则套接字关闭时会在SO_LINGER
(1秒内) 时间内尝试发送剩余数据。
1.7.3. 绑定地址并监听
int ret = 0;
struct sockaddr_in address;
//在网络编程中,sockaddr_in 结构体用来存储地址信息(例如IP地址和端口)。为了避免结构体中的某些未初始化的成员包含随机值(即内存中的“垃圾”数据),在使用 address 结构体之前,通常先将它的所有字节清零,这样可以确保结构体中的所有字段初始值为0。
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(m_port);
int flag = 1;
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret >= 0);
ret = listen(m_listenfd, 5);
assert(ret >= 0);
sockaddr_in
结构体用于存储IP地址和端口信息。
sin_family = AF_INET;
:设置地址族为IPv4。sin_addr.s_addr = htonl(INADDR_ANY);
:绑定到本地所有IP地址(设置了服务器要监听的IP地址)。
tips:
绑定到本地所有IP地址的含义:
在网络编程中,一个服务器通常需要绑定到一个特定的IP地址和端口,以便能够接收来自客户端的连接请求。INADDR_ANY 是一个特殊的常量,用于表示“本地所有IP地址”。
INADDR_ANY: 当服务器的套接字绑定到 INADDR_ANY 时,意味着服务器会监听本地机器上所有的网络接口(如Wi-Fi、以太网、回环地址等)的IP地址。换句话说,不论客户端连接到本地机器的哪个IP地址,服务器都能接受到请求。
sin_port = htons(m_port);
:设置了服务器要监听的端口号,并在设置的过程中将主机字节序的端口号转换为网络字节序。
这一步的作用是将端口号从主机字节序(通常是小端序)转换为网络字节序(大端序),并将其绑定到套接字,以确保端口号在网络上传输时能够被正确识别。
tips:
sin_port
: 这是 sockaddr_in
结构体中的一个字段,用于存储端口号。这个结构体常用于指定服务器绑定的IP地址和端口。
htons(m_port)
:
htons
是 “Host TO Network Short” 的缩写,它将主机字节序的端口号转换为网络字节序。
不同计算机架构可能使用不同的字节序来表示数据。在网络通信中,数据必须按照统一的字节序进行传输,通常使用大端序(网络字节序)。
通过 htons
函数,确保在不同架构之间传输的端口号能够被正确理解,在网络通信中,统一的字节序是必要的,以确保不同主机之间的互操作性。通过使用 htons
,开发者可以确保本地主机上的端口号在网络上传输时能够被远端主机正确解析。
setsockopt(SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
:设置SO_REUSEADDR
选项,允许重用本地地址。
tips:结合场景来理解什么是重用本地地址
场景1:快速重启服务器
你在电脑上运行了一个程序(比如服务器),它占用了一个端口号(比如8080)。当你关闭这个程序时,系统会“保留”这个端口一段时间,防止旧的网络数据混乱。在这段时间里,你无法立即重新启动服务器去占用同样的端口,系统会报错说“端口被占用了”。
但是如果设置了 SO_REUSEADDR,你可以立刻再次使用这个端口,程序可以顺利重启,不用等待系统释放端口。
场景2:多个程序同时监听同一个端口
假设你有两个程序,它们都需要监听同一个端口(比如8080),但分别处理不同的IP地址。一般情况下,系统不允许两个程序同时占用一个端口,但 SO_REUSEADDR 允许这种“共享”情况。
bind()
函数将套接字绑定到指定的IP地址和端口。listen()
函数使套接字进入监听状态,准备接收连接。
1.7.4 初始化定时器
utils.init(TIMESLOT);
- 初始化定时器工具
utils
,并设置时间间隔TIMESLOT
,通常用于管理连接的超时事件。
1.7.5 创建 epoll
内核事件表
epoll_event events[MAX_EVENT_NUMBER];
m_epollfd = epoll_create(5);
assert(m_epollfd != -1);
epoll_create(5);
:创建一个epoll
事件表,参数5
是提示内核事件表的大小,但实际上Linux 2.6.8之后的内核忽略了这个参数。assert(m_epollfd != -1);
:确保epoll
创建成功。
1.7.6 添加监听文件描述符到 epoll
事件表
utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
http_conn::m_epollfd = m_epollfd;
- 将监听套接字
m_listenfd
添加到epoll
事件表中,以便epoll
可以监控这个套接字的事件(如新连接到来)。 http_conn::m_epollfd = m_epollfd;
:将epoll
文件描述符存储在静态变量中,以便HTTP连接可以使用。
1.7.7 创建用于信号通信的管道
ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
assert(ret != -1);
utils.setnonblocking(m_pipefd[1]);
utils.addfd(m_epollfd, m_pipefd[0], false, 0);
socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
:创建一个双向通信的UNIX域套接字对m_pipefd
,用于进程内部通信。- 将管道的读端
m_pipefd[0]
添加到epoll
事件表中,以便可以通过信号驱动epoll
的事件循环。
tips:
关于通道的读端和写端
socketpair
函数创建的是一对全双工的套接字,这对套接字之间可以相互通信。尽管每个端都可以同时进行读写,但在实际应用中,通常将一个端用于读取,另一个端用于写入,形成单向数据流。这种方式使得编程和理解逻辑更加清晰。
在常见的使用模式中:
m_pipefd[1]
被用于写入数据。m_pipefd[0]
被用于读取数据。
这种分工明确的方式使得数据流动更容易控制和管理。
为什么要将 m_pipefd[1]
写端设置为非阻塞?
将写端 m_pipefd[1]
设置为非阻塞有以下几个原因:
-
避免写操作阻塞进程:
- 如果
m_pipefd[1]
是阻塞的,当你尝试写入数据时,如果管道已满(即m_pipefd[0]
没有被读取),写操作将阻塞进程,直到有足够的空间可用。这可能导致程序挂起,等待管道缓冲区变得可写。 - 通过将
m_pipefd[1]
设置为非阻塞模式,如果管道已满,写操作将立即返回失败(通常返回EAGAIN
或EWOULDBLOCK
),程序可以处理这个情况而不被阻塞。
- 如果
-
提高程序的响应速度:
- 非阻塞 I/O 使得程序可以在无法立即完成 I/O 操作时继续执行其他任务,这对于高性能服务器或需要实时响应的系统特别重要。
- 当你使用
epoll
等异步 I/O 机制时,非阻塞 I/O 能够很好地与事件驱动模型结合,使得程序可以在数据就绪时立即处理,而不需要因为 I/O 操作而挂起。
-
配合
epoll
的事件驱动模型:epoll
本身是一个异步事件驱动机制,常与非阻塞 I/O 一起使用。通过设置写端为非阻塞,程序可以在检测到epoll
事件时决定如何处理写入操作,而不是在管道满的情况下阻塞写入操作。
将 m_pipefd[1]
设置为非阻塞可以避免程序在管道缓冲区满的情况下挂起,使程序在高负载或复杂并发情况下能够继续处理其他任务,提升响应速度和整体性能。这种设计与 epoll
的异步事件驱动模型相结合,可以高效地处理 I/O 操作。
结合一个场景来理解为什么需要创建双向通信的套接字
假设你有一个餐厅,餐厅里有一个服务员负责处理顾客的点单。这时候有两种情况会让服务员忙起来:
- 顾客点餐:这类似于服务器处理网络请求。
- 厨房准备好了菜:需要通知服务员去上菜,这就像服务器内部需要处理的事情,比如定时任务或信号。
问题
如果厨房想通知服务员“菜好了”,但服务员正在忙着处理顾客点餐,这时候服务员可能没法立刻去处理厨房的通知。
解决方案:创建一个内部通信通道
为了让服务员在处理顾客点餐的同时也能及时收到厨房的通知,你在餐厅内部装了一个“内部电话”(类似于双向套接字)。当厨房准备好菜后,它就会通过这个内部电话告诉服务员。服务员可以一边处理顾客的点单,一边通过电话听到厨房的通知。
代码中的实现
这个“内部电话”就是通过 socketpair
创建的双向套接字对。
- 当你的服务器处理网络请求时,它还会通过
epoll
监控这个套接字对。 - 如果有一些内部事件发生,比如需要处理的定时任务,服务器就可以通过写入这个套接字对来通知自己:“嘿,有事情要处理!”。
- 服务器会通过
epoll
监听这个通知,并做出相应的处理。
创建这个内部通信的套接字对,就像给餐厅的服务员装了一个内部电话,确保他在忙着处理顾客时,也能及时接收到厨房的通知,避免漏掉任何重要的事情。这样,服务器既能处理外部网络请求,也能处理自己的内部任务。
1.7.8 初始化信号的处理
//忽略SIGPIPE:不会因为向关闭的连接写数据而崩溃。
utils.addsig(SIGPIPE, SIG_IGN);
//处理SIGALRM:可以在定时器触发时执行特定任务,比如检查超时。
utils.addsig(SIGALRM, utils.sig_handler, false);
//处理SIGTERM:在终止程序前可以先完成一些必要的清理工作。
utils.addsig(SIGTERM, utils.sig_handler, false);
utils.addsig(SIGPIPE, SIG_IGN);
:忽略SIGPIPE
信号,防止在向一个已关闭的连接写数据时引发进程终止。utils.addsig(SIGALRM, utils.sig_handler, false);
:添加定时器信号SIGALRM
的处理函数sig_handler
。utils.addsig(SIGTERM, utils.sig_handler, false);
:添加终止信号SIGTERM
的处理函数sig_handler
。
1.7.9 启动定时器
alarm(TIMESLOT);
- 设置定时器,每隔
TIMESLOT
秒发送一次SIGALRM
信号,用于处理定时任务,比如检查超时连接。
1.7.10 初始化工具类中的全局变量
Utils::u_pipefd = m_pipefd;
Utils::u_epollfd = m_epollfd;
- 将管道文件描述符和
epoll
文件描述符传递给工具类Utils
,使工具类可以访问并处理这些描述符。
1.7.11 eventListen函数总结
这个 eventListen
函数完成了服务器在启动时所需的各项初始化工作,包括创建监听套接字、设置套接字选项、初始化 epoll
、设置信号处理的管道和定时器等。最终,服务器准备好监听来自客户端的连接,并可以处理各种事件和信号。
1.8 定时器相关函数
void WebServer::timer(int connfd, struct sockaddr_in client_address)
{
users[connfd].init(connfd, client_address, m_root, m_CONNTrigmode, m_close_log, m_user, m_passWord, m_databaseName);
// 初始化client_data数据
users_timer[connfd].address = client_address;
users_timer[connfd].sockfd = connfd;
util_timer *timer = new util_timer;
timer->user_data = &users_timer[connfd];
timer->cb_func = cb_func;
time_t cur = time(NULL);
timer->expire = cur + 3 * TIMESLOT;
users_timer[connfd].timer = timer;
utils.m_timer_lst.add_timer(timer);
}
- 定时器相关函数
WebServer::timer
:为新连接初始化定时器。- 初始化
http_conn
对象。 - 初始化
client_data
对象,并创建新的util_timer
定时器。 - 将定时器加入定时器链表
m_timer_lst
。
- 初始化
1.9 调整定时器
void WebServer::adjust_timer(util_timer *timer)
{
time_t cur = time(NULL);
timer->expire = cur + 3 * TIMESLOT;
utils.m_timer_lst.adjust_timer(timer);
LOG_INFO("%s", "adjust timer once");
}
- 调整定时器函数
WebServer::adjust_timer
:调整定时器的过期时间并重新加入定时器链表。
1.10 定时器回调函数
void WebServer::deal_timer(util_timer *timer, int sockfd)
{
timer->cb_func(&users_timer[sockfd]);
if (timer)
{
utils.m_timer_lst.del_timer(timer);
}
LOG_INFO("close fd %d", users_timer[sockfd].sockfd);
}
- 定时器回调函数
WebServer::deal_timer
:处理定时器过期事件,关闭连接并删除定时器。
1.11 处理客户端连接
bool WebServer::dealclinetdata()
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
if (0 == m_LISTENTrigmode)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
return false;
}
if (http_conn::m_user_count >= MAX_FD)
{
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
return false;
}
timer(connfd, client_address);
}
else
{
while (1)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
break;
}
if (http_conn::m_user_count >= MAX_FD)
{
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
break;
}
timer(connfd, client_address);
}
return false;
}
return true;
}
dealclinetdata函数代码逐行解释如下:
bool WebServer::dealclinetdata()
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
struct sockaddr_in client_address;
:定义一个sockaddr_in
结构体变量client_address
,用于存储客户端的地址信息(IP地址和端口)。socklen_t client_addrlength = sizeof(client_address);
:定义client_addrlength
变量,并将其初始化为client_address
的大小。这是为了在accept
函数调用时传递客户端地址结构体的大小。
if (0 == m_LISTENTrigmode)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (0 == m_LISTENTrigmode)
:判断服务器监听模式是否为LT模式(Level Triggered,电平触发)。int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
:调用accept
函数从监听套接字m_listenfd
接受一个新的客户端连接。accept
返回一个新的文件描述符connfd
,用于与该客户端通信。如果accept
失败,connfd
将返回-1
。
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
return false;
}
if (connfd < 0)
:判断accept
是否失败。accept
失败时connfd
会返回-1
。LOG_ERROR("%s:errno is:%d", "accept error", errno);
:记录一个错误日志,说明accept
调用失败,同时输出errno
错误代码,帮助排查问题。return false;
:如果accept
失败,返回false
,表示处理客户端连接失败。
if (http_conn::m_user_count >= MAX_FD)
{
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
return false;
}
if (http_conn::m_user_count >= MAX_FD)
:检查当前活跃的客户端连接数是否已经达到最大值MAX_FD
。m_user_count
是一个静态变量,记录当前的连接数。utils.show_error(connfd, "Internal server busy");
:调用show_error
函数,向客户端发送一个错误信息,提示服务器忙碌,无法处理新的连接。LOG_ERROR("%s", "Internal server busy");
:记录一个错误日志,说明服务器当前无法处理更多的连接。return false;
:返回false
,表示处理客户端连接失败。
timer(connfd, client_address);
}
timer(connfd, client_address);
:调用timer
函数,为这个新连接设置一个定时器(通常用于超时处理)。定时器的作用是防止客户端长时间占用资源但不发送数据,从而影响服务器性能。
else
{
while (1)
{
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
LOG_ERROR("%s:errno is:%d", "accept error", errno);
break;
}
if (http_conn::m_user_count >= MAX_FD)
{
utils.show_error(connfd, "Internal server busy");
LOG_ERROR("%s", "Internal server busy");
break;
}
timer(connfd, client_address);
}
return false;
}
else
:如果服务器的监听模式为ET(Edge Triggered,边缘触发)模式,则执行这个分支。while (1)
:进入一个无限循环,不断调用accept
函数,尝试接受所有可能的客户端连接。int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
:与前面相同,接受一个新的客户端连接。if (connfd < 0)
:与前面相同,判断accept
是否失败。如果失败,记录错误并跳出循环。if (http_conn::m_user_count >= MAX_FD)
:与前面相同,检查是否超过最大连接数。如果是,显示错误信息并跳出循环。timer(connfd, client_address);
:与前面相同,为这个新连接设置定时器。return false;
:跳出循环后,返回false
,表示处理客户端连接失败。
return true;
}
return true;
:如果在LT模式下(即if
分支中),没有发生错误,则返回true
,表示成功处理客户端连接。
dealclinetdata函数码的主要功能是处理新的客户端连接,根据不同的触发模式(LT或ET)来决定如何接受连接:
- 在LT模式下:一次只处理一个连接。
- 在ET模式下:使用循环处理所有可能的连接。
同时,代码还检查服务器是否达到了最大连接数,并在必要时设置定时器来管理连接的生命周期。
1.12 信号处理函数
bool WebServer::dealwithsignal(bool &timeout, bool &stop_server)
{
int ret = 0;
int sig;
char signals[1024];
ret = recv(m_pipefd[0], signals, sizeof(signals), 0);
if (ret == -1)
{
return false;
}
else if (ret == 0)
{
return false;
}
else
{
for (int i = 0; i < ret; ++i)
{
switch (signals[i])
{
case SIGALRM:
{
timeout = true;
break;
}
case SIGTERM:
{
stop_server = true;
break;
}
}
}
}
return true;
}
- 信号处理函数
WebServer::dealwithsignal
:处理信号。- 接收信号并根据信号类型设置标志位。
- 处理
SIGALRM
信号,设置timeout
标志位。SIGALRM信号是由定时器(通过调用alarm()或setitimer()函数设置)触发的,用来通知程序一个时间段已经过去,通常用于超时管理。 - 处理
SIGTERM
信号,设置stop_server
标志位。SIGTERM信号是用于请求进程终止的信号。当你在命令行使用kill命令终止某个进程时,默认情况下,操作系统会发送SIGTERM信号给该进程,通常用于优雅关闭服务器。
1.13 处理读事件
void WebServer::dealwithread(int sockfd)
{
util_timer *timer = users_timer[sockfd].timer;
// reactor
if (1 == m_actormodel)
{
if (timer)
{
adjust_timer(timer);
}
m_pool->append(users + sockfd, 0);
while (true)
{
if (1 == users[sockfd].improv)
{
if (1 == users[sockfd].timer_flag)
{
deal_timer(timer, sockfd);
users[sockfd].timer_flag = 0;
}
users[sockfd].improv = 0;
break;
}
}
}
else
{
if (users[sockfd].read_once())
{
LOG_INFO("deal with the client(%s)", inet_ntoa(users[sockfd].get_address()->sin_addr));
// 若监测到读事件,将该事件放入请求队列
m_pool->append_p(users + sockfd);
if (timer)
{
adjust_timer(timer);
}
}
else
{
deal_timer(timer, sockfd);
}
}
}
这个WebServer::dealwithread
函数用于处理Web服务器中客户端的读事件。根据设置的不同,该函数支持两种不同的并发处理模型:Reactor模型和Proactor模型。
以下为详细分析:
void WebServer::dealwithread(int sockfd)
{
util_timer *timer = users_timer[sockfd].timer;
dealwithread
函数接受一个参数sockfd
,这是客户端连接的套接字文件描述符。- 从一个数组
users_timer
中获取与该套接字相关联的定时器timer
。定时器通常用于跟踪每个连接的超时时间。
// reactor
if (1 == m_actormodel)
{
if (timer)
{
adjust_timer(timer);
}
m_pool->append(users + sockfd, 0);
if (1 == m_actormodel)
判断是否使用Reactor模型。如果m_actormodel
为1,则表示使用Reactor模型。if (timer) { adjust_timer(timer); }
检查定时器是否存在,如果存在则调整定时器。通常这是为了重置定时器的超时时间,确保连接在规定时间内没有任何活动时不会被关闭。m_pool->append(users + sockfd, 0);
将与套接字相关的用户数据指针users + sockfd
及标志0
(可能表示读事件)加入线程池队列,等待线程池中的工作线程来处理该事件。
while (true)
{
if (1 == users[sockfd].improv)
{
if (1 == users[sockfd].timer_flag)
{
deal_timer(timer, sockfd);
users[sockfd].timer_flag = 0;
}
users[sockfd].improv = 0;
break;
}
}
}
- 一个无限循环,等待
users[sockfd].improv
标志变为1。这通常表示某个事件已经被处理完毕或完成了某个状态的转换。 - 如果
improv
标志为1,并且timer_flag
也为1,则处理定时器事件(如关闭连接)。 - 重置
timer_flag
和improv
标志后,跳出循环。这段代码可能是为了确保在处理事件时没有定时器事件影响。
else
{
if (users[sockfd].read_once())
{
LOG_INFO("deal with the client(%s)", inet_ntoa(users[sockfd].get_address()->sin_addr));
- 如果
m_actormodel
不为1(即使用Proactor模型),则调用users[sockfd].read_once()
来尝试读取数据。如果读取成功:- 记录日志,输出客户端IP地址。
// 若监测到读事件,将该事件放入请求队列
m_pool->append_p(users + sockfd);
- 将读取的数据处理任务加入线程池(不同于Reactor模型,这里可能是直接处理已经读取的数据)。
if (timer)
{
adjust_timer(timer);
}
}
else
{
deal_timer(timer, sockfd);
}
}
}
- 如果读取成功,并且存在定时器,调整定时器。
- 如果读取失败,则处理定时器事件(如关闭连接)。
1.13.2 Reactor模型与Proactor模型
-
Reactor模型:
- 概念:Reactor模型是一种事件驱动的模式,应用程序(Web服务器)注册回调函数到某个事件处理器。当事件发生时(如有数据可读),事件处理器负责调用相应的回调函数处理事件。简单来说,Reactor模型只负责监听事件,并将事件交给工作线程去处理。
- 实际过程:在上述代码中,Reactor模型是通过将事件放入线程池 (
m_pool->append(...)
) 来处理的,处理的具体工作在工作线程中执行。 - 特点:事件的处理由多个线程来完成,通常用于高并发的网络编程。应用程序被动等待事件发生,然后响应这些事件。
-
Proactor模型:
- 概念:Proactor模型则是另一种模式,应用程序提前将需要执行的操作提交给操作系统(如读取数据)。操作系统完成操作后,通过事件通知应用程序,应用程序再来处理结果。即:操作系统负责事件的处理,应用程序负责后续的操作处理。
- 实际过程:在上述代码中,Proactor模型直接调用
users[sockfd].read_once()
来读取数据,然后将处理后的任务加入线程池 (m_pool->append_p(...)
)。Proactor模型中数据的读取和写入由操作系统负责,应用程序只在I/O操作完成后处理结果。 - 特点:这种模型将更多的处理任务交给操作系统,从而可能减少应用程序层面的处理负担,提高效率。
1.13.3 不同模型的区别和目的
-
区别:
- Reactor模型:应用程序主动等待事件发生并处理事件。I/O操作的实际执行和处理是分开的,事件到达时交给应用程序去处理。
- Proactor模型:应用程序事先提交操作请求,当操作系统完成后再通知应用程序。I/O操作的执行是由操作系统完成的,应用程序只处理结果。
-
目的:
- 两种模型的目的是相同的:高效地处理高并发的I/O请求。通过异步处理避免阻塞,提高系统的响应速度和吞吐量。
- Reactor 适合需要更细粒度控制的场景,开发者需要控制事件的处理过程。
- Proactor 更适合将I/O操作委托给操作系统管理的场景,能够更高效地利用系统资源。
1.14 处理写事件
void WebServer::dealwithwrite(int sockfd)
{
util_timer *timer = users_timer[sockfd].timer;
// reactor
if (1 == m_actormodel)
{
if (timer)
{
adjust_timer(timer);
}
m_pool->append(users + sockfd, 1);
while (true)
{
if (1 == users[sockfd].improv)
{
if (1 == users[sockfd].timer_flag)
{
deal_timer(timer, sockfd);
users[sockfd].timer_flag = 0;
}
users[sockfd].improv = 0;
break;
}
}
}
else
{
if (users[sockfd].write())
{
LOG_INFO("send data to the client(%s)", inet_ntoa(users[sockfd].get_address()->sin_addr));
if (timer)
{
adjust_timer(timer);
}
}
else
{
deal_timer(timer, sockfd);
}
}
}
- 处理写事件函数
WebServer::dealwithwrite
:处理客户端的写事件。- 如果是Reactor模型,则调整定时器并将事件加入线程池处理。
- 如果是Proactor模型,则直接写入数据,并将事件加入线程池处理。
1.15 主循环函数
void WebServer::eventLoop()
{
bool timeout = false;
bool stop_server = false;
while (!stop_server)
{
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR)
{
LOG_ERROR("%s", "epoll failure");
break;
}
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
// 处理新到的客户连接
if (sockfd == m_listenfd)
{
bool flag = dealclinetdata();
if (false == flag)
continue;
}
// 处理信号
else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
{
bool flag = dealwithsignal(timeout, stop_server);
if (false == flag)
continue;
}
// 处理客户连接上接收到的数据
else if (events[i].events & EPOLLIN)
{
dealwithread(sockfd);
}
else if (events[i].events & EPOLLOUT)
{
dealwithwrite(sockfd);
}
}
if (timeout)
{
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
1.15.1 主循环函数逐行解释
void WebServer::eventLoop()
{
bool timeout = false;
bool stop_server = false;
- 这段代码定义了两个布尔变量:
timeout
:用于标记是否发生了超时事件(通常与定时器相关)。stop_server
:用于标记是否应该停止服务器的主循环,控制服务器的运行状态。
while (!stop_server)
{
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
while (!stop_server)
:进入一个无限循环,只要stop_server
为false
,服务器将一直运行。epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1)
:- 这是
epoll
机制的核心函数,用于等待事件的发生。 m_epollfd
是epoll
实例的文件描述符。events
是用于存储发生的事件的数组。MAX_EVENT_NUMBER
是数组的最大容量。-1
表示这个调用会无限等待,直到有事件发生。
- 这是
if (number < 0 && errno != EINTR)
{
LOG_ERROR("%s", "epoll failure");
break;
}
number
表示epoll_wait
返回的事件数量。- 如果
number < 0
且错误不是由于信号中断(EINTR
),那么说明epoll_wait
出错了,记录错误日志并退出循环。
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
for
循环遍历所有返回的事件。sockfd
是触发事件的文件描述符。通过events[i].data.fd
获取触发该事件的对应文件描述符(例如,客户端的套接字或监听套接字)。
// 处理新到的客户连接
if (sockfd == m_listenfd)
{
bool flag = dealclinetdata();
if (false == flag)
continue;
}
- 如果
sockfd
等于服务器的监听文件描述符m_listenfd
,说明有新的客户端连接到来。 - 调用
dealclinetdata()
函数处理新连接,如果处理失败(flag == false
),则跳过后续代码继续处理下一个事件。
// 处理信号
else if ((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN))
{
bool flag = dealwithsignal(timeout, stop_server);
if (false == flag)
continue;
}
- 如果
sockfd
等于m_pipefd[0]
且事件类型为EPOLLIN
(表示可读),说明有信号事件需要处理。 - 信号通常通过管道传递,
m_pipefd[0]
是管道的读取端。 - 调用
dealwithsignal
函数来处理信号,处理信号可能会影响timeout
和stop_server
的状态。如果处理失败,跳过后续代码继续处理下一个事件。
// 处理客户连接上接收到的数据
else if (events[i].events & EPOLLIN)
{
dealwithread(sockfd);
}
- 如果
events[i].events
包含EPOLLIN
标志,说明对应的套接字上有数据可读(来自客户端的请求数据)。 - 调用
dealwithread(sockfd)
处理这个读事件,即读取客户端发送的数据。
else if (events[i].events & EPOLLOUT)
{
dealwithwrite(sockfd);
}
- 如果
events[i].events
包含EPOLLOUT
标志,说明对应的套接字可以写数据(可以向客户端发送响应)。 - 调用
dealwithwrite(sockfd)
处理这个写事件,即将服务器的响应数据发送给客户端。
if (timeout)
{
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
}
}
if (timeout)
:如果timeout
标志被设置为true
,则表示定时器已经超时,需要处理超时事件。- 调用
utils.timer_handler()
来处理超时事件,例如关闭长时间未响应的连接。 - 处理完定时器事件后,将
timeout
标志重置为false
,继续循环等待新的事件。
1.15.2 eventLoop总结
- 这个
eventLoop
函数是Web服务器的核心,它使用epoll
机制来处理多种类型的事件,包括新客户端连接、信号处理、客户端数据读写等。 - 通过这种事件驱动的模型,服务器可以高效地处理大量并发连接,而不会阻塞在某个特定的操作上,保证了服务器的高性能和稳定性。
这个主循环的设计允许服务器在接收到不同的事件时,采取相应的处理措施,从而确保服务器能够在高负载下稳定运行。这是编写高性能网络服务器的关键部分。
相关疑问总结
1. 为什么网络编程需要套接字(Socket)
套接字(Socket)是计算机网络编程中的基础概念和工具,它的作用和必要性可以从以下几个方面理解:
1.1通信抽象
- 统一的接口:套接字提供了一个统一的接口,使程序员能够通过相同的方式进行网络通信,无论底层使用的是哪种协议(例如TCP、UDP)。这就像是一种抽象层,屏蔽了底层实现的复杂性。
- 跨平台:套接字在不同操作系统上表现一致,提供了跨平台的通信能力,使开发者能够编写具有良好可移植性的网络应用程序。
1.2 网络通信的基础
- 网络通信的端点:在网络通信中,套接字扮演的是“通信端点”的角色。任何网络通信都是在两个端点(一个客户端和一个服务器端)之间进行的。套接字就是这个端点,它代表了一个IP地址和端口的组合。
- 支持多种协议:套接字不仅仅支持TCP(面向连接的通信),还支持UDP(无连接的通信)等协议,能够满足不同类型的网络通信需求。
1.3 数据传输的机制
- 数据收发:套接字提供了发送(send)和接收(recv)数据的机制,通过这些函数,程序可以在网络中传输数据。这是实现网络功能的核心部分。
- 流控制和连接管理:对于TCP套接字,套接字还提供了连接的管理(例如监听、接受连接)以及流控制等功能,使得数据能够可靠地传输。
1.4 操作系统的支持
- 操作系统接口:在操作系统中,套接字是与操作系统网络栈交互的接口。通过套接字,应用程序可以与操作系统内核进行通信,进而通过网络适配器与外部世界通信。
- 资源管理:套接字作为一种系统资源,由操作系统管理,能够确保资源的合理分配和回收。这避免了网络资源的浪费和冲突。
1.5 总结
套接字在网络编程中是不可或缺的,因为它提供了网络通信的基础设施和统一的接口,使得复杂的网络操作变得可管理和可操作。通过套接字,开发者能够构建出跨平台、可扩展的网络应用程序。没有套接字,程序将无法直接与网络进行通信,网络编程也就无从谈起。
2 epoll是什么
epoll
是 Linux 内核提供的一种高效的 I/O 多路复用机制,用于监控多个文件描述符,以便在这些文件描述符上发生事件时通知应用程序进行相应处理。相比于传统的 select
和 poll
,epoll
在处理大量文件描述符时表现更为高效,特别是在高并发场景下。
2.1 epoll
的主要特点:
-
高效性:
epoll
使用的是基于事件通知的机制,只有发生事件的文件描述符才会被返回,因此在大量文件描述符中只有少数有事件发生时,epoll
的性能优势显著。epoll
在内核空间维护了一个事件表,避免了每次调用都要传递整个文件描述符集合,减少了内核与用户态之间的数据拷贝。
-
水平触发和边缘触发:
- 水平触发(Level-triggered, LT):默认模式,只要某个文件描述符上有事件发生,
epoll_wait
就会返回该文件描述符,直到事件被处理。 - 边缘触发(Edge-triggered, ET):更为高效,但要求更细致的处理。当文件描述符状态从无事件变为有事件时才会通知,适用于减少系统调用频率,提高程序效率。
- 水平触发(Level-triggered, LT):默认模式,只要某个文件描述符上有事件发生,
-
对文件描述符数量的支持:
epoll
能够支持大规模的文件描述符集合,理论上上限是系统的最大文件描述符数,而select
和poll
通常有较小的文件描述符限制。
2.2 epoll
的工作流程:
-
创建
epoll
实例:- 使用
epoll_create
或epoll_create1
函数创建一个epoll
实例,返回一个epoll
文件描述符。
- 使用
-
注册事件:
- 使用
epoll_ctl
函数将需要监控的文件描述符添加到epoll
实例中,并指定要监听的事件类型(如可读、可写、异常等)。
- 使用
-
等待事件发生:
- 使用
epoll_wait
函数等待事件的发生,当某个或多个文件描述符上的事件满足条件时,epoll_wait
会返回这些文件描述符。
- 使用
-
处理事件:
- 处理返回的事件,执行相应的读写操作,或根据应用程序逻辑进行其他处理。
使用场景:
epoll
特别适合用于高并发的网络服务器中,比如 Web 服务器、聊天服务器等。这些应用通常需要处理大量并发连接,并且每个连接可能频繁进行 I/O 操作。epoll
能够有效地提升这些应用的性能。
总之,epoll
是在 Linux 环境下构建高性能网络服务器的重要工具,它通过高效的事件通知机制帮助开发者更好地管理大量并发 I/O 操作。
2.3 为什么在项目实现中,总是将类的定义(包括成员变量和成员函数的声明)放在头文件(.h文件)中,而将成员函数的实现放在源文件(.cpp文件)中?
在C++编程中,通常将类的定义(包括成员变量和成员函数的声明)放在头文件(.h
文件)中,而将成员函数的实现放在源文件(.cpp
文件)中。这种做法有几个重要的原因:
代码分离与清晰度:
- 头文件:主要用于定义类的接口(即类的定义),包括类的成员函数声明和成员变量。这使得头文件的内容比较简洁,便于其他开发者了解类的结构和使用方法,而不需要关注具体的实现细节。
- 源文件:用于实现类的具体行为,即成员函数的实现。这种分离使得代码更容易维护和阅读,因为实现细节被隐藏在源文件中,头文件仅展示类的接口。
编译时间优化:
- 当类的实现放在
.cpp
文件中时,只有在实现发生变化时才需要重新编译.cpp
文件。如果类的实现都放在.h
文件中,那么每次该头文件发生变化时,所有包含该头文件的文件都需要重新编译,这可能会大幅增加编译时间。
信息隐藏与封装性:
- 将实现细节隐藏在
.cpp
文件中可以更好地实现信息隐藏(encapsulation),这是一种面向对象编程的关键原则。外部代码只需要了解类的接口(即头文件中的内容),而不需要知道类是如何实现的。这也有助于保护类的实现不被意外修改或依赖。
防止重复定义:
- 如果将类的定义和实现都放在头文件中,那么在多个源文件中包含这个头文件时,可能会导致重复定义的问题。而将实现放在
.cpp
文件中,每个源文件只会包含一次相应的实现,从而避免了这个问题。
3. 复现过程中遇到的问题
3.1 解决“E: 无法定位软件包 mysql-workbench-community”问题
用这个指令:
sudo apt install mysql-workbench-community
会报错“E: 无法定位软件包 mysql-workbench-community”问题
解决方法为改用这个指令:
apt-get install mysql-workbench
成功:
分析下可能的原因:使用 mysql-workbench 是因为它在 Ubuntu 默认的软件源中,而 mysql-workbench-community 需要从 MySQL 官方仓库中获取。如果没有配置 MySQL 官方仓库,系统会找不到 mysql-workbench-community 包,导致错误信息的出现。
3.2 解决"正在设定ttf-mscorefonts-installer"
这里如果直接关了会导致后续包安装时会出现非法占用
解决方案:
按tab将光标移动到确定键上 然后回车就完事了