服务器三个主要模块:
四种IO模型,两种高效事件处理模式。
阻塞与非阻塞IO:阻塞与非阻塞的概念能应用于所有文件描述符(如socket文件描述符),阻塞的文件描述符称为阻塞IO,非阻塞的文件描述符为非阻塞IO。
同步与异步IO:同步IO向应用程序通知的是IO就绪事件,要求用户代码自行执行IO操作;异步IO向应用程序通知的是IO完成事件,由内核执行IO操作。
对于非阻塞IO执行的系统调用总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错情况一样。此时,需要根据errno来区分两种情况。对于accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN(”再来一次“)或者EWOULDBLOCK(”期望阻塞“);对connect而言,errno则被设置成EINPROCESS(”在处理中“)。
IO多路复用保证了每次调用的IO事件都是就绪事件,此时使用阻塞IO和非阻塞IO在效率上应该区别不大。但在epoll的ET模式下必须使用非阻塞的socket,因为ET模式下就绪的事件只会被通知一次。(个人理解而已)
select没有统一事件类型,poll和epoll统一了事件类型(把文件描述符和事件定义在一个结构体中)。
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
selec和poll采用轮询的方式检测就绪事件,O(n);epoll_wait采用回调的方式,O(1)。内核检测到就绪的文件描述符时,将触发回调函数,回调函数将文件描述符上的事件插入内核就绪链表中。内核最后在适当的时候将该就绪链表中的内容拷贝到用户空间。
epoll_wait适用于连接数量多,但活动连接较少的情况。(webserver属于IO密集型应用,不是计算密集型。因此,每个request的大部分生命都是在网络传输中,实际上花在server机器上的时间片不多。因此,webserver就属于连接数量多,但是实际活动连接较少的应用,epoll_wait适用于它。)当活动连接比较多的时候,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发得过于频繁。
epoll支持的事件类型和和poll基本相同,单epoll有两个额外的事件类型:EPOLLET和EPOLLONESHOT,它们对于epoll的高效运作非常关键。
服务器程序通常需要处理三类事件:I/O事件、信号以及定时事件。Reactor模式和Proactor模式。
Reactor模式(单Reactor+多线程):主线程只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。读写数据以及处理客户端请求均在工作线程中完成。
Proactor模式:将所有I/O操作都交给内核来处理,工作线程仅仅负责业务逻辑。
模拟Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
Reactor模式-----单Reactor多线程与主从Reactor多线程
主从Reactor模式(多Reactor+多线程/多进程):MainReactor只负责监视socket描述符的连接事件,接收到连接事件并处理后,将向内核注册了可读事件的socket描述符分配给SubReactor, SubReactor将socket描述符加入到可读(已连接)队列进行监视,并创建handler进行各种事件处理。
方案说明:
优点:
缺点:
统一事件源,是指将信号事件与其他事件(I/O事件)一样被处理。
具体的实现:信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。
两种高效并发模式,高效的逻辑处理方式——有限状态机。
并发编程的目的是让程序“同时”执行多个任务,提高CPU的利用率。
异步线程执行效率高,实时性强,但编写以异步方式执行的程序相对复杂,难于调试和扩展,不适合于大量的并发;同步线程执行效率相对较低,实时性较差,但逻辑简单。服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,适合采用半同步/半异步模式来实现。
同步线程用于处理客户逻辑(逻辑单元),异步线程用于处理I/O事件(I/O处理单元)。主线程负责充当异步线程,负责监听socket上事件。
综合考虑两种事件处理模式和几种I/O模型,半同步/半异步模式有多种变体:半同步/半反应堆模式、高效的半同步/半异步模式。
半同步/半反应堆模式(单reactor+多线程)的缺点:
高效的半同步/半异步模式(多reactor+多线程/进程):每个工作线程都能同时处理多个客户连接。在这种模式下,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。主线程向工作线程派发socket的最简单方式是往它和工作线程之间的管道写数据,避免了锁的使用。
为什么使用状态机? -> 状态机是有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型。使用状态机,在预先设定的有限个状态之间转移,可以使代码逻辑更加清晰。
在服务器中使用主从状态机解析HTTP报文:从状态机负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。
高效的半同步/半异步模式
比半同步/半反应堆模式
效率高。再例如,服务器必须使用“锁“,则可以考虑减小锁的粒度,比如使用读写锁。(C++多线程:互斥锁、自旋锁、条件变量、读写锁的定义与使用)项目中线程池使用互斥锁mtx_queue
保护任务队列一次只能被一个工作线程访问;使用判断队列非空的条件变量cond_not_empty
同步线程,如果队列为空,则阻塞当前空闲的工作线程this->cond_not_empty.wait(locker)
,直到有新的任务进入队列,去唤醒阻塞的工作线程cond_not_empty.notify_one()
。
线程同步的机制还有哪些? -> 信号量(建立在原子操作基础上)、自旋锁、读写锁
sem m_queuestat; //信号量表示任务队列中的task数量。
调用sem::wait()以原子操作的方式将信号量减1,信号量为0时,阻塞当前线程。(P操作)
调用sem::post()以原子操作的方式将信号量加1(当有新的任务进入队列,则调用post通知阻塞的工作线程),信号量大于0时,唤醒阻塞的线程。(V操作)
为什么要用定时器? -> HTTP/1.1起,默认使用长连接,用以保持连接特性。客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。因此,需要为每个HTTP连接设置一个定时器,处理这些非活跃连接。
服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。此外,利用升序双向时间链表将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务(将定时器的预设超时时间与当前实际实际比较,若小于当前实际时间,则执行定时任务,即关闭非活跃连接的文件描述符,释放连接资源)。
/* WebServer::eventListen() */
// SIG_IGN是信号处理方式,表示忽略SIGPIPE信号(详见书189页)
utils.addsig(SIGPIPE, SIG_IGN);
//传递给主循环的信号值,这里只关注SIGALRM和SIGTERM
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);
// 每隔TIMESLOT时间触发SIGALRM信号(项目中设置 TIMESLOT=5s )
alarm(TIMESLOT);
// 自定义信号处理函数
// 信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响。
void Utils::sig_handler(int sig)
{
//为保证函数的可重入性,保留原来的errno
//可重入性表示中断后再次进入该函数,环境变量与之前相同,不会丢失数据
int save_errno = errno;
int msg = sig;
//将信号值从管道写端写入,传输字符类型,而非整型
send(u_pipefd[1], (char *)&msg, 1, 0);
//将原来的errno赋值为当前的errno
errno = save_errno;
}
//设置信号函数
void Utils::addsig(int sig, void(handler)(int), bool restart)
{
//创建sigaction结构体变量
struct sigaction sa;
memset(&sa, '\0', sizeof(sa));
// handler为传入的信号处理函数
sa.sa_handler = handler;
if (restart){
sa.sa_flags |= SA_RESTART;
}
//将所有信号添加到信号集中
sigfillset(&sa.sa_mask);
//执行sigaction函数
assert(sigaction(sig, &sa, NULL) != -1);
}
WebServer::eventLoop()
中通过epoll监测统一的事件源,当管道对应文件描述符发生可读事件时,在主循环中执行信号对应的逻辑代码。如果从管道读到的是SIGALRM信号值(alarm函数设置的实时闹钟一旦超时,将触发SIGALARM信号),则timeout标志设置为true。bool WebServer::dealwithsignal(bool& timeout, bool &stop_server){
int ret = 0;
int sig;
char signals[1024];
//从管道读端读出信号值,成功返回字节数,失败返回-1
//正常情况下,这里的ret返回值总是1,只有14和15两个ASCII码对应的字符(SIGALRM->14, SIGTERM->15)
ret = recv(m_pipefd[0], signals, sizeof(signals), 0);
if (ret == -1){
return false;
}else if (ret == 0){
return false;
}else{
/*信号本身是整型数值,管道中传递的是ASCII码表中整型数值对应的字符。
switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASCII码。*/
//处理信号值对应的逻辑
for (int i = 0; i < ret; ++i){
//signals[i]是字符
switch (signals[i]){
//SIGALRM、SIGTERM是整型
case SIGALRM: // 时钟定时信号
{
timeout = true;
break;
}
case SIGTERM: // 程序结束信号
{
stop_server = true;
break;
}
}
}
}
return true;
}
if (timeout){
utils.timer_handler();
LOG_INFO("%s", "timer tick");
timeout = false;
}
tick()
相当于一个心搏函数,它每隔固定的时间就执行一次(由前步骤可知,每隔TIMESLOT时间,tick函数就会执行一次),以检测并处理到期的任务。void Utils::timer_handler()
{
m_timer_lst.tick();
alarm(m_TIMESLOT);
}
void sort_timer_lst::tick()
{
if (!head)
{
return;
}
//获取当前时间
time_t cur = time(NULL);
util_timer *tmp = head;
//遍历定时器链表
while (tmp)
{
//链表容器为升序排列
//当前时间小于定时器的超时时间,后面的定时器也没有到期
if (cur < tmp->expire)
{
break;
}
//当前定时器到期,则调用回调函数,执行定时事件
// tmp->cb_func(tmp->user_data);
tmp->run(cb_func, tmp->user_data);
//将处理后的定时器从链表容器中删除,并重置头结点
head = tmp->next;
if (head)
{
head->prev = NULL;
}
delete tmp;
tmp = head;
}
}
// 定时器回调函数,删除非活动socket上的注册事件,并关闭
void cb_func(client_data *user_data)
{
//删除非活动连接在socket上的注册事件
epoll_ctl(Utils::u_epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);
assert(user_data);
//关闭文件描述符
close(user_data->sockfd);
//减少连接数
http_conn::m_user_count--;
}
保存状态了吗?如果要保存,你会怎么做? -> Session和Cookie(由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识别具体的用户,这个机制就是Session)看完就彻底懂了session和cookie
Cookie保存状态过程:第一次客户端请求报文没有cookie信息,服务端生成并在响应报文中加入cookie信息返回给客户端;客户端下次发送请求报文的时候,自动发送保存着的cookie信息。
Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中;Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。
连接池中的资源为一组数据库连接,由程序动态地对池中的连接进行使用,释放。当系统开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配;当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源。
为什么要用连接池? -> 若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。
class connection_pool{
public:
//局部静态变量单例模式
static connection_pool *GetInstance();
private:
connection_pool();
~connection_pool();
}
connection_pool *connection_pool::GetInstance(){
static connection_pool connPool;
return &connPool;
}
//初始化mysql
MYSQL *con = NULL;
con = mysql_init(con);
//连接本地数据库
con = mysql_real_connect(con, url.c_str(), User.c_str(), PassWord.c_str(), DBName.c_str(), Port, NULL, 0);
// 建立好的连接加入连接池中
connList.push(con);
获取、释放连接:获取连接时,若连接池内没有连接了,则需要阻塞等待,这通过条件变量实现,并配合互斥锁保证共享的连接池资源的操作安全。(也可以用信号量配合互斥锁实现)
销毁连接池
//销毁数据库连接池
void connection_pool::DestroyPool(){
unique_lock<mutex> locker(mtx);
while(!connList.empty())
{
MYSQL *con = connList.front();
connList.pop();
mysql_close(con);
}
m_CurConn = 0;
m_FreeConn = 0;
}
// 定义
class connectionRAII{
public:
connectionRAII(MYSQL **con, connection_pool *connPool);
~connectionRAII();
private:
MYSQL *conRAII;
connection_pool *poolRAII;
};
//实现
connectionRAII::connectionRAII(MYSQL **SQL, connection_pool *connPool){
*SQL = connPool->GetConnection();
conRAII = *SQL;
poolRAII = connPool;
}
connectionRAII::~connectionRAII(){
poolRAII->ReleaseConnection(conRAII);
}
当系统需要访问数据库时,系统先创建数据库连接,完成数据库操作,然后系统断开数据库连接。
使用数据库连接池(单例模式和队列实现)实现服务器访问数据库的功能,使用POST请求完成注册和登录的校验工作(登录校验2,注册校验3,当使用POST请求时CGI标志设置为1)。
详细过程:首先,在网站输入URL http://127.0.0.1:9006/
,网站发送GET请求,服务器响应GET请求,使网站跳转到注册或登录页面。然后在页面上输入用户名和密码后,点击注册或登录,网站便发送POST请求,请求实体包含了注册或登录的用户名和密码,服务器解析POST请求,完成注册或登录的校验,然后根据校验结果跳转到相应的页面。
map<string, string> users;
// 同步校验,使用连接池
void http_conn::initmysql_result(connection_pool *connPool){
//先从连接池中取一个连接
MYSQL *mysql = NULL;
connectionRAII mysqlcon(&mysql, connPool);
//在user表中检索username,passwd数据,浏览器端输入
//查询成功返回0,失败返回非0值。
if (mysql_query(mysql, "SELECT username,passwd FROM user")){
LOG_ERROR("SELECT error:%s\n", mysql_error(mysql));
}
//从表中检索完整的结果集
MYSQL_RES *result = mysql_store_result(mysql);
//返回结果集中的列数
int num_fields = mysql_num_fields(result);
//返回所有字段结构的数组
MYSQL_FIELD *fields = mysql_fetch_fields(result);
//从结果集中获取下一行,将对应的用户名和密码,存入map中
while (MYSQL_ROW row = mysql_fetch_row(result)){
string temp1(row[0]);
string temp2(row[1]);
users[temp1] = temp2;
}
}
Redis和MySQL的区别与使用(redis做mysql的缓存并且数据同步)
登录时候用户名和密码load到本地,使用unordered_map存储,如果有10亿数据,load到本地后查询也是很耗时的,怎么优化? -> 将Redis用于缓存。把表中经常访问的记录放在了Redis中,然后用户查询时先去查询Redis再去查询MySQL,实现读写分离,也就是Redis只做读操作。由于缓存在内存中,所以查询会很快。
Redis基于内存,读写速度快,也可做持久化,但是内存空间有限,当数据量超过内存空间时,需扩充内存,但内存价格贵。
MySQL基于磁盘,读写速度没有Redis快,但是不受空间容量限制,性价比高。
大多数的应用场景是MySQL(主)+Redis(辅),MySQL做为主存储,Redis用于缓存,加快访问速度。需要高性能的地方使用Redis,不需要高性能的地方使用MySQL。存储数据在MySQL和Redis之间做同步。
现在大量的软件使用redis作为mysql在本地的数据库缓存,然后在适当的时候和mysql同步。
日志系统的运行机制:单例模式创建日志系统,多生产者(工作线程)-单消费者(写线程)模式实现异步的日志写入。(多个工作线程 ->缓冲区(循环数组or队列) -> 写线程)
同步日志就是同一个工作线程完成日志的生成和写入,异步日志就是日志生成和写入在不同的线程进行,中间通过一个阻塞队列实现。
为什么要异步? -> 工作线程处理业务的同时生成日志,同步的IO会阻塞工作线程的执行,从而导致服务器处理并发的能力下降。
为什么做这个项目:增加自己开发经历,同时写web服务器的过程能够学习到很多知识,比如HTPP协议、socket编程、线程池,IO模型,并发模型等。
轻量级的web服务器,主要实现的功能解析HTTP报文,支持GET和POST请求;定时器处理非活跃的连接;访问本的mysql数据库实现用户的注册和登录;实现异步日志系统,记录服务器运行状态。
IO多路复用那部分你是怎么去抽象这个事情的?
主线程负责接收连接和读写,工作线程进行报文的解析。
怎么去实现业务逻辑和核心逻辑的区分是怎么去组织这个代码?
请求队列是各单元之间的通信方式的抽象。主线程往任务队列里加入任务,工作线程组从任务队列获取任务。
怎么保证你epoll的代码可以尽量的被复用呢? -> 不太理解此问题,问的是epoll怎么实现IO多路复用?
epoll的原理你知道么?
首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。对于LT模式epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须是该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次。
epoll的边沿触发和水平触发这个了解么?说一下
水平触发LT,只要socketfd的状态为就绪状态(如socketfd的内核接收缓存区中还有未读完的数据),下次调用epoll_wait()仍然会向应用程序通知此就绪事件;边沿触发ET,只有当socketfd的状态由未就绪变成就绪(如socketfd的内核接收缓存区中有新的数据到来),调用epoll_wait()才会向应用程序通知此就绪事件。
ET模式下事件被触发的次数要比LT模式下少很多。(减少了内核态与用户态切换的消耗)
PS:读的时候,设置应用程序的缓存区长度大于报文的长度;写的时候,如果内核缓冲区满了,就重新注册写事件,返回true。
你使用的是哪种模式呢?
ET
为什么epoll在ET模式下必须设置非阻塞的socketfd?
在ET模式下,内核只通知应用程序一次就绪事件,需要一次性读完内核缓存区的数据。如果设置成阻塞IO,进行循环的read操作,当某次读完内核缓冲区数据时候,程序一定会阻塞在下一次read操作。因为当内核缓冲区没有数据时,阻塞IO不会返回errno,会一直阻塞当前线程;而非阻塞IO会不断地check,当内核缓冲区没有数据时会立即返回errno,EAGAIN或者EWOULDBLOCK,通过返回的errno可以判断是否读完。
什么情况下一次读不完内核缓冲区数据?
PS:Socket 读写就绪条件
当满足下列条件之一时,一个套接字准备好读:
选择边沿触发,虽然只通知一次但是你应用层还是要把这个状态记录下来的,那么一个是应用层消耗一个是内核态消耗,有考虑过么?
ET模式,循环读/写,一次性处理完是应用层消耗;LT模式,未读/写完成的由内核再次通知,这是内核态消耗。(LT模式下事件多次通知,需要内核态和用户态多次切换)
ET模式减少了内核态和用户态切换的消耗。(内核态和用户态频繁切换消耗比较大)
EPOLLONESHOT:
保证一个socket连接在任一时刻都只被一个线程处理。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
线程数,射多少更舒适?
主要考虑IO密集型还是CPU密集型。web服务器是IO密集型。