1.Reactor模式
核心思想: Reactor模型的核心思想是将IO事件处理分离出来,使得程序能够通过事件驱动的方式处理IO操作。这种模型的主要目标是提高系统的并发性能和可伸缩性。 工作流程:
-
事件注册:程序通过系统调用(如 epoll 、 select 等)向操作系统注册感兴趣的事件(如读 就绪、写就绪等)以及对应的回调函数(事件处理器)。
-
事件循环:程序进入一个事件循环(通常是一个无限循环),不断地向操作系统询问是否有已 注册的事件就绪。
-
事件分发:当有事件就绪时,操作系统会通知程序,程序会根据事件类型调用对应的事件处理 器进行处理。
-
事件处理:事件处理器会执行相应的业务逻辑,可能包括读取数据、写入数据、连接处理等操 作。
-
事件驱动:在处理完一个事件后,程序会继续等待下一个事件的到来,整个过程是事件驱动 的。
特点:
事件驱动:所有的IO操作都是由事件触发的,程序不需要主动去轮询IO状态,这样可以减少系 统资源的浪费。 非阻塞IO:在Reactor模型中,通常使用非阻塞IO来处理事件,可以提高系统的并发性能。 事件处理器:Reactor模型通过事件处理器来处理不同类型的事件,使得程序结构清晰,易于 扩展和维护。
适用性广泛:Reactor模型适用于各种类型的IO操作,如网络通信、文件IO等。
单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。但是,这种方案存在 2 个缺点:
第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能;
第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务 处理耗时比较长,那么就造成响应的延迟;
所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。
裸写Socket编程相比使用成熟的协议有以下几个优点:
1灵活性:裸写Socket编程可以更加灵活地控制和定制通信的细节。可以根据具体需求自定义通信 协议、数据格式和消息交互方式,以满足特定的业务需求。这种自定义能力可以在某些特殊场景下提供 更高的性能和效率。
2 效率和性能:使用成熟的协议通常会带来一定的开销,例如协议解析、报文封装等。而裸写 Socket编程可以避免这些额外开销,直接操作底层的Socket接口,从而提高效率和性能。在对性能要求 较高的场景下,裸写Socket编程可能是更好的选择。
3 学习和理解网络原理:通过裸写Socket编程,你可以更深入地学习和理解网络通信的原理和细 节。这对于网络编程的学习和进一步的技术深入是非常有帮助的。裸写Socket编程可以帮助你更好地理 解网络协议栈、网络通信机制和底层数据传输原理。
Preactor 是非阻塞同步网络模式,感知的是就绪可读写事件。Proactor 是异步网络模式, 感知的 是已完成的读写事件。
2.高并发内存池 e.g.超过128page咋办?测试是在空的时候测试的,如果比较满的情况测试,效率?
3.在Linux中,可以使用以下两个命令来查找某一文件和查看指定文件的大小: 1查找某一文件:使用find命令可以在指定目录及其子目录中查找文件。
语法:find [路径] -name [文件名]
示例:find /home/user -name example.txt 2查看指定文件大小:使用ls命令可以查看文件的详细信息,包括文件大小。
语法:ls -l [文件路径]
示例:ls -l /path/to/file.txt
4.线程池
template<class T> class ThreadPool { private:
bool IsEmpty() {
return _task_queue.size() == 0; }
void LockQueue() {
pthread_mutex_lock(&_mutex); }
void UnLockQueue() {
pthread_mutex_unlock(&_mutex); }
void Wait() {
pthread_cond_wait(&_cond, &_mutex); }
void WakeUp() {
pthread_cond_signal(&_cond); }
public: ThreadPool(int num = NUM)
: _thread_num(num) {
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr); }
~ThreadPool() {
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond); }
//线程池中线程的执行例程
static void* Routine(void* arg) {
pthread_detach(pthread_self()); ThreadPool* self = (ThreadPool*)arg; //不断从任务队列获取任务进行处理
while (true){
主线程就负责不断向任务队列当中Push任务就行了,此后线程池当中的线程会从任务队列当中获取 到这些任务并进行处理。
线程池中的任务队列是会被多个执行流同时访问的临界资源,因此我们需要引入互斥锁对任务队列 进行保护。线程池当中的线程要从任务队列里拿任务,前提条件是任务队列中必须要有任务,因此线程 池当中的线程在拿任务之前,需要先判断任务队列当中是否有任务,若此时任务队列为空,那么该线程 应该进行等待,直到任务队列中有任务时再将其唤醒,因此我们需要引入条件变量。
5.线程有什么东西是不共享的?寄存器和线程栈。
线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要 是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导 致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一 系列严重的后果,于是干脆让整个进程崩溃。kill 执行的是系统调用,将控制权转移给了内核(操作系 统),由内核来给指定的进程发送信号让其崩溃。
self->LockQueue(); while (self->IsEmpty()){
self->Wait(); }
T task; self->Pop(task); self->UnLockQueue();
task.Run(); //处理任务 }
} void ThreadPoolInit() {
pthread_t tid; for (int i = 0; i < _thread_num; i++){
pthread_create(&tid, nullptr, Routine, this); //注意参数传入this指针 }
}
//往任务队列塞任务(主线程调用) void Push(const T& task)
{
LockQueue(); _task_queue.push(task); UnLockQueue(); WakeUp();
}
//从任务队列获取任务(线程池中的线程调用) void Pop(T& task)
{
task = _task_queue.front();
_task_queue.pop(); }
private:
std::queue<T> _task_queue; //任务队列 int _thread_num; //线程池中线程的数量 pthread_mutex_t _mutex; pthread_cond_t _cond;
};
操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退 出;如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序,但如果注册 了,则会执行自己的信号处理函数。
区别:
1. 定义:
进程(Process):是程序在执行过程中的一个实例,是操作系统资源分配的基本单位, 每个进程有独立的内存空间。 线程(Thread):是进程中的一个执行单元,是CPU调度和分派的基本单位,同一进程 内的多个线程共享相同的内存空间。
2. 资源分配: 进程独享资源:每个进程有独立的内存空间、文件描述符、进程控制块等系统资源。
线程共享资源:同一进程内的多个线程共享相同的内存空间和文件描述符等资源。 3. 创建和销毁:
进程创建和销毁较为复杂:创建新进程需要分配独立的内存空间、建立进程控制块等,销 毁进程需要释放资源并通知操作系统。 线程创建和销毁较为简单:在同一进程内部创建新线程只需分配线程控制块即可,销毁线 程也比较轻量级。
4. 调度和切换: 进程调度和切换开销大:进程切换涉及到切换内存空间、文件描述符表等,开销较大。
线程调度和切换开销小:线程切换只需切换线程控制块和栈即可,开销较小。 5. 通信方式:
进程间通信复杂:需要使用进程间通信(IPC)机制,如管道、信号量、消息队列等。 线程间通信简单:由于线程共享相同的内存空间,可以直接通过共享内存、全局变量等方 式进行通信。
6.为什么能进行进程通信,本质原因?
Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连管道、socket 等,也都 是统一交给文件系统管理的。Linux 文件系统会为每个文件分配两个数据结构:索引节点(node)来记 录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置。 索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用 磁盘空间。目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联 关系。多个目录项关联起来,就会形成目录结构。一个目录文件的内容:该目录下所以文件的目录项。
从用户角度来看文件的话,我们要怎么使用文件?过程:
首先用 open 系统调用打开文件, open 的参数中包含文件的路径名和文件名。
使用 write 写数据,其中 write 使用 open 所返回的文件描述符,Linux进程默认情况下会 有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2。并不使用文件名 作为参数。
使用完文件后,要用 close 系统调用关闭文件,避免资源的泄露。
从系统内部这个过程分成三步:
从目录文件数据块中的所有目录项找到和这个文件名path相匹配的目录项(保存目录的格式改成哈 希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过 名称取哈希),从而找对对应的inode号;
通过inode号码,获取inode信息;
根据inode信息,找到文件数据所在的block,读出数据。 本质上,文件描述符就是指针数组的下标。用户程序不能直接访问内核中的文件描述符表,而只能使
用文件描述符表的索引 (即0、1、2、3这些数字。file* fd_array是files_struct中的一个成员变量。
内核为所有打开文件维护一张文件表项,每个文件表项包含内容可以由结构体看出:
struct file { mode_t f_mode;//表示文件是否可读或可写,FMODE_READ或FMODE_WRITE dev_ t f_rdev ;// 用于/dev/tty unsigned short f_reada; } |
7.操作系统中的缺页中断是什么?
缺页中断(Page Fault)是操作系统中的一种异常情况,发生在程序访问虚拟内存中的某个页面 (Page)时,但该页面并未加载到物理内存中。这种情况通常发生在使用虚拟内存管理的系统中,即将 虚拟地址空间映射到物理地址空间的过程中。当发生缺页中断时,操作系统会执行以下步骤:
-
中断处理:CPU收到缺页中断信号后,会暂停当前正在执行的指令,切换到内核态。
-
异常处理:操作系统内核会根据异常的原因进行处理。在缺页中断的情况下,操作系统会根据
缺页地址查找页表(Page Table),判断该页面是否已经加载到物理内存中。
-
页表查找:如果页表中记录了该页面的物理地址,则表示该页面已经在物理内存中,操作系统 会更新页表中的相关信息(如访问位、修改位等),并重新执行导致缺页中断的指令,以完成
对该页面的访问操作。
-
页面调入:如果页表中未记录该页面的物理地址,表示该页面尚未加载到物理内存中,需要进
行页面调入(Page In)操作。操作系统会选择一个物理页面用于存储该虚拟页面的内容,并 将该页面从磁盘读取到物理内存中。在页面调入完成后,操作系统会更新页表中的相关信息, 并重新执行导致缺页中断的指令。
-
继续执行:当页面调入完成后,操作系统会重新执行导致缺页中断的指令,以完成对该页面的 访问操作。
8.排序
归并排序采用分治法(Divide and Conquer)策略。它的基本思想是将待排序的序列分成两部分, 分别对这两部分进行排序,然后将两个已排序的部分合并成一个有序序列。
下面是归并排序的详细步骤:
-
分解(Divide):将待排序的序列分成两个长度相等(或相差最多 1)的子序列,找到序列的 中间位置。
-
解决(Conquer):递归地对左右两个子序列进行归并排序,直到子序列的长度为 1 或 0。
-
合并(Merge):将两个已排序的子序列合并成一个有序序列。合并过程需要额外的空间来存 储临时序列。
快速排序:
快速排序(Quick Sort)的时间复杂度为 O(n log n),待排数据有序时,时间复杂度为O(n^2)其中 n 为待排序序列的长度。原理如下:
-
选择基准元素:从待排序序列中选择一个元素作为基准(pivot)。通常选择第一个元素、最 后一个元素或者中间元素作为基准。
-
分区(Partition):将序列中的其他元素按照与基准的比较结果分为两部分,一部分小于基 准,一部分大于基准。在分区过程中,使用双指针法或者三指针法进行操作,将小于基准的元 素放在基准的左边,大于基准的元素放在基准的右边。
-
递归排序:对基准元素左右两部分分别进行递归排序,直到每个部分只有一个元素或为空。
-
合并:合并左右两部分已排序的子序列,得到最终的排序结果
三指针的快速排序最坏情况下不会退化为O(N*N),而且时间复杂度为O(N).
9.mplace_back 与 push_back 有啥区别?
1. push_back(const T& value) :将一个类型为 T 的元素 value 添加到 std::vector 的末 尾。如果 value 是一个临时对象或者右值引用,会调用移动构造函数将其添加到容器中;如 果 value 是一个左值引用,会调用拷贝构造函数将其添加到容器中。
2. emplace_back(Args&&... args) :直接在 std::vector 的末尾构造一个类型为 T 的元 素,使用 args 作为构造函数的参数。与 push_back 不同的是, emplace_back 不需要创建 临时对象或者进行拷贝或移动操作,它直接在容器的末尾构造元素,因此效率更高。
emplace_back 相比 push_back 更高效,因为它避免了拷贝或移动操作,直接在容器的末尾构造 元素。在使用时,可以根据具体情况选择合适的函数来添加新元素,如果需要添加已有对象,可以使用
push_back ;如果需要直接在容器中构造新对象,可以使用 emplace_back 。
10.了解哪些设计模式?
单例模式(Singleton Pattern):用于确保一个类只有一个实例,并提供全局访问点。在项目中, 单例模式常用于管理共享资源,如配置信息、日志记录器等。需要注意的是:
线程安全性:如果单例对象在多线程环境下使用,需要考虑其线程安全性。可以通过加锁(悲 观锁或乐观锁)、双重检查锁(Double-Checked Locking)等方式来保证线程安全。 对象生命周期管理:单例对象的生命周期通常与整个应用程序的生命周期相同,需要注意在合 适的时机释放资源,避免内存泄漏。
全局状态管理:单例对象是全局唯一的,因此对全局状态的管理需要谨慎,避免出现意外的状 态修改导致程序错误。
class LazySingleton { public: // 获取单例实例的静态成员函数 // 使用静态局部变量来延迟初始化 static LazySingleton instance; return instance; } // 其他成员函数和数据成员 private: LazySingleton() { // 构造函数的实现 } // 禁用拷贝构造函数和赋值操作符 }; class EagerSingleton { public: // 获取单例实例的静态成员函数 return instance; } // 其他成员函数和数据成员 private: EagerSingleton() { // 构造函数的实现 } // 禁用拷贝构造函数和赋值操作符 // 在类加载时创建单例对象 static EagerSingleton instance; }; // 在类外部初始化静态成员变量 |
工厂模式(Factory Pattern):用于创建对象的模式,通过将对象的创建逻辑封装在工厂类中,客 户端代码可以通过工厂类来创建对象,而不必直接实例化对象。工厂模式在项目中常用于解耦对象的创 建和使用。
例如,一个图形库可以使用工厂模式来创建不同类型的图形对象。
// 基类
class Shape { public:
virtual void draw() = 0;
11.互斥锁(Mutex)和自旋锁
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败会从用户态陷入到内核态,让内核帮我们切换线程,虽然简 化了使用锁的难度,但是会有两次线程上下文切换的成本:
当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行; 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时 间,把 CPU 切换给该线程运行。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操 作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
第一步,查看锁的状态,如果锁是空闲的,则执行第二步; 第二步,将锁设置为当前线程持有;
};
// 具体的图形类
class Circle : public Shape { public:
void draw() override { std::cout << "Draw a circle." << std::endl;
} };
class Rectangle : public Shape { public:
void draw() override { std::cout << "Draw a rectangle." << std::endl;
} };
// 图形工厂
class ShapeFactory { public:
static Shape* createShape(const std::string& type) { if (type == "Circle") {
return new Circle(); }
else if (type == "Rectangle") { return new Rectangle();
}
return nullptr; }
};
// 在应用程序中使用工厂创建图形对象 int main() {
Shape* shape1 = ShapeFactory::createShape("Circle"); shape1->draw(); Shape* shape2 = ShapeFactory::createShape("Rectangle"); shape2->draw();
delete shape1; delete shape2; return 0;
}
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可 分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
12.LRU在多线程时存在什么问题?如何解决?用锁来解决,如何提高效率?举个例子:os里的 pagecache页面换入换出,多线程下如何提高效率?
存在的问题:
-
线程不安全:LRUCache类的成员函数get和put在多线程环境下可能会导致数据竞争和不一致 性,因为多个线程可能同时访问和修改LRUCache对象的内部状态。
-
数据竞争:多个线程同时访问和修改LRUCache对象的cache head、tail_等成员变量,可能 导致数据竞争和未定义行为。
解决办法:
-
在LRUCache类中添加一个互斥锁成员变量,用于保护LRUCache对象的访问和修改。
-
在LRUCache类的成员函数中,使用互斥锁对LRUCache对象的访问和修改进行加锁和解锁操
作,确保在任意时刻只有一个线程可以访问和修改LRUCache对象。
-
在对LRUCache对象进行访问和修改的地方使用互斥锁进行保护,包括对cache head、tail_
等成员变量的访问和修改。
13.谈一下在浏览器输入一段URL后获取资源的全过程?
-
URL 解析:浏览器首先会对输入的 URL 进行解析,提取出其中的协议、主机名、端口号、路 径等信息。
-
DNS 解析:如果输入的 URL 中包含了主机名,浏览器会向 DNS 服务器发送 DNS 请求,将主 机名解析为 IP 地址。
-
建立 TCP 连接:浏览器根据 URL 中的协议确定使用的传输协议,通常是 HTTP 或 HTTPS。浏 览器会向服务器发起 TCP 连接请求,建立与服务器的连接。
-
发起 HTTP 请求:一旦 TCP 连接建立成功,浏览器就会构建一个 HTTP 请求报文,包括请求的 方法(GET、POST 等)、请求头、请求体等信息,然后将请求报文发送给服务器。
-
服务器处理请求:服务器收到浏览器发送的 HTTP 请求后,根据请求的内容进行相应的处理, 可能是查询数据库、读取文件等操作。
-
服务器返回响应:服务器处理完请求后,会生成一个 HTTP 响应报文,包括状态码、响应头、 响应体等信息,然后将响应报文发送给浏览器。
-
浏览器渲染页面:浏览器收到服务器返回的响应后,会根据响应的内容进行页面的渲染,包括 解析 HTML、加载 CSS 和 JavaScript、渲染页面等操作。
-
关闭 TCP 连接:页面渲染完成后,浏览器会关闭与服务器的 TCP 连接.
14.进程占用的内存比较多,该怎么调试是什么情况?
当一个进程占用的内存比较多时,可能会遇到内存泄漏(memory leak)或者内存使用不当等问 题。
-
使用内存分析工具:可以使用专门的内存分析工具来检测内存泄漏和内存使用情况。例如, Valgrind 是一个常用的开源工具,它可以检测内存泄漏和内存错误,并提供详细的报告。
-
监控系统资源:使用系统监控工具(如 top、htop、ps 等)来监视进程的内存使用情况。通
过观察进程的内存占用情况和系统资源的使用情况,可以初步判断进程是否存在内存泄漏或者
内存使用异常的情况。
-
检查代码中的内存分配和释放:仔细检查代码中的内存分配和释放操作,确保每次分配内存后
都能正确释放,避免内存泄漏。特别注意在循环中分配内存而未释放的情况,以及在异常情况
下未释放内存的情况。
-
分析堆栈和内存转储:在发生内存泄漏或内存使用异常时,可以通过分析进程的堆栈信息和内
存转储(core dump)来定位问题。堆栈信息可以帮助你找到内存分配和释放的位置,而内存
转储可以提供进程崩溃时的内存状态,有助于定位问题的根本原因。
-
使用代码审查和静态分析工具:进行代码审查可以帮助发现潜在的内存泄漏和内存使用问题。
另外,一些静态分析工具可以帮助检测代码中可能存在的内存问题,提前发现潜在的风险。
15、定位到一个进程的内存比较异常,该如何进一步查找为什么内存会异常?
-
使用内存分析工具进行检测:使用专门的内存分析工具(如Valgrind、AddressSanitizer等) 对进程进行内存分析,以检测是否存在内存泄漏或者内存错误。这些工具可以提供详细的内存 使用情况报告,帮助定位问题的具体原因。
-
检查内存分配和释放的逻辑:仔细检查代码中的内存分配和释放逻辑,确保每次分配的内存都 能够正确释放。特别关注循环中的内存分配和释放,以及异常情况下的内存处理逻辑。
-
分析内存使用模式:观察进程的内存使用模式,包括内存的分配和释放情况、内存占用的变化 趋势等。通过分析内存使用模式,可以发现是否存在内存泄漏或者内存占用异常的情况。
-
检查第三方库和系统调用:如果进程使用了第三方库或者系统调用,需要检查这些部分是否存 在内存使用不当的情况。有些第三方库可能存在内存泄漏或者内存占用过高的问题,需要特别 注意。
-
分析核心转储(core dump):如果进程发生了崩溃,可以分析核心转储文件以获取进程崩溃 时的内存状态。核心转储文件包含了进程崩溃时的内存快照,可以帮助定位问题的根本原因。
-
使用日志和调试信息:在代码中添加日志和调试信息,记录内存分配和释放的情况,以及内存 使用过程中的关键参数和状态。这些信息可以帮助你更好地理解进程的内存使用情况,并定位
问题。