Skip to content

百度自动驾驶C++一面,

来源:https://www.nowcoder.com/feed/main/detail/b08c7d2291214287bc95585721dca7a2

1、C++11有哪一些智能指针?都有什么作用?和使用场景

C++11引入了三种主要的智能指针,旨在解决传统裸指针在内存管理方面容易出现的内存泄漏、野指针等问题。它们通过RAII机制,在对象生命周期结束时自动释放所管理的资源。

1.1 std::unique_ptr

  • 作用:独占式拥有所指向的对象。这意味着在任何给定时间,只有一个unique_ptr可以指向一个特定的对象。当unique_ptr被销毁时,它所指向的对象也会被自动销毁。
  • 特点:
    • 独占所有权:不能被复制,但可以通过std::move进行所有权转移。
    • 轻量级:通常与裸指针大小相同,几乎没有额外开销。
    • 自动释放:离开作用域时自动调用delete
  • 使用场景:
    • 独占资源:当资源(如动态分配的对象、文件句柄、网络连接等)只应由一个对象管理时。
    • 工厂函数返回:工厂函数返回新创建的对象时,通常返回unique_ptr以表示所有权的转移。
    • 容器存储:在C++标准库容器中存储指向动态分配对象的指针时,unique_ptr是更好的选择,因为它明确了所有权。

1.2 std::shared_ptr

  • 作用:共享式拥有所指向的对象。多个shared_ptr可以共同拥有同一个对象,并通过引用计数(reference count)来管理对象的生命周期。当最后一个shared_ptr被销毁时,对象才会被释放。
  • 特点:
    • 共享所有权:可以被复制,每次复制都会增加引用计数。
    • 引用计数:内部维护一个引用计数,当计数归零时自动释放资源。
    • 线程安全:引用计数的增减是原子操作,但所管理对象的访问不是线程安全的。
  • 使用场景:
    • 共享资源:当多个对象需要共享同一个资源,并且资源的生命周期由这些共享者共同决定时。
    • 对象图或树结构:在复杂的数据结构中,当一个对象可能被多个父节点或相关对象引用时。
    • 缓存:当多个客户端可能需要访问同一个缓存对象时。

1.3 std::weak_ptr

  • 作用:weak_ptr是一种不控制对象生命周期的智能指针。它指向一个由shared_ptr管理的对象,但不会增加对象的引用计数。它主要用于解决shared_ptr的循环引用问题。
  • 特点:
    • 非拥有:不增加引用计数,因此不会阻止shared_ptr释放对象。
    • 安全性:可以通过lock()方法尝试获取一个shared_ptr,如果对象已被销毁,lock()会返回一个空的shared_ptr,从而避免访问无效内存。
  • 使用场景:
    • 解决循环引用:当两个或多个shared_ptr相互引用,导致引用计数永远无法归零,从而造成内存泄漏时,可以使用weak_ptr打破循环。
    • 观察者模式:观察者需要访问被观察者,但又不希望影响被观察者的生命周期时。
    • 缓存管理:缓存中的对象可能被外部引用,但当外部引用消失时,缓存希望能够自动清除这些对象。

1.4 std::auto_ptr (C++11已弃用)

  • 作用:auto_ptr是C++98引入的智能指针,也实现了独占所有权。但其所有权转移语义存在缺陷,尤其是在作为函数参数或返回值时,可能导致意外的行为。因此在C++11中被unique_ptr取代并弃用。
  • 问题:复制auto_ptr会导致所有权转移,原auto_ptr变为空,这与常规的复制语义不符,容易引发错误。

更详细的总结可以详见:

2、weak_ptr除了循环引用问题,还解决了什么问题?

std::weak_ptr最主要的设计目的确实是为了解决std::shared_ptr可能导致的循环引用(Circular Reference)问题,从而避免内存泄漏。

然而,除了这个核心问题之外,weak_ptr的非拥有(non-owning)特性使其在其他场景中也发挥着重要作用,主要体现在以下几个方面:

  1. 安全观察者模式:

    在观察者模式中,观察者需要持有被观察者的引用以便接收通知。如果观察者持有被观察者的shared_ptr,而反过来被观察者又持有观察者的shared_ptr,就可能形成循环引用。

    使用weak_ptr,观察者可以安全地“观察”被观察者,而不会增加其引用计数,从而不影响被观察者的生命周期。

    当被观察者被销毁时,weak_ptr会自动失效,观察者可以通过lock()方法检查其有效性,避免访问已销毁的对象。

  2. 缓存管理:

    在实现缓存机制时,我们希望缓存中的对象在没有外部强引用时可以被自动清除,以节省内存。

    如果缓存持有对象的shared_ptr,那么只要对象在缓存中,它就不会被销毁。

    通过让缓存持有对象的weak_ptr,当外部所有shared_ptr都释放后,对象就可以被正常销毁,缓存中的weak_ptr会失效,从而实现缓存的自动清理。

  3. 避免悬空指针(Dangling Pointers):

    虽然weak_ptr本身不拥有对象,但它提供了一种机制来安全地检查所指向对象是否仍然存活。

    通过weak_ptr::lock()方法,可以尝试获取一个shared_ptr。如果对象已经销毁,lock()会返回一个空的shared_ptr

    这比裸指针或unique_ptr在对象生命周期结束后尝试访问内存要安全得多,因为裸指针会直接导致未定义行为,而unique_ptr一旦被reset或移走,其指向的对象就可能被销毁。

3、比如在lambda表达式当中捕获了什么东西?是否可以用weak_ptr去解决?

在C++的lambda表达式中,捕获(capture)列表用于指定lambda可以访问其外部作用域中的哪些变量。

捕获方式可以是值捕获或引用捕获。

当lambda的生命周期可能超出其捕获的局部变量的生命周期时,或者当lambda捕获了shared_ptr管理的对象时,可能会遇到一些问题,weak_ptr可以有效地解决这些问题。

Lambda捕获的潜在问题

  1. 值捕获([var]):

    如果捕获的是一个shared_ptr对象,那么lambda会复制这个shared_ptr,从而增加对象的引用计数。

    这可能导致对象被不必要地延长生命周期,尤其是在异步回调或事件处理中,如果lambda被长期持有,它所捕获的对象也无法被释放,即使其他地方已经不再需要它。

  2. 引用捕获([&var]):

    如果捕获的是一个局部变量的引用,当lambda的生命周期超出该局部变量的生命周期时,就会导致悬空引用,访问已销毁的内存,引发未定义行为。

weak_ptr在Lambda捕获中的应用

weak_ptr可以在lambda表达式中作为一种安全的捕获机制,尤其适用于以下场景:

  • 避免shared_ptr导致的循环引用或生命周期延长:

    当lambda需要访问一个由shared_ptr管理的对象,但又不希望延长该对象的生命周期时,可以捕获该对象的weak_ptr

    在lambda内部,通过weak_ptr::lock()方法尝试获取一个shared_ptr

    如果lock()成功,说明对象仍然存活,可以安全访问;如果失败,则说明对象已被销毁,避免了对无效内存的访问。

    cpp
    #include <iostream>
    #include <memory>
    #include <functional>
    
    class MyClass {
    public:
        int value;
        MyClass(int v) : value(v) { std::cout << "MyClass(" << value << ") created.\n"; }
        ~MyClass() { std::cout << "MyClass(" << value << ") destroyed.\n"; }
        void doSomething() { std::cout << "MyClass(" << value << ") doing something.\n"; }
    };
    
    void execute_async_task(std::function<void()> task) {
        // 模拟异步执行
        std::cout << "Async task scheduled.\n";
        task(); // 实际应用中这里会是线程或事件循环
        std::cout << "Async task finished.\n";
    }
    
    int main() {
        std::shared_ptr<MyClass> ptr = std::make_shared<MyClass>(100);
        std::cout << "ptr use_count: " << ptr.use_count() << "\n";
    
        // 捕获 weak_ptr
        std::weak_ptr<MyClass> weakPtr = ptr;
    
        execute_async_task([weakPtr]() {
            if (std::shared_ptr<MyClass> lockedPtr = weakPtr.lock()) {
                // 对象仍然存活,可以安全访问
                lockedPtr->doSomething();
                std::cout << "Locked ptr use_count: " << lockedPtr.use_count() << "\n";
            } else {
                std::cout << "Object has been destroyed.\n";
            }
        });
    
        std::cout << "ptr use_count after async task scheduled: " << ptr.use_count() << "\n";
        ptr.reset(); // 提前释放 shared_ptr,模拟对象提前销毁
        std::cout << "ptr use_count after reset: " << (ptr ? ptr.use_count() : 0) << "\n";
    
        // 再次执行异步任务,此时对象可能已销毁
        execute_async_task([weakPtr]() {
            if (std::shared_ptr<MyClass> lockedPtr = weakPtr.lock()) {
                lockedPtr->doSomething();
            } else {
                std::cout << "Object has been destroyed in second call.\n";
            }
        });
    
        return 0;
    }

    上述示例中,即使ptrreset()weakPtr捕获的lambda也能安全地检查对象是否存活。

    这在处理异步回调、事件处理器或UI组件的生命周期管理时非常有用,可以避免因持有强引用而导致的对象无法销毁,同时又防止了访问已销毁对象的问题。

4、裸指针,是否可以用weak_ptr解决?

std::weak_ptr不能直接用于解决裸指针的问题。weak_ptrstd::shared_ptr的伴侣,它只能指向由std::shared_ptr管理的对象。

它的核心价值在于提供对shared_ptr所管理对象的非拥有引用,并能安全地检测对象是否存活。

裸指针的问题主要包括:

  • 内存泄漏:忘记delete动态分配的内存。
  • 悬空指针:指向的内存已被释放,但指针仍然存在。
  • 重复释放:对同一块内存多次delete

weak_ptr无法直接解决这些问题,因为它不具备所有权语义,也不会自动管理内存。

要解决裸指针的问题,应该使用:

  1. std::unique_ptr:当资源具有独占所有权时,unique_ptr能确保资源在超出作用域时自动释放,有效防止内存泄漏和重复释放。
  2. std::shared_ptr:当资源需要共享所有权时,shared_ptr通过引用计数机制自动管理资源的生命周期,防止内存泄漏和重复释放。

如果你有一个裸指针,并希望对其进行安全管理,你需要先将其包装进unique_ptrshared_ptr

一旦裸指针被智能指针管理,你才能考虑使用weak_ptr来创建对该对象的非拥有引用,以解决特定场景(如循环引用、安全观察)下的问题。

如果你有一个MyObject* rawPtr = new MyObject();,你不能直接创建一个std::weak_ptr<MyObject> wptr(rawPtr);

正确的做法是先创建一个shared_ptr

cpp
std::shared_ptr<MyObject> sptr = std::make_shared<MyObject>(); // 或者 std::shared_ptr<MyObject> sptr(rawPtr);
std::weak_ptr<MyObject> wptr = sptr;

所以,weak_ptrshared_ptr生态系统的一部分,它通过与shared_ptr协同工作来增强内存安全性,而不是直接替代裸指针或解决所有裸指针相关的问题。

5、shared_ptr 可以newmake_shared创建,更推荐哪一个?

在C++中,创建std::shared_ptr主要有两种方式:

  1. 直接使用new表达式和shared_ptr构造函数:

    cpp
    std::shared_ptr<MyObject> sptr(new MyObject());
  2. 使用std::make_shared工厂函数:

    cpp
    std::shared_ptr<MyObject> sptr = std::make_shared<MyObject>();

强烈推荐使用std::make_shared。 尽管两者都能创建shared_ptr,但make_shared在性能、内存效率和异常安全性方面具有显著优势。

5.1 shared_ptr(new T()) 的缺点

当使用std::shared_ptr<T> sptr(new T());这种方式时,会发生至少两次内存分配:

  1. MyObject对象本身分配内存:new MyObject()会进行一次堆内存分配。
  2. shared_ptr的控制块(Control Block)分配内存:shared_ptr需要一个控制块来存储引用计数(强引用和弱引用计数)以及自定义删除器等信息。这个控制块会进行另一次堆内存分配。

这种分离的内存分配不仅带来了性能开销(两次系统调用),还可能导致异常安全性问题。

考虑以下情况:

cpp
void process(std::shared_ptr<MyObject> sptr1, std::shared_ptr<MyObject> sptr2);

// 潜在的异常安全性问题
process(std::shared_ptr<MyObject>(new MyObject()), std::shared_ptr<MyObject>(new MyObject()));

process函数调用之前,编译器可能会按任意顺序执行:

  1. new MyObject() (第一个对象)
  2. new MyObject() (第二个对象)
  3. std::shared_ptr构造函数 (第一个对象)
  4. std::shared_ptr构造函数 (第二个对象)

如果在步骤2之后、步骤3或4之前抛出异常(例如,第二个MyObject的构造函数抛出异常),那么第一个new MyObject()分配的内存可能永远不会被包装成shared_ptr,从而导致内存泄漏。这是因为在异常发生时,第一个裸指针还未被shared_ptr接管,无法自动释放。

5.2 std::make_shared 的优点

std::make_shared的优势在于:

  1. 单次内存分配:

    make_shared会一次性分配足够的内存来同时容纳MyObject对象和shared_ptr的控制块。

    这减少了内存分配的次数,提高了性能(一次系统调用而不是两次),并改善了缓存局部性(对象和控制块在内存中相邻,访问效率更高)。

  2. 异常安全性:

    由于对象和控制块是在一次操作中创建的,make_shared能够提供更强的异常安全性。

    如果在创建过程中发生异常,所有已分配的内存都能被正确地释放,避免了上述裸指针可能导致的内存泄漏问题。

  3. 简洁性:代码更简洁,更易读。

5.3 std::make_shared 的局限性

尽管make_shared有很多优点,但在某些特定情况下,你可能无法使用它或需要考虑其潜在影响:

  1. 自定义删除器:

    make_shared不支持自定义删除器。

    如果你需要为shared_ptr指定一个非默认的删除器(例如,释放数组、文件句柄或使用特定的内存池),必须使用shared_ptr的构造函数,并传入自定义删除器:

    cpp
    std::shared_ptr<FILE> filePtr(fopen("test.txt", "w"), [](FILE* f){ fclose(f); });
  2. 私有构造函数:

    如果类的构造函数是私有的(例如,为了实现单例模式),make_shared将无法直接访问它。

    此时,需要通过一个友元函数或公共静态方法来创建shared_ptr

  3. 内存释放延迟:

    由于对象和控制块是共同分配的,即使所有shared_ptr都已销毁,只要还有weak_ptr存在(它需要访问控制块来检查对象是否存活),那么包含对象内存的整个内存块就不会被释放。

    只有当所有shared_ptrweak_ptr都销毁后,这块内存才会被完全释放。这可能导致对象本身已经不可用,但其占用的内存仍未被回收,直到最后一个weak_ptr也失效。

6、mapunordered_map讲讲?

在C++标准库中,std::mapstd::unordered_map都是关联容器,用于存储键值对(key-value pairs)。它们的主要区别在于底层实现、数据存储顺序、性能特征以及使用场景。

6.1 std::map

  • 底层实现:std::map的底层通常实现为红黑树(Red-Black Tree)。红黑树是一种自平衡的二叉搜索树,它保证了在最坏情况下的对数时间复杂度。
  • 数据顺序:std::map中的元素总是根据键(key)的严格弱序(strict weak ordering)进行排序。当你遍历map时,元素会按照键的升序(或自定义的比较器定义的顺序)返回。
  • 性能:
    • 插入、删除、查找(insert, erase, find):平均和最坏情况下的时间复杂度都是 O(log N),其中N是容器中的元素数量。这是因为红黑树的高度是log N。
    • 空间复杂度:由于需要存储额外的指针和颜色信息来维护树结构,其空间开销相对较大。
  • 优点:
    • 有序性:元素总是保持有序,这对于需要按键范围查找或有序遍历的场景非常有用。
    • 最坏情况性能保证:所有操作都有可靠的对数时间复杂度保证,不会出现哈希冲突导致的性能骤降。
    • 内存局部性较差:由于节点可能分散在内存中,遍历时缓存命中率可能不如连续存储的结构。
  • 使用场景:
    • 需要维护键的顺序,例如字典、电话簿或按字母顺序排列的数据。
    • 需要进行范围查询(如查找所有键在A和Z之间的元素)。
    • 对最坏情况性能有严格要求的场景,避免哈希表在极端情况下的性能退化。
    • 键类型没有定义哈希函数(std::hash)或相等比较(operator==)的场景,但定义了小于比较(operator<)。

6.2 std::unordered_map

  • 底层实现:std::unordered_map的底层通常实现为哈希表(Hash Table)。它使用哈希函数将键映射到存储桶中,并通过链表或开放寻址等方式解决哈希冲突。
  • 数据顺序:std::unordered_map中的元素是无序的。元素的存储位置由其键的哈希值决定,因此遍历unordered_map时,元素的顺序是不确定的,并且每次运行可能不同。
  • 性能:
    • 插入、删除、查找(insert, erase, find):平均情况下的时间复杂度是 O(1)。在理想的哈希函数和均匀分布的键下,性能非常高。
    • 最坏情况:当发生大量哈希冲突时(例如,所有键都映射到同一个桶),性能可能退化到 O(N)。这通常可以通过选择好的哈希函数和适当的负载因子来避免。
    • 空间复杂度:通常比map占用更多内存,因为它需要维护一个桶数组,并且可能为了减少冲突而预留额外空间。
  • 优点:
    • 平均性能高:在大多数实际应用中,unordered_map的平均查找、插入和删除速度比map快得多。
    • 内存局部性可能更好:如果哈希函数设计得好,并且负载因子合理,桶内的元素可能在内存中较为集中,从而提高缓存命中率。
  • 缺点:
    • 无序性:不保留键的顺序,不能进行范围查询。
    • 最坏情况性能:哈希冲突可能导致性能显著下降,尤其是在恶意输入或哈希函数选择不当的情况下。
    • 需要哈希函数:键类型必须是可哈希的,即需要提供std::hash特化或自定义哈希函数。
  • 使用场景:
    • 需要快速查找、插入和删除,而对键的顺序不关心时。
    • 作为查找表或缓存,例如根据ID查找对象。
    • 键类型是内置类型或自定义类型,并且可以提供高效的哈希函数。

6.3 对比

特性std::mapstd::unordered_map
底层实现红黑树(自平衡二叉搜索树)哈希表
数据顺序键有序(升序或自定义)无序
查找性能O(log N)平均 O(1),最坏 O(N)
插入性能O(log N)平均 O(1),最坏 O(N)
删除性能O(log N)平均 O(1),最坏 O(N)
空间开销相对较小相对较大(桶数组和负载因子)
内存局部性较差较好(如果哈希函数和负载因子合理)
额外要求键类型需支持 < 比较键类型需支持 == 和哈希函数 std::hash

选择建议:

  • 优先考虑std::unordered_map,除非你确实需要键的有序性或者对最坏情况性能有严格要求。
  • 如果需要键的有序性,或者键类型无法提供有效的哈希函数,那么选择std::map
  • 在性能敏感的场景下,可以对两种容器进行基准测试,以确定哪种更适合特定数据集和操作模式。

7、vector可以用下标访问元素,也可以用vector.at()访问,有什么区别?

在C++的std::vector中,operator[](下标运算符)和at()成员函数都可以用来访问vector中的元素。它们之间最主要的区别在于是否进行边界检查以及如何处理越界访问。

7.1 operator[] (下标运算符)

  • 功能:提供对vector中指定位置元素的直接访问。
  • 边界检查:不执行边界检查。这意味着如果你提供的索引超出了vector的有效范围(即小于0或大于等于vector.size()),行为是未定义的。
  • 性能:由于不执行边界检查,operator[]通常比at()更快,因为它省去了检查的开销。
  • 返回类型:返回对元素的引用。
  • 示例:
    cpp
    std::vector<int> vec = {10, 20, 30};
    int val = vec[1]; // 访问第二个元素,val = 20
    // vec[3] = 40; // 越界访问,未定义行为,可能导致程序崩溃或数据损坏

7.2 at() 成员函数

  • 功能:提供对vector中指定位置元素的访问。
  • 边界检查:执行边界检查。如果你提供的索引超出了vector的有效范围,at()会抛出一个std::out_of_range异常。
  • 性能:由于需要执行边界检查,at()通常比operator[]慢。
  • 返回类型:返回对元素的引用。
  • 示例:
    cpp
    #include <iostream>
    #include <vector>
    #include <stdexcept>
    
    int main() {
        std::vector<int> vec = {10, 20, 30};
        try {
            int val = vec.at(1); // 访问第二个元素,val = 20
            std::cout << "Element at index 1: " << val << std::endl;
    
            vec.at(3) = 40; // 越界访问,抛出 std::out_of_range 异常
            std::cout << "Element at index 3: " << vec.at(3) << std::endl; // 不会被执行
        } catch (const std::out_of_range& e) {
            std::cerr << "Caught exception: " << e.what() << std::endl;
        }
        return 0;
    }

7.3 对比

特性operator[]at()
边界检查
越界处理未定义行为(Undefined Behavior)抛出 std::out_of_range 异常
性能较快(无额外开销)较慢(有边界检查开销)
安全性较低(可能导致难以调试的错误)较高(通过异常机制提供错误信息)
使用场景确定索引始终有效时(例如,循环遍历已知范围)需要运行时安全检查,或不确定索引是否有效时

7.4 何时使用哪个?

  • 使用 operator[]

    • 当你确定索引是有效的,例如在for循环中遍历vector的已知范围时。
    • 在性能是极其关键的场景下,并且你已经通过其他方式(例如,在循环前检查size())确保了索引的有效性。
  • 使用 at()

    • 当你不确定索引是否有效时,例如接收用户输入作为索引,或者从外部数据源获取索引时。
    • 在开发和调试阶段,at()可以帮助你快速发现越界访问错误,因为它会抛出明确的异常。
    • 在需要健壮性和错误处理的生产代码中,即使有轻微的性能损失,at()提供的安全性通常是值得的。

在大多数情况下,尤其是在不确定索引有效性的情况下,优先使用at()以提高代码的健壮性和可维护性。只有在性能成为瓶颈且已通过其他方式保证索引有效时,才考虑使用operator[]

8、vector迭代器失效的原因?

在C++中,迭代器是访问容器中元素的一种通用方式,它类似于指针。当容器的底层存储发生变化时,之前获取的迭代器可能会变得无效,这被称为迭代器失效。

std::vector迭代器失效的主要原因可以归结为以下几点:

8.1 内存重新分配

std::vector为了保持元素的连续存储,当其容量不足以容纳新元素时,会进行扩容操作。

扩容的步骤通常是:

  1. 分配一块更大的内存空间。
  2. 将旧内存空间中的所有元素复制或移动到新内存空间。
  3. 释放旧的内存空间。

在这个过程中,所有元素的内存地址都可能发生变化。因此,在扩容操作之后,所有指向旧内存空间的迭代器(包括指向vector开头、中间或末尾的迭代器)都将失效,因为它们指向的内存地址已经不再是vector的有效元素,甚至可能已经被释放。

常见触发场景:

  • push_back():当vectorsize()达到capacity()时,push_back()会导致扩容。
  • insert():在vector中间或开头插入元素时,如果需要扩容,也会导致所有迭代器失效。
  • emplace_back()emplace():与push_back()insert()类似。
  • resize():当resize()操作导致vector容量增加时。

8.2 插入操作(Insertion)

即使不发生内存重新分配(即vector的容量足够),在vector的头部或中间插入元素也可能导致迭代器失效。

  • 原因:vector中的元素必须保持连续存储。当在某个位置插入新元素时,插入点之后的所有元素都需要向后移动一个位置,以便为新元素腾出空间。这些被移动的元素的地址发生了变化。
  • 失效范围:
    • insert()操作点及之后的所有迭代器都会失效,因为它们指向的元素可能已经被移动。
    • 指向vector开头(begin())的迭代器通常不会失效,除非发生了扩容。
  • 常见触发场景:
    • insert():在begin()或中间位置插入元素。
    • emplace():在begin()或中间位置构造元素。

8.3 删除操作(Deletion)

vector中删除元素也可能导致迭代器失效。

  • 原因:当从vector中删除一个元素时,删除点之后的所有元素都需要向前移动一个位置,以填补被删除元素留下的空缺,从而保持内存的连续性。这些被移动的元素的地址也发生了变化。
  • 失效范围:
    • 被删除元素以及删除点之后的所有迭代器都会失效。
    • 指向vector开头(begin())的迭代器通常不会失效。
  • 常见触发场景:
    • erase():删除单个元素或一个范围的元素。
    • pop_back():删除最后一个元素,通常不会使任何迭代器失效(除了end()迭代器,它可能指向旧的末尾位置,但通常不会被直接使用)。

8.4 clear()操作

clear()成员函数会删除vector中的所有元素,使其变为空。这会导致所有迭代器失效。

为了避免迭代器失效带来的未定义行为,通常的策略是:

  • 在进行可能导致迭代器失效的操作(如insert, erase, push_back等)后,重新获取迭代器。
  • 如果需要在循环中删除元素,一种常见的安全做法是使用erase的返回值来更新迭代器,或者从vector的末尾向前遍历。
  • 对于不需要修改vector结构的操作,迭代器是安全的。

9、C++动态多态的实现原理?

C++中的动态多态是面向对象编程的一个核心特性,它允许程序在运行时根据对象的实际类型来调用相应的成员函数,即使这些对象是通过基类指针或引用来操作的。

动态多态主要通过虚函数来实现,其底层机制依赖于虚函数表(Virtual Table,简称vtable)和虚函数指针(Virtual Pointer,简称vptr)。

9.1 虚函数

当基类中的一个成员函数被声明为virtual时,它就成为了一个虚函数。这意味着派生类可以重写(override)这个虚函数,并且通过基类指针或引用调用这个函数时,会根据实际指向的对象类型来决定调用哪个版本的函数。

示例:

cpp
class Base {
public:
    virtual void greet() { // 虚函数
        std::cout << "Hello from Base!\n";
    }
    void sayHello() { // 非虚函数
        std::cout << "Base says hello!\n";
    }
};

class Derived : public Base {
public:
    void greet() override { // 重写虚函数
        std::cout << "Hello from Derived!\n";
    }
    void sayHello() { // 隐藏基类的非虚函数
        std::cout << "Derived says hello!\n";
    }
};

int main() {
    Base* b_ptr = new Derived();
    b_ptr->greet();    // 调用 Derived::greet() - 动态多态
    b_ptr->sayHello(); // 调用 Base::sayHello() - 非虚函数,静态绑定
    delete b_ptr;
    return 0;
}

9.2 虚函数表

当一个类中包含(或继承)至少一个虚函数时,编译器会为该类生成一个虚函数表(vtable)。vtable本质上是一个函数指针数组,其中存储了该类所有虚函数的地址。每个类只有一个vtable,它在编译时生成并存储在程序的只读数据段中。

vtable的内容:

  • 如果派生类没有重写基类的虚函数,那么vtable中对应的条目将指向基类的虚函数实现。
  • 如果派生类重写了基类的虚函数,那么vtable中对应的条目将指向派生类的虚函数实现。
  • 如果派生类定义了新的虚函数,vtable中会新增条目指向这些新虚函数的实现。

9.3 虚函数指针

当一个类中包含虚函数时,该类的每个对象都会在其实例的内存布局中包含一个虚函数指针(vptr)。vptr是一个隐藏的成员指针,它指向该对象所属类的vtable。vptr通常是对象内存布局中的第一个成员,以便于快速查找。

vptr的初始化:

  • 当对象被创建时(通过构造函数),其vptr会被自动初始化,指向该对象实际类型的vtable。
  • 在继承体系中,基类构造函数会将其vptr指向基类的vtable,然后派生类构造函数会将其vptr更新为指向派生类的vtable。

9.4 动态多态的实现流程

当通过基类指针或引用调用一个虚函数时,C++编译器会执行以下步骤来实现动态多态:

  1. 查找vptr:编译器会通过基类指针(或引用)找到对象实例中的vptr。
  2. 访问vtable:vptr指向该对象实际类型的vtable。
  3. 查找函数地址:在vtable中,根据虚函数在类定义中的声明顺序(或者说,虚函数在vtable中的偏移量),找到对应虚函数的地址。
  4. 调用函数:通过找到的函数地址,间接调用正确的虚函数实现。

9.5 总结

动态多态的实现机制,即vtable和vptr,使得C++能够在运行时根据对象的实际类型来决定调用哪个虚函数版本。这种机制虽然带来了一定的运行时开销(查找vptr和vtable),但它提供了极大的灵活性和可扩展性,是实现面向对象设计模式(如策略模式、模板方法模式)和构建复杂软件系统的基石。

需要注意的是,vtable和vptr是编译器实现细节,C++标准并没有强制规定具体的实现方式,但主流编译器(如GCC, Clang, MSVC)都采用了类似vtable的机制。

10、类的构造函数可以是虚函数吗?析构函数可以是虚函数嘛?

10.1 构造函数可以是虚函数吗?

答案:C++的构造函数不能是虚函数。

原因:

  1. 对象尚未完全构造:虚函数的调用依赖于对象的虚函数表(vtable)和虚函数指针(vptr)。vptr是在对象的构造过程中初始化的。

    当构造函数执行时,对象尚未完全构造完成,其vptr可能还未指向最终派生类的vtable,或者在基类构造阶段,vptr指向的是基类的vtable。

    如果在构造函数中调用虚函数,将只会调用当前正在构造的类(或其基类)的版本,而不是最终派生类的版本,这与虚函数的动态绑定机制相悖。

  2. 多态的先决条件:虚函数的目的是实现运行时多态,即通过基类指针或引用调用派生类的成员函数。

    而构造函数是在对象创建时被调用的,此时对象的类型是确定的,不需要多态性。

    在构造函数执行时,编译器已经知道要创建的是哪个具体类型的对象,因此不需要通过vtable来动态决定调用哪个构造函数。

  3. 无法创建对象:虚函数的机制是“通过一个基类指针或引用来调用派生类的成员函数”。

    但是,在调用构造函数之前,对象根本不存在,也就没有所谓的“基类指针或引用”来指向它,更谈不上通过它来调用虚构造函数了。

尽管C++没有虚构造函数,但可以通过虚工厂方法(Virtual Factory Method)或克隆(Clone)模式来模拟“虚构造”的行为,即在运行时根据对象类型创建不同类型的对象。

10.2 析构函数可以是虚函数嘛?

答案:C++的析构函数可以是虚函数,并且在多态场景下,基类的析构函数应该是虚函数。

原因:

当通过基类指针或引用删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中特有的资源(如动态分配的内存、文件句柄等)无法被正确释放,从而造成内存泄漏或其他资源泄漏。

示例:

cpp
#include <iostream>
#include <memory>

class Base {
public:
    Base() { std::cout << "Base Constructor\n"; }
    // 如果没有 virtual 关键字,当通过 Base* 删除 Derived 对象时,只会调用 Base 的析构函数
    // virtual ~Base() { std::cout << "Base Destructor\n"; }
    ~Base() { std::cout << "Base Destructor\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived Constructor\n"; }
    ~Derived() { std::cout << "Derived Destructor\n"; }
};

int main() {
    std::cout << "--- Non-virtual destructor example ---\n";
    Base* b_ptr = new Derived(); // 用基类指针指向派生类对象
    delete b_ptr; // 期望调用 Derived 和 Base 的析构函数
    // 实际输出:
    // Base Constructor
    // Derived Constructor
    // Base Destructor (只有基类析构函数被调用,Derived 的资源未释放)

    std::cout << "\n--- Virtual destructor example (假设 Base 的析构函数是虚函数) ---\n";
    // 为了演示,我们假设 Base 的析构函数是虚函数,实际代码需要修改 Base 类定义
    // class Base {
    // public:
    //     Base() { std::cout << "Base Constructor\n"; }
    //     virtual ~Base() { std::cout << "Base Destructor\n"; }
    // };
    Base* b_ptr_virtual = new Derived();
    delete b_ptr_virtual; // 实际输出:
    // Base Constructor
    // Derived Constructor
    // Derived Destructor
    // Base Destructor (派生类和基类析构函数都被正确调用)

    return 0;
}

总结:

  • 构造函数不能是虚函数:因为在对象构造期间,vptr尚未完全初始化,且对象类型已确定,无需多态。
  • 析构函数可以是虚函数,且在基类中通常应该声明为虚函数:当通过基类指针删除派生类对象时,虚析构函数能够确保正确调用派生类的析构函数,防止资源泄漏,是实现多态性销毁的关键。

11、IO多路复用?epoll和select区别?

11.1 什么是IO多路复用?

I/O多路复用是一种同步I/O模型,它允许单个进程(或线程)同时监听多个文件描述符(File Descriptor,FD),并在某个文件描述符就绪(可读、可写或出现异常)时通知应用程序。

它的核心思想是,当有大量I/O操作需要处理时,通过一个线程管理多个I/O,而不是为每个I/O操作创建一个独立的线程,从而提高系统的并发性和效率,尤其适用于高并发的网络服务。

尽管I/O多路复用允许同时监听多个I/O事件,但它本质上仍然是同步阻塞I/O。这意味着当某个文件描述符就绪后,应用程序仍然需要自己去执行实际的读写操作,并且这个读写操作本身仍然是阻塞的。

Linux系统提供了多种I/O多路复用机制,其中最常用的是selectpollepoll

11.2 select

select是Unix/Linux系统中最老、最广泛支持的I/O多路复用机制。

  • 工作原理:

    select通过维护三个fd_set(文件描述符集)来监听文件描述符:readfds(可读事件)、writefds(可写事件)和exceptfds(异常事件)。

    每次调用select时,都需要将这三个fd_set从用户空间拷贝到内核空间。内核会遍历所有被监听的文件描述符,检查它们是否就绪。

    当有文件描述符就绪时,select会修改对应的fd_set,并返回就绪的文件描述符数量。

    应用程序需要再次遍历fd_set来找出哪些文件描述符就绪。

  • 特点与限制:

    • 文件描述符数量限制:select使用fd_set来表示文件描述符集合,fd_set通常由一个位图实现,其大小是固定的。在Linux上,默认最大文件描述符数量通常是1024,这意味着select一次最多只能监听1024个文件描述符。这个限制可以通过修改内核参数或重新编译内核来改变,但通常不推荐。
    • 每次调用都需要拷贝fd_set:每次调用select都需要将整个fd_set从用户空间拷贝到内核空间,这在高并发场景下会产生较大的开销。
    • 每次调用都需要遍历fd_set:内核和用户空间都需要遍历整个fd_set来查找就绪的文件描述符,时间复杂度为O(N),N是文件描述符的总数。当N很大时,效率会很低。
    • 只支持水平触发(Level-Triggered)模式:当文件描述符就绪时,如果应用程序没有处理完所有数据,select会在下一次调用时继续通知该文件描述符就绪。

11.3 epoll

epoll是Linux特有的I/O多路复用机制,它旨在解决selectpoll在大规模并发连接下的性能瓶颈。

  • 工作原理: epoll通过在内核中维护一个事件表(epoll实例)来管理文件描述符。它提供了三个系统调用:

    1. epoll_create():创建一个epoll实例,返回一个epoll文件描述符。
    2. epoll_ctl():用于向epoll实例中添加、修改或删除要监听的文件描述符及其事件类型。这些文件描述符和事件信息只会在第一次添加时从用户空间拷贝到内核空间。
    3. epoll_wait():等待I/O事件的发生。当有文件描述符就绪时,内核会将就绪的事件列表直接拷贝到用户空间,而不是让用户空间遍历所有文件描述符。
  • 特点与优势:

    • 无文件描述符数量限制:epoll没有select那样的fd_set大小限制,它只受限于系统内存的大小,可以有效地监听数百万个文件描述符。
    • 事件通知机制(回调机制):epoll采用“事件驱动”的机制。当文件描述符就绪时,内核会通过回调机制将其添加到就绪列表中,而不是每次都遍历所有文件描述符。epoll_wait只需要返回已经就绪的文件描述符,避免了大量的无用遍历。
    • 内存拷贝效率高:epoll_ctl只在注册时拷贝一次文件描述符信息。epoll_wait只拷贝就绪的文件描述符列表,而不是整个文件描述符集,大大减少了用户空间和内核空间之间的数据拷贝。
    • 支持两种触发模式:水平触发(Level-Triggered, LT)和边缘触发(Edge-Triggered, ET)。epoll默认是LT模式,但ET模式效率更高,因为它只在状态发生变化时通知一次。
    • 时间复杂度:在理想情况下,epoll的查找、插入、删除操作都是O(1),epoll_wait返回就绪事件的时间复杂度是O(K),其中K是就绪事件的数量,而不是总的文件描述符数量。

11.4 epollselect的区别总结

特性selectepoll
底层实现遍历所有文件描述符内核事件表(红黑树+双向链表)
文件描述符限制默认1024,受FD_SETSIZE限制仅受系统内存限制,可达百万级别
效率O(N)(每次调用都需要遍历所有监听的FD)O(K)(K为就绪FD数量,只遍历就绪的FD)
内存拷贝每次调用select都需要从用户态拷贝整个fd_set到内核态注册FD时拷贝一次,epoll_wait只拷贝就绪列表
工作模式只支持水平触发(LT)支持水平触发(LT)和边缘触发(ET)
编程接口单个函数select()三个函数epoll_create(), epoll_ctl(), epoll_wait()
适用场景连接数较少,活跃度较低的场景高并发、连接数巨大的场景(如高性能网络服务器)

12、水平触发模式(LT)和边缘触发模式(ET)的区别?

水平触发(LT)和边缘触发(ET)是I/O多路复用机制(特别是epoll)中处理事件的两种不同模式。它们决定了内核如何通知应用程序文件描述符上的I/O事件。

12.1 水平触发模式

  • 定义:只要文件描述符上的I/O条件满足(例如,有数据可读,或者有空间可写),epoll_wait就会一直通知应用程序该事件已就绪。它关注的是状态。
  • 工作方式:
    • 可读事件:只要输入缓冲区中还有数据,epoll_wait就会持续报告文件描述符可读,直到所有数据都被读取完毕。
    • 可写事件:只要输出缓冲区中还有空间,epoll_wait就会持续报告文件描述符可写,直到输出缓冲区被填满。
  • 特点:
    • “懒惰”模式:应用程序不需要一次性处理完所有数据。即使只读取了一部分数据,下次调用epoll_wait时,只要缓冲区中仍有数据,该文件描述符依然会被报告为可读。
    • 编程简单:因为事件会持续通知,所以即使应用程序没有一次性处理完所有数据,也不会丢失事件。这使得LT模式的编程相对简单,不易出错。
    • 效率相对较低:如果应用程序没有及时处理完所有事件,可能会导致epoll_wait频繁返回相同的就绪事件,增加了系统调用的开销。
  • 适用场景:适合处理流式数据,或者对编程复杂度要求较低的场景。selectpoll默认且只支持LT模式,epoll的默认模式也是LT。

12.2 边缘触发模式

  • 定义:epoll_wait只在文件描述符上的I/O状态发生变化时(例如,从不可读变为可读,或从不可写变为可写)通知应用程序一次。它关注的是变化。
  • 工作方式:
    • 可读事件:只有当新的数据到达,导致文件描述符从“不可读”变为“可读”时,epoll_wait才会通知一次。应用程序必须一次性读取所有可读数据,直到read()返回EAGAINEWOULDBLOCK(表示缓冲区已空)。如果未读完,内核不会再次通知该文件描述符可读,直到有新数据再次到来。
    • 可写事件:只有当输出缓冲区从“满”变为“有空间”时,epoll_wait才会通知一次。应用程序必须一次性写入所有可以写入的数据,直到write()返回EAGAINEWOULDBLOCK(表示缓冲区已满)。
  • 特点:
    • “严格”模式:要求应用程序必须一次性处理完所有就绪事件,否则可能会丢失后续事件。
    • 编程复杂:需要更精细的I/O操作控制,通常需要配合非阻塞I/O来使用,以避免阻塞在单个文件描述符上,同时确保能一次性处理完所有数据。
    • 效率更高:由于只在状态变化时通知一次,减少了epoll_wait的调用次数,降低了系统开销,在高并发场景下能提供更高的吞吐量。
  • 适用场景:适合处理大量连接的高性能服务器,需要最大化I/O效率的场景。ET模式通常用于非阻塞socket。

12.3 总结对比

特性水平触发模式(LT)边缘触发模式(ET)
通知条件只要I/O条件满足就持续通知仅在I/O状态发生变化时通知一次
关注点状态(Level)变化(Edge)
数据处理可分批处理,不强制一次性读写必须一次性处理完所有数据(读到EAGAIN,写到EAGAIN
编程难度简单,不易丢失事件复杂,需配合非阻塞I/O,防止事件丢失
效率相对较低,可能重复通知相对较高,减少系统调用
默认模式select, poll 默认且只支持LT;epoll 默认LTepoll 可配置支持ET
适用场景普通I/O,对吞吐量要求不高的场景高性能网络服务器,高并发,追求极致吞吐量

选择建议:

  • 对于大多数应用,或者当你对事件处理逻辑没有十足把握时,水平触发(LT)模式通常是更安全的选择,因为它容错性更高。
  • 对于需要处理大量并发连接、追求极致性能的服务器端应用,并且你能够确保正确处理非阻塞I/O和一次性读写所有数据的逻辑时,边缘触发(ET)模式能提供更好的性能。

13、进程和线程的区别?

在现代操作系统中,进程(Process)和线程(Thread)是并发执行的两个基本单位。它们都是CPU时间片调度的实体,但代表着不同层级的抽象和资源管理粒度。理解它们的区别对于编写高效、并发的应用程序至关重要。

13.1 进程(Process)

进程是操作系统进行资源分配和调度的基本单位。 它是程序的一次执行过程,是一个独立的执行环境。每个进程都拥有自己独立的内存空间(地址空间),包括代码段、数据段、堆、栈等,以及独立的系统资源(如文件描述符、信号、I/O端口等)。

主要特点:

  • 独立性:每个进程都是独立的,拥有独立的地址空间。一个进程的崩溃通常不会影响其他进程。
  • 资源拥有者:进程是系统资源分配的最小单位。操作系统会为每个进程分配独立的内存、文件句柄等资源。
  • 开销大:创建、销毁进程以及进程间的切换(上下文切换)开销较大,因为需要保存和恢复大量的状态信息,包括内存映射、寄存器状态等。
  • 通信复杂:进程间通信(IPC)需要特殊的机制,如管道、消息队列、共享内存、信号量等。

13.2 线程(Thread)

线程是CPU调度和分派的基本单位,是进程内的一个执行流。 一个进程可以包含一个或多个线程。所有线程共享其所属进程的地址空间和大部分系统资源,但每个线程拥有独立的程序计数器、栈、寄存器集合以及线程本地存储(TLS)。

主要特点:

  • 共享性:同一进程内的所有线程共享进程的地址空间、文件描述符、全局变量等资源。这使得线程间通信相对容易,可以直接读写共享数据。
  • 轻量级:创建、销毁线程以及线程间的切换(上下文切换)开销较小,因为它们共享进程的大部分资源,只需保存和恢复少量线程特有的状态。
  • 并发性:线程是实现并发的有效手段。在一个进程中创建多个线程可以同时执行多个任务,提高程序的响应速度和吞吐量。
  • 安全性挑战:由于线程共享进程资源,需要同步机制(如互斥锁、信号量)来避免数据竞争和死锁等问题。

13.3 进程与线程的区别总结

特性进程(Process)线程(Thread)
资源分配操作系统资源分配的基本单位。拥有独立的地址空间和系统资源。CPU调度和分派的基本单位。共享进程的地址空间和大部分系统资源。
独立性独立运行,相互隔离。一个进程崩溃不影响其他进程。同一进程内线程共享资源,一个线程崩溃可能导致整个进程崩溃。
开销创建、销毁、切换开销大。创建、销毁、切换开销小。
通信进程间通信(IPC)复杂,需要特殊机制。线程间通信简单,直接读写共享数据,需同步机制。
并发性进程间并发。进程内并发。
拥有资源拥有独立的内存、文件句柄等。拥有独立的栈、寄存器、程序计数器、线程本地存储。
与程序关系程序的一次执行。进程内的一个执行流。

14、Linux当中怎么创建进程?

在Linux系统中,创建进程主要通过fork()系统调用,并通常结合exec系列函数来加载并执行新的程序。此外,还有vfork()clone()等更底层的系统调用,它们提供了更灵活的进程创建方式。

14.1 fork()系统调用

fork()是Unix/Linux系统中最基本的进程创建方式。当一个进程调用fork()时,操作系统会创建一个当前进程的精确副本,这个副本被称为子进程(Child Process),而原进程被称为父进程(Parent Process)。

工作原理:

  • fork()系统调用会复制父进程的地址空间、文件描述符、信号处理设置、寄存器上下文等几乎所有资源。子进程获得父进程数据空间、堆和栈的副本。父进程和子进程在fork()调用之后会继续执行,但它们拥有独立的内存空间,对各自内存的修改不会影响对方。
  • fork()调用成功后,会返回两次:
    • 在父进程中,fork()返回新创建的子进程的PID(Process ID)。
    • 在子进程中,fork()返回0。
    • 如果fork()失败,则返回-1。

特点:

  • 写时复制(Copy-on-Write, COW):为了提高效率,现代Unix/Linux系统通常采用写时复制技术。在fork()之后,父子进程共享相同的物理内存页。只有当任一进程尝试修改这些共享页面时,操作系统才会为该进程复制一份新的页面,从而实现逻辑上的独立地址空间。
  • 资源继承:子进程继承了父进程几乎所有的资源,包括打开的文件描述符。这意味着父进程打开的文件在子进程中也是打开的。

示例:

cpp
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid < 0) {
        // fork 失败
        std::cerr << "Fork failed!\n";
        return 1;
    } else if (pid == 0) {
        // 子进程
        std::cout << "Child process: PID = " << getpid() << ", Parent PID = " << getppid() << "\n";
        // 子进程可以执行自己的逻辑
        // 通常会调用 exec 系列函数加载新程序
        _exit(0); // 子进程退出
    } else {
        // 父进程
        std::cout << "Parent process: PID = " << getpid() << ", Child PID = " << pid << "\n";
        int status;
        waitpid(pid, &status, 0); // 等待子进程结束
        std::cout << "Child process finished.\n";
    }
    return 0;
}

14.2 exec系列函数

fork()创建了一个父进程的副本,但通常我们希望子进程执行一个不同的程序。exec系列函数(如execl, execv, execle, execve, execlp, execvp等)就是用来实现这一目的的。

  • 工作原理:

    • exec系列函数会加载一个新的程序到当前进程的地址空间,并从新程序的main函数开始执行。它不会创建新进程,而是替换当前进程的程序映像(包括代码段、数据段、堆、栈)。
    • 一旦exec函数调用成功,原程序的代码将不再执行,新程序开始运行。如果exec函数返回,则表示执行失败。
  • fork-exec模型:

    这是Linux/Unix下创建新进程并执行新程序的标准模式:

    1. fork():创建一个子进程,它是父进程的副本。
    2. 子进程中调用exec:子进程调用exec系列函数加载并执行新的程序。这样,子进程就变成了运行新程序的独立进程。
    3. 父进程中可选wait():父进程可以选择调用wait()waitpid()来等待子进程终止,或者继续执行自己的任务而不等待子进程。
  • 示例:

    cpp
    #include <iostream>
    #include <unistd.h>
    #include <sys/wait.h>
    
    int main() {
        pid_t pid = fork();
    
        if (pid < 0) {
            std::cerr << "Fork failed!\n";
            return 1;
        } else if (pid == 0) {
            // 子进程:执行 ls -l 命令
            std::cout << "Child process (PID: " << getpid() << ") executing ls -l...\n";
            execlp("ls", "ls", "-l", nullptr); // 加载并执行 ls -l
            // 如果 exec 失败,会执行到这里
            std::cerr << "Exec failed!\n";
            _exit(1); // 确保子进程退出
        } else {
            // 父进程:等待子进程结束
            std::cout << "Parent process (PID: " << getpid() << ") waiting for child...\n";
            int status;
            waitpid(pid, &status, 0);
            std::cout << "Child process finished with status " << status << ".\n";
        }
        return 0;
    }

14.3 vfork()系统调用

vfork()fork()的一个变体,主要用于优化fork-exec模式。它与fork()的主要区别在于:

  • 共享地址空间:vfork()创建的子进程与父进程共享地址空间,而不是复制。这意味着父子进程共享相同的代码、数据、堆和栈。只有当子进程调用exec系列函数加载新程序或退出时,父子进程的地址空间才会分离。

  • 父进程阻塞:vfork()会阻塞父进程,直到子进程调用exec_exit()

  • 特点:

    • 效率高:由于不进行地址空间的复制,vfork()fork()更快,内存开销更小。
    • 使用限制:由于共享地址空间,子进程在调用exec_exit()之前,不能修改父进程的内存(除了返回vfork()的变量),否则可能导致父进程的数据损坏。因此,vfork()几乎总是紧跟着exec使用。

14.4 clone()系统调用

clone()是Linux提供的一个更底层、更灵活的系统调用,fork()pthread_create()(创建线程)都是基于clone()实现的。它允许调用者精确控制子进程(或线程)与父进程共享哪些资源。

  • 工作原理:clone()通过一系列标志位(CLONE_VM, CLONE_FS, CLONE_FILES, CLONE_SIGHAND等)来指定子进程与父进程共享哪些上下文。例如:

    • 如果指定CLONE_VM,则父子进程共享相同的内存空间(类似于线程)。
    • 如果不指定CLONE_VM,则父子进程拥有独立的内存空间(类似于进程)。
  • 特点:

    • 高度可定制:可以创建出介于传统进程和线程之间的“轻量级进程”。
    • 复杂性高:由于其高度的灵活性,clone()的直接使用比fork()exec复杂得多,通常只在需要精细控制进程或线程创建行为的底层库中使用。

15、进程间通信(IPC)的方法?

进程间通信(Inter-Process Communication, IPC)是指不同进程之间进行数据交换和信息传递的方式。由于每个进程都拥有独立的地址空间,它们不能直接访问彼此的内存,因此操作系统提供了多种机制来实现进程间的协作和数据共享。

Linux系统提供了丰富的IPC机制,可以根据不同的需求(如数据量、通信方式、同步要求等)选择合适的方法。

主要包括以下几种:

15.1 管道

管道是最早也是最简单的IPC形式之一,分为匿名管道和命名管道。

  1. 匿名管道:

    特点:

    • 半双工:数据只能单向流动,如果需要双向通信,需要创建两个管道。
    • 父子进程关系:通常用于具有亲缘关系的进程之间(如父子进程)。管道在fork()之前创建,子进程继承父进程打开的管道文件描述符。
    • 基于文件系统:管道在内核中维护一个缓冲区,读写操作通过文件描述符进行。

    优点:简单易用。

    缺点:只能用于亲缘进程,单向通信,容量有限。

    函数:pipe()

  2. 命名管道:

    特点:

    • 半双工:与匿名管道类似,数据单向流动。
    • 任意进程:可以在不相关的进程之间进行通信,只要它们知道命名管道的路径。
    • 文件系统路径:命名管道在文件系统中有一个对应的路径名,可以像普通文件一样打开和关闭。

    优点:可用于任意进程,比匿名管道更灵活。

    缺点:半双工,容量有限。

    函数:mkfifo()创建,open(), read(), write()操作。

15.2 消息队列

消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符(ID)唯一标识。进程可以向消息队列中添加消息,也可以从消息队列中读取消息。

  • 特点:
    • 消息的独立性:消息队列中的消息是独立的,可以指定消息类型,接收方可以根据类型选择性接收消息,而不是像管道那样只能顺序读取。
    • 存储在内核:消息在内核中存储,直到被接收,解决了管道的容量限制。
    • 同步与异步:可以实现同步或异步通信。
  • 优点:消息具有格式,可以实现消息的优先级和选择性接收,克服了管道的缺点。
  • 缺点:消息拷贝开销大(从用户空间到内核空间,再从内核空间到用户空间),有最大消息数和最大消息字节数的限制。
  • 函数:msgget(), msgsnd(), msgrcv()

15.3 共享内存

共享内存允许两个或多个进程共享同一块物理内存区域。这是最快的IPC方式,因为数据不需要在进程间进行拷贝。

  • 特点:
    • 速度快:一旦内存映射建立,进程可以直接读写共享内存,无需系统调用,避免了数据拷贝。
    • 需要同步:由于多个进程可以同时访问共享内存,必须使用信号量、互斥锁等同步机制来防止数据竞争和保证数据一致性。
  • 优点:传输效率最高。
  • 缺点:需要额外的同步机制来保证数据安全。
  • 函数:shmget(), shmat(), shmdt(), shmctl()

15.4 信号量

信号量主要用于进程间的同步,而不是数据传输。它是一个计数器,用于控制对共享资源的访问。

  • 特点:
    • 同步机制:用于控制多个进程对共享资源的访问,防止竞争条件。
    • 计数器:信号量的值表示可用资源的数量。P操作(sem_wait)会减少计数,V操作(sem_post)会增加计数。
  • 优点:有效解决进程间的同步问题。
  • 缺点:不能用于数据传输,只能用于同步。
  • 函数:semget(), semop(), semctl()

15.5 信号

信号是一种软件中断,用于通知进程发生了某个事件。它是异步的,通常用于处理异常情况或通知进程执行特定操作。

  • 特点:
    • 异步通知:进程收到信号后会中断当前执行,转而处理信号。
    • 信息量小:信号本身不携带大量数据,只传递事件类型。
  • 优点:简单,适用于通知事件。
  • 缺点:只能传递少量信息,不可靠(信号可能丢失)。
  • 函数:kill(), raise(), signal(), sigaction()

15.6 套接字

套接字是一种更通用的IPC机制,不仅可以用于同一台机器上的进程间通信,也可以用于网络上不同机器间的进程通信。它提供了全双工通信能力。

  • 特点:
    • 全双工:可以同时进行双向数据传输。
    • 网络通信:支持本地和远程通信。
    • 多种协议:支持TCP、UDP等多种网络协议。
  • 优点:功能强大,通用性强,支持网络通信。
  • 缺点:相对复杂,需要更多的编程工作。
  • 函数:socket(), bind(), listen(), accept(), connect(), send(), recv()等。

15.7 总结对比

IPC机制通信方式适用场景优点缺点
匿名管道半双工亲缘进程间简单数据流简单易用只能亲缘进程,单向,容量有限
命名管道半双工任意进程间简单数据流可用于任意进程单向,容量有限
消息队列消息传递任意进程间结构化消息传递,带优先级消息独立,可选择接收消息拷贝开销大,有大小限制
共享内存共享存储任意进程间高速数据交换速度最快,无数据拷贝需要额外同步机制,编程复杂
信号量同步进程间同步,控制资源访问解决同步问题不能传输数据
信号异步通知进程间异步事件通知简单,通知事件信息量小,可能丢失
套接字全双工本地或网络任意进程间通信功能强大,通用性强,支持网络相对复杂

选择哪种IPC机制取决于具体的应用需求:如果追求极致性能,共享内存是首选;如果需要简单的单向通信且是亲缘进程,管道足够;如果需要结构化消息且有优先级,消息队列更合适;如果需要网络通信,套接字是唯一的选择。

16、匿名管道和命名管道之间的区别?

匿名管道和命名管道都是Linux/Unix系统中用于进程间通信(IPC)的机制,它们都属于管道通信范畴。虽然都提供字节流的半双工通信,但它们在创建方式、使用范围和生命周期上存在显著差异。

16.1 匿名管道

  • 创建方式:通过pipe()系统调用创建。pipe()函数会返回两个文件描述符,一个用于读取,一个用于写入。
  • 通信范围:主要用于具有亲缘关系的进程之间,最典型的是父子进程。通常,父进程在fork()之前创建管道,然后子进程继承父进程打开的管道文件描述符。这样,父子进程就可以通过这两个文件描述符进行通信。
  • 文件系统可见性:匿名管道没有对应的文件系统路径,因此无法通过文件名来访问。它们存在于内核中,并通过文件描述符进行引用。
  • 生命周期:随着创建它们的进程的结束而消失。当所有引用管道的文件描述符都被关闭时,管道会被销毁。
  • 通信模式:半双工,数据只能单向流动。如果需要双向通信,通常需要创建两个管道。
  • 示例:
    cpp
    #include <unistd.h>
    #include <iostream>
    #include <string>
    #include <sys/wait.h>
    
    int main() {
        int pipefd[2]; // pipefd[0] for read, pipefd[1] for write
        if (pipe(pipefd) == -1) {
            std::cerr << "Pipe creation failed\n";
            return 1;
        }
    
        pid_t pid = fork();
    
        if (pid < 0) {
            std::cerr << "Fork failed\n";
            return 1;
        } else if (pid == 0) { // Child process
            close(pipefd[1]); // Close write end of pipe in child
            char buf[32];
            read(pipefd[0], buf, sizeof(buf));
            std::cout << "Child received: " << buf << "\n";
            close(pipefd[0]);
            _exit(0);
        } else { // Parent process
            close(pipefd[0]); // Close read end of pipe in parent
            std::string msg = "Hello from parent!";
            write(pipefd[1], msg.c_str(), msg.length() + 1);
            std::cout << "Parent sent: " << msg << "\n";
            close(pipefd[1]);
            wait(nullptr);
        }
        return 0;
    }

16.2 命名管道

  • 创建方式:通过mkfifo()系统调用或mknod命令创建。它会在文件系统中创建一个特殊的文件。
  • 通信范围:可以用于任意不相关的进程之间进行通信。只要进程知道命名管道的路径,并且有相应的权限,就可以像打开普通文件一样打开它进行读写。
  • 文件系统可见性:命名管道在文件系统中有一个对应的文件路径。尽管它表现得像一个文件,但它不存储任何数据,只是一个用于进程间通信的入口点。
  • 生命周期:命名管道的文件系统入口会一直存在,直到被显式删除(例如使用rm命令)。但是,管道本身(即内核中的缓冲区)的生命周期与打开它的进程有关,当所有读写进程都关闭管道时,管道缓冲区的数据会被清空,但其文件系统入口仍然存在。
  • 通信模式:半双工,数据只能单向流动。同样,如果需要双向通信,通常需要创建两个命名管道。
  • 示例:
    cpp
    // Writer process (fifo_writer.cpp)
    #include <iostream>
    #include <fstream>
    #include <string>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    int main() {
        const char* fifo_path = "/tmp/myfifo";
        mkfifo(fifo_path, 0666); // Create FIFO if it doesn't exist
    
        std::cout << "Writer: Opening FIFO...\n";
        int fd = open(fifo_path, O_WRONLY);
        if (fd == -1) {
            std::cerr << "Writer: Failed to open FIFO\n";
            return 1;
        }
    
        std::string msg = "Hello from named pipe!";
        write(fd, msg.c_str(), msg.length() + 1);
        std::cout << "Writer: Sent: " << msg << "\n";
    
        close(fd);
        std::cout << "Writer: Closed FIFO\n";
        // unlink(fifo_path); // Optional: remove FIFO file after use
        return 0;
    }
    
    // Reader process (fifo_reader.cpp)
    #include <iostream>
    #include <fstream>
    #include <string>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    int main() {
        const char* fifo_path = "/tmp/myfifo";
        // mkfifo(fifo_path, 0666); // Reader usually assumes FIFO is already created by writer
    
        std::cout << "Reader: Opening FIFO...\n";
        int fd = open(fifo_path, O_RDONLY);
        if (fd == -1) {
            std::cerr << "Reader: Failed to open FIFO\n";
            return 1;
        }
    
        char buf[32];
        read(fd, buf, sizeof(buf));
        std::cout << "Reader: Received: " << buf << "\n";
    
        close(fd);
        std::cout << "Reader: Closed FIFO\n";
        return 0;
    }

16.3 区别总结

特性匿名管道(Anonymous Pipes)命名管道(Named Pipes / FIFO)
创建方式pipe()系统调用mkfifo()系统调用或mknod命令
通信范围仅限于亲缘关系的进程(如父子进程)可用于任意不相关的进程
文件系统无文件系统路径,存在于内核有文件系统路径,表现为特殊文件
生命周期随进程结束而消失文件系统入口持续存在,需手动删除(rm
通信模式半双工半双工
使用场景简单、临时的父子进程间数据传输任意进程间的数据传输,如shell命令管道

选择使用匿名管道还是命名管道,主要取决于通信的进程之间是否存在亲缘关系,以及是否需要长期存在或被不相关进程访问。

17、TCP和UDP的区别?

TCP(Transmission Control Protocol,传输控制协议)和UDP(User Datagram Protocol,用户数据报协议)是互联网协议栈中传输层的两个核心协议。它们各自提供了不同的服务特性,适用于不同的应用场景。

17.1 TCP(传输控制协议)

TCP是一种面向连接的、可靠的、基于字节流的传输层协议。

  • 面向连接:

    • 在数据传输之前,通信双方必须通过三次握手建立连接。连接建立后,会维护一个状态信息。
    • 数据传输结束后,通过四次挥手断开连接。
  • 可靠性:

    • 确认应答:发送方发送数据后,接收方会发送确认应答(ACK)。如果发送方在规定时间内没有收到ACK,会认为数据丢失并进行重传。
    • 序列号:对发送的每个字节数据进行编号,接收方可以根据序列号对乱序的数据进行重新排序,并检测重复数据。
    • 校验和:对数据进行校验,确保数据在传输过程中没有损坏。
    • 流量控制:通过滑动窗口机制,协调发送方和接收方的数据传输速率,防止发送方发送过快导致接收方缓冲区溢出。
    • 拥塞控制:通过慢启动、拥塞避免、快速重传、快速恢复等算法,防止网络过载,避免网络拥塞。
  • 基于字节流:TCP不保留消息边界,应用程序发送的数据会被视为一串无结构的字节流。接收方接收到的数据可能与发送方发送的数据块大小不一致。

  • 全双工通信:连接建立后,数据可以在两个方向上同时传输。

  • 头部开销:TCP头部通常为20字节(不含选项),比UDP头部大。

  • 传输速度:由于需要进行连接管理、确认、重传、流量控制和拥塞控制等一系列操作,TCP的传输速度相对较慢。

  • 适用场景:

    对数据完整性和可靠性要求高的应用,如HTTP/HTTPS(网页浏览)、FTP(文件传输)、SMTP(电子邮件)、SSH(远程登录)等。

17.2 UDP(用户数据报协议)

UDP是一种无连接的、不可靠的、基于数据报的传输层协议。

  • 无连接:

    • 在数据传输之前,无需建立连接。发送方直接将数据报发送给接收方,不维护任何连接状态。
  • 不可靠性:

    • 不保证数据到达:不提供确认机制,发送方发送数据后不会等待接收方的确认。
    • 不保证数据顺序:数据报在网络中可能乱序到达,UDP不提供排序功能。
    • 不保证数据完整性:只提供简单的校验和,不保证数据无损。
    • 无流量控制和拥塞控制:发送方可以以任意速率发送数据,不会受到接收方或网络拥塞的影响。
  • 基于数据报:UDP保留消息边界,发送方发送一个数据报,接收方就接收一个数据报。每个数据报都是一个独立的单元。

  • 半双工或全双工:取决于上层应用实现,但UDP本身不提供连接管理。

  • 头部开销:UDP头部固定为8字节,非常小。

  • 传输速度:由于没有连接建立、确认、重传、流量控制和拥塞控制等机制,UDP的传输速度非常快,开销小,实时性高。

  • 适用场景:

    对实时性要求高、允许少量数据丢失的应用,如DNS(域名解析)、SNMP(网络管理)、VoIP(语音通话)、在线视频、网络游戏等。

17.3 TCP与UDP的区别总结

特性TCP(传输控制协议)UDP(用户数据报协议)
连接性面向连接(三次握手,四次挥手)无连接
可靠性可靠传输(确认、重传、序列号、校验和)不可靠传输(不保证数据到达、顺序、完整性)
传输方式字节流数据报
效率/速度相对较慢(开销大)相对较快(开销小)
流量控制有(滑动窗口)
拥塞控制有(慢启动、拥塞避免、快速重传、快速恢复)
头部大小20字节(不含选项)8字节
适用场景文件传输、网页浏览、邮件等(可靠性优先)实时通信、音视频、DNS查询等(实时性优先)

18、TCP怎么保证可靠性?拥塞控制的流程?怎么让这个控制降下来变为1?

18.1 TCP如何保证可靠性?

TCP(传输控制协议)通过一系列复杂的机制来确保数据传输的可靠性,即使在不可靠的网络环境下也能实现数据的准确、有序和完整传输。这些机制包括:

  1. 序列号(Sequence Numbers)和确认应答(Acknowledgements, ACK):

    • 序列号:TCP将发送的每一个字节数据都进行编号。发送方在发送数据时,会为每个报文段(Segment)分配一个序列号,表示该报文段中第一个字节的序号。
    • 确认应答:接收方收到数据后,会发送一个确认报文(ACK),其中包含期望接收的下一个字节的序列号。这表示接收方已经成功收到了该序列号之前的所有数据。
    • 超时重传:发送方会为每个发送的报文段设置一个定时器。如果在定时器到期之前没有收到对应的ACK,发送方就会认为该报文段丢失,并重新发送。通过动态调整超时时间(RTT, Round Trip Time),TCP能适应网络延迟的变化。
  2. 校验和(Checksum):

    TCP在发送数据时,会对报文段(包括头部和数据)计算一个校验和,并将其放入TCP头部。接收方收到报文段后,会重新计算校验和并与头部中的校验和进行比较。如果两者不匹配,说明数据在传输过程中发生了错误,该报文段会被丢弃,并等待发送方重传。

  3. 流量控制(Flow Control):

    TCP使用滑动窗口(Sliding Window)机制来实现流量控制。接收方会在ACK报文中告知发送方自己当前的接收窗口大小(Advertised Window),即还能接收多少字节的数据。发送方会根据接收方的窗口大小来限制发送的数据量,防止发送速度过快导致接收方缓冲区溢出,从而避免数据丢失。

  4. 拥塞控制(Congestion Control):

    拥塞控制是为了防止过多的数据注入到网络中,导致网络过载。它通过调整发送窗口的大小来控制发送速率,以适应网络的承载能力。这将在下一节详细说明。

  5. 乱序处理和重复数据丢弃:

    由于网络传输可能导致报文段乱序到达或重复到达,TCP接收方会根据序列号对收到的数据进行排序,并丢弃重复的报文段,确保上层应用接收到的数据是按序且不重复的。

18.2 拥塞控制的流程?

TCP的拥塞控制主要通过四个核心算法实现:慢启动(Slow Start)、拥塞避免(Congestion Avoidance)、快速重传(Fast Retransmit)和快速恢复(Fast Recovery)。这些算法协同工作,动态调整发送方的拥塞窗口(Congestion Window, cwnd)大小,以适应网络的拥塞状况。

核心思想:

  • 探测网络容量:在网络状况良好时,逐渐增加发送速率。
  • 避免拥塞:在网络出现拥塞迹象时,降低发送速率。

拥塞控制变量:

  • 拥塞窗口(cwnd):发送方能够发送但尚未收到确认的数据量。cwnd是拥塞控制的核心,它与接收方的接收窗口(rwnd)共同决定了发送窗口的实际大小,即 min(cwnd, rwnd)
  • 慢启动阈值(ssthresh, Slow Start Threshold):一个阈值,用于区分慢启动和拥塞避免阶段。

拥塞控制的四个阶段:

  1. 慢启动(Slow Start):

    目的:在连接刚建立或发生严重拥塞后,逐步探测网络的可用带宽。

    过程:cwnd初始化为1个MSS(Maximum Segment Size)。每收到一个ACK,cwnd就增加1个MSS。这意味着cwnd呈指数级增长(每经过一个RTT,cwnd翻倍)。

    退出条件:当cwnd达到ssthresh时,进入拥塞避免阶段;或者发生超时(RTO),此时ssthresh更新为cwnd的一半,cwnd重置为1,再次进入慢启动。

  2. 拥塞避免(Congestion Avoidance):

    目的:在cwnd达到ssthresh后,以更谨慎的方式探测网络容量,避免拥塞。

    过程:每收到一个ACK,cwnd只增加1/cwnd个MSS。这意味着cwnd呈线性增长(每经过一个RTT,cwnd增加1个MSS)。

    退出条件:

    • 发生超时(RTO):认为网络发生严重拥塞。ssthresh更新为cwnd的一半,cwnd重置为1,进入慢启动阶段。
    • 收到3个重复ACK:认为网络发生轻微拥塞(可能只是丢包,而不是网络整体拥塞)。ssthresh更新为cwnd的一半,cwnd更新为ssthresh的值,进入快速恢复阶段。
  3. 快速重传(Fast Retransmit):

    目的:在没有等到RTO的情况下,尽快重传丢失的报文段。

    过程:当发送方收到3个重复的ACK时,立即重传丢失的报文段,而不需要等待定时器超时。

    触发:收到3个重复ACK。

  4. 快速恢复(Fast Recovery):

    目的:在快速重传之后,避免cwnd直接降到1,而是以更温和的方式恢复发送速率。

    过程:

    • 当收到3个重复ACK时,ssthresh设置为当前cwnd的一半,cwnd设置为ssthresh的值。
    • cwnd再增加3个MSS(因为收到了3个重复ACK,表明有3个报文段离开了网络)。
    • 之后,每收到一个重复ACK,cwnd增加1个MSS。
    • 当收到新的ACK(确认了重传的报文段)时,cwnd设置为ssthresh,进入拥塞避免阶段。

18.3 怎么让这个控制降下来变为1?

要让TCP的拥塞窗口(cwnd)降到1个MSS,主要有两种情况会触发:

  1. 发生RTO(Retransmission Timeout)超时:

    这是最严重的拥塞信号。当发送方发送一个报文段后,在设定的重传定时器(RTO)到期之前,没有收到该报文段的确认(ACK),TCP协议会认为网络发生了严重的拥塞,导致报文段丢失。在这种情况下,TCP会执行以下操作:

    • 将慢启动阈值(ssthresh)更新为当前cwnd的一半(至少为2个MSS)。
    • 将拥塞窗口(cwnd)重置为1个MSS。
    • 重新进入慢启动阶段,从头开始探测网络容量。

    这是最直接也最常见的导致cwnd降为1的原因。

  2. 连接刚建立时:

    在TCP连接刚建立时,为了探测网络的初始容量,cwnd会被初始化为较小的值,通常是1个MSS、2个MSS或10个MSS(取决于TCP实现和RFC标准)。如果按照最严格的实现,cwnd会从1个MSS开始慢启动。

19、TCP粘包如何解决?

19.1 什么是TCP粘包?

TCP粘包(TCP Sticky Packets)是指在TCP协议传输数据时,发送方发送的多个数据包在接收方可能被合并成一个更大的数据块进行接收,或者一个数据包被拆分成多个小的数据块进行接收。这使得接收方无法准确地判断每个数据包的起始和结束边界,从而导致应用层数据解析错误。

需要注意的是,TCP协议本身没有“包”的概念,它是一个面向字节流的协议。TCP只负责将发送方的数据按顺序、可靠地传输到接收方的缓冲区,而不会关心这些数据在应用层是如何划分的。粘包问题是应用层协议设计不当导致的,而不是TCP协议本身的缺陷。

19.2 TCP粘包产生的原因

TCP粘包的产生主要有以下几个原因:

  1. 发送方原因:

    • Nagle算法:TCP协议为了提高网络利用率,减少网络中传输的小数据包数量,默认会启用Nagle算法。该算法会将小的、分散的发送数据合并成一个较大的数据包再发送。

      如果发送方应用程序连续发送多个小数据包,并且第一个数据包的ACK还没有收到,Nagle算法可能会将后续的小数据包缓存起来,然后一次性发送,这就会导致粘包。

    • 发送缓冲区:发送方TCP协议栈的缓冲区机制。当应用程序多次调用send()发送数据时,如果发送缓冲区还有空间,这些数据可能会被TCP协议栈缓存起来,然后一次性发送出去。

  2. 接收方原因:

    • 接收缓冲区:接收方TCP协议栈的缓冲区机制。当接收方应用程序调用recv()read()从TCP缓冲区读取数据时,可能一次性读取了多个应用层数据包的数据,或者只读取了一个应用层数据包的一部分数据。这取决于接收缓冲区中当前有多少数据以及应用程序一次请求读取多少数据。
    • read()/recv()不保证读取完整消息:read()recv()系统调用并不能保证一次读取到的数据就是一个完整的应用层消息。它只是从内核缓冲区中读取当前可用的字节数。

19.3 解决TCP粘包的方案

解决TCP粘包问题的核心思想是在应用层定义一套清晰的消息边界协议,让接收方能够准确地识别每个消息的开始和结束。

常见的解决方案有以下几种:

  1. 定长消息:

    • 原理:发送方和接收方约定好每个消息的长度是固定的。发送方不足长度时用特定字符(如空格、0x00)填充,超出长度时进行截断或分包。
    • 优点:实现简单,接收方每次读取固定长度的数据即可。
    • 缺点:消息长度不灵活,如果实际消息长度变化大,会造成带宽浪费(填充)或数据丢失(截断)。
    • 适用场景:消息内容长度变化不大的场景。
  2. 消息边界符:

    • 原理:在每个消息的末尾添加一个特殊的、不会出现在消息内容中的分隔符(如\n\r\n0x00等)。接收方在读取数据时,根据分隔符来判断消息的结束。
    • 优点:实现相对简单,消息长度灵活,节省带宽。
    • 缺点:如果消息内容本身可能包含分隔符,需要进行转义处理,增加了复杂性。如果分隔符丢失,可能导致消息解析错误。
    • 适用场景:文本协议,如HTTP头部、FTP命令等。
  3. 消息头+消息体:

    • 原理:这是最常用和最健壮的解决方案。发送方在每个消息的实际内容(消息体)之前,添加一个固定长度的消息头。消息头中包含消息体的长度信息。接收方首先读取固定长度的消息头,解析出消息体的长度,然后再根据这个长度去读取完整的消息体。
    • 优点:
      • 健壮性高:能够准确地识别消息边界,不受消息内容限制。
      • 灵活性好:消息体长度可变。
      • 效率高:避免了额外的扫描操作。
    • 缺点:实现相对复杂,需要处理大小端问题,以及消息头长度本身的定义。
    • 适用场景:绝大多数自定义二进制协议,如RPC框架、游戏协议等。

    示例流程:

    1. 发送方:将消息体编码,计算消息体长度L
    2. 发送方:将L(通常是4字节或2字节整数)作为消息头,拼接在消息体前面,然后发送。
    3. 接收方:循环读取数据。
    4. 接收方:首先尝试读取消息头(例如4字节)。
    5. 接收方:如果读取到完整的消息头,解析出消息体长度L
    6. 接收方:根据L,继续读取L字节的数据作为消息体。
    7. 接收方:如果读取到完整的消息体,则一个完整的应用层消息被成功接收和解析。
    8. 重复步骤4-7,直到所有数据处理完毕。
  4. 关闭Nagle算法:

    • 原理:在某些对实时性要求较高,且消息本身很小的场景,可以考虑关闭Nagle算法(设置TCP_NODELAY选项)。这样,即使没有收到ACK,小数据包也会立即发送,减少粘包的可能性。但这会增加网络中小数据包的数量,可能降低网络效率。
    • 注意事项:通常不建议作为解决粘包的主要方案,因为它治标不治本,且可能引入其他性能问题。

20、TCP——socket编程的流程

TCP Socket编程是网络编程的基础,它允许应用程序通过网络进行可靠的数据通信。一个典型的TCP通信涉及一个服务器(Server)和一个或多个客户端(Client)。

20.1 服务器端编程流程

服务器端通常需要在一个特定的端口上监听客户端的连接请求,并为每个连接的客户端提供服务。

  1. 创建套接字(socket()):

    使用socket()系统调用创建一个套接字文件描述符。指定协议族(如AF_INET表示IPv4)、套接字类型(SOCK_STREAM表示TCP)和协议(通常为0,表示使用默认协议)。

    cpp
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) { /* error handling */ }
  2. 绑定地址和端口(bind()):

    • 将创建的套接字与一个本地IP地址和端口号绑定。这使得客户端可以通过这个地址和端口找到服务器。
    • 通常,IP地址会绑定到INADDR_ANY(或0.0.0.0),表示监听所有可用的网络接口。
    cpp
    struct sockaddr_in address;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有IP
    address.sin_port = htons(PORT); // 端口号,htons用于将主机字节序转换为网络字节序
    
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) { /* error handling */ }
  3. 监听连接(listen()):

    使套接字进入监听状态,准备接受客户端的连接请求。listen()的第二个参数是待处理连接队列的最大长度(backlog)。

    cpp
    if (listen(server_fd, 10) < 0) { /* error handling */ }
  4. 接受连接(accept()):

    • accept()系统调用会阻塞,直到有客户端连接请求到达。一旦有客户端连接,它会从待处理连接队列中取出一个连接,创建一个新的套接字文件描述符用于与该客户端通信,并返回这个新的文件描述符。原始的server_fd仍然用于监听新的连接。
    • accept()还会填充一个sockaddr_in结构体,包含连接客户端的地址信息。
    cpp
    int new_socket;
    struct sockaddr_in client_address;
    socklen_t addrlen = sizeof(client_address);
    
    new_socket = accept(server_fd, (struct sockaddr *)&client_address, &addrlen);
    if (new_socket < 0) { /* error handling */ }
    // 此时,new_socket 用于与特定客户端通信
  5. 数据传输(send() / recv()):

    • 使用new_socket文件描述符与客户端进行数据的发送和接收。send()用于发送数据,recv()用于接收数据。
    • 这些函数可能会阻塞,直到数据发送或接收完成。
    cpp
    char buffer[1024] = {0};
    recv(new_socket, buffer, 1024, 0); // 接收数据
    std::string hello = "Hello from server";
    send(new_socket, hello.c_str(), hello.length(), 0); // 发送数据
  6. 关闭套接字(close()):

    • 数据传输完成后,关闭用于与客户端通信的new_socket
    • 当服务器不再需要监听时,关闭server_fd
    cpp
    close(new_socket);
    // 当不再需要服务时
    close(server_fd);

20.2 客户端编程流程

客户端通常需要知道服务器的IP地址和端口号,然后主动发起连接请求。

  1. 创建套接字(socket()):

    与服务器端类似,创建一个套接字文件描述符。

    cpp
    int client_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (client_fd == -1) { /* error handling */ }
  2. 设置服务器地址(sockaddr_in):

    设置服务器的IP地址和端口号,用于connect()

    cpp
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT); // 服务器端口
    
    // 将IP地址从字符串形式转换为网络字节序
    if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) { /* error handling */ }
  3. 连接服务器(connect()):

    • 向指定的服务器IP地址和端口发起连接请求。connect()会阻塞直到连接建立成功或失败。
    • 一旦连接成功,客户端就可以使用client_fd与服务器进行通信。
    cpp
    if (connect(client_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) { /* error handling */ }
  4. 数据传输(send() / recv()):

    连接建立后,使用client_fd与服务器进行数据的发送和接收。

    cpp
    std::string message = "Hello from client";
    send(client_fd, message.c_str(), message.length(), 0); // 发送数据
    char buffer[1024] = {0};
    recv(client_fd, buffer, 1024, 0); // 接收数据
  5. 关闭套接字(close()):

    数据传输完成后,关闭client_fd

    cpp
    close(client_fd);

20.3 错误处理和注意事项

  • 错误检查:每个系统调用都应该检查返回值以处理可能的错误(通常返回-1并设置errno)。
  • 字节序转换:网络通信中,IP地址和端口号需要进行主机字节序(Host Byte Order)和网络字节序(Network Byte Order)之间的转换,使用htons()ntohs()htonl()ntohl()等函数。
  • 地址结构体:sockaddr_in用于IPv4地址,sockaddr_in6用于IPv6地址,sockaddr是通用的地址结构体。
  • 非阻塞模式:对于高性能服务器,通常会将套接字设置为非阻塞模式,并结合I/O多路复用(如epoll)来处理多个并发连接,避免accept()send()recv()的阻塞。
  • setsockopt():可以使用setsockopt()设置套接字选项,例如SO_REUSEADDR允许地址重用,避免bind()时出现Address already in use错误。
  • 信号处理:在多进程服务器中,子进程结束后会变成僵尸进程,需要父进程通过wait()waitpid()回收,或者设置SIGCHLD信号处理函数。

掌握这些基本流程和注意事项,是进行TCP网络编程的关键。

21、gcc编译的时候,可执行程序崩溃了,应该怎么查找崩溃的原因?

当使用gcc编译的C++程序崩溃时,通常意味着程序遇到了致命错误,例如访问了非法内存(段错误 Segmentation Fault)、除以零、栈溢出等。查找崩溃原因是一个调试过程,需要系统性的方法和工具。以下是详细的步骤和常用工具:

21.1 编译时添加调试信息

首先,确保你的程序在编译时包含了调试信息。这是使用调试工具(如GDB)进行有效调试的前提。

  • g++ -g -O0 your_program.cpp -o your_program
    • -g:告诉编译器生成调试信息(符号表、行号等),这些信息会被嵌入到可执行文件中,供调试器使用。
    • -O0:禁用优化。编译器优化可能会重新排列代码,使得调试器的执行路径与源代码不完全匹配,从而增加调试难度。在调试阶段,通常建议关闭优化。

21.2 理解崩溃类型

程序崩溃时,操作系统通常会报告一个信号(Signal),例如:

  • Segmentation fault (core dumped):最常见,表示程序试图访问它无权访问的内存区域,例如空指针解引用、越界访问数组等。
  • Bus error (core dumped):通常表示内存访问的对齐问题,或者访问了不存在的物理内存。
  • Aborted (core dumped):通常是程序调用abort()函数,或者assert()断言失败,或者某些运行时错误(如内存分配失败)导致。

21.3 使用GDB分析Core Dump文件

当程序崩溃时,如果系统配置允许,操作系统会生成一个核心转储文件(Core Dump)。这个文件包含了程序崩溃时的内存快照、寄存器状态等信息,可以事后用调试器分析。

步骤:

  1. 开启Core Dump生成:
    • 在shell中执行ulimit -c unlimited命令,允许生成无限大小的core dump文件。这通常只对当前shell会话有效。如果需要永久生效,可以修改/etc/security/limits.conf
    • echo "/var/core/core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern:配置core dump文件的生成路径和命名格式。%e是可执行文件名,%p是进程ID。
  2. 重现崩溃:运行程序,使其崩溃并生成core dump文件。
  3. 使用GDB分析:
    • gdb your_program core_file_name:用GDB加载可执行程序和core dump文件。
    • bt (backtrace):查看调用栈(call stack)。这是最关键的命令,它会显示程序崩溃时函数调用的顺序,帮助你定位到导致崩溃的代码行。
    • frame N:切换到调用栈中的第N帧(frame),然后可以使用其他命令查看该帧的上下文。
    • print variable_name:打印变量的值。在定位到可疑代码行后,可以查看相关变量的值是否异常(例如,指针是否为NULL,数组索引是否越界)。
    • list:查看当前代码行的上下文(源代码)。
    • info locals:查看当前函数栈帧中的局部变量。
    • info args:查看当前函数的参数。

示例GDB会话:

bash
$ ulimit -c unlimited
$ g++ -g -O0 my_program.cpp -o my_program
$ ./my_program
Segmentation fault (core dumped)
$ ls core.*
core.my_program.12345
$ gdb my_program core.my_program.12345
(gdb) bt
#0  0x000000000040060e in crash_func (ptr=0x0) at my_program.cpp:7
#1  0x0000000000400632 in main () at my_program.cpp:12
(gdb) frame 0
#0  0x000000000040060e in crash_func (ptr=0x0) at my_program.cpp:7
7           *ptr = 10; // Dereferencing a null pointer
(gdb) print ptr
$1 = (int *) 0x0
(gdb) quit

21.4 实时调试(Live Debugging)

如果程序崩溃后没有生成core dump,或者需要更交互式的调试,可以使用GDB进行实时调试。

  1. 直接运行程序:

    • gdb your_program
    • (gdb) run:运行程序。当程序崩溃时,GDB会停在崩溃点,你可以使用bt等命令进行分析。
  2. 附加到正在运行的进程:

    gdb your_program PID:如果程序正在运行且可能崩溃,可以将其PID附加到GDB。当程序崩溃时,GDB会捕获到。

21.5 其他辅助工具和技巧

  1. 日志(Logging):

    在程序关键路径、函数入口/出口、异常捕获点等位置添加详细的日志输出。崩溃前最后打印的日志可以帮助缩小问题范围。

  2. 断言(Assertions):

    使用assert()宏在代码中添加断言,检查程序状态是否符合预期。断言失败会导致程序终止,并打印错误信息,有助于发现逻辑错误。

  3. 内存检测工具:

    • Valgrind:一个强大的内存调试工具,可以检测内存泄漏、非法内存访问、未初始化内存使用等问题。运行方式:valgrind --leak-check=full ./your_program
    • AddressSanitizer (ASan):GCC和Clang内置的内存错误检测工具,通过在编译时插桩,运行时检测内存错误(如越界访问、Use-After-Free等),并提供详细的错误报告。编译时添加-fsanitize=address
    • UndefinedBehaviorSanitizer (UBSan):检测未定义行为,如整数溢出、空指针解引用等。编译时添加-fsanitize=undefined
  4. 代码审查(Code Review):

    让同事或团队成员审查代码,往往能发现自己忽略的问题。

  5. 最小化重现(Minimal Reproduction):

    尝试找出导致崩溃的最小代码片段或最小输入,这有助于隔离问题。

  6. 单元测试(Unit Tests):

    编写全面的单元测试可以帮助在早期发现问题,防止回归。

22、Linux的pid如何查找?杀掉进程?

22.1 如何查找进程PID?

查找进程PID的方法有多种,主要根据你已知的信息(如进程名、端口号、用户等)来选择。

  1. 使用ps命令: ps命令用于报告当前系统的进程状态。结合grep可以过滤出你需要的进程。

    • ps aux | grep <进程名或关键词>

      • ps aux:显示所有用户的所有进程,包括没有控制终端的进程。
        • a:显示所有进程(包括其他用户的进程)。
        • u:显示面向用户的格式(user-oriented format)。
        • x:显示没有控制终端的进程。
      • grep <进程名或关键词>:通过管道将ps aux的输出作为grep的输入,过滤出包含指定关键词的行。
      • 注意:grep命令本身也会作为一个进程运行,所以输出中可能会包含grep自身的进程。通常需要通过grep -v grep来排除它。
      • 示例:查找名为nginx的进程
        bash
        ps aux | grep nginx | grep -v grep
        输出示例:
        root       1234  0.0  0.1  12345  6789 ?        Ss   Oct17   0:00 nginx: master process /usr/sbin/nginx
        www-data   1235  0.0  0.1  12345  6789 ?        S    Oct17   0:00 nginx: worker process
        第二列的数字(1234, 1235)就是PID。
  2. 使用pgrep命令:

    pgrep命令可以直接根据进程名查找PID,比ps | grep更简洁和精确。

    • pgrep <进程名>
      • 示例:查找名为nginx的进程
        bash
        pgrep nginx
        输出示例:
        1234
        1235
    • pgrep -u <用户名> <进程名>:查找指定用户运行的特定进程。
    • pgrep -l <进程名>:同时显示进程名和PID。
  3. 使用pidof命令:

    pidof命令用于查找正在运行的程序的PID。

    • pidof <程序名>
      • 示例:查找sshd服务的PID
        bash
        pidof sshd
        输出示例:
        1024 1023
  4. 使用tophtop命令:

    tophtop是交互式的进程查看工具,可以实时显示系统中的进程信息,包括PID、CPU使用率、内存使用率等。在这些工具中,你可以直接看到进程的PID。

    • top:默认按CPU使用率排序。
    • htoptop的增强版,提供更友好的界面和更多功能(如鼠标操作、树状视图)。
  5. 通过端口查找(lsofnetstat):

    如果知道进程监听的端口号,可以使用lsofnetstat来查找。

    • lsof -i :<端口号>:列出所有打开指定端口的进程。
      • 示例:查找监听80端口的进程
        bash
        sudo lsof -i :80
        输出示例(PID通常在第二列):
        COMMAND    PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
        nginx     1234  root    6u  IPv4  12345      0t0  TCP *:http (LISTEN)
    • netstat -tulnp | grep :<端口号>:显示所有监听的TCP/UDP端口和对应的进程信息(需要root权限)。
      • 示例:查找监听80端口的进程
        bash
        sudo netstat -tulnp | grep :80
        输出示例(PID通常在最后一列):
        tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      1234/nginx

22.2 如何杀掉进程?

查找到进程PID后,可以使用killkillall命令来终止进程。

  1. 使用kill命令:

    kill命令用于向指定的PID发送信号。默认发送SIGTERM(终止)信号。

    • kill <PID>:发送SIGTERM信号。进程可以捕获这个信号并执行清理操作后优雅退出。这是推荐的终止进程方式。
      • 示例:kill 1234
    • kill -9 <PID>:发送SIGKILL信号。这是一个强制终止信号,进程无法捕获或忽略,会立即终止。通常用于进程无响应或无法通过SIGTERM正常终止的情况。但应谨慎使用,因为它可能导致数据丢失或资源未释放。
      • 示例:kill -9 1234
    • kill -SIGNAL <PID>:可以发送其他信号,例如kill -1 <PID>SIGHUP,通常用于重新加载配置文件)或kill -15 <PID>SIGTERM)。
  2. 使用killall命令:

    killall命令可以根据进程名终止所有匹配的进程,而不需要知道它们的PID。

    • killall <进程名>:发送SIGTERM信号给所有匹配的进程。
      • 示例:killall nginx
    • killall -9 <进程名>:强制终止所有匹配的进程。
      • 示例:killall -9 nginx
  3. 使用pkill命令:

    pkill命令结合了pgrepkill的功能,可以根据进程名或其他属性(如用户、终端)直接终止进程。

    • pkill <进程名>:发送SIGTERM信号给所有匹配的进程。
      • 示例:pkill nginx
    • pkill -9 <进程名>:强制终止所有匹配的进程。
      • 示例:pkill -9 nginx
    • pkill -u <用户名> <进程名>:终止指定用户运行的特定进程。

23、http的get和post有什么区别吗?

HTTP(Hypertext Transfer Protocol)定义了多种请求方法,其中GETPOST是最常用也是最基本的两种。它们在语义、数据传输方式、安全性、幂等性等方面存在显著区别,理解这些区别对于正确设计和使用Web服务至关重要。

23.1 核心区别概述

特性GETPOST
语义从服务器获取资源向服务器提交数据,通常导致服务器状态变更或创建新资源
幂等性幂等(Idempotent):重复请求不会对服务器状态造成额外影响非幂等:重复请求可能导致重复创建资源或多次执行操作
安全性不安全:参数暴露在URL中,可能被缓存、记录、泄露相对安全:参数在请求体中,不会暴露在URL,但并非加密
可缓存性可以被缓存默认不可缓存
可书签性可以被书签收藏不可以被书签收藏
数据传输参数通过URL的查询字符串(Query String)传输参数通过请求体(Request Body)传输
数据长度URL长度有限制(不同浏览器和服务器有差异,通常2KB-8KB)理论上无限制,但受服务器配置和内存限制
HTTP报文只有请求头(Request Header)包含请求头和请求体(Request Body)

23.2 详细分析

  1. 语义:

    • GET:用于从指定的资源请求数据。它是一个查询操作,不应该引起服务器状态的改变。例如,获取网页内容、查询数据库记录。
    • POST:用于向指定的资源提交数据,通常会导致服务器状态的改变,或者在服务器上创建新的资源。例如,提交表单、上传文件、发表评论。
  2. 幂等性:

    • GET:是幂等的。这意味着对同一个URL的多次GET请求,服务器的状态不会发生改变。例如,多次访问同一个网页,结果总是一样的。
    • POST:是非幂等的。这意味着对同一个URL的多次POST请求,可能会导致服务器状态的多次改变。例如,多次提交订单可能会创建多个订单。
  3. 安全性:

    • GET:参数直接暴露在URL中,会显示在浏览器地址栏、历史记录、服务器日志中,并且容易被浏览器缓存。因此,不适合传输敏感信息,如密码、银行卡号。
    • POST:参数包含在请求体中,不会显示在URL中,也不会被浏览器历史记录和书签记录。相对而言,传输敏感信息更安全,但并非加密。如果需要加密,应使用HTTPS。
  4. 可缓存性:

    • GET:可以被浏览器和代理服务器缓存。这有助于提高性能,减少服务器负载。
    • POST:默认情况下不可缓存。因为POST请求通常会改变服务器状态,缓存其结果可能会导致不一致性。
  5. 可书签性:

    • GET:由于参数在URL中,可以方便地将带有特定查询参数的URL保存为书签,以便下次直接访问。
    • POST:由于参数在请求体中,无法直接通过URL保存书签。
  6. 数据传输方式:

    • GET:通过URL的查询字符串(key=value&key2=value2)传递数据。数据量受限于URL的长度(通常为2KB到8KB,具体取决于浏览器和服务器)。
    • POST:通过HTTP请求体(Request Body)传递数据。理论上数据量没有限制,但实际上受限于服务器的配置和内存大小。
  7. HTTP报文结构:

    • GET:HTTP请求报文只包含请求行和请求头,没有请求体。
    • POST:HTTP请求报文包含请求行、请求头和请求体。

23.3 何时使用GET,何时使用POST?

  • 使用GET:

    • 当需要从服务器获取数据时(查询、读取操作)。
    • 当请求是幂等的,即重复请求不会产生副作用时。
    • 当传输的数据量较小,且不包含敏感信息时。
    • 当希望请求可以被缓存或保存为书签时。
    • 例如:搜索查询、获取文章列表、查看产品详情。
  • 使用POST:

    • 当需要向服务器提交数据,导致服务器状态改变或创建新资源时(创建、更新、删除操作)。
    • 当请求是非幂等的,即重复请求会产生副作用时。
    • 当传输的数据量较大,或者包含敏感信息时。
    • 例如:用户注册、登录、提交表单、上传文件、发表评论。

24、https——TLS讲讲?

24.1 HTTPS是什么?

HTTPS (Hypertext Transfer Protocol Secure) 是在HTTP协议的基础上,通过结合SSL/TLS协议来实现安全通信的一种网络协议。简单来说,HTTPS = HTTP + SSL/TLS。它的主要目标是提供Web通信的安全性,包括数据的机密性、完整性和身份认证。

当你在浏览器地址栏看到https://开头而不是http://时,就表示你正在使用HTTPS连接。浏览器通常会显示一个挂锁图标,表示连接是安全的。

24.2 TLS是什么?

TLS (Transport Layer Security) 传输层安全协议,是SSL (Secure Sockets Layer)协议的后续版本。由于SSL协议存在一些已知的安全漏洞,IETF(互联网工程任务组)在1999年发布了TLS 1.0,作为SSL 3.0的升级版。现在我们通常说的SSL/TLS,实际上指的是TLS协议。

TLS协议位于TCP/IP模型中的应用层和传输层之间,为应用层协议(如HTTP、FTP、SMTP等)提供安全保障。它通过以下机制实现安全通信:

  • 加密 (Encryption):对传输的数据进行加密,防止窃听者获取敏感信息。
  • 身份认证 (Authentication):验证通信双方的身份,防止中间人攻击 (Man-in-the-Middle Attack)。
  • 数据完整性 (Data Integrity):确保数据在传输过程中没有被篡改。

24.3 HTTPS与TLS的关系

HTTPS是HTTP协议在TLS协议上的一个应用。HTTP本身是明文传输的,不提供任何安全保障。TLS协议则提供了加密、认证和数据完整性服务。当HTTP与TLS结合时,就形成了HTTPS,使得HTTP的通信变得安全。

24.4 TLS如何提供安全保障?

TLS协议通过其握手过程和后续的数据传输过程,提供了以下核心安全保障:

  1. 身份认证:

    • 数字证书:服务器在TLS握手过程中会向客户端发送其数字证书。这个证书由受信任的第三方机构(CA,Certificate Authority)颁发,包含了服务器的公钥、域名信息以及CA的数字签名。
    • 客户端验证:客户端收到证书后,会验证证书的有效性(是否过期、是否由受信任的CA颁发、域名是否匹配等)。如果验证通过,客户端就相信它正在与预期的服务器通信,从而防止了中间人攻击。
    • 可选的客户端认证:在某些高安全要求的场景下,服务器也可以要求客户端提供数字证书进行身份认证。
  2. 机密性:

    • 密钥协商:在TLS握手过程中,客户端和服务器会协商出一套会话密钥(Session Key)。这个过程是安全的,即使攻击者窃听了握手过程,也无法推导出最终的会话密钥。
    • 对称加密:一旦会话密钥协商完成,后续的所有应用层数据(HTTP请求和响应)都将使用这个会话密钥进行对称加密。对称加密的特点是加解密速度快,适合大量数据的传输。
    • 非对称加密:在密钥协商过程中,会使用到服务器证书中的公钥进行非对称加密,以安全地交换对称密钥。非对称加密速度慢,主要用于密钥交换和数字签名。
  3. 完整性:

    • 消息认证码(MAC):TLS在发送每条加密数据之前,会计算一个消息认证码(MAC),并将其附加在加密数据之后。接收方收到数据后,会使用相同的会话密钥重新计算MAC,并与接收到的MAC进行比较。如果MAC不匹配,则表明数据在传输过程中被篡改,数据将被丢弃。
    • 哈希函数:MAC的计算通常依赖于安全的哈希函数(如SHA-256)。

TLS握手过程简述:

  1. Client Hello:客户端向服务器发送一个“Hello”消息,包含客户端支持的TLS版本、加密套件列表、随机数等。
  2. Server Hello:服务器从客户端提供的列表中选择一个TLS版本和加密套件,并发送自己的随机数、数字证书。
  3. Client Key Exchange:客户端验证服务器证书。验证通过后,客户端生成一个预主密钥(Pre-Master Secret),使用服务器的公钥加密后发送给服务器。此时,客户端和服务器都可以通过各自的随机数和预主密钥计算出会话密钥。
  4. Change Cipher Spec & Finished:客户端发送“Change Cipher Spec”通知服务器后续通信将使用加密,并发送一个“Finished”消息(包含之前握手消息的哈希值,用会话密钥加密)。
  5. Change Cipher Spec & Finished:服务器收到客户端的“Finished”消息并验证通过后,也发送自己的“Change Cipher Spec”和“Finished”消息。
  6. 应用数据:握手完成后,客户端和服务器开始使用协商好的会话密钥进行对称加密通信。

25、讲一讲Redis中Hash的应用场景?

Redis的Hash(哈希)数据类型是一个键值对的集合,其中每个键(key)都映射到一个哈希表,哈希表中存储着字段(field)和值(value)的映射。它非常适合存储对象,例如用户资料、商品信息等。相比于将对象的每个属性存储为独立的Redis键,使用Hash类型可以更有效地组织和管理数据,并节省内存。

25.1 Redis Hash的特点

  • 存储结构:一个Hash键可以存储多个field-value对。
  • 内存效率:当一个Hash键包含的field-value对数量较少时,Redis会采用紧凑的存储方式(ziplist或hashtable),非常节省内存。当field-value对数量增多或field/value的长度变大时,Redis会自动转换为更通用的哈希表结构。
  • 原子操作:Redis提供了对Hash中单个field进行原子操作的命令,如HINCRBY(增加数字)、HSET(设置字段值)等。
  • 批量操作:可以一次性获取或设置多个field-value对,如HMGETHMSET

25.2 Redis Hash的典型应用场景

  1. 存储对象信息:

    • 场景:存储用户个人信息(如用户ID、姓名、年龄、性别、邮箱等)、商品详细信息(如商品ID、名称、价格、库存、描述等)。
    • 示例:
      • Key:user:1001
      • Hash Fields:name -> "Alice", age -> "30", email -> "alice@example.com"
    • 优势:
      • 将一个对象的多个属性归类到一个Redis键下,逻辑清晰。
      • 可以方便地一次性获取或更新对象的多个属性,减少网络往返时间(RTT)。
      • 相比于为每个属性创建一个独立的键(如user:1001:name, user:1001:age),更节省内存(因为Hash在内部会进行优化)。
  2. 购物车:

    • 场景:存储用户的购物车内容。每个用户的购物车可以是一个Hash,field为商品ID,value为商品数量。
    • 示例:
      • Key:cart:user:123
      • Hash Fields:product:A -> "2", product:B -> "1"
    • 优势:
      • 方便地增加、减少商品数量(HINCRBY)。
      • 快速获取购物车中所有商品及其数量(HGETALL)。
      • 删除购物车中的某个商品(HDEL)。
  3. 计数器:

    • 场景:存储某个实体(如文章、用户)的各种统计计数,例如文章的阅读量、点赞数、评论数等。
    • 示例:
      • Key:article:100
      • Hash Fields:views -> "1234", likes -> "56", comments -> "10"
    • 优势:
      • 可以对单个字段进行原子递增/递减操作(HINCRBY),避免并发问题。
      • 将相关计数聚合在一个键下,便于管理和查询。
  4. 会话管理:

    • 场景:存储Web应用程序的用户会话信息,如会话ID、用户ID、登录时间、权限等。
    • 示例:
      • Key:session:abcdefg12345
      • Hash Fields:user_id -> "1001", login_time -> "2023-10-26 10:00:00", role -> "admin"
    • 优势:
      • 快速存取会话数据。
      • 可以设置过期时间(EXPIRE),实现会话的自动失效。
  5. 缓存:

    • 场景:缓存数据库中的复杂对象。当从数据库中查询到一条记录时,可以将其转换为Hash存储在Redis中,下次直接从Redis读取。
    • 优势:
      • 减少数据库负载,提高响应速度。
      • 方便地更新对象中的某个属性,而不需要重新序列化整个对象。
  6. 配置信息管理:

    • 场景:存储应用程序的动态配置信息,如不同环境的配置参数、功能开关等。
    • 示例:
      • Key:app:config:production
      • Hash Fields:feature_A_enabled -> "true", max_connections -> "1000"
    • 优势:
      • 集中管理配置,方便动态修改和读取。

26、hash的命令的用法?

Redis的Hash类型提供了一系列丰富的命令来操作哈希表中的字段(field)和值(value)。这些命令可以分为增/改、查、删、其他等几类。

26.1 增加/修改

  1. HSET key field value

    • 作用:将哈希表key中的字段field的值设为value
    • 如果key不存在,会创建一个新的哈希表并进行HSET操作。
    • 如果field已经存在于哈希表中,旧值将被覆盖。
    • 返回值:如果field是哈希表中的一个新建字段,并且值设置成功,返回1。如果field已经存在且旧值被覆盖,返回0。
    • 示例:
      redis-cli
      HSET user:1001 name "Alice"  # 返回 1
      HSET user:1001 name "Bob"    # 返回 0
  2. HMSET key field1 value1 [field2 value2 ...] (已废弃,建议使用HSET)

    • 作用:同时将多个field-value对设置到哈希表key中。
    • 注意:从Redis 4.0.0开始,HSET命令可以一次设置多个字段,因此HMSET被认为是已废弃的。推荐使用HSET的变种形式。
    • 示例:
      redis-cli
      HSET user:1001 name "Alice" age 30 email "alice@example.com" # 使用HSET设置多个字段
  3. HINCRBY key field increment

    • 作用:为哈希表key中的字段field的值加上增量increment。如果field不存在,会先创建field并将其值设为0,再执行加法操作。
    • increment可以是正数或负数。
    • 返回值:执行操作后field的新值。
    • 示例:
      redis-cli
      HINCRBY article:100 views 1       # 返回 1 (views字段从0开始)
      HINCRBY article:100 views 10      # 返回 11
      HINCRBY article:100 likes -1      # 返回 -1 (likes字段从0开始)

26.2 查询

  1. HGET key field

    • 作用:获取哈希表key中给定field的值。
    • 返回值:field的值。如果fieldkey不存在,返回nil
    • 示例:
      redis-cli
      HGET user:1001 name  # 返回 "Alice"
  2. HMGET key field1 [field2 ...]

    • 作用:获取哈希表key中一个或多个给定field的值。
    • 返回值:一个包含所有给定field值的列表。如果某个field不存在,对应位置返回nil
    • 示例:
      redis-cli
      HMGET user:1001 name age city  # 返回 ["Alice", "30", nil]
  3. HGETALL key

    • 作用:获取哈希表key中所有的field-value对。
    • 返回值:一个包含所有field-value对的列表。
    • 注意:当哈希表很大时,HGETALL可能会阻塞Redis服务器,应谨慎使用。可以考虑使用HSCAN进行分批获取。
    • 示例:
      redis-cli
      HGETALL user:1001  # 返回 ["name", "Alice", "age", "30", "email", "alice@example.com"]
  4. HKEYS key

    • 作用:获取哈希表key中所有的字段(field)。
    • 返回值:一个包含所有field的列表。
    • 示例:
      redis-cli
      HKEYS user:1001  # 返回 ["name", "age", "email"]
  5. HVALS key

    • 作用:获取哈希表key中所有的值(value)。
    • 返回值:一个包含所有value的列表。
    • 示例:
      redis-cli
      HVALS user:1001  # 返回 ["Alice", "30", "alice@example.com"]

26.3 删除

  1. HDEL key field1 [field2 ...]
    • 作用:删除哈希表key中的一个或多个指定field
    • 返回值:成功删除的field数量。
    • 示例:
      redis-cli
      HDEL user:1001 email  # 返回 1

26.4 其他

  1. HLEN key

    • 作用:获取哈希表keyfield-value对的数量。
    • 返回值:field-value对的数量。如果key不存在,返回0。
    • 示例:
      redis-cli
      HLEN user:1001  # 返回 3
  2. HEXISTS key field

    • 作用:检查哈希表key中是否存在指定的field
    • 返回值:如果field存在,返回1。如果fieldkey不存在,返回0。
    • 示例:
      redis-cli
      HEXISTS user:1001 name  # 返回 1
      HEXISTS user:1001 city  # 返回 0
  3. HSCAN key cursor [MATCH pattern] [COUNT count]

    • 作用:迭代哈希表key中的field-value对。用于处理大型哈希表,避免HGETALL的阻塞问题。
    • cursor:游标,第一次迭代时为0,后续迭代使用上一次返回的游标。
    • MATCH pattern:可选,只返回匹配指定模式的field
    • COUNT count:可选,每次迭代返回的元素数量的提示值。
    • 返回值:一个包含新游标和一批field-value对的列表。

26.5 命令用法总结

命令用法描述
HSETHSET key field value [field value ...]设置一个或多个字段的值
HGETHGET key field获取一个字段的值
HMGETHMGET key field [field ...]获取多个字段的值
HGETALLHGETALL key获取所有字段和值
HDELHDEL key field [field ...]删除一个或多个字段
HLENHLEN key获取哈希表中字段的数量
HEXISTSHEXISTS key field判断字段是否存在
HKEYSHKEYS key获取所有字段
HVALSHVALS key获取所有值
HINCRBYHINCRBY key field increment将字段的值增加指定的整数
HSCANHSCAN key cursor [MATCH pattern] [COUNT count]迭代哈希表中的字段和值

27、单例模式讲讲?

27.1 什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式,其核心思想是确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。这种模式通常用于需要全局唯一对象,例如日志记录器、配置管理器、线程池、数据库连接池等。

27.2 单例模式的实现方式

在C++中,实现单例模式有多种方法,主要分为“饿汉式”和“懒汉式”,并且需要考虑多线程环境下的线程安全性。

27.2.1 饿汉式(Eager Initialization)

“饿汉式”的特点是在程序启动时(或单例类被加载时)就创建好唯一的实例,无论后续是否会用到这个实例。

  • 优点:

    • 线程安全:由于实例在程序启动时就已经创建,所以不存在多线程并发创建实例的问题,天生线程安全。
    • 简单:实现代码简单。
  • 缺点:

    • 非懒加载:如果实例的创建过程比较耗时,或者实例在程序运行期间可能根本不会被用到,那么提前创建实例会浪费资源。
  • 实现:

    cpp
    class SingletonEager {
    private:
        // 私有构造函数,防止外部直接创建实例
        SingletonEager() {
            // 构造函数可以进行一些初始化操作
            // std::cout << "SingletonEager instance created.\n";
        }
        // 禁止拷贝构造和赋值操作
        SingletonEager(const SingletonEager&) = delete;
        SingletonEager& operator=(const SingletonEager&) = delete;
    
        // 静态成员变量,在程序启动时即被创建
        static SingletonEager instance;
    
    public:
        // 全局访问点
        static SingletonEager& getInstance() {
            return instance;
        }
    
        void doSomething() {
            // std::cout << "SingletonEager doing something.\n";
        }
    };
    
    // 静态成员变量的定义和初始化
    SingletonEager SingletonEager::instance;

27.2.2 懒汉式(Lazy Initialization)

“懒汉式”的特点是在第一次使用时才创建实例,实现了按需创建。

  • 优点:

    • 懒加载:只有在需要时才创建实例,节省了资源。
  • 缺点:

    • 线程安全问题:在多线程环境下,如果没有适当的同步机制,可能会导致多个线程同时创建实例,从而破坏单例性。
  • 实现(非线程安全示例):

    cpp
    class SingletonLazyNonThreadSafe {
    private:
        SingletonLazyNonThreadSafe() {}
        SingletonLazyNonThreadSafe(const SingletonLazyNonThreadSafe&) = delete;
        SingletonLazyNonThreadSafe& operator=(const SingletonLazyNonThreadSafe&) = delete;
    
        static SingletonLazyNonThreadSafe* instance; // 静态指针
    
    public:
        static SingletonLazyNonThreadSafe* getInstance() {
            if (instance == nullptr) { // 第一次检查
                instance = new SingletonLazyNonThreadSafe();
            }
            return instance;
        }
    
        // 析构函数中释放资源 (需要手动调用或使用atexit)
        static void destroyInstance() {
            if (instance != nullptr) {
                delete instance;
                instance = nullptr;
            }
        }
    };
    
    SingletonLazyNonThreadSafe* SingletonLazyNonThreadSafe::instance = nullptr;

    上述代码在多线程环境下存在竞态条件:当两个线程同时判断instance == nullptr为真时,它们都可能尝试创建实例,导致创建多个对象。

  • 实现(双重检查锁定 - Double-Checked Locking,DCL): 为了解决懒汉式的线程安全问题,可以使用双重检查锁定。

    cpp
    #include <mutex>
    
    class SingletonLazyDCL {
    private:
        SingletonLazyDCL() {}
        SingletonLazyDCL(const SingletonLazyDCL&) = delete;
        SingletonLazyDCL& operator=(const SingletonLazyDCL&) = delete;
    
        static SingletonLazyDCL* instance; // 静态指针
        static std::mutex mtx;           // 互斥锁
    
    public:
        static SingletonLazyDCL* getInstance() {
            if (instance == nullptr) { // 第一次检查:避免每次都加锁
                std::lock_guard<std::mutex> lock(mtx); // 加锁
                if (instance == nullptr) { // 第二次检查:防止多线程并发创建
                    instance = new SingletonLazyDCL();
                }
            }
            return instance;
        }
    
        static void destroyInstance() {
            std::lock_guard<std::mutex> lock(mtx);
            if (instance != nullptr) {
                delete instance;
                instance = nullptr;
            }
        }
    };
    
    SingletonLazyDCL* SingletonLazyDCL::instance = nullptr;
    std::mutex SingletonLazyDCL::mtx;

    注意:在C++11之前,DCL可能存在内存乱序访问问题,导致instance指针在对象完全构造之前就可见。但在C++11及以后,由于内存模型的强化,特别是局部静态变量的初始化规则,使得DCL变得相对安全,但更推荐使用Meyers Singleton。

27.2.3 Meyers Singleton (C++11及以后推荐)

C++11标准规定,局部静态变量的初始化是线程安全的。如果多个线程同时尝试初始化一个局部静态变量,只有一个线程会执行初始化,其他线程会阻塞并等待初始化完成。这使得Meyers Singleton成为C++中最简洁、最推荐的线程安全懒汉式单例实现。

  • 优点:

    • 线程安全:由C++标准保证。
    • 懒加载:只有在第一次调用getInstance()时才创建实例。
    • 自动资源管理:实例是局部静态变量,其生命周期与程序相同,在程序结束时会自动调用其析构函数,无需手动管理内存(delete)。
    • 简洁:代码非常简洁。
  • 实现:

    cpp
    class SingletonMeyers {
    private:
        SingletonMeyers() {
            // std::cout << "SingletonMeyers instance created.\n";
        }
        ~SingletonMeyers() {
            // std::cout << "SingletonMeyers instance destroyed.\n";
        }
        SingletonMeyers(const SingletonMeyers&) = delete;
        SingletonMeyers& operator=(const SingletonMeyers&) = delete;
    
    public:
        static SingletonMeyers& getInstance() {
            static SingletonMeyers instance; // 局部静态变量,C++11保证线程安全初始化
            return instance;
        }
    
        void doSomething() {
            // std::cout << "SingletonMeyers doing something.\n";
        }
    };

27.3 单例模式的优缺点

27.3.1 优点

  1. 确保唯一实例:严格控制一个类只有一个实例,避免了多个实例可能导致的逻辑错误或资源冲突。
  2. 提供全局访问点:通过一个公共方法(如getInstance())提供对唯一实例的全局访问,方便客户端使用。
  3. 节约系统资源:对于那些创建开销大、但又需要频繁使用的对象(如数据库连接池、线程池),单例模式可以避免重复创建,从而节约系统资源。
  4. 按需创建(懒汉式):懒汉式单例可以在第一次使用时才创建实例,避免了不必要的资源占用。

27.3.2 缺点

  1. 违反单一职责原则:单例类既要负责自身的业务逻辑,又要负责控制自己的唯一实例,职责不够单一。
  2. 隐藏依赖:由于单例提供了全局访问点,客户端代码可以直接获取单例实例,从而隐藏了对单例的依赖关系,增加了代码的耦合度。
  3. 不利于测试:单例模式使得单元测试变得困难。因为单例实例是全局唯一的,测试用例之间可能会相互影响,难以隔离。模拟(Mock)或替换单例实例也比较麻烦。
  4. 扩展性差:单例模式通常难以扩展。如果需要多个实例(例如,每个模块一个实例),修改单例模式的实现会比较复杂。
  5. 多线程问题:非线程安全的懒汉式单例在多线程环境下可能创建多个实例。虽然有解决方案(如DCL或Meyers Singleton),但增加了实现的复杂性。
  6. 生命周期管理:对于需要手动管理内存的懒汉式单例,需要考虑何时释放资源,避免内存泄漏。

27.4 单例模式的使用场景

单例模式虽然存在一些争议,但在以下场景中仍然是合理的选择:

  1. 日志记录器(Logger):整个应用程序通常只需要一个日志记录器实例,负责将日志信息写入文件或控制台。
  2. 配置管理器(Configuration Manager):应用程序的配置信息通常是全局共享的,并且只需要一个实例来加载和管理这些配置。
  3. 线程池(Thread Pool)和数据库连接池(Database Connection Pool):这些资源通常是有限且昂贵的,通过单例模式可以统一管理和分配,避免资源浪费和过度创建。
  4. 缓存(Cache):应用程序可能需要一个全局的缓存实例来存储常用数据,提高访问速度。
  5. 设备管理器:例如,打印机管理器,确保只有一个实例控制打印设备。
  6. ID生成器:需要生成全局唯一ID的场景。

28、算法题:反转链表

28.1 问题描述

给定一个单链表的头节点 head,请你反转链表,并返回反转后的链表的头节点。

示例:

  • 输入:head = [1,2,3,4,5]
  • 输出:[5,4,3,2,1]

28.2 链表节点定义

在C++中,单链表的节点通常定义如下:

cpp
struct ListNode {
    int val;         // 节点的值
    ListNode *next;  // 指向下一个节点的指针
    ListNode() : val(0), next(nullptr) {}
    ListNode(int x) : val(x), next(nullptr) {}
    ListNode(int x, ListNode *next) : val(x), next(next) {}
};

28.3 迭代法

迭代法是反转链表最常见且效率较高的方法。其核心思想是遍历链表,在遍历过程中,逐个改变每个节点的next指针,使其指向前一个节点。为了实现这一点,我们需要维护三个指针:

  1. prev:指向当前节点的前一个节点,初始化为nullptr(因为反转后原头节点的next将指向nullptr)。
  2. curr:指向当前正在处理的节点,初始化为链表的头节点head
  3. next_node:临时保存curr节点的下一个节点,以防止在修改curr->next后丢失后续链表的连接。

算法步骤:

  1. 初始化 prev = nullptrcurr = head
  2. 进入循环,条件是 curr != nullptr(即当前节点不为空)。
  3. 在循环内部: a. 保存 curr 的下一个节点:next_node = curr->next。 b. 反转 currnext 指针:curr->next = prev。 c. 移动 prev 指针:prev = curr。 d. 移动 curr 指针:curr = next_node
  4. 循环结束后,prev 将指向原链表的最后一个节点,也就是反转后链表的头节点,返回 prev

图示:

假设链表为 1 -> 2 -> 3 -> nullptr

初始状态: prev = nullptrcurr = 1next_node = ?

第一次迭代:

  1. next_node = curr->next (即 2) => next_node = 2
  2. curr->next = prev (即 nullptr) => 1 -> nullptr
  3. prev = curr (即 1) => prev = 1
  4. curr = next_node (即 2) => curr = 2

当前状态:nullptr <- 1 2 -> 3 -> nullptrprev curr

第二次迭代:

  1. next_node = curr->next (即 3) => next_node = 3
  2. curr->next = prev (即 1) => 2 -> 1
  3. prev = curr (即 2) => prev = 2
  4. curr = next_node (即 3) => curr = 3

当前状态:nullptr <- 1 <- 2 3 -> nullptrprev curr

第三次迭代:

  1. next_node = curr->next (即 nullptr) => next_node = nullptr
  2. curr->next = prev (即 2) => 3 -> 2
  3. prev = curr (即 3) => prev = 3
  4. curr = next_node (即 nullptr) => curr = nullptr

当前状态:nullptr <- 1 <- 2 <- 3prev

循环结束:currnullptr,循环终止。返回 prev (即 3)。

最终结果:3 -> 2 -> 1 -> nullptr

C++ 代码实现:

cpp
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        ListNode* prev = nullptr;  // 指向反转后链表的尾部(原链表的头部)
        ListNode* curr = head;     // 指向当前正在处理的节点
        
        while (curr != nullptr) {
            ListNode* next_node = curr->next; // 临时保存当前节点的下一个节点
            curr->next = prev;                // 反转当前节点的next指针,指向前一个节点
            prev = curr;                      // 移动prev指针到当前节点
            curr = next_node;                 // 移动curr指针到下一个节点
        }
        
        return prev; // prev最终会指向反转后链表的头节点
    }
};

28.4 递归法

递归法反转链表也十分优雅,但理解起来可能稍显抽象。其基本思想是:

  1. 基本情况:如果链表为空(head == nullptr)或只有一个节点(head->next == nullptr),则无需反转,直接返回head
  2. 递归调用:假设我们已经能够反转从head->next开始的子链表,并返回其新的头节点new_head
  3. 连接操作:现在,head->next(原链表的第二个节点)的next指针已经指向了原链表的第三个节点(反转后是new_head的倒数第二个节点)。我们需要将head节点连接到反转后的子链表的末尾。即 head->next->next = head
  4. 断开连接:将原head节点的next指针置为nullptr,防止循环引用:head->next = nullptr
  5. 返回:返回new_head

C++ 代码实现:

cpp
class Solution {
public:
    ListNode* reverseListRecursive(ListNode* head) {
        // 基本情况:链表为空或只有一个节点,无需反转
        if (head == nullptr || head->next == nullptr) {
            return head;
        }
        
        // 递归反转从 head->next 开始的子链表
        // new_head 是反转后子链表的头节点(原链表的最后一个节点)
        ListNode* new_head = reverseListRecursive(head->next);
        
        // 将当前节点 head 连接到反转后的子链表的末尾
        // head->next 当前指向原链表的第二个节点,反转后它将是倒数第二个节点
        // 它的next指针应该指向 head
        head->next->next = head;
        
        // 将当前节点 head 的next指针置为nullptr,防止循环引用
        // (head 成为反转后链表的最后一个节点)
        head->next = nullptr;
        
        // 返回反转后链表的头节点
        return new_head;
    }
};

28.5 复杂度分析

28.5.1 迭代法

  • 时间复杂度:O(N),其中N是链表的节点数量。我们只需要遍历链表一次。
  • 空间复杂度:O(1),我们只使用了常数个额外指针变量。

28.5.2 递归法

  • 时间复杂度:O(N),其中N是链表的节点数量。每个节点都会被访问一次。
  • 空间复杂度:O(N),由于递归调用的深度与链表长度N成正比,函数调用栈会占用O(N)的额外空间。在链表很长的情况下,可能会导致栈溢出。