TinywebServer知识点(3)
TinywebServer知识点(3)
该blog内容转自:小白视角:一文读懂社长的TinyWebServer(Raw_Version)
该blog对上述内容进行补充(在本人的角度)
结合此前牛客项目记录的blog一起学习:牛客WebServer项目实战(点击跳转)
原项目地址(点击跳转)
博主添加注释后项目地址(点击跳转)
一、WebServer服务器
1.客户端如何与web服务器通信
通常用户使用Web浏览器与相应服务器进行通信。在浏览器中键入“域名”或“IP地址:端口号”,浏览器则先将你的域名解析成相应的IP地址或者直接根据你的IP地址向对应的Web服务器发送一个HTTP请求。这一过程首先要通过TCP协议的三次握手建立与目标Web服务器的连接(**HTTP
协议底层是通过TCP
协议去通讯的**),然后HTTP协议生成针对目标Web服务器的HTTP请求报文,通过TCP、IP等协议发送到目标Web服务器上
2.Web服务器如何接收客户端的HTTP请求
Web服务器端通过socket
监听来自用户的请求
1 |
|
- 远端的很多用户会尝试去
connect()
这个Web Server上正在listen
的这个port
,而监听到的这些连接会排队等待被accept()
- 由于用户连接请求是随机到达的异步事件,每当监听socket(
listenfd
)listen
到新的客户连接并且放入监听队列,我们都需要告诉我们的Web服务器有连接来了 accept
这个连接,并分配一个逻辑单元来处理这个用户请求。而且,我们在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理(并发,同时处理多个事件,后面会提到使用线程池实现并发)。这里,服务器通过epoll这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd
)和连接socket(客户请求)的同时监听。注意I/O复用虽然可以同时监听多个文件描述符,但是它本身是阻塞的,并且当有多个文件描述符同时就绪的时候,如果不采取额外措施,程序则只能按顺序处理其中就绪的每一个文件描述符,所以为提高效率,我们将在这部分通过线程池来实现并发(多线程并发),为每个就绪的文件描述符分配一个逻辑单元(线程)来处理
1 |
|
3.事件处理模式
服务器程序通常需要处理三类事件: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。但在此项目中,我们使用的是同步I/O模拟的Proactor事件处理模式
4.web服务器如何处理以及响应受到的HTTP请求报文
该项目使用线程池(半同步半反应堆模式)并发处理用户请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等)。通过之前的代码,我们将listenfd
上到达的connection
通过 accept()
接收,并返回一个新的socket文件描述符connfd
用于和用户通信,并对用户请求返回响应,同时将这个connfd
注册到内核事件表中,等用户发来请求报文。这个过程是:通过epoll_wait
发现这个connfd
上有可读事件了(EPOLLIN
),主线程就将这个HTTP的请求报文读进这个连接socket的读缓存中users[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请求,然后交给线程池中的空闲线程来处理
(1)为什么使用线程池
当你需要限制你应用程序中同时运行的线程数时,线程池非常有用。因为启动一个新线程会带来性能开销,每个线程也会为其堆栈分配一些内存等,因此主线程可以将任务放到线程池的任务队列上,由线程池中的工作线程进行竞争
(2)项目中服务器如何解析HTTP报文
process_read()
函数的作用就是将类似上述例子的请求报文进行解析,因为用户的请求内容包含在这个请求报文里面,只有通过解析,知道用户请求的内容是什么,是请求图片,还是视频,或是其他请求,我们根据这些请求返回相应的HTML页面等。项目中使用主从状态机的模式进行解析,从状态机(parse_line
)负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。每解析一部分都会将整个请求的m_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的这部分是有数据的,项目中的这部分数据为用户名和密码,我们会根据这部分内容做登录和校验,并涉及到与数据库的连接
经过上述解析,当得到一个完整的,正确的HTTP请求时,就到了do_request
代码部分,我们需要首先对GET请求和不同POST请求(登录,注册,请求图片,视频等等)做不同的预处理,然后分析目标文件的属性,若目标文件存在、对所有用户可读且不是目录时,则使用mmap
将其映射到内存地址m_file_address
处,并告诉调用者获取文件成功
抛开mmap
这部分,先来看看这些不同请求是怎么来的:
假设你已经搭好了你的HTTP服务器,然后你在本地浏览器中键入localhost:9000
,然后回车,这时候你就给你的服务器发送了一个GET请求,什么都没做,然后服务器端就会解析你的这个HTTP请求,然后发现是个GET请求,然后返回给你一个静态HTML页面,也就是项目中的judge.html
页面,那POST请求怎么来的呢?这时你会发现,返回的这个judge
页面中包含着一些新用户
和已有账号
这两个button
元素,当你用鼠标点击这个button
时,你的浏览器就会向你的服务器发送一个POST请求,服务器段通过检查action
来判断你的POST请求类型是什么,进而做出不同的响应
项目中POST请求,是在客户端注册新用户时,会将新注册的账号密码进行提交
1 | /* judge.html */ |
5.生成HTTP响应并放回给用户
通过以上操作,我们已经对读到的请求做好了处理,然后也对目标文件的属性作了分析,若目标文件存在、对所有用户可读且不是目录时,则使用mmap
将其映射到内存地址m_file_address
处,并告诉调用者获取文件成功FILE_REQUEST
。 接下来要做的就是根据读取结果对用户做出响应了,也就是到了process_write(read_ret);
这一步,该函数根据process_read()
的返回结果来判断应该返回给用户什么响应,我们最常见的就是404
错误了,说明客户请求的文件不存在,除此之外还有其他类型的请求出错的响应,具体的可以去百度。然后呢,假设用户请求的文件存在,而且已经被mmap
到m_file_address
这里了,那么我们就将做如下写操作,将响应写到这个connfd
的写缓存m_write_buf
中去:
1 | case FILE_REQUEST: { |
首先将状态行
写入写缓存,响应头
也是要写进connfd
的写缓存(HTTP类自己定义的,与socket无关)中的,对于请求的文件,我们已经直接将其映射到m_file_address
里面,然后将该connfd
文件描述符上修改为EPOLLOUT
(可写)事件,然后epoll_Wait
监测到这一事件后,使用writev
来将响应信息和请求文件聚集写到TCP Socket本身定义的发送缓冲区(这个缓冲区大小一般是默认的,但我们也可以通过setsockopt
来修改)中,交由内核发送给用户。OVER!
二、数据库
1.数据库连接池如何运行
在处理用户注册,登录请求的时候,我们需要将这些用户的用户名和密码保存下来用于新用户的注册及老用户的登录校验,相信每个人都体验过,当你在一个网站上注册一个用户时,应该经常会遇到“您的用户名已被使用”,或者在登录的时候输错密码了网页会提示你“您输入的用户名或密码有误”等等类似情况,这种功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的。若每次用户请求我们都需要新建一个数据库连接,请求结束后我们释放该数据库连接,当用户请求连接过多时,这种做法过于低效,所以类似线程池的做法,我们构建一个数据库连接池,预先生成一些数据库连接放在那里供用户请求使用
(1)单个数据库连接如何运行
- 使用
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_CONN
,当前可用连接数FREE_CONN
和当前已用连接数CUR_CONN
这三个变量。同样注意在对连接池操作时(获取,释放),要用到锁机制,因为它被所有线程共享
2.CGI校验
对用户的登录及注册等POST请求,服务器是如何做校验的。当点击新用户
按钮时,服务器对这个POST请求的响应是:返回用户一个登录界面;当你在用户名和密码框中输入后,你的POST请求报文中会连同你的用户名密码一起发给服务器,然后我们拿着你的用户名和密码在数据库连接池中取出一个连接用于mysql_query()
进行查询,逻辑很简单,同步线程校验SYNSQL
方式,这里给出其他两种校验方式,**CGl
校验**
(1)同步线程校验
同步线程校验(Synchronized Thread Check)是一种并发编程技术,用于确保多个线程在访问共享资源或执行关键操作时,不会导致数据不一致或竞态条件(Race Conditions)。这种技术通常涉及使用同步原语(如互斥锁、条件变量、信号量等)来协调线程之间的操作,确保它们以线程安全的方式进行
同步线程校验关键概念:
- 互斥锁(Mutex):互斥锁是用于保护共享资源的同步原语。在同一时刻,只有一个线程可以获得互斥锁,从而访问共享资源。其他线程必须等待,直到锁被释放
- 条件变量(Condition Variable):条件变量用于线程间的通信,允许线程在等待某个条件成立时被阻塞,当条件满足时,再被唤醒
- 信号量(Semaphore):信号量是一个计数器,用于控制对资源的访问。信号量可以用来限制对资源的并发访问次数
(2)什么是CGl校验
CGI(通用网关接口),它是一个运行在Web服务器上的程序,在编译的时候将相应的.cpp
文件编程成.cgi
文件并在主程序中调用即可(通过社长的makefile
文件内容也可以看出)。这些CGI程序通常通过客户在其浏览器上点击一个button
时运行。这些程序通常用来执行一些信息搜索、存储等任务,而且通常会生成一个动态的HTML网页来响应客户的HTTP请求。我们可以发现项目中的sign.cpp
文件就是我们的CGI程序,将用户请求中的用户名和密码保存在一个id_passwd.txt
文件中,通过将数据库中的用户名和密码存到一个map
中用于校验。在主程序中通过execl(m_real_file, &flag, name, password, NULL);
这句命令来执行这个CGI文件,这里CGI程序仅用于校验,并未直接返回给用户响应。这个CGI程序的运行通过多进程来实现,根据其返回结果判断校验结果(使用pipe
进行父子进程的通信,子进程将校验结果写到pipe的写端,父进程在读端读取)
三、服务器优化
1.定时器处理非活动链接
1 | // 预先为每个可能的客户连接分配一个http_conn对象 |
- 如果某一用户
connect()
到服务器之后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。这时候就应该利用定时器把这些超时的非活动连接释放掉,关闭其占用的文件描述符。这种情况也很常见,当你登录一个网站后长时间没有操作该网站的网页,再次访问的时候你会发现需要重新登录。 - 项目中使用的是
SIGALRM信号
来实现定时器,利用alarm
函数周期性的触发SIGALRM
信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。
项目中具体实现:
1 | /* 定时器相关参数 */ |
alarm
函数会定期触发SIGALRM
信号,这个信号交由sig_handler
来处理,每当监测到有这个信号的时候,都会将这个信号写到pipefd[1]
里面,传递给主循环:
主循环中
1 | /* 处理信号 */ |
当我们在读端pipefd[0]
读到这个信号的的时候,就会将timeout
变量置为true
并跳出循环,让timer_handler()
函数取出来定时器容器上的到期任务,该定时器容器是通过升序链表来实现的,从头到尾对检查任务是否超时,若超时则调用定时器的回调函数cb_func()
,关闭该socket连接,并删除其对应的定时器del_timer
1 | void timer_handler() { |
(1)定时器优化
这个基于升序双向链表实现的定时器存在着其固有缺点:
- 每次遍历添加和修改定时器的效率偏低(O(n)),使用最小堆结构可以降低时间复杂度降至(O(logn))。
- 每次以固定的时间间隔触发
SIGALRM
信号,调用tick
函数处理超时连接会造成一定的触发浪费,举个例子,若当前的TIMESLOT=5
,即每隔5ms触发一次SIGALRM
,跳出循环执行tick
函数,这时如果当前即将超时的任务距离现在还有20ms
,那么在这个期间,SIGALRM
信号被触发了4次,tick
函数也被执行了4次,可是在这4次中,前三次触发都是无意义的。对此,我们可以动态的设置TIMESLOT
的值,每次将其值设置为当前最先超时的定时器与当前时间的时间差,这样每次调用tick
函数,超时时间最小的定时器必然到期,并被处理,然后在从时间堆中取一个最先超时的定时器的时间与当前时间做时间差,更新TIMESLOT
的值。
2.服务器优化:日志
日志,由服务器自动创建,并记录运行状态,错误信息,访问数据的文件。
这部分内容个人感觉相对抽象一点,涉及单例模式以及单例模式的两种实现方式:懒汉模式和恶汉模式,以及条件变量机制和生产者消费者模型。这里大概就上述提到的几点做下简单解释,具体的还是去看参考中社长的笔记。
单例模式
最常用的设计模式之一,保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
实现思路:私有化它的构造函数,以防止外界创建单例类的对象;使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。
- 懒汉模式:即非常懒,不用的时候不去初始化,所以在第一次被使用时才进行初始化(实例的初始化放在
getinstance
函数内部)- 经典的线程安全懒汉模式,使用双检测锁模式(
p == NULL
检测了两次) - 利用局部静态变量实现线程安全懒汉模式
- 经典的线程安全懒汉模式,使用双检测锁模式(
- 饿汉模式:即迫不及待,在程序运行时立即初始化(实例的初始化放在
getinstance
函数外部,getinstance
函数仅返回该唯一实例的指针
日志系统运行机制
- 日志文件
- 局部变量的懒汉模式获取实例
- 生成日志文件,并判断同步和异步写入方式
- 同步
- 判断是否分文件
- 直接格式化输出内容,将信息写入日志文件
- 异步
- 判断是否分文件
- 格式化输出内容,将内容写入阻塞队列,创建一个写线程,从阻塞队列取出内容写入日志文件
四、压力测试
一个服务器项目,你在本地浏览器键入localhost:9000
发现可以运行无异常还不够,你需要对他进行压测(即服务器并发量测试),压测过了,才说明你的服务器比较稳定了。社长的项目是如何压测的呢?
用到了一个压测软件叫做Webbench,可以直接在社长的Gtihub里面下载,解压,然后在解压目录打开终端运行命令(-c
表示客户端数, -t
表示时间):
1 | ./webbench -c 10001 -t 5 http://127.0.0.1:9006/ |
直接解压的webbench-1.5
文件夹下的webbench
文件可能会因为权限问题找不到命令或者无法执行,这时你需要重新编译一下该文件即可:
1 | gcc webbench.c -o webbench |
然后我们就可以压测得到结果了(我本人电脑的用户数量-c
设置为10500
会造成资源不足的错误):
1 | Webbench - Simple Web Benchmark 1.5 |
(1)Webbench原理
父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。
五、添加功能
- 添加文件上传功能,实现与服务器的真正交互
- 试着调用摄像头,来实现一些更有趣的功能