TInyWebServer面试题

一、项目介绍

(1)为什么要做这样一个项目?
热爱编程,为了巩固所学知识,这个项目综合性比较强,从中技能学习LInux环境的一些系统调用,也能熟悉网络编程和一些网络框架,并且可优化性和扩展性也比较强。
(2)介绍一下你的项目
这个项⽬是我在学习计算机⽹络和Linux socket编程过程中独⽴开发的轻量级Web服务器,服务器的⽹络模型是主从reactor加线程池的模式,IO处理使⽤了⾮阻塞IO和IO多路复⽤技术,具备处理多个客户端的http请求的能⼒。这个项⽬从5⽉份开始做,到7⽉份完成了项⽬的整体功能。
项⽬中的⼯作可以分为4个部分,包括线程池的设计、http连接处理、定时器处理非活动连接、数据库连接池的设计。
在开发阶段对于⼀些功能的不同实现⽅法,我先思考⼀下各个⽅法的应⽤场景和效率,再进⾏选择,通过这个项⽬,我学习了两种Linux下的⾼性能⽹络模式,熟悉了Linux环境下的编程。
(3)项目的难点
1)在服务器网络框架的基本搭建中,主要的难点是对技术的理解和对处理方法的选择上,还有针对编程中的一些细节问题的处理,比如内存泄露等;
2)在对项目的优化上,比如怎么提高服务器的性能,包括处理连接的速度、缓存机制上去做一些改进。

在开发和维护 Web 服务器时,会遇到各种各样的问题。以下是一些常见问题及其解决办法的示例:

  1. 性能瓶颈
    问题描述:当用户访问量增加时,服务器响应变慢,甚至出现超时,无法满足需求。
    解决办法:
    优化代码:检查是否有不必要的计算或阻塞操作,优化数据结构和算法。
    数据库优化:使用索引、优化查询语句、减少不必要的查询,或考虑数据库的缓存机制。
    缓存机制:引入缓存机制,如 Redis 或 Memcached,用于缓存频繁访问的数据,减少对数据库的直接访问。
    负载均衡:使用负载均衡器(如 Nginx、HAProxy)将请求分发到多台服务器,降低单台服务器的压力。
    异步处理:将一些耗时操作放到后台异步处理,如通过队列(RabbitMQ、Kafka)来处理任务。
  2. 内存泄漏
    **问题描述:**随着服务器运行时间的增加,内存占用不断增加,最终导致系统内存耗尽,服务器崩溃。
    解决办法:
    代码检查:检查是否有未释放的资源,如未关闭的数据库连接、文件句柄或循环引用的对象。
    工具检测:使用内存分析工具(如 Valgrind、VisualVM)检测内存泄漏。
    定期重启:作为临时解决方案,可以定期重启服务来释放内存,直到根本问题解决。
  3. 死锁
    **问题描述:**在多线程或多进程环境下,可能出现多个线程或进程互相等待对方释放资源,导致死锁,系统无法继续处理请求。
    解决办法:
    避免死锁:确保线程或进程获取资源的顺序一致,尽量减少锁的粒度和锁的使用时间。
    超时机制:引入锁获取的超时机制,如果超时未能获取锁,则释放已持有的资源并重新尝试。
    分析工具:使用调试工具分析死锁情况,查找死锁发生的代码路径。
  4. 安全漏洞
    **问题描述:**Web 服务器可能存在 SQL 注入、跨站脚本攻击(XSS)、跨站请求伪造(CSRF)等安全漏洞,导致数据泄露或系统被入侵。
    解决办法:
    输入验证:严格验证和清理用户输入,防止 SQL 注入和 XSS 攻击。
    使用预编译语句:在数据库查询时,使用预编译语句(Prepared Statements)来避免 SQL 注入。
    防御 CSRF:在敏感操作中引入 CSRF token,确保请求是合法的。
    使用 HTTPS:通过 SSL/TLS 加密通信,防止数据在传输过程中被窃取或篡改。
    安全扫描:定期使用安全扫描工具(如 OWASP ZAP)进行安全测试,及时修复发现的漏洞。
  5. 文件上传问题
    问题描述:用户上传大文件时,服务器可能超时或耗尽存储空间,导致服务中断。
    解决办法:
    限制上传大小:在服务器配置中限制上传文件的大小,防止上传超大文件。
    分块上传:支持分块上传,大文件分成多个小块上传,可以避免一次性占用太多资源。
    异步处理:上传文件后,将文件处理的操作放在后台异步处理,减少用户等待时间。
    存储优化:使用分布式存储系统(如 HDFS、Amazon S3)来存储文件,避免单台服务器存储空间不足的问题。
  6. 请求超时
    问题描述:某些请求由于处理时间过长,导致超时,用户无法获得结果。
    解决办法:
    优化代码路径:检查请求路径,优化处理逻辑,减少不必要的操作。
    超时设置:在服务器配置中合理设置请求处理的超时时间,并告知用户处理状态。
    异步处理或延迟任务:对于耗时操作,返回响应后通过异步任务处理,并通知用户结果(例如通过邮件或通知系统)。
    通过不断优化和监控,可以及时发现和解决 Web 服务器中出现的问题,提升服务器的稳定性和用户体验。

(4)针对项目做了哪些优化:
1、程序本身
1)减少程序等待IO的时间:采用非阻塞IO+IO多路复用的方式;
2)设计高性能网络框架:同步IO(主从reactor+线程池)和异步IO(proactor)
3)【减少系统调用】采用零拷贝技术senfile()来发送文件,避免拷贝数据到用户态;
4)【减少系统调用】尽量减少锁的使用,如果需要,尽量减小临界区(日志系统和线程池)
2、系统参数调优
1)最大文件描述符个数(用户级和系统级)
2)tcp连接的参数(半连接、全连接队列的长度、tcp syscookies的设置)

(5)项目有部署上线嘛?
自己租了一个阿里云服务器,在上面部署过
·(6)项目中如何体现代码规范:模块解耦、命名空间、命名规范、注释
IO任务和计算任务解耦,避免计算密集型连接占用子reactor导致无法响应其他来凝结,可扩展性好。

二、线程池相关

(1)手写线程池
C++实现的简易线程池,包含线程数量,启动标志位,线程列表以及条件变量。
构造函数主要是声明未启动和线程数量的。
start函数为启动线程池,将num个线程绑定threadfunc⾃定义函数并执⾏,加⼊线程列表
stop是暂时停⽌线程,并由条件变量通知所有线程。
析构函数是停⽌,阻塞所有线程并将其从线程列表剔除后删除,清空线程列表。

#pragma once
#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>

class ThreadPool {
public:
	 ThreadPool(size_t threads);
	 ~ThreadPool();
 	template<typename F, typename... Args>
 	auto enqueue(F&& f, Args&&... args)
 		-> std::future<typename std::result_of<F(Args...)>::type>; // result_of 的作⽤与decltype 相同,不国针对的是函数返回值
private:
	 std::vector<std::thread> _workers;
	 std::queue<std::function<void()>> _tasks;
	 std::mutex _queueMutex;
	 std::condition_variable _condition;
	 bool _stop;
};
inline ThreadPool::ThreadPool(size_t threads) : _stop(false) {
	 // 设置线程任务
	 for(size_t i = 0; i < threads; ++i) {
	 // 每个线程需要做的事情很简单:
	 // 1. 从任务队列中获取任务(需要保护临界区)
	 // 2. 执⾏任务
	 _workers.emplace_back([this] {
		 while(true) {
			 std::function<void()> task;
			 {
				 std::unique_lock<std::mutex> lock(this->_queueMutex);
				 // 等待唤醒,条件是停⽌或者任务队列中有任务
				 this->_condition.wait(lock,
									 [this]{ return this->_stop || !this->_tasks.empty(); });
				 if(this->_stop && this->_tasks.empty())
				 return;
				 task = std::move(this->_tasks.front());
				 this->_tasks.pop();
			 }
			 task();
		 }
	 });
 }
template<typename F, typename... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
 	-> std::future<typename std::result_of<F(Args...)>::type>
{
	 using return_type = typename std::result_of<F(Args...)>::type;
	 
	 // 将需要执⾏的任务函数打包(bind),转换为参数列表为空的函数对象
	 auto task = std::make_shared<std::packaged_task<return_type()>> (
	 std::bind(std::forward<F>(f), std::forward<Args>(args)...)
 );
 std::future<return_type> res = task->get_future();
 {
	 std::unique_lock<std::mutex> lock(_queueMutex);
	 if(_stop)
	 throw std::runtime_error("enqueue on stopped ThreadPool");
	 // 最妙的地⽅,利⽤ lambda函数 包装线程函数,使其符合 function<void()> 的形式
	 // 并且返回值可以通过 future 获取
	 _tasks.emplace([task]() {
	 	(*task)();
 	});
 }
 _condition.notify_all();
 return res;
}
inline ThreadPool::~ThreadPool() {
 	{
		 std::unique_lock<std::mutex> lock(_queueMutex);
		 _stop = true;
	}
	 _condition.notify_all(); // 唤醒所有线程,清空任务
	 for(std::thread& worker : _workers) {
		 worker.join();
	 }
}

⼀些问题

  1. 项⽬的中线程池是怎么搭建的,⼤概讲⼀下原理?(项⽬中的线程池有两种,分别去讲了⼀下)
    pthread_create陷阱:函数原型中的第三个参数,指向处理线程函数的地址,该函数,要求为静态函数,如果处理线程函数为类成员函数是,需要将其设置为静态成员函数。(因为如果是非静态,该成员函数有this指针,从而和函数类型void*不能匹配,不能通过编译)。

  2. 如果服务器在运⾏的过程中,实际存储的⽂件被其他⽤户修改了,会发⽣什么?(类似于内存⻚⾯的换⼊换出,每次从cache读⼊时,先判断实际⽂件去对⽐,可以对⽐最后修改时间或者通过摘要算法判断)

  3. ⽇志系统的异步体现在什么地⽅?与同步的区别?为什么设计成双缓冲⽽不⽤更多的缓冲区?(前端缓冲不⾜时会⾃动扩展,但是双缓冲⾜够应付使⽤场景,因为⽇志只记录必要的信息,并不会太多)

  4. 讲⼀下你项⽬中缓存池的作⽤?为什么要选择 LFU ⽽不是 LRU?LFU有什么缺点?(最近加⼊的数据因为起始的频率很低,容易被淘汰,⽽早期的热点数据会⼀直占据缓存)可以进⾏优化吗?(LFUaging,或者使⽤LRU+LFU的形式)

  5. 缓存机制为什么选⽤LFU?(主要考虑热点⻚⾯)热点⻚⾯指的什么?怎么知道是热点⻚⾯?有考虑过其他的缓存算法吗?(LRU,ARC)为什么不选⽤?(balabala)LFU什么缺陷吗,可以怎么进⾏优化?(LFU-aging,window-LFU,ARC)

  6. 为什么⽤LFU,不⽤LRU?(保证热点⻚⾯的响应)LFU的实现可以讲⼀下吗?(双链表+双哈希)

  7. 连接对象和缓存对象都⾛内存池,这⾥挖坑了
    相⽐于 new 的性能提升在哪?(避免了系统调⽤的开销), new 的系统调⽤开销有多少?(纳秒级),可
    以忽略吗?(不能,在⾼并发场景下,越往后的连接延迟越明显)
    new 的主要开销在哪?(系统调⽤和构造函数)
    new ⼀定会陷⼊内核态吗?(不⼀定,因为底层是 malloc , malloc 根据分配内存的⼤⼩不同有两种分配
    ⽅式,⼩于128k使⽤ brk() ,⼤于 128k 使⽤ mmap() )
    代码随想录知识星球
    那你内存池对于⼩内存的申请相⽐ new 还有优势吗?(到这⾥才明⽩⾯试官挖的坑,赶紧sorrymaker,考
    虑不周),但是对于缓存的对象是有必要⾛内存池的,下去再好好理⼀理。

  8. 如果⼀个连接请求耗时⾮常⻓,会发⽣什么情况?(反问是IO耗时,还是计算耗时?如果是IO
    耗时的话,则会阻塞同⼀subreactor中的所有线程,如何是计算耗时的话,因为本身也设计了计算线程池,
    对服务器本身并没有太多影响)

  9. 如果⼀个连接请求的资源⾮常⼤,在发送响应报⽂时会造成响应其他连接的请求吗?有什么优化
    的⽅法?(断点续传,设置响应报⽂的⼤⼩上限,当响应报⽂超出上限时,可以记录已经发送的位置,之后可
    以选择继续由该线程进⾏发送,也可以转交给其他线程进⾏发送)

(2)线程的同步机制有哪些?
信号量、条件变量、互斥锁、读写锁。
(无锁队列?)

(3)线程池中的工作线程是一直等待吗?
不是,当请求队列中有任务到来的时候,就会通过条件变量去通知工作线程来执行任务。

(4)你的线程池工作线程处理完一个任务后的状态是什么?
这⾥要分两种情况考虑
(1)当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态
(2)当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。

(5)如果1000个客户端同时进行访问请求,线程数不多,怎么能及时响应处理每一个呢?
⾸先这种问法就相当于问服务器如何处理⾼并发的问题。
⾸先我项⽬中使⽤了I/O多路复⽤技术,每个线程中管理⼀定数量的连接,只有线程池中的连接有请求,epoll就会返回请求的连接列表,管理该连接的线程获取活动列表,然后依次处理各个连接的请求。如果该线程没有任务,就会等待主reactor分配任务。这样就能达到服务器⾼并发的要求,同⼀时刻,每个线程都在处理⾃⼰所管理连接的请求。

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

是的,如果一个客户请求占用了线程池中的一个线程很长时间,这可能会影响接下来的客户请求,具体表现如下:

线程占用:线程池中的线程数量是有限的。如果一个线程被长时间占用,而其他线程也在忙碌,那么新的客户请求将不得不等待,直到有线程可用。

任务排队:如果所有线程都被占用,新的请求任务将被放入线程池的任务队列中等待执行。如果队列容量有限且已满,新的请求可能会被拒绝或抛出异常(如 RejectedExecutionException)。

响应时间增加:由于某个任务长时间占用线程,后续的客户请求将不得不等待更长时间,导致响应时间增加。

如何应对长时间任务对线程池的影响:
合理配置线程池大小:根据系统的吞吐量和任务特性(CPU密集型或I/O密集型)合理配置核心线程数和最大线程数,以确保即使在高负载下,也有足够的线程处理任务。

任务超时机制:为线程池中的任务设置超时机制,如果任务执行时间超过某个阈值,主动中止任务,以防止单个任务长时间占用资源。

异步处理:对于耗时较长的任务,可以考虑使用异步处理或分布式系统,将任务拆分并交给不同的服务或线程池处理,以减少单个线程的占用时间。

监控和调整:持续监控线程池的运行情况,及时发现和处理线程池中可能存在的长时间占用情况,动态调整线程池的配置。

通过这些方法,可以有效减少长时间任务对线程池以及后续客户请求的影响

影响分析
会影响这个⼤请求的所在线程的所有请求,因为每个eventLoop都是依次处理它通过epoll获得的活动事件,也就是活动连接。如果该eventloop处理的连接占⽤时间过⻓的话,该线程后续的请求只能在请求队列中等待被处理,从⽽影响接下来的客户请求。
应对策略
1. 主 reactor 的⻆度:可以记录⼀下每个从reactor的阻塞连接数,主reactor根据每个reactor的当前负载来分发请求,达到负载均衡的效果。
2. 从 reactor 的⻆度:
超时时间:为每个连接分配⼀个时间⽚,类似于操作系统的进程调度,当当前连接的时间⽚⽤完以后,将其重新加⼊请求队列,响应其他连接的请求,进⼀步来说,还可以为每个连接设置⼀个优先级,这样可以优先响应重要的连接,有点像 HTTP/2 的优先级。
关闭时间:为了避免部分连接⻓时间占⽤服务器资源,可以给每个连接设置⼀个最⼤响应时间,当⼀个连接的最⼤响应时间⽤完后,服务器可以主动将这个连接断开,让其重新连接。

(7)线程池设置为多大?为什么
8个。

将线程池中的线程数量默认设置为8个是一个常见的选择,主要基于以下几个原因:

  1. 合理的并行度:
    现代计算机通常具有多个CPU核心,常见的台式机和服务器通常有4到8个核心,支持硬件多线程的CPU可能会有更多的逻辑核心。
    设置线程数为8可以在具有4到8个物理或逻辑核心的系统上充分利用多核处理器的并行处理能力。在多核处理器上,每个线程可以被分配到不同的核心,从而实现真正的并行执行。
  2. 减少上下文切换:
    线程数过多会导致频繁的上下文切换,这种切换是昂贵的,会影响性能。选择8个线程是一个权衡点,能够提供足够的并行性,同时不会引入太多的上下文切换开销。
  3. 任务类型的考虑:
    如果线程池处理的任务大多数是I/O密集型的,比如数据库查询、文件操作等,通常线程数会设置得相对较多,因为这些任务大部分时间在等待I/O操作完成,线程处于阻塞状态,适当增加线程数能够更好地利用系统资源。
    如果任务是计算密集型的,则线程数一般接近或略高于CPU核心数,以最大化CPU使用率。
  4. 系统资源的限制:
    在很多场景下,开发者会根据预期的负载情况和系统资源的限制(如内存、CPU等)来选择一个合适的线程数。8个线程通常是一个安全的选择,适合多数中等规模的任务处理场景。
  5. 经验性的默认值:
    许多库和框架在设计时会选择一个经验性默认值,8个线程是常见的选择。它在大多数情况下能够提供良好的性能,而不需要开发者进行复杂的调整。
    当然,在实际应用中,线程数的设置应该根据具体的应用场景、系统配置和任务特性进行调整。8只是一个通用的默认值,并不一定适合所有情况。开发者可以通过性能测试来找到最适合自己系统的线程数目。

(8)请求队列是阻塞还是非阻塞的?请求队列的长度
在并发编程中,阻塞队列和非阻塞队列是两种不同类型的队列,它们在处理线程间的任务传递时有不同的行为和应用场景。以下是它们的主要区别及各自的特点:

阻塞队列
阻塞队列在队列满或空时,线程会被阻塞,直到条件满足。

特点
线程阻塞:

当线程尝试从一个空的队列中取数据时,它会被阻塞,直到有数据可用。
当线程尝试向一个满的队列中放数据时,它会被阻塞,直到有空间可用。
适用场景:

用于需要协调生产者和消费者之间的工作负载的场景,比如生产者-消费者模型。
有助于实现流量控制,防止过度消费或生产。
在C++中,可以使用std::condition_variable与std::mutex来手动实现阻塞队列,或使用第三方库实现

非阻塞队列
非阻塞队列在操作时不会阻塞线程,即使队列为空或已满,它也会立即返回结果或抛出异常。

特点
无阻塞操作:

尝试从一个空的队列中取数据时,如果没有数据,操作会立即返回一个特殊值(如null)或抛出异常。
尝试向一个满的队列中放数据时,如果队列已满,操作会立即返回一个错误值或抛出异常。
适用场景:

在需要确保高性能和响应时间的场景中,非阻塞队列更适用,比如高频率的任务调度或实时系统。
适用于不需要生产者和消费者之间的严格同步的场景。
在C++中,可以使用标准库中的std::queue与原子操作(std::atomic)来实现非阻塞队列,或使用第三方库实现。

三、并发模型相关

(1)简单说一下服务器使用的并发模型
半同步proactor:主线程处理连接和读写,返回就绪事件,并将处理好的数据从内核态拷贝到用户态
(2)reactor\proactor\主从reactor模型的区别

  1. Reactor 是⾮阻塞同步⽹络模式,感知的是就绪可读写事件。在每次感知到有事件发⽣(⽐如可读就绪事件)后,就需要应⽤进程主动调⽤ read ⽅法来完成数据的读取,也就是要应⽤进程主动将 socket 接收缓存中的数据读到应⽤进程内存中,这个过程是同步的,读取完数据后应⽤进程才能处理数据。
  2. Proactor 是异步⽹络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传⼊数据缓冲区的地址(⽤来存放结果数据)等信息,这样系统内核才可以⾃动帮我们把数据的读写⼯作完成,这⾥的读写⼯作全程由操作系统来做,并不需要像 Reactor 那样还需要应⽤进程主动发起 read/write 来读写数据,操作系统完成读写⼯作后,就会通知应⽤进程直接处理数据。
    Proactor这么好⽤,那你为什么不⽤?
    在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接⼝,不是真正的操作系统级别⽀持的,⽽是在⽤户空间模拟出来的异步,并且仅仅⽀持基于本地⽂件的 aio 异步操作,⽹络编程中的 socket 是不⽀持的,也有考虑过使⽤模拟的proactor模式来开发,但是这样需要浪费⼀个线程专⻔负责 IO 的处理。
    ⽽ Windows ⾥实现了⼀套完整的⽀持 socket 的异步编程接⼝,这套接⼝就是 IOCP ,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows ⾥实现⾼性能⽹络程序可以使⽤效率更⾼的 Proactor ⽅案。

reactor模式中,各个模式的区别?
Reactor模型是⼀个针对同步I/O的⽹络模型,主要是使⽤⼀个reactor负责监听和分配事件,将I/O事件分派给对应的Handler。新的事件包含连接建⽴就绪、读就绪、写就绪等。reactor模型中⼜可以细分为单reactor单线程、单reactor多线程、以及主从reactor模式。

  1. 单reactor单线程模型就是使⽤ I/O 多路复⽤技术,当其获取到活动的事件列表时,就在reactor中进⾏读取请求、业务处理、返回响应,这样的好处是整个模型都使⽤⼀个线程,不存在资源的争夺问题。但是如果⼀个事件的业务处理太过耗时,会导致后续所有的事件都得不到处理。
  2. 单reactor多线程就是⽤于解决这个问题,这个模型中reactor中只负责数据的接收和发送,reactor将业务处理分给线程池中的线程进⾏处理,完成后将数据返回给reactor进⾏发送,避免了在reactor进⾏业务处理,但是 IO 操作都在reactor中进⾏,容易存在性能问题。⽽且因为是多线程,线程池中每个线程完成业务后都需要将结果传递给reactor进⾏发送,还会涉及到共享数据的互斥和保护机制。
  3. 主从reactor就是将reactor分为主reactor和从reactor,主reactor中只负责连接的建⽴和分配,读取请求、业务处理、返回响应等耗时的操作均在从reactor中处理,能够有效地应对⾼并发的场合。

subreactor负责读写数据,由线程池进⾏业务处理。

在这里插入图片描述

(3)你用了epoll,说一下为什么用epoll,还有其他复用方式吗?区别是什么

⽂件描述符集合的存储位置
对于 select 和 poll 来说,所有⽂件描述符都是在⽤户态被加⼊其⽂件描述符集合的,每次调⽤都需要将整个集合拷⻉到内核态;epoll 则将整个⽂件描述符集合维护在内核态,每次添加⽂件描述符的时候都需要执⾏⼀个系统调⽤。系统调⽤的开销是很⼤的,⽽且在有很多短期活跃连接的情况下,由于这些⼤量的系统调⽤开销,epoll 可能会慢于 select 和 poll。

⽂件描述符集合的表示⽅法
select 使⽤线性表描述⽂件描述符集合,⽂件描述符有上限;poll使⽤链表来描述;epoll底层通过红⿊树来描述,并且维护⼀个就绪列表,将事件表中已经就绪的事件添加到这⾥,在使epoll_wait调⽤时,仅观察这个list中有没有数据即可。
遍历⽅式
select 和 poll 的最⼤开销来⾃内核判断是否有⽂件描述符就绪这⼀过程:每次执⾏ select 或 poll 调⽤时,它们会采⽤遍历的⽅式,遍历整个⽂件描述符集合去判断各个⽂件描述符是否有活动;epoll 则不需要去以这种⽅式检查,当有活动产⽣时,会⾃动触发 epoll 回调函数通知epoll⽂件描述符,然后内核将这些就绪的⽂件描述符放到就绪列表中等待epoll_wait调⽤后被处理。
触发模式
select和poll都只能⼯作在相对低效的LT模式下,⽽epoll同时⽀持LT和ET模式。
适⽤场景
当监测的fd数量较⼩,且各个fd都很活跃的情况下,建议使⽤select和poll;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使⽤epoll会明显提升性能。

(4)IO多路复⽤
1)说⼀下什么是ET,什么是LT,有什么区别?

  1. LT:⽔平触发模式,只要内核缓冲区有数据就⼀直通知,只要socket处于可读状态或可写状态,就会⼀直返回sockfd;是默认的⼯作模式,⽀持阻塞IO和⾮阻塞IO
  2. ET:边沿触发模式,只有状态发⽣变化才通知并且这个状态只会通知⼀次,只有当socket由不可写到可写或由不可读到可读,才会返回其sockfd;只⽀持⾮阻塞IO

2)LT什么时候会触发?ET呢?
LT模式
1. 对于读操作
只要内核读缓冲区不为空,LT模式返回读就绪。
2. 对于写操作
只要内核写缓冲区还不满,LT模式会返回写就绪。
ET模式
1. 对于读操作
当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
当有新数据到达时,即缓冲区中的待读数据变多的时候。
当缓冲区有数据可读,且应⽤进程对相应的描述符进⾏EPOLL_CTL_MOD 修改EPOLLIN事件时。
2. 对于写操作
当缓冲区由不可写变为可写时。
当有旧数据被发送⾛,即缓冲区中的内容变少的时候。
当缓冲区有空间可写,且应⽤进程对相应的描述符进⾏EPOLL_CTL_MOD 修改EPOLLOUT事件时。

3)为什么ET模式不可以⽂件描述符阻塞,⽽LT模式可以呢?
因为ET模式是当fd有可读事件时,epoll_wait()只会通知⼀次,如果没有⼀次把数据读完,那么要到下⼀次fd有可读事件epoll才会通知。⽽且在ET模式下,在触发可读事件后,需要循环读取信息,直到把数据读完。如果把这个fd设置成阻塞,数据读完以后read()就阻塞在那了。⽆法进⾏后续请求的处理。
LT模式不需要每次读完数据,只要有数据可读,epoll_wait()就会⼀直通知。所以 LT模式下去读的话,内核缓冲区肯定是有数据可以读的,不会造成没有数据读⽽阻塞的情况。

为什么选择主从reactor+线程池的模式(将IO操作和业务处理解耦)

(5)epoll有线程安全问题吗

epoll 是 Linux 内核提供的高效 I/O 事件通知机制,主要用于处理大量并发的网络连接。它在多线程环境下的使用确实涉及一些线程安全的问题,具体来说:

epoll 的线程安全性
epoll 对象本身是线程安全的:

在 Linux 内核中,epoll 对象的操作(如 epoll_ctl、epoll_wait)是线程安全的。即,多个线程可以安全地同时对同一个 epoll 对象进行读写操作,而不会导致数据竞争或不一致的状态。
线程安全操作的限制:

虽然 epoll 对象本身是线程安全的,但应用程序需要确保对 epoll 对象的操作符合设计意图。例如,多个线程同时调用 epoll_ctl 来添加或删除文件描述符时,必须确保这些操作的逻辑一致性。
线程间共享的资源,如 epoll 对象的文件描述符集合,可能会因为不同线程对其进行并发修改而导致问题。通常需要通过适当的锁机制来确保一致性,尤其是在复杂的场景中。
文件描述符的管理:

需要注意的是,虽然 epoll 对象的 API 是线程安全的,但管理文件描述符(如 socket、pipe)的过程可能不是线程安全的。因此,文件描述符的创建、关闭、修改等操作需要在多线程环境中小心处理,避免竞争条件。
事件处理的线程安全:

epoll_wait 通常会被单个线程调用来等待事件的发生。虽然 epoll_wait 本身是线程安全的,但事件处理的代码(比如处理读写操作的逻辑)需要确保线程安全,以避免竞态条件和数据不一致的问题。
总结
epoll 对象的 API 是线程安全的,多个线程可以并发调用。
应用程序需要确保对 epoll 对象的操作逻辑一致性,特别是在涉及到文件描述符的修改和共享时。
在多线程环境中,合理地使用同步机制来保护共享资源,避免竞态条件和数据不一致。
总之,epoll 机制本身提供了线程安全的接口,但应用程序开发者需要合理设计多线程访问 epoll 对象的方式,并处理好线程安全相关的问题

四、http报文解析相关

(1)为什么用状态机

(2)状态机转移图画一下

(3)https协议为什么安全

(4)https的ssl连接过程

(5)GET和POST的区别
如果是post,那么要设置cgi=1,启用cgi校验。
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的写端,父进程在读端读取)。
(6)怎么判断报文读取结束(主从状态机解析http报文)
1、从状态机,用于分析出一行内容
//返回值为行的读取状态,有LINE_OK,LINE_BAD,LINE_OPEN
/用于解析 HTTP 请求行或头部行。
它的目的是找到行的结束标志(即 \r\n 或仅 \n),并将每行提取出来进行进一步处理。
/
/*
在 HTTP 协议中,行结束标志通常是 \r\n(回车符加换行符)。
这个习惯来源于传统的文本文件格式,特别是在网络传输中被广泛采用。
如果只遇到单独的 \n(换行符)而没有之前的 \r(回车符),
这通常被认为是格式错误,或者数据不完整*/

2、主状态机
//主状态机逻辑
1)// 主状态机的初始状态是CHECK_STATE_REQUESTLINE,通过调用从状态机来驱动主状态机
2)// 在主状态机进行解析前,从状态机已经将每一行的末尾\r\n符号改为\0\0,以便于主状态机直接读出对应字符串进行处理
// CHECK_STATE_REQUESTLINE
// //主状态机的初始状态。调用parse_request_line函数解析请求行
3)// //解析函数从m_read_buf中解析出HTTP请求行,获得请求方法、目标URL和HTTP版本号
// //解析完成后主状态机的状态变为CHECK_STATE_HEADER
//解析http请求行,获得请求方法,目标url及http版本号

4)//在http报文中,请求行用来说明请求类型,要访问的资源以及所用的http版本没其中各个部分之间
//通过\t或空格分割
//请求行中最先含有空格和\t任意字符的位置并返回
//从 text 开始,依次检查每个字符,直到找到第一个空格或制表符为止,并返回指向该字符的指针。
//如果没有\t或空格,则报文格式有误
//将该位置改为\0,用于将前面数据取出

5)//取出数据,并通过与GET和POST比较,以确定请求方式
/*int strcasecmp(const char *s1, const char s2);
功能:
比较两个字符串 s1 和 s2,忽略大小写。
如果 s1 和 s2 相等,返回 0。
如果 s1 小于 s2,返回负值。
如果 s1 大于 s2,返回正值。
/
6)//m_url此时跳过了第一个空格或者\t,但不知道之后是否还有
//将m_url向后偏移,通过查找,继续跳过空格和\t字符,指向请求资源的第一个字符

7) //对请求资源的前7个字符进行判断
//这里主要是有些报文的请求资源中会带有http://,这里需要对这种情况单独处理
8) //当url为/时,显示判断界面
/这段代码的作用是在 URL 只有一个字符(即 ‘/’)的情况下,将其修改为指向 judge.html 文件。
这段代码的作用是确保当 URL 只有 ‘/’ 时,服务器返回默认的 judge.html 页面。
这在 HTTP 服务器中很常见,通常用于将根 URL(“/”)映射到默认的主页或欢迎页面。
/
9) //请求处理完毕,将主状态机转移到处理请求头(之后还要继续处理请求头)

五、数据库登录注册相关

(1)登录说一下?
注册登录功能,具体的涉及到流程图,载入数据库表,提取用户名和密码,注册登录流程与页面跳转的的代码实现。
流程图,描述服务器从报文中提取出用户名密码,并完成注册和登录校验后,实现页面跳转的逻辑。
在这里插入图片描述

载入数据库表,结合代码将数据库中的数据载入到服务器中。提取用户名和密码,结合代码对报文进行解析,提取用户名和密码。
注册登录流程,结合代码对描述服务器进行注册和登录校验的流程。
页面跳转,结合代码对页面跳转机制进行详解。

注册登录的过程
1)//将初始化的m_real_file赋值为网站根目录,//找到m_url中/的位置
2)//实现登录和注册校验
//处理cgi,如果 cgi 标志为1(表示是需要进行CGI处理的请求),并且请求类型为登录或注册
//根据标志判断是登录检测还是注册检测
1*//将用户名和密码提取出来
2*//如果是注册,先检测数据库中是否有重名的
//没有重名的,进行增加数据
//实现这一行:insert into user(username,passwd)values(‘my’,‘密码’);
//如果用户不存在,首先使用互斥锁 m_lock 对数据库操作进行加锁,避免多线程环境下的竞争条件。
//检查是否已存在同名用户。mysql_query(mysql, sql_insert)是一个 MySQL 数据库查询函数调用,执行插入操作,将 name 和 password 插入到数据库中。
//向数据库插入新用户。
//如果插入成功(res 返回值为 0),将 “/log.html” 复制到 m_url 中,表示注册成功后跳转到登录页面。
//如果插入失败(res 返回值非 0),将 “/registerError.html” 复制到 m_url 中,表示注册出现错误或用户名已存在。
//如果 users.find(name) 找到了用户名 name,则直接将 “/registerError.html” 复制到 m_url 中,表示注册失败,用户名已存在,需要重定向到注册错误页面。//根据数据库操作结果设置重定向的 URL。
3*//如果是登录,直接判断
//若浏览器端输入的用户名和密码在表中可以查找到,返回1,否则返回0
//检查用户输入的用户名和密码是否匹配数据库中的记录,并设置重定向的 URL。
4* //如果请求资源为0,表示跳转到注册页面
5* //如果请求资源为1,表示跳转到登录页面
6* //如果请求资源为5,表示跳转到图片页面
7* //如果请求资源为6,表示跳转到视频页面
8*/都不是就直接将url与网站目录拼接,这里的情况是welcome界面,请求服务器上的一个图片
3)//通过stat获取请求资源文件信息,成功则将信息更新到m_file_stat结构体,包括文件类型、权限和大小
//失败返回NO_RESOURCE状态,表示资源不存在
//判断文件的权限,是否可读,不可读则返回FORBIDDEN_REQUEST
//判断文件类型,如果是目录,返回BAD_REQUEST,表示报文格式有误
//以只读方式获取文件描述符,通过mmap将其映射到内存中

(2)你这个保存状态了吗?如果要保存,你会怎么做?(cookie和session)

(3)登录中的用户名和密码你是load到本地,然后使用map匹配的,如果有10亿数据,即使load到本地后hash,也是很耗时的,你要怎么优化?

处理大规模用户认证(如10亿条用户名和密码)的优化是一项复杂的任务,需要综合考虑性能、扩展性和安全性。直接将所有数据加载到本地并使用哈希映射(map)进行匹配在这种规模下不仅会消耗大量内存,还会导致匹配速度缓慢。以下是一些优化策略,可以有效应对这种挑战:

  1. 使用分布式数据库和存储系统
    分布式键值存储
    选择合适的数据库:使用专为高并发和大规模数据设计的分布式键值存储系统,如 Cassandra, Amazon DynamoDB, Redis Cluster, 或 CockroachDB。这些系统能够水平扩展,处理数十亿级别的数据。
    数据分片(Sharding):将数据分布到多个节点上,每个节点只负责一部分数据。这可以通过哈希分片或范围分片来实现,确保负载均衡和高可用性。
    NoSQL数据库
    MongoDB 和 Couchbase 等NoSQL数据库也适用于大规模数据存储和快速查询,支持水平扩展和分布式架构。
  2. 高效的数据索引和哈希
    优化索引结构
    二级索引:为用户名创建高效的索引结构,如B树或LSM树,支持快速查找。
    哈希索引:使用哈希表或哈希索引加速用户名的查找,减少查找时间。
    预计算哈希值
    密码哈希:存储密码的哈希值而非明文密码(确保安全)。选择高效的哈希算法(如 bcrypt, scrypt, Argon2),在保证安全性的前提下,优化哈希计算性能。
  3. 缓存机制
    使用内存缓存
    Redis 或 Memcached:将常用的用户名和哈希密码缓存到内存中,显著提高访问速度。通过缓存命中率提升整体性能,减少对后端数据库的访问压力。
    LRU缓存策略:实现最近最少使用(Least Recently Used)的缓存策略,确保高频访问的数据始终驻留在缓存中。
    分层缓存
    多级缓存:结合本地缓存(如应用服务器的内存缓存)和分布式缓存,以进一步提高性能和可扩展性。
  4. 使用布隆过滤器(Bloom Filters)
    快速存在性检查:在访问数据库之前,使用布隆过滤器快速检查用户名是否存在。布隆过滤器具有空间效率高和查询速度快的特点,能够显著减少不必要的数据库访问。
    内存优化:布隆过滤器占用较少内存,适合处理大规模数据。
  5. 并行处理和负载均衡
    水平扩展
    多实例部署:将认证服务部署在多个实例上,使用负载均衡器(如 Nginx, HAProxy, AWS ELB)分配请求,确保系统能够处理高并发请求。
    自动伸缩:根据流量动态调整服务实例的数量,保证在高峰期和低谷期都有最佳性能。
    并行计算
    多线程和异步处理:在应用层面实现多线程或异步处理,充分利用多核CPU,提高处理吞吐量。
    分布式计算框架:使用 Apache Kafka 和 Apache Spark 等分布式计算框架,处理和分析大规模数据。
  6. 数据压缩和存储优化
    数据压缩
    压缩存储:使用压缩算法(如 Snappy, LZ4)减少存储空间占用,提高I/O效率。
    列式存储:对于某些数据库,使用列式存储格式(如 Parquet, ORC)优化查询性能和存储效率。
    数据分区
    按范围或哈希分区:将数据按范围或哈希值分区,减少查询时需要扫描的数据量,提升查询速度。
  7. 安全性和加密
    密码安全
    哈希算法:使用强大的哈希算法(如 bcrypt, scrypt, Argon2)存储密码哈希,防止密码泄露后的暴力破解。
    盐值(Salt):为每个密码生成唯一的盐值,增加哈希的复杂性,防止彩虹表攻击。
    数据加密
    传输层加密:使用 TLS/SSL 加密传输中的数据,确保用户名和密码在传输过程中不被窃取。
    静态数据加密:对存储在数据库中的敏感数据进行加密,保护数据隐私。
  8. 优化查询逻辑
    减少查询次数
    批量查询:如果可能,将多个查询合并为一次批量查询,减少数据库连接和查询次数的开销。
    延迟加载:仅在必要时加载数据,避免不必要的数据访问。
    预热数据
    预加载热点数据:将高频访问的数据预先加载到缓存中,减少查询延迟。
  9. 监控和性能优化
    监控系统性能
    实时监控:使用监控工具(如 Prometheus, Grafana, Datadog)实时监控系统性能,识别瓶颈和异常。
    日志分析:分析日志数据,优化查询和系统配置,提升整体性能。
    性能调优
    数据库优化:调整数据库配置参数,如连接池大小、缓存大小等,优化数据库性能。
    代码优化:优化认证逻辑,减少不必要的计算和数据访问,提高代码执行效率。
  10. 使用专用认证服务
    第三方认证提供商
    OAuth, OpenID Connect:考虑使用第三方认证服务(如 Auth0, Okta, Firebase Authentication),这些服务已经针对大规模用户认证进行了高度优化,提供安全、可靠且可扩展的解决方案。
    分布式架构:第三方服务通常采用分布式架构,能够处理高并发和大规模数据,减轻自身系统的负担。
    综合示例
    以下是一个综合应用上述优化策略的示例架构:

数据存储:

使用 Cassandra 作为分布式键值存储,存储用户的用户名和密码哈希。
实现数据分片,确保数据在多个节点上均匀分布。
缓存层:

部署 Redis Cluster 作为分布式缓存,存储高频访问的用户名和密码哈希。
使用布隆过滤器缓存存在性检查结果,快速过滤不存在的用户名。
应用层:

部署多个认证服务实例,通过 Nginx 负载均衡器进行请求分发。
实现异步处理和多线程,提升并发处理能力。
安全性:

所有传输的数据通过 TLS/SSL 加密。
密码哈希使用 Argon2 算法,并为每个密码生成唯一的盐值。
监控和调优:

使用 Prometheus 和 Grafana 实时监控系统性能,自动调整资源分配。
定期分析日志,优化查询和缓存策略。
总结
处理10亿级别的用户名和密码匹配,需要结合分布式系统、高效的数据存储和索引、智能缓存机制以及严格的安全措施。通过上述优化策略,可以显著提升系统的性能和可扩展性,同时确保数据的安全性。具体的实现细节需要根据实际业务需求、硬件资源和技术栈进行调整和优化。

(4)用的mysql啊,redis了解吗?用过吗?

六、定时器相关

(1)为什么要用定时器?

(2)说一下定时器的工作原理
本项目中,服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。
另外,利用升序时间链表容器将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务。
Linux下提供了三种定时的方法:socket选项、SO_RECVTIMEO和SO_SNDTIMEOSIGALRM信号
I/O复用系统调用的超时参数三种方法没有一劳永逸的应用场景,也没有绝对的优劣。由于项目中使用的是SIGALRM信号,这里仅对其进行介绍,另外两种方法可以查阅游双的Linux高性能服务器编程 第11章 定时器。
具体的,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。
从上面的简要描述中,可以看出定时器处理非活动连接模块,主要分为两部分,其一为定时方法与信号通知流程,其二为定时器及其容器设计与定时任务的处理。

使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。具体的逻辑如下,遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器若当前时间小于定时器超时时间,跳出循环,即未找到到期的定时器,若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表中删除,然后继续遍历

(3)双向链表啊,删除和添加的时间复杂度说一下?还可以优化吗?
O(1),使用最小堆优化
优化双向链表的性能和使用主要集中在以下几个方面:内存管理、操作效率和特定应用场景的优化。

  1. 内存管理优化
    节点对象池:如果双向链表需要频繁的插入和删除操作,可以考虑使用节点对象池来减少内存分配和释放的开销。节点对象池预先分配一组节点,在需要时重用这些节点,避免频繁的动态内存分配。
    内存布局优化:通过内存池或分配器来连续分配内存,减少内存碎片和缓存不命中带来的性能损失。
    减少节点大小:优化节点结构体,使其尽可能小,以减少每个节点的内存占用。例如,如果不需要使用双向链表的某些功能,可以去掉不必要的指针或成员。
  2. 操作效率优化
    头尾指针:确保链表维护头(head)和尾(tail)指针,以便在需要频繁进行头部或尾部插入、删除时保持操作的高效性(O(1) 时间复杂度)。
    大小跟踪:维护链表的大小(size),避免每次需要获取链表长度时都遍历整个链表,直接返回已经维护的大小。
    索引优化:如果链表需要支持随机访问,可以考虑结合其他数据结构(如跳表或索引数组)来优化随机访问效率。
  3. 特定场景优化
    自适应链表:对于有访问局部性的数据,可以设计自适应链表(如自调整链表或基于频率的链表),使得常访问的节点更靠近表头,从而加速访问频率较高的数据。
    链表与哈希表结合:在需要快速查找节点的场景中,可以将链表与哈希表结合使用,利用哈希表进行O(1)时间复杂度的查找,并用链表维护元素的顺序(如LRU缓存的实现)。
  4. 并发访问优化
    锁优化:如果链表需要在多线程环境中使用,可以引入细粒度锁或无锁化(Lock-Free)实现来提高并发性能。常见策略包括:
    读写锁:允许多个读线程并发访问,但写操作独占。
    细粒度锁:锁住链表中的部分节点,而不是整个链表。
    无锁算法:使用原子操作和CAS(Compare-And-Swap)实现无锁并发访问。
  5. 减少链表遍历
    缓存最近访问:对于需要频繁访问某些节点的情况,可以缓存这些节点的指针,避免重复遍历链表。
    跳表优化:如果链表元素数量庞大且需要高效查找,考虑使用跳表作为链表的改进版结构,通过多层索引减少查找的时间复杂度。
  6. 优化特定操作
    合并相邻的操作:如果有多次连续的插入或删除操作,可以考虑批量操作以减少链表的遍历次数。
    懒删除:在删除节点时,标记节点为删除状态,但不立即从链表中移除,在适当的时机统一清理。

(4)最小堆优化?说一下时间复杂度和工作原理
最小堆(Min-Heap)是一种特殊的完全二叉树,满足以下两个性质:

结构性质:最小堆是一棵完全二叉树,即除了最后一层外,所有层都是满的,并且最后一层的节点尽可能向左排列。
堆性质:在最小堆中,任何一个节点的值都不大于其子节点的值。也就是说,根节点的值是整个堆的最小值。
工作原理
最小堆通常以数组的形式实现。对于一个数组索引为 i 的节点,其父节点、左子节点和右子节点的位置分别为:

父节点:parent(i) = (i - 1) // 2
左子节点:left(i) = 2 * i + 1
右子节点:right(i) = 2 * i + 2
最小堆的基本操作包括插入元素、删除最小元素(即根节点),以及堆化过程(Heapify)。

  1. 插入元素
    步骤:将新元素添加到堆的末尾,然后通过“上浮”操作调整堆,以保持最小堆性质。
    时间复杂度:O(log n),其中 n 是堆中的元素个数。最坏情况下,需要沿着树的高度从叶子节点移动到根节点。
  2. 删除最小元素
    步骤:将堆顶元素(即最小元素)移除,并用堆的最后一个元素替代堆顶,然后通过“下沉”操作调整堆,保持最小堆性质。
    时间复杂度:O(log n)。同样需要沿着树的高度从根节点下移。
  3. 堆化(Heapify)
    堆化是将一个无序数组转换成堆的过程,主要通过调整每个非叶子节点的值来确保堆的性质。堆化的过程分为两种:

上浮堆化(Percolate Up):用于插入操作,调整从叶子到根的路径。

下沉堆化(Percolate Down):用于删除操作,调整从根到叶子的路径。

时间复杂度:

构建堆:通过对所有非叶子节点依次执行下沉操作,可以将无序数组转换为最小堆。构建堆的总体时间复杂度为 O(n),因为尽管每个下沉操作的最坏情况时间复杂度是 O(log n),但由于树的高度不同,累积下来构建堆的复杂度为 O(n)。
4. 取最小值
步骤:最小堆的最小值始终位于堆顶,可以通过直接访问堆数组的第一个元素来获取。
时间复杂度:O(1)。
总结时间复杂度
插入元素:O(log n)
删除最小元素:O(log n)
构建堆:O(n)
取最小值:O(1)
使用场景
最小堆广泛应用于需要频繁获取最小值或进行优先级调度的场景,例如:

优先队列:在实现优先队列时,最小堆常用于保证在 O(log n) 时间内插入元素和删除最小元素。
排序算法:堆排序利用堆的性质,能够在 O(n log n) 时间内对数组进行排序。
图算法:在 Dijkstra 算法和 Prim 算法中,最小堆用于高效地找到具有最小权重的边或节点。
**

(5)数据库连接池怎么实现?

七、日志相关

**
(1)说下你的日志系统的运行机制?
采用异步日志的操作机制
线程
在这里插入图片描述

(2)为什么要异步?和同步的区别是什么?

异步日志是一种高效的日志记录机制,通过将日志写入操作从主线程中解耦出来,在后台单独的线程中处理,从而避免日志写入对主线程性能的影响。在高并发、高负载的应用场景下,异步日志能够显著提高系统的吞吐量和响应速度。

异步日志的工作原理
日志消息的生产者和消费者模型:

  1. 异步日志通常基于生产者-消费者模型。主线程作为日志消息的生产者,将日志消息放入一个共享的消息队列中。
    后台线程作为消费者,从消息队列中取出日志消息,并将其写入日志文件或其他输出目标。

  2. 消息队列: 主线程生成的日志消息被异步地放入消息队列,消息队列通常是线程安全的,可以是阻塞队列或无锁队列。
    队列的作用是缓冲日志消息,避免主线程直接等待I/O操作,从而减少日志记录对主线程性能的影响。

  3. 后台日志线程:

    一个或多个后台线程从消息队列中取出日志消息,并执行实际的I/O操作,将日志写入文件、数据库、网络或其他输出目标。
    后台线程通常会批量处理日志消息,以减少频繁的I/O操作,提高写入效率。

  4. 日志丢失的控制:

    在某些情况下,如果消息队列已满,主线程可以选择丢弃新日志消息或阻塞等待队列有空闲空间。
    也可以采用写入到内存缓冲区,再由后台线程从缓冲区批量写入的方式来控制日志丢失。

异步日志的优点

  1. 提高性能:

    异步日志记录将I/O操作放在后台线程执行,主线程可以专注于业务逻辑,减少了因日志记录造成的性能开销,特别是在高并发场景下效果显著。

  2. 避免阻塞:

    在同步日志记录中,日志写入操作会阻塞主线程,而异步日志通过队列和后台线程处理,避免了主线程的阻塞,从而提高了系统的响应速度。

  3. 平滑处理高负载:

    异步日志能够平滑处理日志高峰期的写入压力。队列的缓冲作用可以在短时间内吸收突发的日志请求,后台线程逐步处理。

异步日志和同步的区别
同步日志:日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,所能处理的并发能力有所下降,尤其是在峰值的时候,写日志可能称为系统的瓶颈。
异步日志:将所写的日志内容先存入阻塞队列,写线程从阻塞队列中去除内容,写入日志。

(3)现在你要监控一台服务器的状态,输出监控日志,请问如何将该日志分发到不同的机器上?(消息队列)
将服务器监控日志分发到不同机器上,通常需要一个分布式日志收集和处理系统。以下是常见的几种实现方式:

  1. 使用集中式日志系统
    ELK/EFK Stack:使用 Elasticsearch、Logstash/Fluentd 和 Kibana 的组合来集中处理日志。
    Logstash/Fluentd:安装在服务器上,负责收集和过滤日志,然后将日志转发到 Elasticsearch。
    Elasticsearch:集中存储和索引日志数据。
    Kibana:用于日志的可视化和分析。
    Filebeat/Fluent Bit:轻量级的日志收集器,安装在需要监控的服务器上,将日志发送到 Logstash/Fluentd,再进一步处理和分发。
  2. 使用消息队列
    Kafka:日志数据首先被发送到 Kafka 这种高吞吐量的消息队列中,然后通过消费者将日志数据分发到不同的机器上进行存储和处理。
    RabbitMQ:使用 RabbitMQ 这种轻量级消息代理,将监控日志发送到多个消费端进行分发。
  3. 使用分布式日志存储服务
    云服务提供商的解决方案:使用 AWS CloudWatch、Azure Monitor 或 Google Cloud Logging 等云服务,将服务器日志发送到云端,并利用其日志分发功能将日志数据转发到其他机器或者云存储服务中。
    Splunk:企业级日志管理系统,可以将日志数据从多个来源集中并分发到指定的服务器上。
  4. 使用日志代理
    Rsyslog/Fluentd/Fluent Bit:
    配置 Rsyslog、Fluentd 或 Fluent Bit 作为日志代理,收集日志并将其转发到不同的机器上。可以通过配置不同的输出插件将日志发送到多个目标(例如另一台服务器的存储服务、消息队列或者云存储服务)。
  5. 使用文件传输协议
    SCP/RSYNC:使用 scp 或 rsync 定期将日志文件复制到指定的远程机器上。
    NFS/SMB:将日志文件输出到一个共享文件系统(如 NFS 或 SMB),多个机器可以通过挂载共享文件系统来访问这些日志。
  6. 直接网络发送
    HTTP/HTTPS:通过 HTTP/HTTPS 协议将日志发送到一个接收端服务,该服务负责将日志分发到其他机器上。
    Syslog:使用 Syslog 协议,通过网络将日志发送到一个或多个远程 Syslog 服务器上。
    实现步骤概述:
    日志收集:在监控服务器上安装日志收集代理(如 Fluentd、Filebeat)。
    日志传输:配置代理将日志通过消息队列、HTTP、Syslog 等协议发送到集中处理节点或直接发送到目标机器。
    日志分发:集中处理节点将日志分发到不同机器,可以使用消息队列的消费组、专用日志处理服务(如 Logstash/Fluentd)进行转发。
    日志存储与分析:目标机器接收到日志后,存储到本地或者发送到一个集中式存储服务进行后续分析。
    通过这种方式,你可以实现实时、高效的日志分发,并在多个机器上集中管理和监控日志数据。

(4)为什么要做⽇志系统?讲⼀下⽇志系统的双缓冲?异步和同步的区别?
日志系统是软件系统中非常重要的组成部分,它主要用于记录系统运行时的各种事件和数据。构建和使用日志系统的原因包括:

为什么要做日志系统?
问题排查和调试:

日志可以记录系统的详细运行情况,包括错误、异常、警告等。当出现问题时,开发人员可以通过分析日志来快速定位和解决问题。
性能监控:

日志可以记录系统的性能指标,如请求响应时间、资源使用情况等,帮助运维人员监控系统的健康状况,及时发现性能瓶颈。
安全审计:

系统日志可以记录用户的操作行为、系统访问日志等,支持安全审计和合规性检查,帮助防范和追溯安全事件。
业务分析:

通过分析日志数据,可以获得业务运行的实时数据,如用户行为分析、流量分析等,为业务决策提供数据支持。
历史记录:

日志提供了一个系统活动的时间线,可以用于回顾历史操作,验证系统行为是否符合预期。
日志系统的双缓冲
双缓冲(Double Buffering)是日志系统中常用的一种技术,用于提高日志写入性能并减少对主程序的干扰。具体做法如下:

双缓冲机制:

使用两个缓冲区来存储待写入的日志信息。一个缓冲区用于当前写入操作,另一个缓冲区用于后台线程将其内容写入磁盘或发送到日志存储系统。
当一个缓冲区写满或者达到写入条件时,程序将切换到另一个缓冲区继续写入,同时将第一个缓冲区的数据异步写入磁盘或其他存储介质。
优点:

提高了日志写入的吞吐量,减少了频繁的I/O操作对程序性能的影响。
保证日志记录的连续性和及时性。
异步和同步的区别
在日志系统中,异步和同步的区别主要体现在日志写入的方式上:

同步日志(Synchronous Logging):

日志写入操作在主线程中执行,写入操作必须完成后,程序才能继续执行后续代码。
优点:日志写入的顺序与程序执行的顺序一致,确保日志记录的完整性和可靠性。
缺点:如果日志写入速度较慢,可能会阻塞主线程,影响程序的性能。
异步日志(Asynchronous Logging):

日志写入操作在单独的后台线程中进行,主线程将日志信息放入缓冲区后立即返回,继续执行后续代码。
优点:不会阻塞主线程,能够显著提高程序的响应速度和整体性能。
缺点:如果系统崩溃,可能导致缓冲区中尚未写入的数据丢失;另外,日志的顺序性可能无法严格保证。
总结
日志系统是现代软件中不可或缺的工具,提供了调试、监控、安全和分析的基础。双缓冲技术通过引入两个缓冲区来减少对系统性能的影响,而异步日志则通过将日志写入操作从主线程中剥离出来,以提高系统的整体效率。在实际应用中,选择同步还是异步的日志方式,需要权衡系统的性能要求和日志的可靠性需求。

八、压测相关

(1)服务器并发量测试过吗?怎么测试的?
webbench
用到了一个压测软件叫做Webbench,可以直接在社长的Gtihub里面下载,解压,然后在解压目录打开终端运行命令(-c表示客户端数, -t表示时间):

./webbench -c 10001 -t 5 http://127.0.0.1:9006/

直接解压的webbench-1.5文件夹下的webbench文件可能会因为权限问题找不到命令或者无法执行,这时你需要重新编译一下该文件即可:

gcc webbench.c -o webbench

然后我们就可以压测得到结果了(我本人电脑的用户数量-c设置为10500会造成资源不足的错误)

Webbench - Simple Web Benchmark 1.5
Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.

Benchmarking: GET http://127.0.0.1:9006/
10001 clients, running 5 sec.

Speed=1044336 pages/min, 2349459 bytes/sec.
Requests: 87028 susceed, 0 failed.

(2)webbench是什么?介绍一下原理
父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。

(3)测试的时候有没有遇到问题?
用户数量-c设置为10500会造成资源不足的错误

九、综合能力

(1)你的项目解决了哪些其他同类项目没有解决的问题?
项⽬中我主要的⼯作可以分为两部分:

  1. ⼀部分是服务器⽹络框架、⽇志系统、存储引擎等⼀些基本系统的搭建,这部分的难点主要就是技术的理解和选型,以及将⼀些开源的框架调整后应⽤到我的项⽬中去。

  2. 另⼀部分是为了提⾼服务器性能所做的⼀些优化,⽐如缓存机制、内存池等⼀些额外系统的搭建。这部分的难点主要是找出服务器的性能瓶颈,然后结合⾃⼰的想法去突破这个瓶颈,提⾼服务器的性能

  3. ⼀⽅⾯是对不同的技术理解不够深刻,难以选出最合适的技术框架。这部分的话我主要是反复阅读作者在GitHub提供的⼀些技术⽂档,同时也去搜索⼀些技术对⽐的⽂章去看,如果没有任何相关的资料我会尝试去联系作者。

  4. 另⼀⽅⾯是编程期间遇到的困难,在代码编写的过程中由于⼯程能⼒不⾜,程序总会出现⼀些bug。这部分的话我⾸先是通过⽇志去定位bug,然后推断bug出现的原因并尝试修复,如果是⾃⼰⽬前⽔平⽆法修复的bug,我会先到⽹上去查找有没有同类型问题的解决⽅法,然后向同学或者直接到StackOverflow等⼀些国外知名论坛上求助。
    (2)说一下前端发送请求后,服务器处理的过程,中间涉及哪些协议?
    tcp、udp、http

(3)项⽬中⽤到哪些设计模式?
单例模式:在线程池、内存池中都有使⽤到。
单例模式可以分为懒汉式和饿汉式,两者之间的区别在于创建实例的时间不同:
懒汉式:指系统运⾏中,实例并不存在,只有当需要使⽤该实例时,才会去创建并使⽤实例。(这种⽅式要考虑线程安全)
饿汉式:指系统⼀运⾏,就初始化创建实例,当需要时,直接调⽤即可。(本身就线程安全,没有多线程的问题)

// 懒汉式
class Singleton
{
  public:
	 //返回引⽤,避免⽤户对指针使⽤delete
	 static Singleton& getInstance(){
		 static Singleton instance;
		 return instance;
	 }
 
  public:
	 Singleton(const Singleton&) = delete;
	 Singleton(Singleton&&) = delete;
	 Singleton& operator=(const Singleton&) = delete;
	 Singleton& operator=(Singleton&&) = delete;
 private:
	 Singleton(){
		 std::cout<<"constructor called!"<<std::endl;
	 } 
	 ~Singleton(){
	 	std::cout<<"destructor called!"<<std::endl;
	 }
};
// 饿汉式
class Singleton
{
  public:
	 static Singleton& getInstance() {
		 return instance;
 	 }
 
  public:
	 Singleton(const Singleton&) = delete;
	 Singleton(Singleton&&) = delete;
	 Singleton& operator=(const Singleton&) = delete;
	 Singleton& operator=(Singleton&&) = delete;
  private:
	 static Singleton instance;
	 Singleton(){
	 	std::cout<<"constructor called!"<<std::endl;
	 }
	 ~Singleton(){
	 	std::cout<<"destructor called!"<<std::endl;
	 }
};
// 默认初始化
Singleton Singleton::instance;

由于在main函数之前初始化,所以没有线程安全的问题。但是潜在问题在于no-local static对象(函数外的static对
象)在不同编译单元中的初始化顺序是未定义的。也即,static Singleton instance;和static Singleton&
getInstance()⼆者的初始化顺序不确定,如果在初始化完成之前调⽤ getInstance() ⽅法会返回⼀个未定义的实
例。

(4)这个web服务器是你⾃⼰申请的域名吗
我没有申请域名,但是阿⾥云服务器上⾃带⼀个公⽹ip,可以直接通过公⽹ip来访问服务器。但是本地测试的话,我⼀般使⽤本地回环ip,127.0.0.1来进⾏访问。能 ping 通 127.0.0.1 说明本机的⽹卡和IP协议安装都没有问题。

(5)你是如何对项⽬进⾏测试的?
对项⽬的测试分为两部分:基本功能模块的测试和扩展功能模块的测试。
【基本功能测试】

  1. ⽇志系统
    写⼊⽇志类型、等级
    多个线程同时写⼊的响应
    短期⼤量⽇志进⾏写⼊
  2. ⽹络框架
    正确接收 client 连接和分发
    正确感知 client 连接的读写事件
    正确对 client 连接进⾏读写
    ⾼并发、多请求(webbench)
  3. 存储引擎
    正确写⼊和读取
    定时落盘(写⼊⽂件和定时写⼊)
    多个线程同时读取和写⼊
    单个线程读取和写⼊⼤量数据
    【扩展功能测试】
  4. 线程池
    能否将任务放⼊任务队列,任务是否能够正确执⾏多个线程同时往任务队列放⼊任务
  5. 内存池
    能否正确分配内存(空对象、⼤内存、⼩内存),调⽤对象的构造函数和析构函数多个线程同时申请内存
  6. 缓存机制
    能否成功命中缓存
    多个线程读取缓存

(6) C++面向对象特性在项目中的体现

c++⾯向对象特性有封装、继承、多态。
⾸先是封装,我在项⽬中将各个模块使⽤类进⾏封装,⽐如连接⽤ httpconnection/ftpconnection 类来封装,⽇志就⽤ log 类来封装,将类的属性私有化,⽐如请求的解析状态,并且对外的接⼝设置为公有,⽐如连接的重置,不对外暴露⾃身的私有⽅法,⽐如读写的回调函数等。还有⼀个就是,项⽬中每个模块都使⽤了各⾃的命名空间进⾏封装,避免了命名冲突或者名字污染。
然后是继承,项⽬中的继承⽤得⽐较少,主要是对⼯具类的继承,项⽬中多个地⽅使⽤到 noncopyable 和enable_shared_from_this,保证了代码的复⽤性。实际上对共有功能可以设计⼀个基类来继承,⽐如我项⽬中的connection⽬前有 httpconnection 和 tcpconnection 两种,可以通过继承 connection 基类来减少重复代码,因为我当时做的时候只考虑到 http连接,ftp是后⾯加上去的,所以就没⽤这样设计,后⾯可以优化⼀下。
最后是多态,我项⽬中的多态主要⽤了静态多态,动态多态没有涉及。静态多态在⽇志系统中对流输⼊运算符进⾏了重载,以及在⽇志系统和内存池中都有各种函数模板的泛型编程。实际上刚刚说的 httpconnection 从 connection 派⽣出来后是可以使⽤动态多态的。

(7)为了减少系统调用,你具体做了哪些工作?
线程池、内存池、缓存机制;零拷贝;读写锁

(8)udp的使用场景

  1. 实时通信
    WebRTC:这是一个用于浏览器和移动应用的实时通信协议,通常用于视频通话、语音通话和点对点文件传输。WebRTC 的媒体数据传输部分使用 UDP 协议,因为 UDP 提供了低延迟的传输,适合实时性要求高的应用场景。
  2. DNS 查询
    Web 服务器本身并不会直接处理 DNS 请求,但在 Web 请求的过程中,DNS 查询是一个关键步骤。DNS 使用 UDP 协议进行域名解析查询,这是 Web 请求的前置条件。
  3. 负载均衡和健康检查
    一些负载均衡器或代理服务器可能会使用 UDP 协议来进行健康检查或监控服务的状态,尤其是在需要快速、低开销的检测时。
  4. HTTP/3
    HTTP/3 是 HTTP 协议的最新版本,它使用 QUIC 协议,而 QUIC 协议是基于 UDP 的。HTTP/3 提供了更好的性能,尤其是在高延迟或不稳定的网络环境下,因此未来可能会有更多的 Web 服务器和浏览器支持 HTTP/3,从而在实际应用中更多地使用 UDP。
  5. 日志和监控
    有些 Web 服务器或相关系统可能会使用 UDP 协议将日志、监控数据或其他非关键数据发送到远程服务器。UDP 的低开销使其适合在高吞吐量的环境中传输此类数据。
  6. 内容分发网络(CDN)
    一些 CDN 服务可能会在特定情况下使用 UDP 协议来优化内容传输,特别是在视频流、实时数据流等场景中。

(9)线程池数量 :8
数据库连接池数量:8
最大连接数:10000
最大文件描述符个数:65535
定时alarm:5

(10)双线程交替打印

#include <iostream>
#include <condition_variable>
#include <mutex>
#include <thread>

using namespace std;

// 互斥锁,用于保护共享数据的访问
mutex data_mutex;

// 条件变量,用于线程间的等待和通知
condition_variable flag_cond;

// 标志变量,决定哪个线程执行打印操作
bool flag = true; // 初始状态为 true,表示奇数线程先执行

// 函数 printji:负责输出奇数
void printji()
{
    int i = 1; // 初始化奇数
    // 循环输出奇数,直到 i > 100
    while (i <= 100)
    {
        // 进入临界区,保护共享数据访问
        unique_lock<mutex> lck(data_mutex);
        
        // 当前线程等待,直到 flag 为 true(奇数线程可以执行)
        flag_cond.wait(lck, [] {return flag; });
        
        // 输出奇数
        cout << "A " << i << endl;
        i += 2; // 递增,获取下一个奇数
        
        // 切换标志变量,使偶数线程可以执行
        flag = false;
        
        // 通知等待的偶数线程可以继续
        flag_cond.notify_one();
    }
}

// 函数 printou:负责输出偶数
void printou()
{
    int i = 2; // 初始化偶数
    // 循环输出偶数,直到 i > 100
    while (i <= 100)
    {
        // 进入临界区,保护共享数据访问
        unique_lock<mutex> lck(data_mutex);
        
        // 当前线程等待,直到 flag 为 false(偶数线程可以执行)
        flag_cond.wait(lck, [] {return !flag; });
        
        // 输出偶数
        cout << "B " << i << endl;
        i += 2; // 递增,获取下一个偶数
        
        // 切换标志变量,使奇数线程可以执行
        flag = true;
        
        // 通知等待的奇数线程可以继续
        flag_cond.notify_one();
    }
}

int main()
{
    // 创建两个线程:一个执行 printji,另一个执行 printou
    thread t1(printji);
    thread t2(printou);
    
    // 等待两个线程执行完毕
    t1.join();
    t2.join();
    
    return 0;
}

(10)new和malloc开辟的是虚拟内存还是物理内存

在 C++ 中,new 和 malloc 都是用于动态分配内存的操作,但它们的实现和行为有所不同。无论是 new 还是 malloc,它们最终都会在物理内存上分配内存,而不是直接分配虚拟内存。

new 和 malloc 的比较
malloc
功能:malloc 是 C 语言的内存分配函数,用于在堆上分配指定大小的内存块。
语法:
cpp
Copy code

void* ptr = malloc(size_t size);
特点:
malloc 只分配内存,不会初始化内存块。
malloc 返回的是一个 void* 类型的指针,需要进行类型转换。
如果分配失败,它返回 NULL。
malloc 是 C 标准库的一部分,主要用于 C 程序,但也可以在 C++ 中使用。
new
功能:new 是 C++ 的内存分配操作符,用于在堆上分配内存并调用构造函数(如果分配的是对象的话)。
语法:
cpp
Copy code
T* ptr = new T;         // 分配一个对象
T* ptr = new T(args);   // 分配并初始化对象
T* ptr = new T[n];      // 分配一个数组

特点:
new 会初始化对象(如果分配的是对象)。
new 返回的是指定类型的指针,无需进行类型转换。
如果分配失败,它会抛出 std::bad_alloc 异常,除非使用了 new(std::nothrow) 形式。
new 是 C++ 的一部分,具有更高层次的抽象和类型安全。
虚拟内存和物理内存
虚拟内存:是操作系统提供的一种抽象,允许程序使用比实际物理内存更多的内存空间。操作系统通过虚拟内存机制将虚拟地址映射到实际的物理地址。
物理内存:是计算机系统中实际存在的 RAM。
当你使用 new 或 malloc 分配内存时:

操作系统会将虚拟内存中的地址空间映射到物理内存。具体来说,操作系统会管理虚拟地址和物理地址之间的映射,确保程序可以在虚拟地址空间中访问分配的内存。

内存分配:malloc 和 new 都是在虚拟内存中分配内存块,操作系统会在后台管理虚拟内存的使用,并根据需要将虚拟内存页映射到物理内存中。

物理内存的分配:操作系统的内存管理单元(MMU)负责将虚拟内存中的页映射到实际的物理内存。当物理内存不足时,操作系统可能会将某些内存页交换到磁盘(即交换空间或分页文件),以保持虚拟内存的可用性。

总结
malloc 和 new 都在虚拟内存中分配内存。
操作系统通过虚拟内存管理机制,将这些虚拟地址映射到实际的物理内存。
无论是 new 还是 malloc,它们最终都涉及到物理内存的分配和管理,但从程序员的角度看,它们操作的是虚拟内存。
(11)什么时候会分分配物理内存
ChatGPT said:
ChatGPT
在现代计算机系统中,内存分配通常涉及虚拟内存和物理内存的管理。物理内存的分配和使用是由操作系统的内存管理单元(MMU)和虚拟内存系统动态处理的。以下是一些常见的场景和机制,解释了什么时候以及如何实际分配物理内存:

  1. 首次分配内存时
    当一个程序首次请求分配内存(例如通过 malloc 或 new)时,操作系统将根据程序的虚拟内存请求来分配物理内存。这个过程通常包括以下步骤:

虚拟内存分配:操作系统将为程序分配一个虚拟内存地址空间,创建虚拟地址到物理地址的映射。
物理内存分配:如果虚拟内存的请求需要实际的物理内存,操作系统会在物理内存中为该请求分配一块区域。操作系统通常会先分配一部分物理内存,剩余的部分则可能暂时处于交换空间(如分页文件)中。
2. 页面置换
当系统的物理内存不足时,操作系统会使用页面置换算法来管理内存。以下是相关的机制:

页面调度:当需要物理内存而没有足够的可用物理内存时,操作系统会将一部分当前在物理内存中的内容移到磁盘上的交换空间(或分页文件)中。
页面置换:将磁盘中的一部分内容读入物理内存,这个过程叫做“页面置换”或“页面调度”。物理内存中的内容将被交换出去以腾出空间。
3. 内存映射文件
内存映射文件(memory-mapped files)允许程序将磁盘上的文件直接映射到虚拟内存中:

文件映射:当一个程序将一个文件映射到虚拟内存中时,操作系统将该文件的内容与虚拟内存地址关联起来。实际的物理内存只会在需要时被分配,例如,当程序访问映射的文件区域时,操作系统会将对应的文件页从磁盘读取到物理内存中。
4. 内存压力
在系统的内存使用接近或达到上限时,操作系统可能会对内存进行管理以避免内存不足的情况:

内存回收:操作系统会尝试释放不再使用的内存块或缓存,从而回收物理内存。
内存压缩:一些系统可能使用内存压缩技术来提高内存使用效率。
5. 页面错误(Page Fault)
当程序访问一个虚拟内存地址,而该地址的物理内存页不在物理内存中时,会发生页面错误。操作系统会捕捉到页面错误,并进行以下操作:

加载页面:从磁盘(或交换空间)中加载缺失的物理内存页到物理内存中。
更新映射:更新虚拟内存到物理内存的映射,以便程序可以继续访问这部分内存。
总结
物理内存的实际分配通常在以下情况发生:

程序首次分配内存时。
在物理内存不足时进行页面置换。
在映射文件到虚拟内存时进行实际的物理内存分配。
当程序访问尚未加载到物理内存的虚拟内存页面时,通过页面错误处理进行实际的物理内存分配。

(12)在 C++ 中使用线程(std::thread)和 lambda 表达式时,需要注意以下几个关键问题:

  1. 线程安全
    共享数据的同步:如果 lambda 表达式在多个线程中访问共享数据,需要确保数据的访问是线程安全的。可以使用 std::mutex、std::lock_guard 或 std::unique_lock 等同步机制来保护共享数据。

避免数据竞争:确保 lambda 捕获的数据在多线程环境下不会导致数据竞争(data race)。如果 lambda 捕获了局部变量,需要确认这些变量不会在多个线程中被同时修改。

  1. 捕获方式
    按值捕获:使用 [=] 按值捕获变量时,lambda 表达式会复制变量的副本。确保捕获的变量在 lambda 表达式执行期间不会被改变,否则可能会使用过时的值。

按引用捕获:使用 [&] 按引用捕获变量时,lambda 表达式捕获的是变量的引用。确保捕获的变量在 lambda 执行期间不会超出作用域或被销毁,否则可能会出现悬空引用的问题。

混合捕获:可以使用混合捕获方式,如 [=, &x],按值捕获除 x 外的所有变量,按引用捕获 x。这种方式可以帮助平衡安全性和性能。

  1. Lambda 表达式的生命周期
    避免悬空指针:如果 lambda 表达式捕获了指针,确保在 lambda 执行期间指针指向的对象是有效的。如果指针指向的对象被销毁或超出作用域,将导致悬空指针问题。

避免捕获临时对象:如果 lambda 捕获了临时对象(如局部变量),确保这些临时对象在 lambda 执行期间不会被销毁。可以使用按值捕获方式来避免这个问题。

  1. 线程的启动和管理
    线程的启动:在创建 std::thread 时,可以将 lambda 表达式作为线程函数传递。确保 lambda 表达式的参数和捕获列表正确。

线程的同步:线程创建后,需要确保主线程或其他线程正确地等待其完成,使用 std::thread::join 来等待线程的结束,以避免线程资源泄漏。

异常处理:在 lambda 表达式中可能会抛出异常,需要确保这些异常在合适的地方被捕获和处理,以避免未处理的异常导致程序崩溃。

  1. 线程性能和调度
    线程数量:创建过多的线程可能会导致线程管理的开销增加,并且可能会引起性能下降。通常使用与硬件线程数相等或稍多的线程数量较为合理。

线程优先级:可以根据任务的优先级设置线程优先级,但在 C++ 标准库中,线程优先级设置是平台相关的,可能需要使用平台特定的 API。

示例代码
以下是一个简单的示例,展示如何使用 lambda 和线程来执行并发任务:

cpp
Copy code
#include
#include
#include
#include

std::mutex mtx;

void threadFunction(int id) {
std::lock_guardstd::mutex lock(mtx);
std::cout << “Thread " << id << " is running.” << std::endl;
}

int main() {
std::vectorstd::thread threads;

// 创建和启动多个线程
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([i]() {
        threadFunction(i);
    });
}

// 等待所有线程完成
for (auto& th : threads) {
    if (th.joinable()) {
        th.join();
    }
}

return 0;

}
总结
在使用 C++ 中的线程和 lambda 表达式时,确保线程安全、正确管理变量的生命周期、合理使用捕获方式以及有效管理线程的创建和同步,是编写可靠多线程程序的关键。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值