操作系统——webserver项目收获
io操作的阶段是io调用和io执行,io执行又分成数据准备和数据拷贝。
阻塞和非阻塞是io操作数据准备层面的概念,数据准备就是io设备准备好放到内核缓冲区。如果是阻塞就是阻塞等到它放好,但是不占用cpu。
非阻塞就是没准备好就先回去,然后采用轮询的方式,在下一次再来看看有没有准备好。
对于异步来说,用户进程启动io操作直接返回就可以去做别的事,数据准备和数据拷贝过程都不会导致用户进程阻塞。
同步是用户进程启动io操作就进入了等待状态,直到操作完成。异步是用户进程告诉进程这个io操作后就可以去做别的事情,都是由内核处理好直接发送给用户进程缓冲区的。
这个项目里主要设计了一个线程池来处理多个浏览器请求并发的情况。首先,服务端的主线程使用epoll系统调用函数进行socket的监听,接收socket和注册读写事件,然后封装成请求对象加入请求队列。接下来就是线程池和请求队列之间的处理机制。这个项目使用的是互斥锁和信号量来解决多线程同步的问题,这里的互斥锁就是用互斥量实现的锁。服务端epoll系统调用在把请求加入请求队列的时候就有先用一个互斥锁把请求队列锁住,然后加入之后,会采用信号量post的方式来通知线程池里面的线程进行竞争处理。线程池方面,一开始是创建了一个固定大小的线程池,然后每一个线程都绑定到一个run 回环函数,等待信号量post就说明有新的请求加入了请求队列,在这里会不停的while循环去等待这个信号量。 当有了信号量,线程池里的线程就会把请求队列锁住,然后去拿请求来进行处理。总的来说,就是一开始线程池里的所有线程都是相互独立的睡眠状态,只有当请求队列里的信号量更新的时候才会唤醒进行竞争。
使用有序链表,每个节点都是一个定时器,键是定时时间(相对和绝对,使用绝对),值是回调函数。然后一开始使用alarm函数去触发SIGALRM信号,一开始是先把listenfd和pipefd[0](统一事件源,信号处理函数通常使用管道来将信号传递给主循环,信号处理函数往管道写端写入信号值,主循环则从管道的读端读出信号值)都挂在树上了,然后在树上检测到listenfd就可以把那些连接connfd也挂在树上,同时把那些connfd对应的定时器也挂在链表上面。后来每次触发定时信号SIGALRM就会使得timeout有标记,如果触发SIGTERM信号系统就要退出了。
然后后来客户连接有接收到数据的时候,先去处理,如果连接出错就删除连接对应的定时器,如果连接关闭也是删除定时器,如果连接有数据就要调整定时器。在这里面还要处理定时信号到达,调用tick函数去处理每一个定时任务,然后处理完又要alarm(TIMEOUT)。最底层的原理就是利用alarm函数周期性触发SIGALRM信号,该信号的信号处理函数会通过管道通知主线程,主线程就去执行定时器链表上的定时任务,比如关闭非活动的连接。
前面的方案是以固定的频率调用tick,并在其中依次检测到期的定时器,然后执行到期定时器上面的回调函数。另外一种时间堆的思路。把所有定时器的最小的一个定时器的相对超时时间最为心跳间隔。一个心跳函数被调用,超时时间最小的定时器一定会到期,我们就可以在tick函数里面处理这个定时器。然后,再次从剩余的定时器里面找出超时时间最小的一个,并将这个时间设置为下一次心跳间隔。如此反复就可以实现比较精确的定时。
之所以用epoll而不是select和poll的原因。
(2)文件描述符组织形式不同。select使用线性表描述,poll使用链表描述,epoll使用红黑树描述,并且维护了一个就绪队列,使用epoll wait的时候,只要观察就绪队列有没有数据就可以。红黑树在fd数量少的情况下,相比于哈希、B+树等占用内存少,在fd数量多时查询效率稳定。
(3)节省遍历的时间开销。select/poll的开销来自内核判断是否有文件描述符就绪的过程,前面拷贝也要遍历。epoll不是采用遍历的方式,当红黑树fd有活动产生,会自动触发epoll回调函数通知,然后把这些fd放到就绪队列,等待epoll_wait函数调用后被处理。
(4)select和poll只能工作在相对低效的LT模式,epoll同时支持LT和ET模式。
在fd数量小而且每个fd都很活跃的时候,建议使用select和poll。在fd数量多,而且单位事件内仅部分活跃的时候,建议使用epoll。
ET和LT的区别:体现在同一个线程请求的epoll_wait函数处理不同,ET是第一次请求才会发送,不管用户有没有处理完都不会再发送;LT是只要用户没出来就会一直发送。ET一定要设置非阻塞,ET模式只会响应一次,通常方法是再一个while循环利对其进行读取。非阻塞读,直到出现EAGAIN或EWOULDBLOCK这两个错误标识socket是空,不用再读了,然后停止循环如果弄成阻塞模式,此时循环读在socket为空的时候会阻塞在哪里,导致其他的监听事件没办法完成。
Reactor模式是主线程负责监听fd上是否有可读或可写事件发生。如果有就通知工作线程,把该fd就放到请求队列。读写数据、接收新连接、处理客户请求都在工作线程完成。
Proactor模式是主线程不仅负责监听fd上是否有可读或可写事件发生,还要接收新连接、读写数据等操作。工作线程仅负责业务逻辑,处理客户请求。
上面两个是事件处理模型,下面的半同步/半异步是具体的线程并发模型,用于怎么处理客户逻辑和接收客户请求。
在IO模型中,同步和异步的区别是内核向用户通知的是就绪IO事件还是已完成的IO事件,以及该由用户还是内核来完成IO读取。
在线程并发模型里面,同步是指程序完全按照代码序列顺序执行,异步是指程序的执行需要由系统事件来驱动。
半同步/半异步模式:同步线程用于处理客户逻辑,异步线程处理IO事件,监听到客户请求之后,将其封装成请求对象并插入请求队列。
具体哪个工作线程来为新的请求服务,取决于请求队列的设计,RoundRobin算法,或者通过条件变量或信号量随机选择一个。
有限状态机可以把有限个变量描述的状态变化过程描述出来。根据不同状态或消息类型进行相应的逻辑处理,是逻辑清晰易懂。
GET和POST的区别:
功能,参数传递的方式,传输大小,安全性。
(1)get是从服务器获取数据,参数在url请求行发送,因此不太安全,url的传输大小比较小。get请求会被浏览器主动cache。get请求只能进行url编码,参数只接收ascii字符。
(2)post是向服务器传送数据。参数在请求体里面传送,因此比较安全,传输大小比较大。post请求不会被浏览器主动缓存,除非手动设置。get请求支持任何编码,参数类型没有限制。
此外,从浏览器端来看,get产生一个TCP数据包,post产生两个(发送过去收到100,然后接收到之后,再次发送)。
实际上,get和post都是HTTP协议中的两种发送请求的方法。因此,get和post都是TCP连接。
(3)幂等性。
注册和登录的流程:先将数据库中的数据载入服务器,然后对报文进行进行解析并提取用户名和密码,然后对描述进行注册和登录校验,最后执行页面跳转机制。
登录密码状态保存:可以使用session或者cookie的方式实现。
cookie就是服务器给客户端分配的字符串身份标志。每次浏览器发送数据时,在HTTP报文加上这个串,服务器就知道是谁了。
session是保存在服务端的,当一个客户发送报文过来,服务器在自己记录的数据中去找,类似核对名单。.
一开始,客户端发送账号密码过去,服务端生成一个sessionID,然后把账号和用户信息作为value,sessionID作为key,存到session表里面,再把sessionID通过set-cookie字段发送回去给客户端。然后客户端保存sessionID,下次访问的时候带上。服务端第一次收到就会去session表里面找这个用户的登录状态、权限等信息,完成请求响应。
如果登录的用户名和密码加载到本地使用map匹配,这个时候数据有10亿,就会很耗时,可以怎么优化。
将所有的用户信息加载到内存中很耗时,对于大数据最便利的方式就是哈希。
利用哈希建立多级索引,加快用户验证。首先,将10亿的用户信息,利用大致缩小1000倍的哈希算法进行哈希,获得了100万的哈希数据,每一个hash数据代表一个用户信息级(一级);然后,分别对这100万的数据再进行哈希,最后剩下1000个hash数据(二级)。
定时器的作用就是处理定时任务,或者处理非活跃连接(一直没有任务的事件)。把每一个定时事件都放到一个升序链表上面,然后通过alarm()函数周期性触发SIGALRM信号说明定时时间到,然后信号回调函数通过管道通知主循环。主循环收到信号后对升序链表的定时器进行处理:如果一定事件内没有数据交换就关闭连接。
升序链表的添加要O(N),删除只要O(1)。可以考虑最小堆、跳表的优化。
最小堆也是用定时器的剩余过期时间进行排序,最小的定时(即最快要到到期的)放在堆顶。SIGALRM信号触发时就会执行定时器清除,如果堆顶的定时器时间过期,就会重新建堆再判断是否过期,如此循环直到没有过期。
最小堆的添加要O(logN),删除要O(logN)。
为什么要用单例模式初始化日志系统?
同步方式就是实时写入日志,会产生很多系统调用,如果某条日志很大,写的时间很长,就会阻塞日志系统,造成性能瓶颈。
异步方式就是使用生产者消费者模式,生产够了就写到日志,具有较高的并发能力。
比如现在要记录监控服务器状态的日志,怎么把这个日志分到不同的机器?可以使用MQTT或RABITMQ等消息队列进行消息分发。
压测即并发量测试。
webbench实现的原理:
父进程fork很多子进程,子进程在一定时间内对服务器循环发出实际访问请求。
父子进程通过pipe管道通信,管道是单向的,子进程写入若干次请求访问完毕记录到的总信息,父进程读取子进程的信息。
子进程到时间了就退出,父进程在子进程退出之后统计并给用户显示最后的测试结果,然后再退出。
提升服务器并发能力:(1)提升服务端操作系统的IO连接管理能力,基于非阻塞IO多路复用技术,让上亿的请求同时连在一台服务器上高效管理;(2)提升服务端网络系统的IO读写能力,零拷贝、DMA、缓存等;(3)改善服务端系统资源竞争,包括线程数量、锁竞争(无所队列)、内存申请释放等;(4)改善代码架构、代码逻辑、代码对内存的操作。
上万并发连接的具体数量。虚拟机内存配置4G运行webserver,服务器内存配置为16G运行webbench压测软件。利用webbench进行测试得到的具体数量为:客户端数量不能超过10050 。
webbench -c 10050 -t 5 http://192.168.1.130:9006/ 并发10050运行5秒,产生的TCP连接数有,显示有个failed。
LT:每秒钟响应请求数目:153960 pages/min。每秒钟传输数据量:287392 bytes/sec。Requests: 12830 susceed, 0 failed.
ET:每秒钟响应请求数目:154692pages/min。每秒钟传输数据量:288758bytes/sec。Requests: 12891susceed, 0 failed.
统计应用流量。应用流量是指某段时间内访问的请求数量吗?是的话,可以使用redis或mysql数据库存储对应连接的客户端IP地址+时间,然后统计指定时间段内不同IP地址的数量。
数据库连接池数量是8,线程池的线程数量也是8。
项目遇到的问题和解决方案。实际测试的时候,请求小文件时服务端调用一次writev就能将数据全部发送出去。但是请求大文件的时候,就会需要调用writev函数多次,此时出现文件显示不全或者无法显示的问题。是因为writev的m_iv结构体成员有问题,每次传输之后不会自动偏移文件指针和传输长度,还会按照原有的指针和长度进行发送。根据前面的基础API分析,我们知道writev函数以顺序iov[0]、iov[1]到iov[iovcnt-1]从缓冲区中聚集输出数据。项目中,申请了两个iov,其中iov[0]是存储报文状态行的缓冲区,iov[1]是资源文件指针。改进:考虑到报文消息头比较小,一次传输就能完成,第一次传输之后就将其下次的传输长度设置成0,然后更新文件需要传递的开始地址和长度,下次就只会传递文件。在此基础上,每次传输之后都更新下次传输的文件的起始位置和长度。
如何进行多线程调试。这里利用vscode编辑器+GDB进行调试,对程序中需要监测的位置打断点,利用-exec执行对应的 GDB 调试指令。在此基础上,让程序全速运行到指定的断点处,此时在调试控制台中采用info threads 可以显示当前可调试的所有线程,其中带*号的是正在调试的线程。此时如果需要在当前线程中单步运行的话,可以使用set scheduler-locking on命令进行上锁,执行单步调试。在最后使用set scheduler-locking off 解除锁定,返回原先的线程。
——————————————————————————————————————————————————————————————
数据结构:升序链表(我的项目使用的)、跳表、时间轮、红黑树、最小堆。
由于非活跃连接占用了连接资源,严重影响服务器的性能。
通过实现一个服务器定时器,处理这种非活跃连接,释放连接资源。
利用alarm函数周期性地触发SIGALRM信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务。
定时器在服务器端作用:
(1)多客户端连接服务器,需要通信发送数据包,有些客户端连接了长时间不干事,就需要断开。
(2)某些任务当前不想执行,需要一定时间之后执行。
如何实现定时器?
(1)单线程环境下:定时事件通常与网络事件协调处理。
举例:redis单线程,nginx多进程但是每个进程下都是单线程。
epoll_wait(epfd,用户态数组用于接收已经触发的事件,从网络协议栈最大取多少数据,没有事件到达时最长阻塞时间)。
epoll_wait和定时事件的绑定:定时时间是离散有序的一个链表上。最近要触发的定时器会作为epoll_wait的第四个参数。
红黑树:平衡二叉搜索树
平衡的规则:从根节点出发到任意叶子节点的黑节点数一定相等。
增加和删除的时候都会满足平衡规则,提供一个搜索稳定时间复杂度。
找最左侧的节点就可以找最小的节点,就可以找到最近要触发的定时器。
O(logn):100万个节点,比较20次;10亿个节点,比较30次。
(2)多线程环境下:
有一个单独的定时线程进行处理定时事件thread_timer。
采用时间轮结构实现定时器,跳表也可以,最小堆也可以。
时间轮知识:
时针、分针、秒针。时间精度是每一秒,时间范围是60。单层级时间轮是循环数组,使用取余来操作。(time%60)
插入任务时间复杂度永远是O(1)。不能执行删除任务。单层级中,数组的大小必须要大于 支持最大的定时任务。此外,0有任务,59有任务,中间没有,就会造成空推进的问题。
使用多层级的时间轮解决上述两个问题。分成秒层级(60)、分层级(60)、时层级(12)。
132解决数组太大的问题,接下来关注秒针运转。
跳表知识:
跳表是多层级有序链表。(有大量节点数据的时候才提高效率,本质还是二分查找,空间换时间)
跳表可以通过加锁的方式,提供并发读写。每个节点都会存一个互斥锁。(值得做跳表KV存储引擎项目研究研究)
如果要插入8,先查找7到9之间,插入节点的时候分配三层1->8->20/1->7->8->20/...7->8->9....。
如果要删除节点7,加上原子变量,方便我们并发读。
跳表使用场景:rocksdb是kv数据库,有内存数据和磁盘数据,需要提供组织kv并提供并发读写。
———————————————————————————————————————————————————————————————
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?