项目介绍
Linux下C++轻量级Web服务器,助力初学者快速实践网络编程,搭建属于自己的服务器.
- 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
- 使用状态机解析HTTP请求报文,支持解析GET和POST请求
- 访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
- 实现同步/异步日志系统,记录服务器运行状态
- 经Webbench压力测试可以实现上万的并发连接数据交换
第一部分 网络编程相关知识
一、基本介绍
1.1 服务器的基本知识
1.1.1 什么是web server?
可以说是运行服务的软件,也可以说是运行该服务的硬件(计算机)。其主要功能是通过HTTP协议与客户端(通常是浏览器(Browser))进行通信,来接收,存储,处理来自客户端的HTTP请求,并对其请求做出HTTP响应,返回给客户端其请求的内容(文件、网页等)或返回一个Error信息。功能如下图:
至于server如何接受http请求,这就需要提到计网的知识了:IP协议处于网络层、TCP/UDP协议处于传输层,而HTTP位于应用层,在中应用层与传输层之间利用一个被称为套接字(socket)的介质连接。
PS:套接字其实应用与应用(即端口到端口)的抽象,一个套接字就是一个通信的一端
于是web server可以通过监听对应的sockets来获悉是否有http请求。常用的代码逻辑如下:
#include <sys/socket.h> #include <netinet/in.h> /* 创建监听socket文件描述符 */ int listenfd = socket(AF_INET, SOCK_STREAM, 0); /* 创建监听socket的TCP/IP的IPV4 socket地址 */ struct sockaddr_in address; bzero(&address, sizeof(address)); address.sin_family = AF_INET; /* INADDR_ANY:将套接字绑定到所有可用的接口 */ // htonl是将小端顺序转换到网络的大端顺序 address.sin_addr.s_addr = htonl(INADDR_ANY); address.sin_port = htons(port); int flag = 1; /* SO_REUSEADDR 允许端口被重复使用 */ // 为了防止服务器端口不够用 // 或是出于close wait的端口数量太多 setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)); /* 绑定socket和它的地址 */ ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address)); /* 创建监听队列以存放待处理的客户连接,在这些客户连接被accept()之前 */ ret = listen(listenfd, 5);
PS:socket(AF_INET*)来源于int socket(int domain, int type, int protocol); 其中int domain表示协议族,有以下这些: ![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/20210313102319200.png)
1.1.2 I/O复用技术:select以及poll
试想,如果有多个用户同时想去连接(connect)这个服务器该如何处理呢?
首先得有一个队列来对这个连接请求进行排序存放,而后需要通过并发多线程的手段对已连接的客户进行应答处理。在一过程中还涉及I/O及文件描述符的特殊操作,这就需要通过select、poll、epoll(按出现顺序)来处理。
PS:epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流. 发明它的原因,是尽量多的提高服务器的吞吐能力。(参考知乎答案:IO 多路复用是什么意思? https://www.zhihu.com/question/32163005/answer/55772739)
在介绍着IO复用之前先复习一下Unix五种基本的IO模型:
·阻塞式IO(守株待兔)
·非阻塞式IO(没有就返回,直到有,其实是一种轮询(polling)操作)
·IO复用(select、poll等,使系统阻塞在select或poll调用上,而不是真正的IO系统调用(如recvfrom),等待select返回可读才调用IO系统,其优势就在于可以等待多个描述符就位)
·信号驱动式IO(sigio,即利用信号处理函数来通知数据已完备且不阻塞主进程)
·异步IO(posix的aio_系列函数,与信号驱动的区别在于,信号驱动是内核告诉我们何时可以进行IO,而后者是内核通知何时IO操作已完成)
PS:服务器程序通常需要处理三类事件:I/O事件,信号及定时事件。有两种事件处理模式:
Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程,将socket可读可写事件放入请求队列,交给工作线程处理。
Proactor模式:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后users[sockfd].read(),选择一个工作线程来处理客户请求pool->append(users + sockfd)。
通常使用同步I/O模型(如epoll_wait)实现Reactor,使用异步I/O(如aio_read和aio_write)实现Proactor。
PS:那么什么是同步I/O,什么是异步I/O呢?
同步(阻塞)I/O:在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。(五种模式的前四种)
异步(非阻塞)I/O:当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。(异步操作的潜台词就是你先做,我去忙其他的,你好了再叫我)(五种模式的最后一种)
1.1.3 select函数
#include <sys/select.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
传递给 select函数的参数会告诉内核:
•我们所关心的文件描述符(不限于套接字)
•对每个描述符,我们所关心的状态。(我们是要想从一个文件描述符中读或者写,还是关注一个描述符中是否出现异常)
•我们要等待多长时间。(我们可以等待无限长的时间,等待固定的一段时间,或者根本就不等待)
从select函数返回后,内核告诉我们以下信息:
•对我们的要求已经做好准备的描述符的个数
•对于三种条件哪些描述符已经做好准备.(读,写,异常)
有了这些返回信息,我们可以调用合适的I/O函数(通常是 read 或 write),并且这些函数不会再阻塞.
关于描述符集,是一个整数数组,数组中整数的每一位对应一个描述符。(如实32位机,则第一个整数表示0~31)(按位表示这只是一种实现方式)
Maxfdp1是待测试的描述符个数。在linux中只能监听1024个。
Select有以下缺点:
·会修改传入的参数数组;
·其返回的是有描述符做好准备了,但是需要通过循环FD_ISSET来找到底是哪个描述符变了,如果描述符过多就会造成性能损失;
·并非线程安全,如果另外的线程将某个sock关闭,则当前线程的select结果不可测。
1.1.4 poll函数
相比于select,取消了描述符个数限制,设计上不修改传入数组,还是不线程安全。也是轮询操作。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd{
int fd; //文件描述符
short events; //等待的事件
short revents; //实际发生的事件
};
1.1.5 epoll函数
Epoll最大的优点就在于它基于事件的就绪通知方式:只管“活跃”的连接 ,而跟连接总数无关,其算法复杂度为O(1),因此在实际的网络环境中,epoll的效率就会远远高于 select 和 poll 。select和poll都是轮询方式的,每次调用要扫描整个注册文件描述符集合,并将其中就绪描述符返回给用户程序。epoll_wait采用的是回调方式,内核检测到就绪描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪队列,不需要轮询。
其关键函数介绍如下:(更详细的可以参考博客:https://mp.weixin.qq.com/s/BfnNl-3jc_x5WPrWEJGdzQ)
int epoll_create(int size)
·创建一个指示epoll内核事件表的文件描述符,该描述符将用作其他epoll系统调用的第一个参数,size不起作用。
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 */
};
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
·该函数用于等待所监控文件描述符上有事件的产生,返回就绪的文件描述符个数
Epoll有两种触发方式,分别是LT和ET:
Level-Triggered是缺省工作方式,有阻塞和非阻塞两种方式。内核告诉你一个描述符是否就绪,然后可以对就绪的fd进行IO操作。如果不做任何操作,内核还会继续通知,因此该模式下编程出错可能性小。传统的select/poll是这样的模型。此方式可以认为是一个快速的poll。
Edge-Triggered是只支持非阻塞模式。当一个新的事件到达时,ET模式从epoll_wait调用中获取该事件,如果这次没有将该事件对应的套接字缓冲区处理完,在这个套接字中没有新的事件再次到来时,在ET模式下是无法再次从epoll_wait调用中获取这个事件的。而LT模式,只要一个事件对应的套接字缓冲区中还有数据,就总能从epoll_wait中获取这个事件。
二者的差异在于 level-trigger 模式下只要某个 fd 处于 readable/writable 状态,无论什么时候进行 epoll_wait 都会返回该 fd;而 edge-trigger 模式下只有某个 fd 从unreadable 变为 readable 或从 unwritable 变为 writable 时,epoll_wait 才会返回该 fd。
1.2数据库连接池是用来做什么?
(跟线程池的用途类似:预先申请数据库连接资源,需要时直接获取即可)
用户登录功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的。若每次用户请求我们都需要新建一个数据库连接,请求结束后我们释放该数据库连接,当用户请求连接过多时,这种做法过于低效,所以类似线程池的做法,我们构建一个数据库连接池,预先生成一些数据库连接放在那里供用户请求使用。
对于一个数据库连接池来讲,就是预先生成多个这样的数据库连接,然后放在一个链表中,同时维护最大连接数MAX_CONN,当前可用连接数FREE_CONN和当前已用连接数CUR_CONN这三个变量。
PS: CGI(通用网关接口), 是一个运行在Web服务器上的程序,在编译的时候将相应的.cpp文件编程成.cgi文件并在主程序中调用即可。
1.3 什么是C/S、B/S模型?
->客户/服务器模型(C/S)
特点:
·多个客户进程同时访问一个服务器进程(n:1)
·一个客户进程同时访问多个服务器提供的服务(1:n ).
PS:
有状态和无状态的服务器
判断依据:服务器或客户本地端是否保存状态信息。
无状态服务器举例:禁用cookie功能的web服务器
有状态服务器举例:网络游戏服务器
循环服务器和并发服务器
循环服务器:通过在单线程内设置循环控制实现对多个客户请求的逐一响应。
并发服务器:通过使请求处理(多线程)和I/O部分重叠达到高性能。
->浏览器/服务器模型(B/S)
用户通过www浏览器,一部分事务逻辑在前端(浏览器)实现,主要事务逻辑在服务端实现。通常以三层架构(表现层、事务逻辑层、数据处理层)部署实施。
B/S模型是特殊的客户/服务器模型,特殊之处在于,客户端软件特指浏览器,使用HTTP协议通信。用简单浏览器实现原来需要复杂专用软件才能实现的客户功能,节约了开发成本。
1.4 服务器编程的基本框架如何?
主要由I/O单元,逻辑单元和网络存储单元组成,其中每个单元之间通过请求队列进行通信,从而协同完成任务。其中I/O单元用于处理客户端连接,读写网络数据;逻辑单元用于处理业务逻辑的线程;网络存储单元指本地数据库和文件等。