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

CPP

虚函数实现

在 C++ 中,虚函数是实现多态的关键机制。当我们声明或定义一个虚函数,C++ 编译器会在内部为每个包含虚函数的类或者结构体生成一个虚函数表(也被称为 vtable)。每个对象实例会包含一个指向这个虚函数表的指针,通过这个指针可以找到所有的虚函数。这种机制允许我们在运行时动态决定调用哪个函数,实现多态。
虚函数表的查找过程可以分为以下步骤:
  1. 查找对象的虚函数表指针:首先,从对象实例中获取虚函数表的指针。这个指针通常被存储在对象内存的开始位置。(基类类型的指针没有子类对象的任何信息,不了解其内存布局,所以默认存在的虚表指针必然就放在对象的首部位置,使其可以直接获取到子类的虚表地址)
  1. 查找虚函数在虚函数表中的索引:然后,根据虚函数的声明顺序,确定函数在虚函数表中的索引。比如,一个类中第一个声明的虚函数在虚函数表中的索引就是0,第二个就是1,以此类推。
  1. 使用索引查找虚函数的地址:最后,使用从上一步获取的索引,在虚函数表中查找对应虚函数的地址。
以下是一个简单的例子:
在调用 b->func1() 时,虚函数查找的过程是这样的:
  1. 查找 b 的虚函数表指针。因为 b 实际上是 Derived 类型的,所以它的虚函数表指针指向 Derived 类的虚函数表。
  1. Derived 类的虚函数表中,根据 func1 的声明顺序确定索引,func1 是第一个声明的虚函数,所以索引是 0。
  1. 在虚函数表中使用索引 0 查找 func1 的地址,找到的是 Derived::func1() 的地址。
  1. 调用找到的 Derived::func1() 函数。
类似的,对于 b->func2() 的调用,虚函数查找的过程与 func1 类似,只是在虚函数表中的索引是 1。
在C++中,虚函数表和对象实例在内存中的布局可能如下所示:
在这个示意图中,每个对象实例都含有一个名为 vptr 的指针,这个指针指向该对象类型的虚函数表。虚函数表是一个函数指针数组,每个元素都是一个指向虚函数代码的指针。当我们调用一个对象的虚函数时,编译器会根据函数在虚函数表中的索引找到对应的函数指针,然后通过这个指针调用函数。
这只是一种可能的实现方式,不同的C++编译器可能会有自己的实现细节。比如,某些编译器可能会将虚函数表放在对象的末尾,而不是开头。此外,虚函数表中的函数指针的顺序可能也会因编译器而异。

类型转换

C++ 提供了四种类型转换运算符:static_castdynamic_castconst_castreinterpret_cast。以下是它们的基本区别:
  1. static_cast:这是最常见的类型转换运算符,可以用于大多数情况。它在编译时执行类型转换,因此不能进行运行时类型检查。例如,它可以用于基本数据类型之间的转换,将枚举转换为整数,将void指针转换为其他类型的指针等。
    1. dynamic_cast:这个类型转换运算符主要用于类层次结构中基类和派生类之间的转换。它在运行时执行类型检查,如果转换是安全的(即,目标类型是对象的实际类型或其基类型),那么转换就会进行,否则就会失败(对于指针类型,结果为 nullptr)。注意,为了使 dynamic_cast 正常工作,基类必须含有虚函数。
      1. const_cast:这个类型转换运算符用于修改类型的 constvolatile 属性。最常见的用途是在函数中删除参数的 const 性质,以便可以对其进行修改。
        1. reinterpret_cast:这是最不安全的类型转换运算符。它会生成一个新的值,这个值在位模式上与原始值相同,但类型可以完全不同。它通常用于进行低级操作,如操作特定硬件,或实现某些依赖于特定编译器或平台的功能。
          dynamic_cast 补充
          dynamic_cast的实现原理涉及到多种概念,包括虚函数、虚函数表(vtable)、运行时类型信息(RTTI,Runtime Type Information)和类型擦除。
          1. 虚函数和虚函数表(vtable):在C++中,通过将函数声明为虚函数,可以实现多态。每个含有虚函数的类或结构体都有一个与之关联的虚函数表。虚函数表是一个存储函数指针的数组,这些函数指针指向类的虚函数实现。每个类的实例都有一个指向其虚函数表的指针。
          1. 运行时类型识别(RTTI):RTTI是C++语言的一部分,允许在运行时确定对象的类型。dynamic_casttypeid运算符就依赖RTTI来工作。
          1. 类型擦除:在某些情况下,可能需要在运行时重新发现对象的类型,尤其是在处理基类指针或引用时,它可能实际上指向或引用一个派生类对象。
          现在,我们可以谈谈dynamic_cast的工作原理了。假设我们有一个基类Base和一个派生类Derived,并且我们有一个指向Base的指针,但实际上它指向一个Derived对象:
          我们可以尝试使用dynamic_cast将这个Base指针转换为Derived指针:
          在这种情况下,dynamic_cast的工作方式如下:
          1. 它首先检查 b 是否可以安全地转换为 Derived*。为了做这个检查,它使用 RTTI 获取 b 实际指向的对象的类型信息,然后看这个类型是否与 Derived 相兼容。
          1. 如果 b 实际上指向一个 Derived 对象(或派生自 Derived 的对象),那么转换就会成功,dynamic_cast 返回一个指向该对象的 Derived* 指针。
          1. 如果 b 不指向一个 Derived 对象,那么转换就会失败。对于指针类型,dynamic_cast 会返回 nullptr;对于引用类型,dynamic_cast 会抛出 std::bad_cast 异常。
          注意,dynamic_cast的运行时间可能比其他的cast运算符要长,因为它需要在运行时执行类型检查。此外,只有当基类至少有一个虚函数时,dynamic_cast和RTTI才能正常工作,这是因为只有在这种情况下,对象才会有类型信息。如果基类没有虚函数,那么在派生类对象上使用dynamic_cast可能会失败。
          reinterpret_cast 补充:
          reinterpret_cast 是 C++ 中最强大,也是最危险的类型转换运算符。它对位模式不做任何改变,只是告诉编译器将数据看作另一种类型。这意味着它根本不会进行任何类型的检查或转换。因此,它主要用于那些需要直接操作内存或其他资源的低级编程任务。
          以下是 reinterpret_cast 的一些常见用途:
          1. 转换指针类型:这包括函数指针和对象指针。例如,你可能需要将 void* 指针转换为具体的类型指针,或者在函数指针之间进行转换。
          1. 转换整数和指针:你可能需要将地址存储在整数变量中,或者需要直接设置指针的值。
          1. 操作硬件或实现特定的底层功能:例如,你可能需要以特定的方式解释设备发出的数据,或者你可能需要对位模式进行精确的控制。
          然而,由于 reinterpret_cast 不进行任何类型检查或转换,所以使用它有很大的风险。如果转换后的类型与原始数据不兼容,那么结果就是未定义的。例如,如果你将 int 指针转换为 double 指针,然后试图通过新指针读取数据,那么你可能会得到一个完全没有意义的值。
          因此,除非你完全确定你正在做什么,并且没有其他更安全的选项,否则你应该尽量避免使用 reinterpret_cast。在大多数情况下,其他的类型转换运算符(如 static_castdynamic_cast)或者模板元编程技术都是更好的选择。

          操作系统

          线程同步

          同步是指协调线程的运行顺序,避免产生data races导致结果不一致。常见的方式有以下六种。
          • Mutex,互斥锁,确保同一时间只有一个线程能访问资源。
          • ReadWrite lock,读写锁,读时共享,写时其他线程无法访问资源。
          • Spin lock,自旋锁,当资源被锁时,线程循环等待,直到获得锁。
          • Semaphore,信号量,一个计数器,当信号量满足条件时,线程可以访问资源。与mutex的不同在于,mutex只有lock和unlock两种状态,而semaphore可以有多种状态。
          • Conditional variable,条件变量,线程等待直到条件满足。
          • Barrier,屏障,线程等待直到所有线程都到达屏障。
          线程同步方式。互斥锁、读写锁、自旋锁区别。适用什么场景。
          比如自旋锁主要适用短小的互斥代码逻辑。判断依据是执行互斥代码逻辑开销小于线程沉睡唤醒的切换开销。
          以下是这些同步方式的使用场景:
          • 互斥锁:当你需要保护的临界区代码不能被多个线程同时执行时,可以使用互斥锁。比如说,对全局变量的修改,或者对数据库的写入操作。
          • 读写锁:当你的程序中有大量的读操作和较少的写操作时,可以使用读写锁。比如说,一个在线词典,大部分用户只需要查询词汇(读操作),而只有管理员偶尔需要更新词条(写操作)。
          • 自旋锁:当你需要保护的临界区代码执行非常快,且你预计锁不会被长时间持有时,可以使用自旋锁。比如说,对一个数据结构的简单更新操作,这个操作可能只需要几个CPU指令就能完成。

          线程和进程的共享内存

          有什么区别。提示了一个生命周期。
          线程共享内存: 在同一进程中的线程共享同一内存空间。这意味着一个线程在内存中创建的数据结构可以被同一进程中的其他线程访问。线程之间共享内存主要包括:堆内存、已打开的文件描述符、全局变量等。这使得线程间的通信更加方便,可以通过修改和检查共享内存中的变量来进行。然而,这也意味着线程需要协调对共享内存的访问,以避免冲突和不一致,这就需要使用线程同步机制,如互斥量(mutexes)、条件变量(condition variables)等。
          线程的生命周期从创建开始,结束于线程主动退出或被其他线程取消。当线程结束时,其栈被清空,但它在堆上分配的内存或者打开的文件描述符等资源并不会自动回收,这些资源需要由其他线程或进程进行清理。
          进程共享内存: 默认情况下,进程并不共享内存。每个进程都有自己独立的地址空间,一个进程无法访问另一个进程的内存。但是,进程间也可以通过特定的机制实现共享内存,这就是所谓的共享内存(Shared Memory)。共享内存是进程间通信的一种方式,它允许两个或多个进程访问同一块物理内存。在这种情况下,进程需要协调对共享内存的访问,以避免冲突和不一致。
          共享内存的生命周期并不完全受控于单个进程。它的生命周期开始于某个进程创建共享内存区域(例如在Unix系统中,使用shmget或者mmap等系统调用),并结束于系统中的最后一个使用它的进程解除映射(munmap)并删除它(例如在Unix系统中,使用shmctl函数与IPC_RMID命令)。

          协程

          对比线程如何降低了切换开销。(其实没怎么看懂,用得太少了)
          协程(coroutines)是一种程序组件,它比线程更轻量级,可以用来处理大量的并发任务。协程不同于线程的一大特点就是它不是由操作系统调度,而是由程序员或库在用户空间调度。这使得协程的切换开销比线程小得多。
          在C++20中,引入了对协程的支持。C++协程使用co_await, co_yield, co_return关键字来标记其挂起(suspension)点。
          以下是一个简单的C++20协程的例子:
          在这个例子中,hello()函数是一个协程。当调用hello()时,它会输出"Hello ",然后在co_await语句处挂起,控制权返回给main()函数。注意,此时hello()函数并未结束,它仍然保留着自己的状态。当main()函数结束时,所有未完成的协程都会被恢复并运行至结束,此时hello()函数会被恢复,输出"world!\n"。
          协程在C++中有多种应用场景,主要包括以下几个方面:
          1. 异步编程:协程可以极大地简化异步编程的复杂性。使用协程,你可以像写同步代码一样编写异步代码,无需处理复杂的回调或者Promise结构。例如,你可以使用协程来编写异步的网络编程代码,或者处理其他的异步I/O操作。
          1. 生成器:协程可以用来创建生成器(Generator),即一种特殊的迭代器,可以在每次产生一个新的值时暂停。当需要下一个值时,协程会从上次暂停的地方恢复执行。这在处理大量数据或者无限序列时非常有用。
          1. 并发编程:虽然协程不是并行执行的,但是它们可以用来处理大量的并发任务。由于协程的切换开销非常小,你可以在同一线程中运行数以百万计的协程,用来处理大量的并发任务,比如网络请求。
          1. 协作多任务:协程可以用来编写协作式多任务代码。在这种模式下,每个任务都是一个协程,任务之间可以通过co_yield关键字来互相切换。这种方式比抢占式多任务(线程)更加轻量级,更易于控制。
          1. 异步工作流程和状态机:协程也可以用来编写复杂的异步工作流程和状态机。由于协程可以在任何地方暂停和恢复,这使得你可以用直接的方式来编写复杂的工作流程和状态机,而无需分割你的代码到多个状态函数中。
          协程(Coroutine)和线程(Thread)都是用于并发编程的工具,但它们在设计和实现上有一些关键的区别,这些区别导致了协程在切换开销上相比线程有很大的优势。
          1. 上下文切换:线程的切换(即线程上下文切换)需要操作系统的介入,涉及到了一系列复杂的步骤,如保存和恢复寄存器、程序计数器、堆栈、内存映射等状态信息,这些操作都需要消耗大量的时间。而协程的切换则仅仅涉及到了栈的切换,这个过程可以在用户空间内完成,开销远小于线程的上下文切换。
          1. 调度机制:线程的调度由操作系统来控制,是抢占式的,即操作系统可以在任何时刻打断线程的执行,并切换到另一个线程。这种切换是不可预测的,因此需要复杂的同步机制来避免并发问题。而协程的调度是协作式的,即协程需要显式地指出在哪里切换到另一个协程。这种方式使得协程的切换更加可控,不需要复杂的同步机制。
          1. 内存占用:每个线程都有自己的堆栈,通常大小为几MB,如果创建大量的线程,将会消耗大量的内存。而协程的堆栈大小是动态的,可以根据需要来分配和释放,因此创建大量的协程的内存开销远小于创建大量的线程。

          内核和用户态的使用

          在操作系统的设计中,为了提供系统的安全性和稳定性,通常会有两种运行模式:用户态和内核态。
          • 用户态(User Mode):用户程序在这个模式下运行,受到严格的限制,无法直接访问硬件或其他系统资源,必须通过系统调用来请求内核提供服务。
          • 内核态(Kernel Mode):操作系统内核在这个模式下运行,具有直接访问硬件和内存等资源的权限。
          这两种模式之间的切换被称为模式切换(Mode Switch)
          模式切换通常发生在以下情况:
          1. 当一个用户程序发出系统调用请求时,系统需要切换到内核态来处理这个请求。
          1. 当操作系统需要进行任务调度时,也需要切换到内核态。
          1. 当发生硬件中断时,处理这个中断需要切换到内核态。
          模式切换需要一些代价,包括:
          • 时间代价:模式切换涉及到保存和恢复CPU寄存器,这需要一些时间。因此,频繁的模式切换可能会导致系统性能下降。
          • 空间代价:每次切换模式时,都需要保存当前运行状态,这需要额外的内存。
          • 复杂性代价:管理模式切换的代码需要非常精确和谨慎,因此增加了系统的复杂性。
          为了减少模式切换的代价,一般会通过以下方式来优化:
          • 减少不必要的模式切换:例如,通过将一些常用的内核操作实现为用户级库函数,从而避免不必要的系统调用。
          • 批处理系统调用:将多个系统调用合并为一次模式切换,减少频繁切换带来的开销。
          • 使用更高效的模式切换机制:例如,在某些硬件平台上,可以使用特殊的指令或硬件支持来加快模式切换的速度。

          场景题

          重I/O服务器如何优化性能

          1. I/O多路复用模型。select,poll,epoll。比如epoll模式中,考虑CPU占用率高的话可能在哪些部分。比如系统调用,用户态到内核态的切换会有损耗。如何减小这个开销。提了一个批处理思路。还给了一个DMA技术。
            1. 使用epoll模型时,可能出现CPU占用率高的情况。这主要可能在以下几个方面:一是在处理大量的并发连接时,需要频繁地调用epoll_ctl函数来添加、修改或删除监视的文件描述符,这可能会消耗大量的CPU资源;二是在调用epoll_wait函数等待文件描述符准备好进行I/O操作时,如果设置的超时时间过短,或者在非阻塞模式下频繁地调用epoll_wait函数,可能会导致CPU忙等,从而占用大量的CPU资源;三是在接收到I/O通知后,需要进行数据的读取或写入,如果数据处理的逻辑复杂,也可能会消耗大量的CPU资源。
              为了优化这些问题,可以采取以下几种策略:
            2. 合理设置epoll_wait的超时时间:设置合理的超时时间可以避免频繁地调用epoll_wait函数,从而减少CPU的忙等。
            3. 使用线程池处理I/O操作:当接收到I/O通知后,可以将数据的读取或写入操作交给线程池来处理,这样可以利用多核CPU的并行处理能力,从而提高处理效率。
            4. 使用边缘触发模式:epoll支持两种触发模式:水平触发(Level Triggered)和边缘触发(Edge Triggered)。边缘触发模式只在文件描述符的状态发生变化时发出通知,而不是在文件描述符准备好进行I/O操作时就发出通知,这样可以减少不必要的通知,从而降低CPU的使用率。
            5. 优化数据处理逻辑:在接收到I/O通知后,需要进行数据的读取或写入。如果这个过程中的逻辑复杂,可能会消耗大量的CPU资源。因此,优化这部分的代码,比如通过使用更高效的数据结构和算法,可以帮助降低CPU的使用率。
            6. 直接内存访问(Direct Memory Access, DMA)是一种可以让某些硬件子系统在主内存和设备间直接传输数据,而无需通过CPU的技术。这种技术在需要处理大量数据,尤其是I/O操作时,可以大幅度降低CPU的负载,因为CPU不需要参与每一次数据传输。
              在网络通信中,DMA技术可以用于实现零拷贝(Zero-Copy)数据传输,这对于I/O密集型服务器的性能优化非常关键。以下是一些使用DMA技术优化CPU占用高问题的策略:
            7. 使用零拷贝技术:在传统的网络通信中,数据需要从内核空间拷贝到用户空间,然后再从用户空间拷贝到内核空间,这种多次拷贝操作会占用大量的CPU资源。零拷贝技术利用DMA,使数据可以直接从网络接口卡(NIC)传输到内存,或者从内存直接传输到NIC,无需通过CPU,从而降低CPU的使用率。
            8. 使用内核旁路(Kernel Bypass)技术:内核旁路技术可以让应用程序直接访问网络接口卡,绕过内核的网络栈,从而避免了内核空间与用户空间之间的上下文切换和数据拷贝,降低了CPU的使用率。这种技术需要硬件和驱动的支持,例如Intel的DPDK(Data Plane Development Kit)和Mellanox的RDMA(Remote Direct Memory Access)技术。
            9. 使用异步I/O模型:在异步I/O模型中,应用程序发起I/O操作后,无需等待I/O操作完成,可以立即进行其他操作。当I/O操作完成后,应用程序会收到一个通知。这种模型可以充分利用DMA技术,因为数据的传输可以在后台,无需通过CPU进行。
          1. epoll水平触发和边缘触发的实现。边缘触发的话, 如果有100 byte,然后读了50byte,之后再来了2byte数据,那剩下52byte数据会触发事件回调吗。
            1. 当你第一次读取50 byte数据后,剩下的50 byte数据不会再触发epoll的事件。只有当再有新的数据(也就是这里的2 byte)到来时,epoll才会触发事件。这时你在处理事件时,会读取到52 byte的数据(之前剩下的50 byte和新到来的2 byte)。

          网络

          TCP中拥塞控制的初始阈值的设定依据,以及最后断开连接后客户端等待时间2MSL设定的依据。
          TCP拥塞控制的初始阈值的设定依据
          TCP的拥塞控制算法主要基于四个主要组件:慢启动、拥塞避免、快速重传和快速恢复。这些组件都依赖于一个动态变量:拥塞窗口(cwnd)和拥塞阈值(ssthresh)。
          在TCP连接初始化时,cwnd被设置为一个很小的值(通常为1或2个最大段大小),ssthresh被设置为一个很大的值(通常为65535字节或更大)。这样的设定允许慢启动阶段快速增加cwnd,从而让TCP连接更快地到达其最大吞吐量。
          ssthresh的初始设定值是一个比较保守的选择,目的是尽可能避免在连接初期就引发网络拥塞。然而,一旦出现了实际的网络拥塞(通过丢包等情况检测到),ssthresh就会被设置为出现拥塞时的cwnd的一半。之后,如果没有进一步的拥塞,cwnd会继续按照拥塞避免算法增长,直到达到ssthresh,然后再次进入慢启动阶段。
          TCP标准中的说法是:(更具体的数值设置基本没看到什么好的说法)
          ssthresh 的初始值应该设置为任意高(例如,设置为最大可能的广告窗口的大小),但 ssthresh 必须减少以响应拥塞。将 ssthresh 设置得尽可能高,可以让网络条件(而不是某些任意主机限制)来决定发送速率。在终端系统对网络路径有深入了解的情况下,更仔细地设置初始 ssthresh 值可能会有好处(例如,使得终端主机不会沿路径造成拥塞)。
          MSL的设定依据
          MSL,或者最大段生存时间(Maximum Segment Lifetime),是TCP/IP网络中一个数据包在网络中能够存在的最长时间。在RFC 793中,MSL被定义为2分钟,但在现代网络中,这个时间通常被设置为30秒或更短。
          TCP连接在关闭时,会进入TIME_WAIT状态,持续的时间是2个MSL。这是为了确保TCP连接的双方都能收到彼此的关闭通知,在这个期间,任何延迟的数据包都应该已经在网络中消失。这样可以防止旧的或重复的数据包在新的连接中被错误地接收。
          这个2MSL等待期的设定是基于保守的网络设计原则,即尽可能避免由于网络延迟或数据包重传引起的问题。虽然在高速网络中,这个等待期可能看起来过长,但在可能存在大量网络延迟或数据包丢失的网络环境中,这个设定是非常有必要的。

          秋招复盘8.3:C加加
          秋招复盘8.3:C加加
          恐狂?在中国动不动就劝打狂犬疫苗之我看
          恐狂?在中国动不动就劝打狂犬疫苗之我看