发布于: 2023-9-21最后更新: 2023-10-9字数 00 分钟

开放题

荣耀北京和深圳都有数据中心,假设从深圳数据中心每天要把其所有数据传递给北京,当天传完,日结。问这里的难点是什么、以及设计哪些哪些功能模块。
参考思路:(主要在数据传递上而不是系统层面,分析的可以深入而具体)
难点:
  1. 数据量:如果数据量非常大,那么数据传输可能会需要很长的时间,甚至可能超过一天。
  1. 网络带宽和稳定性:网络带宽限制了每秒可以传输的数据量。另外,网络中断或延迟也可能影响数据传输。
  1. 数据完整性:在传输过程中,数据可能会遭受损坏或丢失,所以需要有办法检测和纠正这些问题。
  1. 安全性:数据在传输过程中可能会被拦截或篡改,所以需要使用加密和身份验证等安全措施。
  1. 数据一致性:如果深圳的数据在传输过程中发生更改,那么需要有一种方法来保证北京的数据能够反映这些更改。
功能模块:
  1. 数据压缩:为了减少传输的数据量,可以在发送前对数据进行压缩。
  1. 数据分片:如果数据量非常大,可以将数据分成小块,然后并行传输。
  1. 错误检测和纠正:可以使用如CRC、校验和、奇偶校验等方法来检测数据是否在传输过程中被损坏,如果可能的话,也可以使用纠错码来纠正错误。
  1. 重试和恢复:如果数据传输失败,需要有一种方法来重新开始传输,或者从失败的地方继续传输。
  1. 安全措施:可以使用TLS或SSL等协议来对数据进行加密,并使用数字证书进行身份验证。
  1. 数据同步:这可能包括锁定源数据以防止在传输过程中进行更改,以及在目标端应用更改以保持数据一致性。
  1. 日志和监控:记录数据传输的详细信息,包括开始和结束的时间,传输了多少数据,是否存在错误等。同时,实时监控数据传输的状态,以便在出现问题时及时发现和解决。

八股

信号槽的实现原理

Qt的信号/槽机制是一种事件驱动机制,它在某种程度上类似于C++的函数指针,但它更为强大和灵活。信号/槽机制的工作原理如下:
  1. 信号(Signal):当在程序中发生某个事件时,一个信号被发出。例如,当用户点击一个按钮时,按钮对象可能会发出一个"被点击"的信号。
  1. 槽(Slot):槽是一个函数,它是在信号被发出时调用的。一个槽可以连接一个或多个信号,当任何一个连接的信号被发出时,槽都会被调用。
  1. 连接(Connect):信号和槽之间的连接是通过QObject::connect()函数建立的。当一个信号被发出时,所有连接到该信号的槽都会被调用。
Qt的信号/槽机制是如何实现的呢?其背后的机制包括元对象系统(Meta-Object System,MOS)和事件队列。
  • 元对象系统:Qt通过MOC(Meta-Object Compiler,元对象编译器)扩展了C++的语言特性。MOC会读取包含Q_OBJECT宏的头文件,并生成一个相应的C++源文件,这个源文件包含了类的元信息,如类名、父类名、信号/槽的名字等。这就是Qt能够在运行时进行类型检查和信号与槽的匹配的原因。
  • 事件队列:当一个信号被发出时,如果连接到该信号的槽函数没有立即执行,那么这个调用就会被放入到事件队列中,等待事件循环下一次循环时执行。这意味着,即使在多线程环境中,槽函数也总是在它所属的线程中被执行,这大大简化了线程同步的问题。
下面进一步深入。

moc系统

首先,给定一个示例:
Q_OBJECT 这个宏是Qt元对象的核心,查看它的展开如下:
相当于对于需要Qt信号槽机制的类,比如注册Qt的元对象(即Q_OBJECT),然后Q_OBJECT为这个类添加了一些列元对象结构和相应的函数接口,比如上面的核心staticMetaObjectqt_static_metacall .
查看该头文件编译后的moc_xx.cpp:
moc_xx.cpp源码
结合其中Qt生成的注释来理解:Moc编译器创建了一个包含信号和槽信息的元对象。这个元对象是MyWidget类的一部分,并包含了类名、信号和槽的名称、信号和槽的参数类型等信息。
元对象的结构
元对象的定义部分看起来是这样的:
可以看到,元对象包含了一些元数据,如类名、信号和槽的名称等。这些信息是在编译时由Moc编译器生成的,并存储在qt_meta_stringdata_MyWidgetqt_meta_data_MyWidget这两个静态变量中。
信号和槽的调用
元对象还包含一个qt_static_metacall函数,这个函数是用来调用信号和槽的。这个函数会接收一个指向QObject的指针、一个表示调用类型的枚举值(如InvokeMetaMethod)、一个表示被调用方法的索引以及一个参数列表。
在这个例子中,qt_static_metacall函数的实现是这样的:

信号槽调用

当Qt需要发射一个信号或调用一个槽时,它会调用qt_static_metacall函数,并传递相应的参数。例如,如果代码中有这样一行:
查看emit,可以看到:
emit关键字在C++中本身并不具有任何意义,它只是一个标记,用于在语义上表示发出一个信号。实际上,emit在预处理后会被完全忽略。当你写下 emit someSignal(); 时,实际上等同于直接调用 someSignal();
emit关键字在C++中本身并不具有任何意义,它只是一个标记,用于在语义上表示发出一个信号。实际上,emit在预处理后会被完全忽略。当你写下 emit someSignal(); 时,实际上等同于直接调用 someSignal();
那么,信号函数是如何实现的呢?在Qt中,信号是通过QMetaObject::activate函数进行发射的。下面是你提供的mySignal信号函数的实现:
QMetaObject::activate函数接受四个参数:发射信号的对象、对象的元对象、信号在元对象中的索引以及信号的参数列表。在这个例子中,发射信号的对象是this,元对象是staticMetaObject,信号在元对象中的索引是0,信号没有参数,所以参数列表是nullptr
QMetaObject::activate函数的工作原理是这样的:
  1. 首先,它会获取信号的接收者列表。这个列表是在信号和槽连接时创建的,包含了所有连接到这个信号的槽。
  1. 然后,它会遍历这个列表,对每一个接收者,都会调用其对应的槽。这是通过调用接收者的qt_metacall函数实现的。
  1. qt_metacall函数会根据传入的索引调用相应的槽。这个过程和我之前描述的qt_static_metacall函数的工作方式类似。唯一的区别是qt_metacall函数是虚函数,可以在子类中被重写,而qt_static_metacall函数是静态函数。

connect

以下部分没认真查看源码,主要来源参考GPT和上面参考链接
在进一步之前,我们来看connect的实现:
QObject::connect 函数是 Qt 信号/槽机制的核心,它用于将信号和槽连接起来。在 Qt 中,你可以这样使用 connect 函数:
sender 发射 someSignal 信号时,receiversomeSlot 槽就会被调用。
相应的源码:
connect干的事情并不多,好像就是构造了一个Connection对象,然后存储在了发送者的内存中。在Qt中,QObject::connect函数创建的槽连接会保存在发射信号的QObject实例中。具体来说,每个QObject实例都有一个QObjectPrivate的私有数据成员。这个QObjectPrivate中有一个名为connectionLists的成员,它就是用来保存槽连接的。每一个槽连接包含了接收者、槽的索引以及其他的一些信息。
信号槽连接后在内存中已QObjectConnectionListVector对象存储,这是一个数组,Qt巧妙的借用了数组快速访问指定元素的方式,把信号所在的索引作为下标来索引他连接的Connection对象,众所周知一个信号可以被多个槽连接,那么我们的的数组自然而然也就存储了一个链表,用于方便的插入和移除,也就是CommectionList对象。
QObject::connect函数被调用时,它会创建一个新的槽连接,并将其添加到connectionLists中。而当QMetaObject::activate函数被调用时,它会遍历connectionLists,并对每一个槽连接调用相应的槽。

信号触发

Qt为我们提供了5种类型的连接方式,如下
  • Qt::AutoConnection 自动连接,根据sender和receiver是否在一个线程里来决定使用哪种连接方式,同一个线程使用直连,否则使用队列连接
  • Qt::DirectConnection 直连
  • Qt::QueuedConnection 队列连接
  • Qt::BlockingQueuedConnection 阻塞队列连接,顾名思义,虽然是跨线程的,但是还是希望槽执行完之后,才能执行信号的下一步代码
  • Qt::UniqueConnection 唯一连接
一般情况下,我们都使用默认的连接方式,除非一些特殊的需求,我们才会主动指定连接方式。当我们执行信号时,函数的调用关系可能会像下面这样
notion image
  1. 直连
对于大多数的开发工作来说,我们可能都是在同一个线程里进行的,因此直连也是我们使用连接方式最多的一种,直连说白了就是函数回调。还记得我们第三小节讲的connect吗,他构造了一个Connection对象,存储在了发送者的内存中,直连其实就是调用了我们之前存储在Connection中的函数地址。以上代码调用的堆栈(没有加上Qt源码部分的堆栈信息):
notion image
  1. 队列连接
connect连接信号槽时,我们使用Qt::QueuedConnection作为连接类型时,槽函数的执行是通过抛出QMetaCallEvent事件,经过Qt的事件循环达到异步的效果。
下面代码摘自Qt源码,queued_activate函数即是处理队列请求的函数,当我们使用自动连接并且接受者和发送者不在一个线程时使用队列连接;或者当我们指定连接方式为队列时使用队列连接。
测试代码
查看调用栈:
notion image

总结

使用 QObject::connect 绑定信号槽的执行工作原理:
  1. 首先,connect 函数会通过信号和槽的名字查找它们在元对象中的索引。这是通过调用 QMetaObject::indexOfSignalQMetaObject::indexOfMethod 函数实现的。
  1. 然后,connect 函数会创建一个槽连接,保存了信号发射者、信号的索引、接收者以及槽的索引等信息。这个槽连接会被添加到发射者的一个列表中,这个列表保存了该发射者的所有槽连接。
  1. 当发射者发射信号时,会调用 QMetaObject::activate 函数。activate 函数会遍历发射者的槽连接列表,找到所有与该信号相关的槽连接。
  1. 对于每一个槽连接,activate 函数会检查接收者是否还存在(因为接收者可能已经被删除)。如果接收者存在,就会调用接收者的 qt_metacall 函数,传入槽的索引和信号的参数。
  1. 最后,qt_metacall 函数会根据传入的索引调用相应的槽

STL中迭代器失效的场景

在STL中,迭代器可能会在以下情况下失效:
  1. 向vector或者string中添加元素:如果添加元素导致容器的容量增长,底层的动态数组将会重新分配内存,原有的迭代器将会失效。即使不需要重新分配内存,添加元素后面的迭代器也会失效。
  1. 从vector或string中删除元素:删除元素会导致元素后的所有迭代器失效。
  1. 对deque进行insert或erase操作:如果在deque的首部或尾部之外的位置进行insert或erase操作,所有迭代器都将失效。
  1. 在list或者forward_list中进行erase或splice操作:只有指向被删除元素的迭代器会失效。
  1. 对set、map、multiset、multimap进行insert或erase操作:只有指向被删除元素的迭代器会失效。
因此,在使用STL容器时,我们需要注意迭代器失效的问题,避免在迭代或者访问元素时使用失效的迭代器,否则会导致未定义行为。

进程、线程,以及多线程同步技术和CAS原子操作

进程是操作系统资源分配的基本单位,是包含一组机器指令、数据和资源(例如文件、设备)的程序的执行环境。每个进程都有自己的独立内存空间。
线程是操作系统调度(执行机器指令)的基本单位,是在进程内部的一个独立的执行路径。与同一进程下的其他线程共享进程的资源。
多线程同步是为了保证多个线程在访问共享数据时,能返回一致的结果,防止数据竞争。常用的同步技术有互斥锁(Mutex)、读写锁(RWLock)、信号量(Semaphore)等。
CAS(Compare And Swap)原子操作是一种常用的无锁同步策略。CAS原子操作包含三个操作数 —— 内存位置V、预期原值A和新值B。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置的值更新为新值B,否则,处理器不做任何操作。整个比较和替换的过程是一个原子操作。
使用示例:cpp里compare_exchange_weak方法执行CAS操作:如果value的当前值等于expected,那么就将value的值更新为updated,否则,就不做任何操作。参考:C++ 原子操作CAS和lockless无锁队列_c++ cas_雪*夹雨夹*雪的博客-CSDN博客
乱序执行(Out-of-Order Execution)是现代CPU采用的一种提高指令执行效率的技术。在乱序执行中,CPU会预先执行一些指令,然后再按照程序的顺序提交执行结果。这可能会导致多线程程序中的数据竞争问题。为了避免这个问题,我们可以使用内存屏障(Memory Barrier)或者原子操作来确保指令的执行顺序。内存屏障通常用于同步原语(如互斥锁)的实现中,以确保正确的内存可见性。例如,在C++11中,std::atomic的成员函数std::atomic::storestd::atomic::load就包含了内存屏障。
以下是一个使用了内存屏障的简单例子:
在这个例子中,std::memory_order_release内存屏障确保了data的赋值操作在ready的赋值操作之前对其他线程可见。std::memory_order_acquire内存屏障确保了在看到ready变为true之后,可以看到data的正确值。

C++11智能指针和循环依赖解决

C++11引入了两种主要的智能指针:std::unique_ptrstd::shared_ptr
std::unique_ptr 是一种独占所有权的智能指针,它禁止拷贝和赋值,确保同一时间只有一个智能指针指向对象。
std::shared_ptr 是一种共享所有权的智能指针,多个shared_ptr可以指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都会增加引用计数。当引用计数变为0,对象就会被自动删除。
但是,如果两个std::shared_ptr互相引用,就会形成循环引用,导致它们的引用计数永远不为0,从而导致内存泄漏。解决这个问题的方法是使用 std::weak_ptrstd::weak_ptr 是一种不控制对象生命周期的智能指针,它指向一个由 std::shared_ptr 管理的对象。将一部分 std::shared_ptr 替换为 std::weak_ptr 可以打破循环引用,解决内存泄漏问题。
关于智能指针在多线程环境下的使用,如果一个线程内有一个智能指针,然后将这个指针赋值给另一个线程,那么在第二个线程销毁后,前一个指针是否能正常使用,取决于这个智能指针的类型和使用方式。
  • 对于 std::unique_ptr,它不支持赋值或拷贝,所以不能直接将一个线程的 std::unique_ptr 赋值给另一个线程。如果我们使用 std::movestd::unique_ptr 从一个线程移动到另一个线程,那么在第二个线程销毁后,原来的 std::unique_ptr 就不再拥有对象,不能再被使用。
  • 对于 std::shared_ptr,它支持拷贝,所以我们可以将一个线程的 std::shared_ptr 拷贝给另一个线程。每个 std::shared_ptr 都有自己的引用计数,当一个 std::shared_ptr 被销毁时,只会减少引用计数,只有当引用计数变为0时,对象才会被删除。所以,在第二个线程销毁后,前一个线程的 std::shared_ptr 仍然可以正常使用。

virtual 析构函数

在C++中,如果基类的析构函数不是虚函数,那么在删除派生类对象的时候,只会调用基类的析构函数,而不会调用派生类的析构函数,可能会导致资源泄漏。因此,如果一个类被设计为基类,并且可能被其他类继承,那么它的析构函数通常应该被声明为虚函数。
当我们删除一个指向派生类对象的基类指针时,如果基类的析构函数是虚函数,那么会先调用派生类的析构函数,然后再调用基类的析构函数。这样就可以确保派生类的资源被正确释放。
析构函数可以是纯虚函数,这样的类是抽象类,不能创建该类的对象。但是,即使析构函数是纯虚函数,我们仍然需要为它提供一个定义(实现),因为当派生类的对象被删除时,派生类的析构函数会调用它。如果没有提供定义,就会导致链接错误。
示例:
在MSVC中,以下写法也可以(标准C++不行):

算法

区间合并之我是傻逼