项目简介
源-项目地址: qinguoyi/TinyWebServer: Linux下C++轻量级Web服务器学习 (github.com)
Linux下C++轻量级Web服务器
- 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
- 使用状态机解析HTTP请求报文
支持解析GET和POST请求, - 访问服务器数据库实现web端用户注册
登录功能、 可以请求服务器图片和视频文件, - 实现同步/异步日志系统
记录服务器运行状态, - 经Webbench压力测试可以实现上万的并发连接数据交换
大纲
1. 什么是Web Server( 网络服务器) ?
一个Web Server就是一个服务器软件

2. 用户如何与你的Web服务器进行通信?
通常用户使用Web浏览器与相应服务器进行通信
3. Web服务器如何接收客户端发来的HTTP请求报文呢?
什么是socket?
Web服务器端通过socket监听来自用户的请求
#include <sys/socket.h>
#include <netinet/in.h>
/* 创建监听socket文件描述符 */
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
/* 创建监听socket的TCP/IP的IPV4 socket地址 */
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY); /* INADDR_ANY<span class="bd-box"><h-char class="bd bd-beg"><h-inner>:</h-inner></h-char></span>将套接字绑定到所有可用的接口 */
address.sin_port = htons(port);
int flag = 1;
/* SO_REUSEADDR 允许端口被重复使用 */
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
/* 绑定socket和它的地址 */
ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
/* 创建监听队列以存放待处理的客户连接<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>在这些客户连接被accept()之前 */
ret = listen(listenfd, 5);
远端的很多用户会尝试去connect()这个Web Server上正在listen的这个portaccept()listenfdlisten到新的客户连接并且放入监听队列accept这个连接
什么是EPOLL I/O复用?
参考: 浅谈Linux IO复用 - 知乎 (zhihu.com)
IO复用
举个简单例子fgets()而阻塞
如果tcp server想要同时处理多个描述符的事件
但IO复用技术的出现
我们在处理用户请求的同时listenfd
服务器程序通常需要处理三类事件
- Reactor模式
要求主线程: I/O处理单元( 只负责监听文件描述符上是否有事件发生) 可读( 可写、 ) 若有, 则立即通知工作线程, 逻辑单元( ) 将socket可读可写事件放入请求队列, 交给工作线程处理, 。 - Proactor模式
将所有的I/O操作都交给主线程和内核来处理: 进行读( 写、 ) 工作线程仅负责处理逻辑, 如主线程读完成后, users[sockfd].read() 选择一个工作线程来处理客户请求, pool->append(users + sockfd)。
通常使用同步I/O模型epoll_waitaio_read和aio_write
Linux下有三种IO复用方式
- 对于select和poll来说
所有文件描述符都是在用户态被加入其文件描述符集合的, 每次调用都需要将整个集合拷贝到内核态, epoll则将整个文件描述符集合维护在内核态; 每次添加文件描述符的时候都需要执行一个系统调用, 系统调用的开销是很大的。 而且在有很多短期活跃连接的情况下, epoll可能会慢于select和poll由于这些大量的系统调用开销, 。 - select使用线性表描述文件描述符集合
文件描述符有上限, poll使用链表来描述; epoll底层通过红黑树来描述; 并且维护一个ready list, 将事件表中已经就绪的事件添加到这里, 在使用epoll_wait调用时, 仅观察这个list中有没有数据即可, 。 - select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程
每次执行select或poll调用时: 它们会采用遍历的方式, 遍历整个文件描述符集合去判断各个文件描述符是否有活动, epoll则不需要去以这种方式检查; 当有活动产生时, 会自动触发epoll回调函数通知epoll文件描述符, 然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理, 。 - select和poll都只能工作在相对低效的LT模式下
而epoll同时支持LT和ET模式, 。 - 综上
当监测的fd数量较小, 且各个fd都很活跃的情况下, 建议使用select和poll, 当监听的fd数量较多; 且单位时间仅部分fd活跃的情况下, 使用epoll会明显提升性能, 。
从工作原理中
优缺点对比如下
- 支持监听的描述符数量
- select支持的描述符数量受限
因为需要遍历描述符列表: 多次用户空间到内核空间的内存拷贝、 。 - epoll可以支撑监听海量的描述符
。
性能 - select随着描述符数量的增多
性能下降严重, 时间复杂度 > O(n), 。 - epoll通过回调函数的方式
在描述符增长的情况下, 依旧有出色的性能, 。
使用场景 - select使用方式较为简单
适用于描述符量级小的情况, 。 - epoll适用于同时监听大量的描述符
。
Epoll对文件操作符的操作有两种模式
- LT
水平触发( 默认, ) 当时间发生时: 如果用户不处理的话, 内核会一直通知此事件, 。 - ET
垂直触发( ) 只有当事件状态发生改变时: 内核才会通知, 。 如果描述符缓冲区还有未处理的数据( 内核下次不会再通知了, 因为状态没有发生改变, )
4. Web服务器如何处理以及响应接收到的HTTP请求报文呢?
参考: C++ 线程池 - 敬方的个人博客 | BY Blog (wangpengcheng.github.io)
该项目使用线程池listenfd上到达的connection通过 accept()接收connfd用于和用户通信connfd注册到内核事件表中
什么是线程池?
这个过程是epoll_wait发现这个connfd上有可读事件了EPOLLINusers[sockfd].read()pool->append(users + sockfd);
- 所谓线程池
就是一个, pthread_t类型的普通数组 通过, pthread_create()函数创建m_thread_number个线程 用来执行, worker()函数以执行每个请求处理函数 HTTP请求的( process函数) 通过, pthread_detach()将线程设置成脱离态 detached( 后) 当这一线程运行结束时, 它的资源会被系统自动回收, 而不再需要在其它线程中对其进行, pthread_join()操作。 - 操作工作队列一定要加锁
( locker) 因为它被所有线程共享, 。 - 我们用信号量来标识请求队列中的请求数
通过, m_queuestat.wait();来等待一个请求队列中待处理的HTTP请求 然后交给线程池中的空闲线程来处理, 。
为什么要使用线程池?
当你需要限制你应用程序中同时运行的线程数时
关于HTTP请求?
本项目中的HTTP请求的入口函数
void http_conn::process() {
HTTP_CODE read_ret = process_read();
if(read_ret == NO_REQUEST) {
modfd(m_epollfd, m_sockfd, EPOLLIN);
return;
}
bool write_ret = process_write(read_ret);
if(!write_ret)
close_conn();
modfd(m_epollfd, m_sockfd, EPOLLOUT);
}
HTTP请求报文由请求行
- GET
Example( )
GET /562f25980001b1b106000338.jpg HTTP/1.1
Host:img.mukewang.com
User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept:image/webp,image/*,*/*;q=0.8
Referer:[http://www.imooc.com/](http://www.imooc.com/)
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8
空行
请求数据为空
- POST
Example( 注意POST的请求内容不为空, )
POST / HTTP1.1
Host:www.wrox.com
User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
Content-Type:application/x-www-form-urlencoded
Content-Length:40
Connection: Keep-Alive
空行
name=Professional%20Ajax&publisher=Wiley
GET和POST的区别
- 最直观的区别就是GET把参数包含在URL中
POST通过request body传递参数, 。 - GET请求参数会被完整保留在浏览器历史记录里
而POST中的参数不会被保留, 。 - GET请求在URL中传送的参数是有长度限制
。 大多数( 浏览器通常都会限制url长度在2K个字节) 而, 大多数( 服务器最多处理64K大小的url) 。 - GET产生一个TCP数据包
POST产生两个TCP数据包; 对于GET方式的请求。 浏览器会把http header和data一并发送出去, 服务器响应200, 返回数据( ) 而对于POST; 浏览器先发送header, 服务器响应100, 指示信息—表示请求已接收( 继续处理, continue) 浏览器再发送data, 服务器响应200 ok, 返回数据( ) 。
process_read()函数的作用就是将类似上述例子的请求报文进行解析parse_linem_check_state状态改变
parse_request_line(text) 解析请求行, 也就是GET中的, GET /562f25980001b1b106000338.jpg HTTP/1.1这一行 或者POST中的, POST / HTTP1.1这一行 通过请求行的解析我们可以判断该HTTP请求的类型。 GET/POST( ) 而请求行中最重要的部分就是, URL部分 我们会将这部分保存下来用于后面的生成HTTP响应, 。 parse_headers(text); 解析请求头部, GET和POST中, 空行以上 请求行以下的部分, 。 parse_content(text); 解析请求数据, 对于GET来说这部分是空的, 因为这部分内容已经以明文的方式包含在了请求行中的, URL部分了 只有POST的这部分是有数据的; 项目中的这部分数据为用户名和密码, 我们会根据这部分内容做登录和校验, 并涉及到与数据库的连接, 。
5. 数据库连接池是如何运行的?
在处理用户注册
我们首先看单个数据库连接是如何生成的
- 使用
mysql_init()初始化连接 - 使用
mysql_real_connect()建立一个到mysql数据库的连接 - 使用
mysql_query()执行查询语句 - 使用
result = mysql_store_result(mysql)获取结果集 - 使用
mysql_num_fields(result)获取查询的列数, mysql_num_rows(result)获取结果集的行数 - 通过
mysql_fetch_row(result)不断获取下一行 然后循环输出, - 使用
mysql_free_result(result)释放结果集所占内存 - 使用
mysql_close(conn)关闭连接
对于一个数据库连接池来讲MAX_CONNFREE_CONN和当前已用连接数CUR_CONN这三个变量
6. 什么是CGI校验?
OK
CGI.cpp文件编程成.cgi文件并在主程序中调用即可makefile文件内容也可以看出button时运行sign.cpp文件就是我们的CGI程序id_passwd.txt文件中map中用于校验execl(m_real_file, &flag, name, password, NULL);这句命令来执行这个CGI文件pipe进行父子进程的通信
7. 如何生成HTTP响应并返回给用户?
通过以上操作mmap将其映射到内存地址m_file_address处FILE_REQUESTprocess_write(read_ret);这一步process_read()的返回结果来判断应该返回给用户什么响应404错误了mmap到m_file_address这里了connfd的写缓存m_write_buf中去
case FILE_REQUEST: {
add_status_line(200, ok_200_title);
if(m_file_stat.st_size != 0) {
add_headers(m_file_stat.st_size);
m_iv[0].iov_base = m_write_buf;
m_iv[0].iov_len = m_write_idx;
m_iv[1].iov_base = m_file_address;
m_iv[1].iov_len = m_file_stat.st_size;
m_iv_count = 2;
bytes_to_send = m_write_idx + m_file_stat.st_size;
return true;
}
else {
const char* ok_string = "<html><body></body></html>";
add_headers(strlen(ok_string));
if(!add_content(ok_string))
return false;
}
}
00 Linux网络开发环境搭建
MacOS虽然也是UNIX操作系统, 但本项目中的许多库函数在Mac下并未得到支持, 所以本次项目使用Docker下的虚拟环境进行开发.
#macos
docker pull centos
docker -it run centos
#centos container
yum update -y
yum install gcc-c++
yum install mysql
yum install mysql-server
ln -s /usr/lib64/libmysqlclient.so /usr/lib/libmysqlclient.so
01 线程同步机制封装类
C++的RSII机制
- RAII全称是
Resource Acquisition is Initialization“ ” 直译过来是, 资源获取即初始化“ .” - 在构造函数中申请分配资源
在析构函数中释放资源, 因为C++的语言机制保证了。 当一个对象创建的时候, 自动调用构造函数, 当对象超出作用域的时候会自动调用析构函数, 所以。 在RAII的指导下, 我们应该使用类来管理资源, 将资源和对象的生命周期绑定, - RAII的核心思想是将资源或者状态与对象的生命周期绑定
通过C++的语言机制, 实现资源和状态的安全管理,智能指针是RAII最好的例子,
Linux中的条件变量
条件变量提供了一种线程间的通知机制,当某个共享数据达到某个值时,唤醒等待这个共享数据的线程.
pthread_cond_init函数用于初始化条件变量
pthread_cond_destory函数销毁条件变量
pthread_cond_broadcast函数以广播的方式唤醒所有等待目标条件变量的线程
pthread_cond_wait函数用于等待目标条件变量.该函数调用时需要传入 mutex参数(加锁的互斥锁) ,函数执行时,先把调用线程放入条件变量的请求队列,然后将互斥锁mutex解锁,当函数成功返回为0时,互斥锁会再次被锁上. 也就是说函数内部会有一次解锁和加锁操作.
Linux中的互斥量
互斥锁,也成互斥量,可以保护关键代码段,以确保独占式访问.当进入关键代码段,获得互斥锁将其加锁;离开关键代码段,唤醒等待该互斥锁的线程.
pthread_mutex_init函数用于初始化互斥锁
pthread_mutex_destory函数用于销毁互斥锁
pthread_mutex_lock函数以原子操作方式给互斥锁加锁
pthread_mutex_unlock函数以原子操作方式给互斥锁解锁
以上
Linux中的信号量
信号量是一种特殊的变量
- P
如果SV的值大于0, 则将其减一, 若SV的值为0; 则挂起执行, - V
如果有其他进行因为等待SV而挂起, 则唤醒, 若没有; 则将SV值加一,
信号量的取值可以是任何自然数
sem_init函数用于初始化一个未命名的信号量
sem_destory函数用于销毁信号量
sem_wait函数将以原子操作方式将信号量减一,信号量为0时,sem_wait阻塞
sem_post函数以原子操作方式将信号量加一,信号量大于0时,唤醒调用sem_post的线程
以上
02 数据库连接池
什么是数据库连接池
池是一组资源的集合
顾名思义
当系统开始处理客户请求的时候
数据库访问的一般流程是什么
当系统需要访问数据库时
为什么要创建连接池
从一般流程中可以看出
在程序初始化的时候
数据库连接池
池可以看做资源的容器
项目中的数据库模块分为两部分
单例模式创建
连接池代码实现
RAII机制释放数据库连接
数据库连接池
- 单例模式
保证唯一 , - list实现连接池
- 连接池为静态大小
- 互斥锁实现线程安全
- connectionRAII类
class connectionRAII{
public:
connectionRAII(MYSQL **con, connection_pool *connPool);
~connectionRAII();
private:
MYSQL *conRAII;
connection_pool *poolRAII;
};
目的是实现connction的自动连接与释放, 以下显示了connectionRAII在用户代码是如何被使用, 起到了怎样的效果
//引入RAII类前: 用户代码中需要手动获得和释放连接
//从连接池中取出一个数据库连接
request->mysql = m_connPool->GetConnection();
//process(模板类中的方法,这里是http类)进行处理
request->process();
//将数据库连接放回连接池
m_connPool->ReleaseConnection(request->mysql);
//引入RAII类之后: 连接不使用可以被自动释放
connectionRAII mysqlcon(&request->mysql, m_connPool);
request->process();
03 半同步半反应堆线程池
服务器编程基本框架

主要由I/O单元
其中I/O单元用于处理客户端连接
五种I/O模型
阻塞IO:调用者调用了某个函数
等待这个函数返回, 期间什么也不做, 不停的去检查这个函数有没有返回, 必须等这个函数返回才能进行下一步动作, 非阻塞IO:非阻塞等待
每隔一段时间就去检测IO事件是否就绪, 没有就绪就可以做其他事。 非阻塞I/O执行系统调用总是立即返回。 不管时间是否已经发生, 若时间没有发生, 则返回-1, 此时可以根据errno区分这两种情况, 对于accept, recv和send, 事件未发生时, errno通常被设置成eagain, 信号驱动IO:linux用套接口进行信号驱动IO
安装一个信号处理函数, 进程继续运行并不阻塞, 当IO时间就绪, 进程收到SIGIO信号, 然后处理IO事件。 。 IO复用:linux用select/poll函数实现IO复用模型
这两个函数也会使进程阻塞, 但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作, 而且可以同时对多个读操作。 写操作的IO函数进行检测、 知道有数据可读或可写时。 才真正调用IO操作函数, 异步IO:linux中
可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小, 文件偏移及通知的方式、 然后立即返回, 当内核将数据拷贝到缓冲区后, 再通知应用程序, 。
事件处理模式
reactor模式中
主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生, 有的话立即通知工作线程(逻辑单元 ), 读写数据, 接受新连接及处理客户请求均在工作线程中完成、 通常由同步I/O实现。 。 proactor模式中
主线程和内核负责处理读写数据, 接受新连接等I/O操作、 工作线程仅负责业务逻辑, 如处理客户请求, 通常由异步I/O实现。 。
同步I/O模拟proactor模式
由于异步I/O并不成熟
同步I/O模型的工作流程如下
主线程往epoll内核事件表注册socket上的读就绪事件
。 主线程调用epoll_wait等待socket上有数据可读
当socket上有数据可读
epoll_wait通知主线程,主线程从socket循环读取数据 , 直到没有更多数据可读 , 然后将读取到的数据封装成一个请求对象并插入请求队列 , 。 睡眠在请求队列上某个工作线程被唤醒
它获得请求对象并处理客户请求 , 然后往epoll内核事件表中注册该socket上的写就绪事件 , 主线程调用epoll_wait等待socket可写
。 当socket上有数据可写
epoll_wait通知主线程 , 主线程往socket上写入服务器处理客户请求的结果 。 。
并发编程模式
并发编程方法的实现有多线程和多进程两种
半同步/半异步模式
领导者/追随者模式
半同步/半反应堆并发模式是半同步/半异步的变体
并发模式中的同步和异步
同步指的是程序完全按照代码序列的顺序执行
异步指的是程序的执行需要由系统事件驱动
半同步/半异步模式工作流程
同步线程用于处理客户逻辑
异步线程用于处理I/O事件
异步线程监听到客户请求后
就将其封装成请求对象并插入请求队列中 , 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象
半同步/半反应堆工作流程
主线程充当异步线程
负责监听所有socket上的事件 , 若有新请求到来
主线程接收之以得到新的连接socket , 然后往epoll内核事件表中注册该socket上的读写事件 , 如果连接socket上有读写事件发生
主线程从socket上接收数据 , 并将数据封装成请求对象插入到请求队列中 , 所有工作线程(线程池)睡眠在请求队列上
当有任务到来时 , 通过竞争 , 如互斥锁 ( 获得任务的接管权 )
线程池
空间换时间,牺牲服务器的硬件资源,换取运行效率.
池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源.
当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配.
当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源.
静态成员变量
将类成员变量声明为static
静态变量在编译阶段就分配了空间
最好是类内声明
类外初始化, 以免类名访问静态成员访问不到( ) 。 无论公有
私有, 静态成员都可以在类外定义, 但私有成员仍有访问权限, 。 非静态成员类外不能初始化
。 静态成员数据是共享的
。
静态成员函数
将类成员函数声明为static
静态成员函数可以直接访问静态成员变量
不能直接访问普通成员变量, 但可以通过参数传递的方式访问, 。 普通成员函数可以访问普通成员变量
也可以访问静态成员变量, 。 静态成员函数没有this指针
非静态数据成员为对象单独维护。 但静态成员函数为共享函数, 无法区分是哪个对象, 因此不能直接访问普通变量成员, 也没有this指针, 。
pthread_create陷阱
static void *worker(void *args);
pthread_create(m_threads[i], NULL, worker, this)!=0
首先看一下该函数的函数原型
pthread_create()函数
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
pthread_create(m_threads[i], NULL, worker, this)!=0
//本质上工作线程的内容还是threadpool<T>::run()
template <typename T>
void *threadpool<T>::worker(void *arg)
{
threadpool *pool = (threadpool *)arg;
pool->run();
return pool;
}
函数原型中的第三个参数
pthread_create的函数原型中第三个参数的类型为函数指针(void *),若线程函数为类成员函数(void*)不能匹配
静态成员函数就没有这个问题
线程池设计
使用一个工作队列完全解除了主线程和工作线程的耦合关系
- 同步I/O模拟proactor模式
- 半同步/半反应堆
- 线程池
线程池的设计模式为半同步/半反应堆
具体的
04 HTTP连接处理
IO复用
一文看懂IO多路复用 - 知乎 (zhihu.com)
epoll涉及的知识较多游双的Linux高性能服务器编程 第9章 I/O复用
epoll_create函数
#include <sys/epoll.h>
int epoll_create(int size)
创建一个指示epoll内核事件表的文件描述符
epoll_ctl函数
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
该函数用于操作内核事件表监控的文件描述符上的事件
epfd
为epoll_creat的句柄: op
表示动作: 用3个宏来表示, : EPOLL_CTL_ADD (注册新的fd到epfd)
, EPOLL_CTL_MOD (修改已经注册的fd的监听事件)
, EPOLL_CTL_DEL (从epfd删除一个fd)
; event
告诉内核需要监听的事件:
上述event是epoll_event结构体指针类型
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */};
events描述事件类型
其中epoll事件类型有以下几种, EPOLLIN
表示对应的文件描述符可以读: 包括对端SOCKET正常关闭( ) EPOLLOUT
表示对应的文件描述符可以写: EPOLLPRI
表示对应的文件描述符有紧急的数据可读: 这里应该表示有带外数据到来( ) EPOLLERR
表示对应的文件描述符发生错误: EPOLLHUP
表示对应的文件描述符被挂断: ; EPOLLET
将EPOLL设为边缘触发(Edge Triggered)模式: 这是相对于水平触发(Level Triggered)而言的, EPOLLONESHOT
只监听一次事件: 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里,
epoll_wait函数
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
该函数用于等待所监控文件描述符上有事件的产生
events
用来存内核得到事件的集合: , maxevents
告之内核这个events有多大: 这个maxevents的值不能大于创建epoll_create()时的size, , timeout
是超时时间: -1
阻塞: 0
立即返回: 非阻塞, 0
指定毫秒: 返回值
成功返回有多少文件描述符就绪: 时间到时返回0, 出错返回-1,
select/poll/epoll
调用函数
select和poll都是一个函数
epoll是一组函数, 文件描述符数量
select通过线性表描述文件描述符集合
文件描述符有上限, 一般是1024, 但可以修改源码, 重新编译内核, 不推荐, poll是链表描述
突破了文件描述符上限, 最大可以打开文件的数目, epoll通过红黑树描述
最大可以打开文件的数目, 可以通过命令ulimit -n number修改, 仅对当前终端有效, 将文件描述符从用户传给内核
select和poll通过将所有文件描述符拷贝到内核态
每次调用都需要拷贝, epoll通过epoll_create建立一棵红黑树
通过epoll_ctl将要监听的文件描述符注册到红黑树上, 内核判断就绪的文件描述符
select和poll通过遍历文件描述符集合
判断哪个文件描述符上有事件发生, epoll_create时
内核除了帮我们在epoll文件系统里建了个红黑树用于存储以后epoll_ctl传来的fd外, 还会再建立一个list链表, 用于存储准备就绪的事件, 当epoll_wait调用时, 仅仅观察这个list链表里有没有数据即可, 。 epoll是根据每个fd上面的回调函数(中断函数)判断
只有发生了事件的socket才会主动的去调用 callback函数, 其他空闲状态socket则不会, 若是就绪事件, 插入list, 应用程序索引就绪文件描述符
select/poll只返回发生了事件的文件描述符的个数
若知道是哪个发生了事件, 同样需要遍历, epoll返回的发生了事件的个数和结构体数组
结构体包含socket的信息, 因此直接处理返回的数组即可, 工作模式
select和poll都只能工作在相对低效的LT模式下
epoll则可以工作在ET高效模式
并且epoll还支持EPOLLONESHOT事件, 该事件能进一步减少可读, 可写和异常事件被触发的次数、 。 应用场景
当所有的fd都是活跃连接
使用epoll, 需要建立文件系统, 红黑书和链表对于此来说, 效率反而不高, 不如selece和poll, 当监测的fd数目较小
且各个fd都比较活跃, 建议使用select或者poll, 当监测的fd数目非常大
成千上万, 且单位时间只有其中的一部分fd处于就绪状态, 这个时候使用epoll能够明显提升性能,
ET、 LT、 EPOLLONESHOT
LT水平触发模式
epoll_wait检测到文件描述符有事件发生
则将其通知给应用程序, 应用程序可以不立即处理该事件, 。 当下一次调用epoll_wait时
epoll_wait还会再次向应用程序报告此事件, 直至被处理, ET边缘触发模式
epoll_wait检测到文件描述符有事件发生
则将其通知给应用程序, 应用程序必须立即处理该事件, 必须要一次性将数据读取完
使用非阻塞I/O, 读取到出现eagain, EPOLLONESHOT
一个线程读取某个socket上的数据后开始处理数据
在处理过程中该socket上又有新数据可读, 此时另一个线程被唤醒读取, 此时出现两个线程处理同一个socket, 我们期望的是一个socket连接在任一时刻都只被一个线程处理
通过epoll_ctl对该文件描述符注册epolloneshot事件, 一个线程处理socket时, 其他线程将无法处理, 当该线程处理完后, 需要通过epoll_ctl重置epolloneshot事件,
HTTP报文
HTTP报文分为请求报文和响应报文两种
其中
请求报文
HTTP请求报文由请求行
其中
- GET
1 GET /562f25980001b1b106000338.jpg HTTP/1.1
2 Host:img.mukewang.com
3 User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64) 4 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 5 Accept:image/webp,image/*,*/*;q=0.8
6 Referer:http://www.imooc.com/
7 Accept-Encoding:gzip, deflate, sdch
8 Accept-Language:zh-CN,zh;q=0.8 9 空行10 请求数据为空
- POST
1 POST / HTTP1.1
2 Host:www.wrox.com
3 UserAgent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
4 Content-Type:application/x-www-form-urlencoded
5 Content-Length:40
6 Connection: Keep-Alive
7 空行
8 name=Professional%20Ajax&publisher=Wiley
响应报文
HTTP响应也由四个部分组成
1 HTTP/1.1 200 OK
2 Date: Fri, 22 May 2009 06:07:21 GMT
3 Content-Type: text/html; charset=UTF-8
4 空行
5<html>
6<head></head>
7 <body>
8 <!--body goes here-->
9 </body>
10</html>
状态行
由HTTP协议版本号 , 状态码 , 状态消息 三部分组成 , 。
第一行为状态行, HTTP/1.1 ( 表明HTTP版本为1.1版本 ) 状态码为200 , 状态消息为OK , 。 消息报头
用来说明客户端要使用的一些附加信息 , 。
第二行和第三行为消息报头Date:生成响应的日期和时间 , Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8 ; 。 空行
消息报头后面的空行是必须的 , 。 响应正文
服务器返回给客户端的文本信息 , 空行后面的html部分为响应正文 。 。
响应状态码
HTTP有5种类型的状态码
1xx
指示信息–表示请求已接收: 继续处理, 。 2xx
成功–表示请求正常处理完毕: 。 200 OK
客户端请求被正常处理: 。 206 Partial content
客户端进行了范围请求: 。 3xx
重定向–要完成请求必须进行更进一步的操作: 301 Moved Permanently
永久重定向: 该资源已被永久移动到新位置, 将来任何对该资源的访问都要使用本响应返回的若干个URI之一, 。 302 Found
临时重定向: 请求的资源现在临时从不同的URI中获得, 。 4xx
客户端错误–请求有语法错误: 服务器无法处理请求, 。 400 Bad Request
请求报文存在语法错误: 。 403 Forbidden
请求被服务器拒绝: 。 404 Not Found
请求不存在: 服务器上找不到请求的资源, 。 5xx
服务器端错误–服务器处理请求出错: 。 500 Internal Server Error
服务器在执行请求时出现错误: 。
HTTP请求解析
有限状态机
有限状态机可以通过if-else,switch-case和函数指针来实现
首先对http报文处理的流程进行简要介绍
浏览器端发出http连接请求
主线程创建http对象接收请求并将所有数据读入对应buffer, 将该对象插入任务队列, 工作线程从任务队列中取出一个任务进行处理, 。 工作线程取出任务后
调用process_read函数, 通过主, 从状态机对请求报文进行解析、 。 解析完之后
跳转do_request函数生成响应报文, 通过process_write写入buffer, 返回给浏览器端, 。
在HTTP报文中
从状态机负责读取buffer中的数据
从状态机从m_read_buf中逐字节读取
判断当前字节是否为\r, 接下来的字符是\n
将\r\n修改成\0\0, 将m_checked_idx指向下一行的开头, 则返回LINE_OK, 接下来达到了buffer末尾
表示buffer还需要继续接收, 返回LINE_OPEN, 否则
表示语法错误, 返回LINE_BAD,
当前字节不是\r
判断是否是\n, 一般是上次读取到\r就到了buffer末尾( 没有接收完整, 再次接收时会出现这种情况, ) - 如果前一个字符是\r
则将\r\n修改成\0\0, 将m_checked_idx指向下一行的开头, 则返回LINE_OK,
- 如果前一个字符是\r
当前字节既不是\r
也不是\n, - 表示接收不完整
需要继续接收, 返回LINE_OPEN,
- 表示接收不完整
主从状态机
流程图和代码分别对状态机和服务器解析请求报文进行详解
流程图部分
代码部分

- 主状态机
三种状态
CHECK_STATE_REQUESTLINE
解析请求行, CHECK_STATE_HEADER
解析请求头, CHECK_STATE_CONTENT
解析消息体, 仅用于解析POST请求, 从状态机
三种状态
LINE_OK
完整读取一行, LINE_BAD
报文语法有误, LINE_OPEN
读取的行不完整,
在http请求接收部分
这里
HTTP_CODE含义
表示HTTP请求的处理结果
NO_REQUEST: 请求不完整
需要继续读取请求报文数据, GET_REQUEST: 获得了完整的HTTP请求
BAD_REQUEST: HTTP请求报文有语法错误
INTERNAL_ERROR: 服务器内部错误
该结果在主状态机逻辑switch的default下, 一般不会触发,
解析报文整体流程
process_read通过while循环
循环条件
while((m_check_state==CHECK_STATE_CONTENT && line_status==LINE_OK)||((line_status=parse_line())==LINE_OK))
在GET请求报文中
但
判断条件
主状态机转移到CHECK_STATE_CONTENT
该条件涉及解析消息体, 从状态机转移到LINE_OK
该条件涉及解析请求行和请求头部, 两者为或关系
当条件为真则继续循环, 否则退出,
循环体
从状态机读取数据
调用get_line函数
通过m_start_line将从状态机读取数据间接赋给text, 主状态机解析text
解析完消息体后
为此
CHECK_STATE_REQUESTLINE
主状态机初始状态是CHECK_STATE_REQUESTLINE
CHECK_STATE_REQUESTLINE
- 主状态机的初始状态
调用parse_request_line函数解析请求行, - 解析函数从m_read_buf中解析HTTP请求行
获得请求方法, 目标URL及HTTP版本号、 - 解析完成后主状态机的状态变为CHECK_STATE_HEADER
- 主状态机的初始状态
解析完请求行后
CHECK_STATE_HEADER
CHECK_STATE_HEADER
调用parse_headers函数解析请求头部信息
判断是空行还是请求头
若是空行, 进而判断content-length是否为0, 如果不是0, 表明是POST请求, 则状态转移到CHECK_STATE_CONTENT, 否则说明是GET请求, 则报文解析结束, 。 若解析的是请求头部字段
则主要分析connection字段, content-length字段, 其他字段可以直接跳过, 各位也可以根据需求继续分析, 。 若解析的是请求头部字段
则主要分析connection字段, content-length字段, 其他字段可以直接跳过, 各位也可以根据需求继续分析, connection字段判断是keep-alive还是close。 决定是长连接还是短连接, content-length字段
这里用于读取post请求的消息体长度,
如果仅仅是GET请求
因为在上篇推文中我们曾说道
但后续的登录和注册功能
为此
CHECK_STATE_CONTENT
仅用于解析POST请求
调用parse_content函数解析消息体, 用于保存post请求消息体
为后面的登录和注册做准备,
HTTP响应处理
基础知识:
介绍statmmapiovecwritev
stat
stat函数用于取得指定文件的文件属性
#include <sys/stat.h>
//获取文件属性<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>存储在statbuf中
int stat(const char *pathname, struct stat *statbuf);
//完整定义很长, 仅介绍用到的
struct stat
{
mode_t st_mode; /* 文件类型和权限 */
off_t st_size; /* 文件大小<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>字节数*/
};
stat(m_real_file, &m_file_stat)<0
mmap
用于将一个文件或其他对象映射到内存
void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
int munmap(void* start,size_t length);
start
映射区的开始地址: 设置为0时表示由系统决定映射区的起始地址, length
映射区的长度: prot
期望的内存保护标志: 不能与文件的打开模式冲突, - PROT_READ 表示页内容可以被读取
flags
指定映射对象的类型: 映射选项和映射页是否可以共享, - MAP_PRIVATE 建立一个写入时拷贝的私有映射
内存区域的写入不会影响到原文件,
- MAP_PRIVATE 建立一个写入时拷贝的私有映射
fd
有效的文件描述符: 一般是由open()函数返回, off_toffset
被映射对象内容的起点:
iovec
定义了一个向量元素
struct iovec
{
void *iov_base; /* Pointer to data. */
size_t iov_len; /* Length of data. */
};
- iov_base指向数据的地址
- iov_len表示数据的长度
writenv
writev函数用于在一次函数调用中写多个非连续缓冲区
#include <sys/uio.h>
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
filedes表示文件描述符
iov为前述io向量机制结构体iovec
iovcnt为结构体的个数
若成功则返回已写的字节数writev以顺序iov[0]iov[1]至iov[iovcnt-1]从缓冲区中聚集输出数据writev返回输出的字节总数
特别注意
do_request()
浏览器端发出HTTP请求报文process_read对其进行解析HTTP_CODE
其中read_once和http_conn::write完成数据的读取与发送

process_read函数的返回值是对请求的文件分析后的结果BAD_REQUESTdo_request的返回结果.该函数将网站根目录和url文件拼接
为了更好的理解请求资源的访问流程urlip:port/xxxxxx通过html文件的action属性进行设置
m_url为请求报文中解析出的请求资源/xxx
m_url为请求报文中解析出的请求资源/xxx
/
GET请求
跳转到judge.html, 即欢迎访问页面, /0
POST请求
跳转到register.html, 即注册页面, /1
POST请求
跳转到log.html, 即登录页面, /2CGISQL.cgi
POST请求
进行登录校验, 验证成功跳转到welcome.html
即资源请求成功页面, 验证失败跳转到logError.html
即登录失败页面, /3CGISQL.cgi
POST请求
进行注册校验, 注册成功跳转到log.html
即登录页面, 注册失败跳转到registerError.html
即注册失败页面, /5
POST请求
跳转到picture.html, 即图片请求页面, /6
POST请求
跳转到video.html, 即视频请求页面, /7
POST请求
跳转到fans.html, 即关注页面,
process_write()
根据do_request的返回状态process_write向m_write_buf中写入响应报文
add_status_line函数
添加状态行, http/1.1 状态码 状态消息: add_headers函数添加消息报头
内部调用add_content_length和add_linger函数, content-length记录响应报文长度
用于浏览器端判断服务器是否发送完数据, connection记录连接状态
用于告诉浏览器端保持长连接, add_blank_line添加空行
上述涉及的5个函数add_response函数更新m_write_idx指针和缓冲区m_write_buf中的内容
响应报文分为两种io向量机制ioveciovecm_write_bufmmap的地址m_file_addressiovecm_write_buf
iovec是一个结构体
里面有两个元素, 指针成员iov_base指向一个缓冲区, 这个缓冲区是存放的是writev将要发送的数据, 。 成员iov_len表示实际写入的长度
http_conn::write()
服务器子线程调用process_write完成响应报文epollout事件http_conn::write函数将响应报文发送给浏览器端
该函数具体逻辑如下
在生成响应报文时初始化byte_to_send
若writev单次发送成功
更新byte_to_send和byte_have_send的大小, 若响应报文整体发送成功,则取消mmap映射,并判断是否是长连接., 长连接重置http类实例
注册读事件, 不关闭连接, , 短连接直接关闭连接
若writev单次发送不成功
判断是否是写缓冲区满了, 。 若不是因为缓冲区满了而失败
取消mmap映射, 关闭连接, 若eagain则满了
更新iovec结构体的指针和长度, 并注册写事件, 等待下一次写事件触发, 当写缓冲区从不可写变为可写( 触发epollout, ) 因此在此期间无法立即接收到同一用户的下一请求, 但可以保证连接的完整性, 。
05定时器处理非活动连接
由于非活跃连接占用了连接资源
- 统一事件源
- 基于升序链表的定时器
- 处理非活动连接
非活跃
定时事件
定时器
定时器容器
本项目中
Linux下提供了三种定时的方法:
socket选项SO_RECVTIMEO和SO_SNDTIMEO
SIGALRM信号
I/O复用系统调用的超时参数
三种方法没有一劳永逸的应用场景SIGALRM信号Linux高性能服务器编程 第11章 定时器
具体的alarm函数周期性地触发SIGALRM信号
从上面的简要描述中
基础APIsigaction结构体sigaction函数sigfillset函数SIGALRM信号SIGTERM信号alarm函数socketpair函数send函数
信号通知流程
代码实现
信号通知流程
Linux下的信号采用的异步处理机制
为避免信号竞态现象发生
一般的信号处理函数需要处理该信号对应的逻辑
这里的解决方案是
统一事件源
统一事件源
具体的
信号处理机制
每个进程之中

信号的接收
接收信号的任务是由内核代理的
当内核接收到信号后, 会将其放到对应进程的信号队列中, 同时向进程发送一个中断, 使其陷入内核态, 注意。 此时信号还只是在队列中, 对进程来说暂时是不知道有信号到来的, 。 信号的检测
进程从内核态返回到用户态前进行信号检测
进程在内核态中
从睡眠状态被唤醒的时候进行信号检测, 进程陷入内核态后
有两种场景会对信号进行检测, : 当发现有新信号时
便会进入下一步, 信号的处理, 。 信号的处理
( 内核 )信号处理函数是运行在用户态的
调用处理函数前, 内核会将当前内核栈的内容备份拷贝到用户栈上, 并且修改指令寄存器, eip( 将其指向信号处理函数) 。 ( 用户 )接下来进程返回到用户态中
执行相应的信号处理函数, 。 ( 内核 )信号处理函数执行完成后
还需要返回内核态, 检查是否还有其它信号未处理, 。 ( 用户 )如果所有信号都处理完成
就会将内核栈恢复, 从用户栈的备份拷贝回来( ) 同时恢复指令寄存器, eip( 将其指向中断前的运行位置) 最后回到用户态继续执行进程, 。
至此
定时器容器
项目中的定时器容器为带头尾结点的升序双向链表
从实现上看
升序双向链表主要逻辑如下
创建头尾节点
其中头尾节点没有意义, 仅仅统一方便调整, add_timer函数
将目标定时器添加到链表中, 添加时按照升序添加, 若当前链表中只有头尾节点
直接插入, 否则
将定时器按升序插入, adjust_timer函数
当定时任务发生变化,调整对应定时器在链表中的位置, 客户端在设定时间内有数据收发,则当前时刻对该定时器重新设定时间
这里只是往后延长超时时间, 被调整的目标定时器在尾部
或定时器新的超时值仍然小于下一个定时器的超时, 不用调整, 否则先将定时器从链表取出
重新插入链表, del_timer函数将超时的定时器从链表中删除
常规双向链表删除结点
06日志系统
同步/异步日志系统主要涉及了两个模块
- 自定义阻塞队列
- 单例模式创建日志
- 同步日志
- 异步日志
- 实现按天
超行分类 、

日志
同步日志
生产者-消费者模型
阻塞队列
异步日志
单例模式
本项目中
其中异步写入方式
日志系统大致可以分成两部分
单例模式
描述懒汉与饿汉两种单例模式 , 并结合线程安全进行讨论 , 。 生产者-消费者模型
描述条件变量 , 基于该同步机制实现简单的生产者-消费者模型 , 。 代码实现
结合代码对阻塞队列的设计进行详解 , 。
单例模式
单例模式作为最常用的设计模式之一
实现思路
单例模式有两种实现方法
阻塞队列
阻塞队列是一种特殊的队列数据结构
通过阻塞队列
07WebServer
总流程

流程图
载入数据库表
提取用户名和密码
注册登录流程
页面跳转