栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 系统运维 > 运维 > Linux

C++ LinuxWebServer项目(2)如何处理请求

Linux 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

C++ LinuxWebServer项目(2)如何处理请求

目录
  • 一、前言
  • 二、设计架构图
  • 三、如何处理HTTP请求报文的?
    • 3.0 线程池中的并发处理模式
    • 3.1 为什么使用线程池
    • 3.2 **处理过程中,线程池线程的选择有哪几种方式**
    • 3.3 线程池实现细节
    • 3.4 线程的同步机制有哪些?
    • 3.5 **线程池**具体做法
    • 3.6 介绍一下几种典型的锁?
        • 读写锁
        • 互斥锁
        • 条件变量
        • 自旋锁
    • 3.7 线程数量的选择
  • 四、实现细节
    • 1. 线程池中的工作线程是一直等待吗?
    • 2. 线程池工作线程处理完一个任务后的状态是什么?
    • 3. 如果同时有1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?
    • 4. 如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?
    • 5 **什么是虚假唤醒?**
    • 五、小结

一、前言

在解决多接受的问题后,我们还需要考虑如何提高处理请求,请求的方式有很多种一种是HTTP请求,数据库访问等逻辑处理请求。

因此我们可以借助线程池,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等)。

二、设计架构图

半同步/半反应堆线程池

三、如何处理HTTP请求报文的? 3.0 线程池中的并发处理模式

半异步:异步处理I/O事件,就是客户端向服务器端的请求的接收,是通过异步线程进行处理的,来请求触发处理,没有来的时候处理其他事情。

半同步:是指同步处理请求数据,异步线程接收完请求之后会封装一下插入队列,工作线程就依次同步从队列中取出请求对象进行处理。

半同步/半反应堆:它是半同步/半异步模式的变体,它核心在于,主线程充当异步线程,只负责监听客户端请求以及向内核注册读写事件,这和前面的rector(反应堆)事件处理模型类似,所以这样称呼。

并发编程方法的实现有多线程和多进程两种,但这里涉及的并发模式指I/O处理单元与逻辑单元的协同完成任务的方法。

  • 半同步/半异步模式
  • 领导者/追随者模式

并发模式中的同步和异步

  • 同步指的是程序完全按照代码序列的顺序执行
  • 异步指的是程序的执行需要由系统事件驱动

半同步/半异步模式工作流程

  • 同步线程用于处理客户逻辑
  • 异步线程用于处理I/O事件
  • 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中
  • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象

半同步/半反应堆工作流程(以Proactor模式为例)

  • 主线程充当异步线程,负责监听所有socket上的事件
  • 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
  • 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
  • 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权

该项目使用线程池(半同步半反应堆模式)并发处理用户请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等)。

具体的,主线程为异步线程,负责监听文件描述符,接收socket新连接,若当前监听的socket发生了读写事件,然后将任务插入到请求队列。工作线程从请求队列中取出任务,完成读写数据的处理。

通过之前的代码,我们将listenfd上到达的connection通过 accept()接收,并返回一个新的socket文件描述符connfd用于和用户通信,并对用户请求返回响应,同时将这个connfd注册到内核事件表中,等用户发来请求报文。这个过程是:通过epoll_wait发现这个connfd上有可读事件了(EPOLLIN),主线程就将这个HTTP的请求报文读进这个连接socket的读缓存中users[sockfd].read(),然后将该任务对象(指针)插入线程池的请求队列中pool->append(users + sockfd);,线程池的实现还需要依靠锁机制以及信号量机制来实现线程同步,保证操作的原子性。

前提是保证所有客户请求都是无状态的,因为同一个连接上的不同请求可能会由不同的线程处理。

3.1 为什么使用线程池

当你需要限制你应用程序中同时运行的线程数时,线程池非常有用。因为启动一个新线程会带来性能开销,每个线程也会为其堆栈分配一些内存等。为了任务的并发执行,我们可以将这些任务传递到线程池,而不是为每个任务动态开启一个新的线程。

3.2 处理过程中,线程池线程的选择有哪几种方式

主线程选择哪个子线程来为新任务服务方式:

  1. 随机算法和轮流选取算法。
  2. 主进程和所有地子进程通过一个共享的工作队列(list 单链表)来同步,子进程都睡眠在该工作队列上。
3.3 线程池实现细节
  • 所谓线程池,就是一个pthread_t类型的普通数组,通过pthread_create()函数创建m_thread_number个线程,用来执行worker()函数以执行每个请求处理函数(HTTP请求的process函数),通过pthread_detach()将线程设置成脱离态(detached)后,当这一线程运行结束时,它的资源会被系统自动回收,而不再需要在其它线程中对其进行 pthread_join() 操作。 消息队列的大小由机器硬件来决定,本实验环境选取max_request = 10000

两种高效的并发模式:并发其实适合于**``I/O`密集型**而不适合于计算密集型,比如经常读写文件,访问数据库等,由于I/O操作的速度远没有CPU计算速度快,所以让程序阻塞于I/O操作将浪费大量的CPU时间。

  • 操作工作队列一定要加锁(locker),因为它被所有线程共享(与最大请求数做个判断,允许)。
  • 我们用信号量来标识请求队列中的请求数,通过m_queuestat.wait();来等待一个请求队列中待处理的HTTP请求,然后交给线程池中的空闲线程来处理。

设置成脱离态的目的:为了在使用线程的时候,避免线程的资源得不到正确的释放,从而导致了内存泄漏的问题。所以要确保进程为可分离的的状态,否则要进行线程等待已回收他的资源。

3.4 线程的同步机制有哪些?

临界区,互斥对象,信号量,事件对象(条件变量的应用)。

其中临界区和互斥对象用于互斥控制;信号量和事件对象主要用于同步控制。

事件对象:通过通知操作的方式来保持线程的同步,还可以实现对多个线程的优先级比较的操作。

  • POSIX信号量:可用于进程同步,也可用于线程同步。
  • POSIX互斥锁 + 条件变量:只能用于线程同步。

ps: 信号量、共享内存,以及消息队列等System V IPC三剑客主要关注进程间通信;

而条件变量、互斥锁,主要关注线程间通信。

3.5 线程池具体做法

通过epoll_wait 发现这个connfd上有可读事件了(EPOLLIN),主线程就将这个HTTP请求报文读进这个连接socket的读缓存中users.[sockfd].read(),讲后将任务对象(指针)插入线程池的请求队列中pool->append(users + sockfd);

线程池的实现还需要依靠锁机制以及信号量机制来实现线程同步,保证操作的原子性

3.6 介绍一下几种典型的锁? 读写锁
  • 多个读者可以同时进行读
  • 写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
  • 写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁

条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

图解操作系统里面 妈妈叫孩子吃饭的例子

自旋锁

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

3.7 线程数量的选择

最佳线程数 = CPU当前可使用的Cores数 * 当前CPU的利用率 * (1 + CPU等待时间 / CPU处理时间)

线程池中线程的数量如何确定:

针对不同的任务性质而言:CPU密集型任务应配置尽可能小的线程,如配置CPU个数+1的线程数,IO密集型任务应配置尽可能多的线程,因为IO操作不占用CPU,不要让CPU闲下来,应加大线程数量,如配置两倍CPU个数+1,而对于混合型的任务,如果可以拆分,拆分成IO密集型和CPU密集型分别处理,前提是两者运行的时间是差不多的,如果处理时间相差很大,则没必要拆分了。

任务对其他系统资源有依赖:如某个任务依赖数据库的连接返回的结果,这时候等待的时间越长,则CPU空闲的时间越长,那么线程数量应设置得越大,才能更好的利用CPU。

四、实现细节 1. 线程池中的工作线程是一直等待吗?

线程池中的工作线程是处于一直阻塞等待的模式下的。在run函数中,我们为了能够处理高并发的问题,将线程池中的工作线程都设置为阻塞等待在请求队列是否不为空的条件上,因此项目中线程池中的工作线程是处于一直阻塞等待的模式下的。

2. 线程池工作线程处理完一个任务后的状态是什么?

这里要分两种情况考虑

(1) 当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态

(2) 当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。

3. 如果同时有1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?

本项目是通过对子线程循环调用来解决高并发的问题的。

首先在创建线程的同时就调用了pthread_detach将线程进行分离,不用单独对工作线程进行回收,资源自动回收。

我们通过子线程的run调用函数进行while循环,让每一个线程池中的线程永远都不会停终止,访问请求被封装到请求队列(list)中,如果没有任务线程就一直阻塞等待,有任务线程就抢占式进行处理,直到请求队列为空,表示任务全部处理完成。

4. 如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?

会影响接下来的客户请求,因为线程池内线程的数量时有限的,如果客户请求占用线程时间过久的话会影响到处理请求的效率,当请求处理过慢时会造成后续接受的请求只能在请求队列中等待被处理,从而影响接下来的客户请求。

应对策略:

我们可以为线程处理请求对象设置处理超时时间, 超过时间先发送信号告知线程处理超时,然后设定一个时间间隔再次检测,若此时这个请求还占用线程则直接将其断开连接。

5 什么是虚假唤醒?

举个例子,我们现在有一个生产者-消费者队列和三个线程。

1) 1号线程从队列中获取了一个元素,此时队列变为空。

2) 2号线程也想从队列中获取一个元素,但此时队列为空,2号线程便只能进入阻塞(cond.wait()),等待队列非空。

3) 这时,3号线程将一个元素入队,并调用cond.notify()唤醒条件变量。

4) 处于等待状态的2号线程接收到3号线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取队列中的元素)。

5) 然而可能出现这样的情况:当2号线程准备获得队列的锁,去获取队列中的元素时,此时1号线程刚好执行完之前的元素操作,返回再去请求队列中的元素,1号线程便获得队列的锁,检查到队列非空,就获取到了3号线程刚刚入队的元素,然后释放队列锁。

6) 等到2号线程获得队列锁,判断发现队列仍为空,1号线程“偷走了”这个元素,所以对于2号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。

五、小结

通过线程池来高效的处理Http请求,子线程向由请求队列(双向链表实现)读取请求时候需要保证线程同步问题—上锁。

如何响应收到的HTTP请求呢?请看下文!

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/944359.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号