手机网站404页面模板,优质的网站建设,泰州网站建设定制,做一普通网站需要多少钱文章目录 一、vector 和 list 的区别#xff1f;二、include 双引号和尖括号的区别#xff1f;三、set 的底层数据结构#xff1f;四、set 和 multiset 的区别#xff1f;五、map 和 unordered_map 的区别#xff1f;六、虚函数和纯虚函数的区别#xff1f;七、extern C … 文章目录 一、vector 和 list 的区别二、include 双引号和尖括号的区别三、set 的底层数据结构四、set 和 multiset 的区别五、map 和 unordered_map 的区别六、虚函数和纯虚函数的区别七、extern C 有了解过吗有什么用介绍一下用途。在 C语言 中可以使用 extern C 吗八、构造函数和析构函数哪个可以定义为虚函数为什么九、多线程如何保证同步如何保证线程安全十、TCP 和 UDP 的区别十一、UDP 是不可靠的如何设计得可靠呢十二、内核是如何发送http数据包的 是二层三层设备呢路由器或交换机是哪一层解到哪一层十三、OS 概念中堆和栈的区别这两个数据结构的效率相比如何 一、vector 和 list 的区别
vector和list都是C标准库中的容器类但它们在存储结构和操作性能上有显著的不同。
存储结构
vector: 底层实现为动态数组即数组大小可动态调整。它支持连续的内存分配。list: 底层实现为双向链表。它由一系列节点组成每个节点存储数据并包含指向前后节点的指针。
元素访问
vector: 支持通过下标直接访问元素例如 v[i]。访问时间是常数时间 O(1)因为它是一个连续内存块。list: 不支持通过下标访问元素。需要遍历链表来访问指定位置的元素访问时间是线性的 O(n)。
插入和删除操作 vector : 在末尾插入或删除元素通常是常数时间 O(1)除非需要扩展或重新分配内存。在中间或前端插入或删除元素时需要移动元素时间复杂度为 O(n)。 list: 在任意位置前端、后端或中间插入和删除元素的时间复杂度是常数时间 O(1)前提是已知位置需要先遍历到该位置。不需要移动其他元素因此在需要频繁插入和删除时性能优越。
内存管理
vector: 由于是动态数组内存是连续分配的因此可能需要重新分配内存以容纳更多元素尤其是在插入大量元素时可能会发生内存重分配。list: 每个节点在内存中是独立分配的因此没有内存重分配的问题。但是由于存储了指针前后节点指针它比vector占用更多内存。
迭代器
vector: 迭代器可以通过加法或减法来移动支持随机访问因此可以高效地进行跳跃式遍历。list: 迭代器只能顺序遍历不能进行随机访问因此需要通过或--来前后移动性能较差。
适用场景
vector: 适用于需要频繁随机访问、较少进行插入或删除操作的场景。比如数组大小已知或者插入和删除发生在末尾的情况。list: 适用于需要频繁插入和删除元素、对访问顺序要求较低的场景比如链表结构、双向链表等。
总结
vector适合频繁随机访问、少量插入删除的场景。list适合频繁插入删除、顺序访问的场景。 二、include 双引号和尖括号的区别
在C中#include指令用于引入头文件。使用双引号和尖括号在 #include 中有一些不同的含义它们决定了编译器搜索头文件的位置。
双引号 #include file.h 搜索顺序 首先编译器会在当前源文件所在的目录中查找该文件。如果在当前目录中没有找到文件编译器会继续在标准系统目录中查找这些目录通常是编译器的库路径或其他指定的路径。 适用场景 当你包含的是项目中自定义的头文件时通常使用双引号。例如你自己编写的类、库、模块等的头文件。 #include myheader.h // 搜索当前目录和标准目录尖括号 #include file.h 搜索顺序 编译器通常首先在系统的标准库路径中查找该文件比如 /usr/include/或者编译器的标准头文件目录。它不会先查找当前目录或项目目录。 适用场景 当你包含的是系统提供的头文件或者标准库的头文件时通常使用尖括号。例如iostream、vector、string 等。 #include iostream // 搜索系统的标准库目录总结
#include file.h编译器先在当前目录查找然后在标准目录中查找。通常用于项目中的自定义头文件。#include file.h编译器只在标准系统库目录查找通常用于标准库或第三方库的头文件。
额外说明
如果你有一个项目中自定义的头文件并且希望它只被从项目目录中查找可以选择使用双引号来确保只从项目目录加载。如果一个头文件是标准库的一部分或者是你希望它从标准系统库中查找的库文件则使用尖括号。 三、set 的底层数据结构
在C标准库中std::set 是一个用于存储唯一元素的容器。它的底层数据结构通常是 平衡二叉搜索树具体来说常见的实现是 红黑树Red-Black Tree。
std::set 的底层数据结构——红黑树
红黑树 红黑树是一种自平衡的二叉查找树BST它能保持树的平衡确保所有操作插入、删除、查找的时间复杂度为 O(log n)。红黑树具有以下几个特性 每个节点是红色或黑色的。根节点是黑色的。每个叶子节点NULL指针是黑色的。红色节点的两个子节点必须是黑色的即没有两个红色节点相邻。从任何节点到其子孙节点的所有路径都包含相同数量的黑色节点。 为什么使用红黑树 红黑树保证了树的高度是对数级别O(log n)这使得 set 的操作如插入、删除、查找在最坏情况下也能保持高效。由于是自平衡的红黑树能够有效地防止树的退化例如形成链表保持对数时间的操作效率。
std::set 的特点
自动排序set 中的元素是有序的默认情况下按升序排列用户也可以提供一个自定义的比较函数。唯一性set 中的元素是唯一的任何重复的元素会被忽略。效率由于底层使用红黑树插入、删除、查找等操作的时间复杂度为 O(log n)。
示例
#include iostream
#include setint main() {std::setint s;s.insert(10);s.insert(20);s.insert(30);s.insert(20); // 插入重复元素失败for (int x : s) {std::cout x ; // 输出: 10 20 30}return 0;
}总结
底层实现std::set 使用红黑树或其他自平衡二叉查找树作为底层数据结构。操作复杂度由于红黑树的特性set 的常见操作如插入、删除、查找的时间复杂度是 O(log n)。排序和唯一性set 保证元素的排序和唯一性适用于需要集合操作且元素需要有序的场景。 四、set 和 multiset 的区别
std::set 和 std::multiset 都是 C 标准库中的关联容器它们非常相似主要的区别在于 元素的唯一性 和 插入行为。
唯一性
std::set: 每个元素在 set 中是唯一的。也就是说如果你尝试插入一个已经存在的元素插入操作会失败元素不会被重复插入。std::multiset: 允许插入重复的元素。即使容器中已经存在相同的元素multiset 也会允许再次插入相同的元素所有的重复元素都会被保留。
插入操作 std::set: 当插入一个元素时如果该元素已经存在插入操作会被忽略不会有任何效果。插入成功时返回一个包含元素和布尔值的 pair布尔值指示插入是否成功。 std::setint s;
s.insert(10); // 插入成功
s.insert(10); // 插入失败10已经存在std::multiset: 允许插入重复元素每次插入都会添加一个新实例即使该元素已经存在。 std::multisetint ms;
ms.insert(10); // 插入成功
ms.insert(10); // 插入成功10会出现两次容器中的元素
std::set: 只包含唯一的元素即没有重复项。std::multiset: 可以包含重复的元素允许多个相同的元素存在。
查找和删除
查找操作在 set 和 multiset 中的效率相同都是 O(log n)。删除操作的行为也类似不同的是 在 set 中删除某个元素时如果该元素存在则会删除该元素。在 multiset 中删除某个元素时删除的是第一个找到的元素如果容器中有多个相同的元素可以通过重复删除来删除所有相同的元素。
迭代器
在 std::set 中迭代器遍历时不会访问重复元素因为它保证元素是唯一的。在 std::multiset 中迭代器会遍历到每个元素的每个副本因为它允许重复元素。
内存和性能差异
在内存使用上std::set 通常比 std::multiset 使用更少的内存因为它不需要存储重复元素的数量。在性能上两者的插入、查找和删除操作的时间复杂度都是 O(log n)但 multiset 可能需要在插入时进行更多的操作因为它可能需要保留多个副本。
常见用途
std::set: 用于需要确保元素唯一的场景如去重、集合运算、需要查找唯一元素的场合。std::multiset: 用于允许重复元素的场景如计数重复元素、统计词频、集合中包含重复项的情况。
代码示例
#include iostream
#include setint main() {std::setint s;s.insert(10);s.insert(20);s.insert(20); // 插入失败因为set不允许重复元素std::cout Set elements: ;for (int x : s) {std::cout x ; // 输出: 10 20}std::cout std::endl;std::multisetint ms;ms.insert(10);ms.insert(20);ms.insert(20); // 插入成功multiset允许重复元素std::cout Multiset elements: ;for (int x : ms) {std::cout x ; // 输出: 10 20 20}std::cout std::endl;return 0;
}总结
std::set元素唯一不能插入重复元素。std::multiset允许重复元素可以插入多个相同的元素。
set 适合需要唯一元素的场景而 multiset 适用于需要保存重复元素的场景。 五、map 和 unordered_map 的区别
std::map 和 std::unordered_map 都是 C 标准库中的关联容器用于存储键值对key-value pairs。它们之间的主要区别在于 存储结构、元素的排序 和 操作的时间复杂度。
底层数据结构 std::map: 使用 平衡二叉搜索树通常是 红黑树作为底层数据结构。键值对根据 键key 排序默认按升序排列可以自定义排序规则。 std::unordered_map : 使用 哈希表hash table作为底层数据结构。键值对不按任何特定顺序排列而是根据哈希函数计算得到的哈希值存储。哈希表存储方式允许更快速的查找。
元素排序 std::map : 键值对会根据键进行排序。默认情况下元素按键的升序排列可以通过提供一个自定义比较器来改变排序规则。查找、插入、删除等操作的时间复杂度是 O(log n)因为每次操作都涉及到红黑树的调整。 std::unordered_map : 键值对的顺序是 无序的即不保证按照任何特定顺序存储元素。查找、插入和删除等操作的平均时间复杂度为 O(1)但最坏情况下哈希冲突严重会退化为 O(n)。
时间复杂度 std::map : 查找、插入、删除等操作的时间复杂度为 O(log n)因为需要在红黑树上进行平衡操作。 std::unordered_map: 查找、插入、删除等操作的平均时间复杂度为 O(1)因为哈希表的查找通常非常高效。只有在哈希冲突发生时操作时间复杂度可能退化为 O(n)。
内存使用 std::map: 每个节点存储一个键值对并且为了保持树的平衡还需要额外的指针和信息来维护树的结构。相对来说map 会占用更多的内存。 std::unordered_map : 哈希表的大小通常较小且在负载因子过大时可能会重新调整大小。每个桶bucket存储一个链表或其他数据结构来处理哈希冲突因此它的内存开销可能会更大。
查找效率
std::map: 查找操作的时间复杂度是 O(log n)每次查找都需要沿着树的路径向下遍历。 std::unordered_map: 查找操作的平均时间复杂度是 O(1)哈希表的查找速度非常快尤其在没有哈希冲突的情况下。
适用场景 std::map: 适用于需要 键有序 的场景。例如你需要按键的顺序遍历元素或者需要执行范围查询比如查找某个区间内的元素。用于需要对元素按顺序进行排序、快速查找的场景。 std::unordered_map : 适用于不关心元素的顺序只关心快速查找、插入和删除的场景。适合用作哈希表比如存储需要快速查找的键值对。
使用示例
#include iostream
#include map
#include unordered_mapint main() {// std::map示例键有序std::mapint, std::string m;m[3] Three;m[1] One;m[2] Two;std::cout Map elements (ordered by key):\n;for (const auto pair : m) {std::cout pair.first : pair.second \n;}// std::unordered_map示例键无序std::unordered_mapint, std::string um;um[3] Three;um[1] One;um[2] Two;std::cout \nUnordered Map elements (unordered):\n;for (const auto pair : um) {std::cout pair.first : pair.second \n;}return 0;
}输出示例
Map elements (ordered by key):
1: One
2: Two
3: ThreeUnordered Map elements (unordered):
1: One
2: Two
3: Three注意unordered_map 的输出顺序是无序的而 map 会根据键的升序排序。
总结
特性std::mapstd::unordered_map底层数据结构红黑树平衡二叉查找树哈希表元素排序按键升序排列可自定义排序无序操作时间复杂度O(log n)O(1)平均情况下查找效率较慢需要遍历树快速哈希查找内存使用较高树节点和指针开销较低但有可能需要更多的内存来存储桶适用场景需要元素有序的场景如范围查询不关心顺序需要快速查找的场景
总结如果你需要保持元素的顺序或者需要按键排序、执行范围查询使用 std::map。如果你不关心元素的顺序只需要快速的查找、插入和删除操作可以使用 std::unordered_map。 六、虚函数和纯虚函数的区别
在 C 中虚函数virtual function和纯虚函数pure virtual function是面向对象编程OOP中的关键概念它们都用于支持多态性但在行为和用途上有一些区别。
虚函数Virtual Function
虚函数是基类中声明为虚拟的成员函数允许派生类对其进行重写即覆盖。虚函数可以在基类中有实现也可以没有实现。关键特性包括
允许多态当基类的指针或引用指向派生类对象时可以调用派生类中重写的虚函数达到多态的效果。可以有实现虚函数可以在基类中提供默认实现派生类可以选择重写或不重写这个虚函数。
示例
#include iostream
using namespace std;class Base {
public:virtual void show() { // 虚函数有实现cout Base class endl;}
};class Derived : public Base {
public:void show() override { // 重写虚函数cout Derived class endl;}
};int main() {Base* b; Derived d;b d;b-show(); // 调用派生类的 show(), 输出 Derived classreturn 0;
}在上面的示例中Base 类中的 show 函数是虚函数当基类指针指向派生类对象时调用的是派生类重写的 show 函数而不是基类的实现。这是 多态 的体现。
纯虚函数Pure Virtual Function
纯虚函数是没有实现的虚函数用 0 语法进行声明。纯虚函数的存在意味着 基类是抽象类无法直接实例化。所有派生类都必须重写纯虚函数除非派生类也定义为抽象类。
特点
没有实现纯虚函数在基类中没有实现只有声明。抽象类包含纯虚函数的类成为抽象类不能直接创建实例。强制派生类重写如果派生类继承了包含纯虚函数的基类那么派生类必须重写这些纯虚函数除非派生类也声明为抽象类。
示例
#include iostream
using namespace std;class Base {
public:virtual void show() 0; // 纯虚函数必须被重写
};class Derived : public Base {
public:void show() override { // 重写纯虚函数cout Derived class endl;}
};int main() {// Base b; // 错误无法实例化抽象类Derived d;d.show(); // 调用派生类的 show()return 0;
}在这个例子中Base 类中定义了纯虚函数 show()因此 Base 是一个抽象类不能直接创建 Base 类的实例。Derived 类继承自 Base 并实现了 show()使得 Derived 类可以实例化并调用 show()。
虚函数与纯虚函数的区别
特性虚函数纯虚函数定义在基类中声明并且可以提供实现。在基类中声明但不提供实现直接赋值为 0。基类是否可实例化基类仍然可以实例化即使有虚函数基类也能创建对象。基类是抽象类不能实例化。派生类的要求派生类可以选择重写或不重写虚函数。派生类必须重写纯虚函数除非派生类也声明为抽象类。使用目的支持多态性和动态绑定允许基类定义行为并在派生类中覆盖。强制派生类实现某些接口通常用于定义接口或抽象类。是否可以有实现可以有实现。没有实现只有声明。
例子总结
虚函数
多态基类提供一个通用接口派生类根据需要实现或覆盖该接口。可选覆盖派生类可以选择覆盖该虚函数也可以使用基类的实现。
纯虚函数
接口用于强制派生类提供特定的实现通常用于定义接口或抽象类。必须覆盖派生类必须实现纯虚函数否则该派生类仍然是抽象类不能实例化。
何时使用虚函数何时使用纯虚函数
虚函数当你希望基类提供一个默认的实现但允许或要求派生类重写这个实现时可以使用虚函数。例如基类提供一个通用的接口派生类可以根据需要改变行为。纯虚函数当你需要一个接口或抽象类强制所有派生类提供特定功能的实现时使用纯虚函数。例如你可以定义一个接口类所有派生类必须实现这个接口。
总结
虚函数允许派生类覆盖可以有默认实现基类可以实例化。纯虚函数强制派生类覆盖没有默认实现基类不能实例化。 七、extern C 有了解过吗有什么用介绍一下用途。在 C语言 中可以使用 extern C 吗
是的extern C 是 C 中的一个关键字它用来告诉编译器使用 C 语言 的链接规则而不是 C 的链接规则。这个关键字在 C 和 C 之间进行混合编程时尤其重要。
extern C 的作用
extern C 主要用于指示 C 编译器按照 C 语言的方式来处理某些函数的名字修饰name mangling和链接linking方式。C 编译器会为每个函数和变量生成一个“修饰过”的符号名name mangling这是为了支持函数重载等特性。但 C 语言不支持重载因此 C 函数没有这种名称修饰。
extern C 告诉编译器不使用 C 的名字修饰而是按照 C 的方式来处理函数的链接这样就能够在 C 中调用 C 语言编写的函数或者在 C 语言代码中调用 C 编写的函数。
extern C 的基本用法
在 C 中当你要调用 C 语言的函数时可以用 extern C 来指定链接方式。通常是在头文件中使用 extern C 来包装 C 函数声明。
示例在 C 中调用 C 函数
// C 函数声明 (C 文件)
#ifdef __cplusplus
extern C {
#endifvoid c_function(); // C 函数声明#ifdef __cplusplus
}
#endif这样编译器就知道 c_function 是一个 C 风格的函数而不是 C 风格的函数并且它会按照 C 的链接规则来处理该函数。
示例在 C 代码中使用 C 函数
// C 代码
#include iostreamextern C {#include c_header.h // 引用 C 语言的头文件
}int main() {c_function(); // 调用 C 语言中的函数return 0;
}为什么需要 extern C
C 是向后兼容 C 的但 C 编译器在处理函数时使用了 名字修饰name mangling这意味着 C 会为每个函数生成一个唯一的符号名。例如如果你定义了一个名为 foo 的函数C 编译器会为它生成一个修饰过的名字比如 _Z3foov。这种修饰是为了支持函数重载、命名空间等特性。然而C 语言并不支持这些特性因此它的符号名没有任何修饰直接是 foo。
如果你在 C 中调用 C 语言的函数而不使用 extern C编译器会试图用 C 的名字修饰规则处理这些函数导致链接错误。通过 extern C你可以强制编译器按照 C 的链接规则处理函数从而避免名字修饰的问题。
如何在 C 语言中使用 extern C
extern C 是 C 的特性C 语言本身不能使用 extern C。但在 C 语言中我们通常使用 extern 来声明其他文件中的函数。在 C 中只需要使用标准的 extern 关键字来引用外部函数
示例C 中使用 extern
// C 文件
extern void c_function(); // 声明外部函数C 语言不需要 extern C因为它没有名字修饰问题。
常见应用场景
C 与 C 混合编程当你需要在 C 项目中调用 C 语言编写的库函数或者在 C 语言项目中调用 C 编写的函数时extern C 可以确保两者之间的符号不会发生冲突。创建 C 库接口供 C 使用有时你可能需要将 C 写的代码封装成 C 风格的接口这样其他 C 语言程序也能调用。使用 extern C 来确保 C 代码导出的符号符合 C 的命名规则。跨语言调用如果你在一个多语言项目中例如C 和 Python、JavaScript 的扩展模块等extern C 可以帮助确保 C 函数能够正确地暴露给其他语言。
示例C 库封装 C 接口
假设你写了一个 C 函数库并希望让 C 语言程序调用这些函数
C 代码库实现
// cplus_library.cpp
#include iostreamextern C {void hello_from_cpp() {std::cout Hello from C! std::endl;}
}C 代码调用 C 库
// main.c
extern void hello_from_cpp(); // 声明 C 函数int main() {hello_from_cpp(); // 调用 C 函数return 0;
}通过在 C 代码中使用 extern C我们确保 hello_from_cpp() 的符号是按 C 风格进行链接的从而使 C 代码能够正确地调用它。
总结
extern C 是 C 的语法用来指示编译器按照 C 的链接规则来处理函数声明或定义避免 C 的名字修饰。它通常用于 C 调用 C 语言库 或 C 库暴露给 C 语言程序 的情况。在 C 语言中没有 extern C因为 C 语言本身没有名字修饰的问题。 八、构造函数和析构函数哪个可以定义为虚函数为什么
在 C 中构造函数和析构函数都可以被定义为虚函数但是它们的使用方式和效果有很大不同。特别是在析构函数上虚函数有着重要的作用而构造函数则不能通常情况下被定义为虚函数。下面详细介绍一下两者的差异
构造函数能否是虚函数
构造函数 不能 被定义为虚函数原因如下
构造函数的调用时机构造函数在对象创建时调用并且只会在对象的实例化过程中执行。因此在构造函数执行时派生类的部分还没有构造完成。也就是说在基类的构造函数执行时派生类的构造函数尚未被调用这使得虚函数机制无法正常工作。虚函数机制的工作原理虚函数是基于动态绑定运行时多态机制的它通过对象的虚表vtable来实现。在构造函数执行时虚表尚未完全建立因此无法正确调用派生类的虚函数。
因此虽然构造函数可以声明为 virtual但它不会按虚函数的方式工作。C 编译器会将构造函数视为普通成员函数而不会进行虚拟调用。
示例
#include iostream
using namespace std;class Base {
public:Base() { // 构造函数不能是虚函数cout Base constructor endl;}virtual void show() { // 虚函数cout Base show endl;}
};class Derived : public Base {
public:Derived() {cout Derived constructor endl;}void show() override {cout Derived show endl;}
};int main() {Base* b new Derived(); // 这里调用的是基类的构造函数而非派生类b-show(); // 调用派生类的show()因为show是虚函数delete b;return 0;
}输出
Base constructor
Derived constructor
Derived show在上面的例子中Base 的构造函数 不会 调用派生类的构造函数因为构造函数不是虚拟的所以在执行基类的构造函数时派生类的部分尚未构建。
析构函数可以是虚函数吗
析构函数通常是虚函数并且 应当 被定义为虚函数特别是在基类中。如果你有一个继承层次结构并且通过基类指针删除派生类对象那么如果基类的析构函数不是虚函数可能会导致 资源泄漏 或 未定义行为。这是因为 C 的删除操作是基于对象的类型来调用析构函数的如果基类的析构函数不是虚函数删除时只会调用基类的析构函数而不会调用派生类的析构函数。
为什么析构函数应该是虚函数
多态删除当你通过基类指针或引用来删除派生类对象时析构函数的虚拟机制确保正确调用派生类的析构函数以便派生类可以释放自己分配的资源比如动态内存、文件句柄等。避免资源泄漏如果基类的析构函数不是虚函数在删除对象时基类析构函数会被调用但派生类的析构函数不会被调用可能会导致派生类中的资源没有得到释放从而发生内存泄漏或其他问题。
示例
#include iostream
using namespace std;class Base {
public:virtual ~Base() { // 虚析构函数cout Base destructor endl;}virtual void show() {cout Base show endl;}
};class Derived : public Base {
public:~Derived() { // 派生类析构函数cout Derived destructor endl;}void show() override {cout Derived show endl;}
};int main() {Base* b new Derived();delete b; // 正确删除派生类对象调用派生类和基类的析构函数return 0;
}输出
Derived destructor
Base destructor在上面的例子中基类的析构函数是虚函数因此当通过基类指针 b 删除派生类对象时首先会调用派生类的析构函数随后再调用基类的析构函数确保所有资源得到释放。
总结构造函数与析构函数作为虚函数的区别
特性构造函数析构函数是否可以是虚函数不可以C 中无法使用虚构造函数可以并且通常应该是虚函数虚函数机制构造函数在对象创建过程中调用无法使用虚函数机制在对象销毁时虚析构函数机制会确保派生类析构函数被调用作用构造函数用于初始化对象不支持多态析构函数用于销毁对象并释放资源支持多态适用情况主要用于初始化对象不能依赖多态主要用于释放资源确保多态析构避免内存泄漏
何时使用虚析构函数
多态对象销毁当你通过基类指针或引用销毁一个派生类对象时基类析构函数应该是虚函数以确保派生类的析构函数被正确调用避免资源泄漏。抽象类的销毁当基类类是抽象类并且它的派生类对象需要被销毁时基类析构函数需要是虚的。
总结
构造函数不能是虚函数因为构造函数是在对象创建过程中调用的而虚函数依赖于对象的虚表vtable而此时虚表尚未建立。析构函数应该是虚函数特别是在基类中以确保通过基类指针或引用删除派生类对象时派生类的析构函数能够被调用从而避免资源泄漏。 九、多线程如何保证同步如何保证线程安全
在多线程编程中同步和线程安全是两个关键概念它们确保了多个线程在访问共享资源时不会产生竞争条件、数据损坏或其他不一致的情况。为了保证多线程程序的同步和线程安全通常使用各种同步机制和设计模式。
同步 (Synchronization)
同步是指在多线程程序中控制对共享资源的访问以防止多个线程同时修改同一数据从而导致数据的不一致性。同步的目标是确保在某一时刻只有一个线程能够访问共享资源。
常见的同步方法包括
互斥量 (Mutex)读写锁 (Read/Write Lock)条件变量 (Condition Variable)信号量 (Semaphore)
1.1 互斥量 (Mutex)
mutex互斥量是最常用的同步工具它确保在同一时刻只有一个线程能访问共享资源。当一个线程在临界区内访问资源时其他线程必须等待该线程释放锁。
使用方法通过加锁和解锁操作确保同一时刻只有一个线程能进入临界区。优点简单易用广泛支持。缺点可能会导致线程阻塞性能下降甚至死锁。
示例
#include iostream
#include thread
#include mutexstd::mutex mtx;void print_numbers(int id) {std::lock_guardstd::mutex guard(mtx); // 加锁自动解锁std::cout Thread id is printing numbers: ;for (int i 0; i 5; i) {std::cout i ;}std::cout std::endl;
}int main() {std::thread t1(print_numbers, 1);std::thread t2(print_numbers, 2);t1.join();t2.join();return 0;
}在上面的代码中std::mutex 用来保护共享资源确保在同一时刻只有一个线程在访问资源。
1.2 读写锁 (Read/Write Lock)
读写锁允许多个线程并行地读取共享数据但如果某个线程正在写数据时其他线程无法读取或写入。它适用于读多写少的场景可以提高性能。
使用方法当多个线程需要读取共享资源时它们可以同时获得读锁。只有当所有读锁释放后写锁才能被获得保证写操作的独占性。优点提高了并发性适用于读多写少的场景。缺点实现相对复杂写操作的性能可能受到影响。
示例
#include iostream
#include thread
#include shared_mutexstd::shared_mutex rw_lock;void read_data(int id) {std::shared_lockstd::shared_mutex lock(rw_lock); // 读锁std::cout Thread id is reading data std::endl;
}void write_data(int id) {std::unique_lockstd::shared_mutex lock(rw_lock); // 写锁std::cout Thread id is writing data std::endl;
}int main() {std::thread t1(read_data, 1);std::thread t2(read_data, 2);std::thread t3(write_data, 3);t1.join();t2.join();t3.join();return 0;
}1.3 条件变量 (Condition Variable)
条件变量通常与互斥量配合使用允许线程在某些条件下等待直到其他线程通知它们继续工作。它通常用于生产者-消费者问题等场景。
使用方法线程可以等待某个条件满足再继续执行其他线程可以通知条件已满足。优点适用于需要线程间协调的复杂场景。缺点使用不当容易出现死锁、活锁等问题。
示例
#include iostream
#include thread
#include mutex
#include condition_variablestd::mutex mtx;
std::condition_variable cv;
bool ready false;void print_id(int id) {std::unique_lockstd::mutex lck(mtx);while (!ready) cv.wait(lck); // 等待通知std::cout Thread id std::endl;
}void go() {std::lock_guardstd::mutex lck(mtx);ready true;cv.notify_all(); // 通知所有等待的线程
}int main() {std::thread threads[10];for (int i 0; i 10; i)threads[i] std::thread(print_id, i);std::cout 10 threads ready to race...\n;go(); // 发出通知for (auto th : threads) th.join();return 0;
}1.4 信号量 (Semaphore)
信号量是另一种同步工具用于控制多个线程对共享资源的访问。与互斥量不同信号量允许多个线程并行地访问资源通常用于控制资源的数量。
使用方法信号量通过计数器来管理资源的数量当计数器为 0 时线程必须等待。优点适用于控制资源池等场景。缺点可能会导致线程饥饿等问题。
保证线程安全
线程安全是指在多线程环境下多个线程可以安全地访问和修改共享数据而不会引起数据不一致或程序崩溃。
保证线程安全通常有以下几种方法
2.1 互斥量 (Mutex)
最常见的保证线程安全的方法是使用互斥量std::mutex来保护共享资源。只有获得锁的线程才能访问共享资源避免多个线程同时访问导致的数据竞争。
2.2 原子操作 (Atomic Operations)
使用原子操作可以在不需要加锁的情况下保证某些操作的原子性。C 提供了原子类型如 std::atomic来确保对共享数据的操作是不可分割的。
优点性能较高不需要加锁。缺点只适用于某些特定的操作例如增减计数器等简单操作对于复杂的操作使用原子类型可能并不合适。
示例
#include iostream
#include atomic
#include threadstd::atomicint counter(0);void increment() {for (int i 0; i 1000; i) {counter.fetch_add(1, std::memory_order_relaxed); // 原子加法}
}int main() {std::thread t1(increment);std::thread t2(increment);t1.join();t2.join();std::cout Counter: counter.load() std::endl; // 输出 2000return 0;
}2.3 线程局部存储 (Thread-local storage, TLS)
线程局部存储确保每个线程都有自己的独立副本避免多个线程共享同一数据。C 提供了 thread_local 关键字来定义线程局部存储。
示例
#include iostream
#include threadthread_local int counter 0;void increment() {counter;std::cout Thread counter: counter std::endl;
}int main() {std::thread t1(increment);std::thread t2(increment);t1.join();t2.join();return 0;
}总结
同步多线程编程中的同步机制确保在同一时刻只有一个线程能访问共享资源避免数据竞争。常见的同步方法有互斥量、读写锁、条件变量和信号量。线程安全通过互斥量、原子操作和线程局部存储等技术可以确保多线程环境下的代码是线程安全的避免数据不一致和竞态条件。
为了实现线程安全选择合适的同步机制是非常重要的同时也要注意性能开销和潜在的死锁风险。在并发编程中通常需要权衡并发性、可扩展性和程序的复杂度。 十、TCP 和 UDP 的区别
TCPTransmission Control Protocol和 UDPUser Datagram Protocol都是用于网络通信的传输层协议它们各有特点适用于不同的场景。以下是它们的主要区别
连接方式
TCP是面向连接的协议。通信前客户端和服务器之间需要通过三次握手three-way handshake建立连接。在数据传输完成后需要通过四次挥手four-way handshake来断开连接。UDP是无连接的协议。发送数据时不需要建立连接数据包可以直接发送到目标地址接收方不需要响应。
可靠性
TCP提供可靠的数据传输确保数据包按顺序到达目标并且在数据丢失或出错时能够进行重传。通过序列号、确认应答、重传机制等方式来确保可靠性。UDP不保证数据的可靠性数据包可能丢失、重复、乱序。UDP 只是简单地将数据从发送方传输到接收方不做额外的检查和控制。
数据顺序
TCP保证数据按顺序到达接收方。即使数据包乱序TCP 也会重新排序。UDP不保证数据顺序。接收方收到的可能是乱序的数据包需要应用层自行处理。
流量控制和拥塞控制
TCP提供流量控制通过滑动窗口技术和拥塞控制如慢启动、拥塞避免算法。它可以根据网络的拥塞情况调整发送速率避免网络崩溃。UDP没有流量控制和拥塞控制。它不关心网络的状况只管尽快把数据发送出去。
传输速度
TCP由于其需要进行连接管理、可靠性保证、数据重传等操作因此相较于 UDPTCP 的传输速度较慢。UDP因为它不需要建立连接、没有重传、没有流量控制等机制所以在理论上UDP 的传输速度更快。
头部开销
TCP头部较大通常为 20 字节包含了连接管理和流控制等额外的信息。UDP头部较小只有 8 字节。它没有连接管理信息因此开销比 TCP 小。
应用场景
TCP适用于需要高可靠性、数据完整性和顺序保证的应用场景。例如 网页浏览HTTP/HTTPS电子邮件SMTP/IMAP/POP3文件传输FTP远程登录SSH/Telnet UDP适用于对传输速度要求较高但对数据丢失或顺序不太敏感的应用。例如 实时音视频传输VoIP、视频会议、直播在线游戏DNS 查询广播/组播如 IPTV
错误检测
TCP使用校验和进行错误检测确保数据传输过程中没有发生错误。出现错误时TCP 会请求重传。UDP也使用校验和进行错误检测但没有错误纠正机制。出现错误时数据会丢弃应用层可以自行决定是否需要重传。
头部内容
TCP 头部比 UDP 更复杂除了基本的源端口、目的端口、数据、校验和外还包括序列号、确认号、窗口大小、标志位如 SYN、ACK、重传计数等。UDP 头部非常简单只有源端口、目的端口、长度和校验和。
速度与效率
TCP提供可靠性但由于需要建立连接、管理流量、进行重传等操作导致开销较大传输速度较慢。UDP没有这些控制机制传输速度较快但不可靠适合实时性要求高的应用。
总结
特性TCPUDP连接方式面向连接无连接可靠性提供可靠的传输确保数据完整性和顺序不保证数据可靠性可能丢失或乱序数据顺序保证按顺序到达不保证数据顺序流量控制提供流量控制避免网络拥堵无流量控制拥塞控制提供拥塞控制无拥塞控制传输速度较慢因其可靠性控制和管理开销较大较快开销小头部开销较大通常为 20 字节较小通常为 8 字节适用场景需要可靠性保证的场景如网页浏览、文件传输实时通信、高速传输要求的场景如视频流、在线游戏
适用场景举例
TCP电子商务网站、邮件系统、FTP 文件传输等需要确保数据传输的完整性和顺序。UDP直播视频、在线语音、DNS 查询等数据传输速率要求高而对丢包和顺序不敏感。
选择使用 TCP 还是 UDP 取决于具体应用的需求。如果你需要可靠的、顺序保证的传输应该选择 TCP如果你的应用对速度要求较高且可以容忍一定的数据丢失可以考虑使用 UDP。 十一、UDP 是不可靠的如何设计得可靠呢
虽然 UDP 是不可靠的协议不保证数据的可靠性、顺序或完整性但是在某些应用场景中我们可能希望通过 UDP 来实现 可靠性尤其是当我们需要较低的延迟或者高效的网络通信时。为了使基于 UDP 的通信可靠通常需要在应用层设计和实现一些机制来弥补 UDP 的不足。以下是几种常用的方法来实现 UDP 的可靠性。
重传机制
UDP 不保证数据的可靠传输因此在应用层实现重传机制是确保可靠性的关键。
丢包检测接收方可以根据收到的数据包的序列号检测哪些包丢失了。如果丢包接收方可以请求发送方重传丢失的数据包。重传请求发送方在发送数据包时为每个数据包分配一个唯一的序列号。接收方收到数据包后会确认ACK如果发送方未收到确认则会重传该数据包。
示例
发送方为每个数据包分配一个序列号发送数据包。接收方根据序列号检查是否接收到丢失的数据包若丢失则向发送方发送重传请求。发送方接收到重传请求后重新发送丢失的数据包。
这种机制可以通过实现 超时重传 和 序列号 来确保数据的可靠性。
序列号与确认机制ACK
为了确保数据的顺序和完整性可以使用 序列号 和 确认机制。
序列号发送方为每个数据包分配一个唯一的序列号接收方根据序列号来确认是否按顺序接收数据。确认ACK接收方在接收到数据包后发送一个确认包ACK表示数据包已成功接收。发送方在一定时间内未收到确认时将重新发送数据包。
序列号和 ACK 的实现过程
发送方为每个数据包分配一个唯一的序列号发送后等待接收方的确认。接收方收到数据包后发送一个确认消息给发送方确认已收到某个序列号的数据包。如果接收方检测到丢失的数据包可以请求发送方重新发送。发送方如果在设定的超时时间内未收到确认消息则重新发送该数据包。
这种方式确保了数据按顺序传输并且能够在发生丢包时进行重传。
顺序控制
因为 UDP 不保证数据的顺序到达所以在某些场景下需要在应用层自己实现顺序控制。
序列号管理每个数据包都要附带一个递增的序列号接收方根据序列号判断是否按顺序接收数据。如果数据包乱序接收方可以将乱序的数据缓存直到缺失的数据包到达。乱序处理接收方需要维护一个缓冲区来存储乱序的数据包并根据序列号进行排序。
数据完整性检查
UDP 提供了简单的校验和功能但这只是用于检测数据在传输过程中的基本错误。为了确保数据的完整性应用层可以实现更复杂的校验和如哈希值、CRC 检查
校验和每个数据包携带校验和接收方对数据进行验证如果数据包损坏则丢弃数据包并请求重传。哈希校验可以使用 SHA 或 MD5 等算法计算数据包的哈希值接收方进行校验确保数据的完整性。
流量控制
虽然 UDP 本身不提供流量控制但在一些应用场景中发送方的发送速率可能会超过接收方的处理能力导致数据丢失。为了避免这种情况可以在应用层实现流量控制
速率控制根据接收方的处理能力控制发送方的发送速率确保接收方有足够的时间来处理接收到的数据。缓冲区管理接收方可以在接收数据时使用缓冲区来暂时存储数据防止数据溢出。
心跳包与超时重传
为了确保连接的活跃性和及时检测丢失的连接应用层可以实现 心跳包 和 超时重传
心跳包定期发送心跳包来保持连接的活跃性。如果发送方未收到心跳响应则认为连接中断。超时重传如果发送方在一定时间内未收到确认包则认为该数据包丢失需要重新发送。
流式协议设计类似于 TCP
可以在 UDP 基础上实现一个简化的 流式协议这个协议类似于 TCP但只做最基础的可靠性保障比如
使用 确认ACK 和 重传机制。通过 序列号 和 窗口管理 来确保顺序。在网络出现问题时进行 拥塞控制 和 重传。
这种方式可以确保数据的可靠传输但也会带来一定的开销。
应用层协议设计如 QUIC、RTP
一些现代的应用层协议如 QUIC 或 RTP采用了基于 UDP 的可靠性增强机制。这些协议通过在 UDP 之上实现自己的可靠性保障如顺序控制、重传机制、拥塞控制等来提高 UDP 的可靠性。例如
QUICGoogle 开发的协议基于 UDP具有流量控制、拥塞控制、加密以及可靠传输等特性广泛应用于 HTTP/3。RTPReal-time Transport Protocol用于音视频传输结合序列号、时间戳、重传等机制保证实时数据的顺序和时效性。
总结
UDP 的 不可靠性 是它的一大特点但在许多场景中如果需要低延迟、高效率的通信并且愿意通过额外的开销来实现可靠性保障可以在 应用层 对 UDP 进行改进采用以下措施
重传机制序列号与确认机制数据完整性校验流量控制与心跳包顺序控制基于 UDP 的应用层协议设计如 QUIC、RTP
这些方法可以有效地增强 UDP 的可靠性适用于那些对低延迟要求较高并能够接受额外编程复杂性的应用场景。 十二、内核是如何发送http数据包的 是二层三层设备呢路由器或交换机是哪一层解到哪一层 内核发送 HTTP 数据包的过程可以分为多个步骤其中涉及到网络协议栈中的各个层次包括应用层、传输层、网络层和数据链路层。具体地这个过程是通过 内核中的网络协议栈 来处理的。这里我将详细介绍内核发送 HTTP 请求时的流程并解释路由器和交换机的工作层次。 HTTP 数据包的发送流程 当内核发送 HTTP 数据包时实际的过程是由操作系统中的网络协议栈进行处理的。HTTP 是 应用层协议它运行在 传输层例如 TCP上而在 TCP 之下则是 网络层例如 IP再往下是 数据链路层例如 以太网。 1.1 应用层HTTP 请求 应用程序如浏览器构造 HTTP 请求并通过 套接字 与操作系统内核进行通信。应用程序通过调用系统 API如 send() 或 write()将 HTTP 请求传递给内核。内核将 HTTP 请求数据交给 传输层TCP/IP 栈的上层进行处理。 1.2 传输层TCP/IP 在传输层HTTP 数据被封装在 TCP 数据包 中。TCP 协议提供了可靠的端到端数据传输。内核为 HTTP 数据包分配 TCP 头部包括源端口、目标端口例如 80 端口用于 HTTP443 端口用于 HTTPS以及其他 TCP 必需的控制信息如序列号、确认号、标志位等。内核计算并添加 校验和在 TCP 和 IP 层然后将封装好的 TCP 数据包传递到 网络层IP 层。 1.3 网络层IP 协议 在 网络层内核将数据包封装在 IP 数据包 中并添加 IP 头部包括源 IP 地址、目标 IP 地址、协议类型例如 TCP 或 UDP以及其他路由所需的信息如生存时间 TTL。如果目标主机在本地网络内数据包将被发送到本地设备如网卡。如果目标主机在远程网络数据包将被发送到默认网关通常是路由器。 1.4 数据链路层以太网协议 数据包传递到 数据链路层根据物理网络类型例如以太网进行封装。数据链路层将数据包封装在 帧 中并加上必要的 MAC 地址源 MAC 地址和目标 MAC 地址。此时数据包已经准备好通过物理网络传输。 1.5 物理层 最后数据通过物理介质如网线、Wi-Fi进行传输。物理层负责将数据转换为电信号、光信号等物理信号并发送出去。 路由器和交换机的工作层次 路由器和交换机在不同的 OSI 七层模型 中工作它们根据接收到的数据包的不同层次进行处理。 2.1 交换机工作在数据链路层第 2 层 交换机的主要功能是根据 MAC 地址 转发数据包。它检查数据包的 以太网帧头 中的目标 MAC 地址并根据交换机的 MAC 地址表或称为转发表决定将数据转发到哪个端口。交换机只工作在 数据链路层不关心 IP 地址 或其他更高层的信息。它不会处理 IP 或 TCP 协议只会转发帧。 2.2 路由器工作在网络层第 3 层 路由器的主要功能是根据 IP 地址 转发数据包。它接收来自其他网络的数据包并根据目的 IP 地址确定数据包的转发路径。路由器检查 IP 头部中的目标地址并决定数据包的下一跳即转发给哪个路由器或最终目标。路由器在处理数据包时不关心数据的应用层内容如 HTTP只根据 目标 IP 地址 进行路由。路由器工作在 网络层它也会检查和修改 IP 头部的相关信息如 TTL生存时间等。 2.3 层级关系总结 交换机工作在 数据链路层L2根据 MAC 地址转发数据帧。路由器工作在 网络层L3根据 IP 地址进行数据包的转发和路由。 实际发送 HTTP 数据包的过程总结 应用层应用程序如浏览器通过套接字发送 HTTP 请求。 传输层内核将 HTTP 请求封装到 TCP 数据包中。 网络层内核将 TCP 数据包封装到 IP 数据包中并根据目标 IP 地址决定路由。 数据链路层数据包被封装为以太网帧并使用 MAC 地址进行本地传输。 物理层数据通过物理介质传输到网络中的其他设备。 经过的设备 交换机在局域网内部交换机会在数据链路层根据 MAC 地址转发数据包。路由器当数据包需要跨越不同网络时路由器会根据 IP 地址决定路径并转发数据包。 路由器和交换机的解封装过程 交换机只处理以太网帧数据链路层它会查看帧头中的目标 MAC 地址将数据转发到正确的端口。如果目的地址是一个其他子网交换机会将数据包交给路由器。路由器路由器检查 IP 数据包的头部查看目标 IP 地址并根据路由表决定如何转发数据包。如果目标在本地网络路由器将数据包发送到目标设备如果目标在远程网络路由器将数据包转发到下一跳可能是另一台路由器。 结论 内核发送 HTTP 数据包 时数据从应用层通过传输层TCP、网络层IP、数据链路层以太网到物理层进行封装和传输。交换机 工作在 数据链路层根据 MAC 地址转发帧。路由器 工作在 网络层根据 IP 地址转发数据包。 总的来说内核通过 传输层TCP 发送 HTTP 数据包经过 网络层IP 和 数据链路层以太网 之后数据会通过物理层到达目标设备。在网络中交换机主要处理数据链路层的 MAC 地址路由器则处理网络层的 IP 地址转发数据包到目的地。 十三、OS 概念中堆和栈的区别这两个数据结构的效率相比如何
在操作系统中堆Heap 和 栈Stack 是两种常见的内存管理方式它们有不同的使用场景、特点和性能效率。以下是它们的主要区别、效率对比和应用场景。
堆Heap与栈Stack区别
1.1 内存分配方式
栈Stack栈是 后进先出LIFOLast In, First Out数据结构内存的分配和回收是自动的且按照调用顺序进行管理。每当一个函数被调用时操作系统会为它分配一块连续的内存区域这块区域被称为“栈帧”。栈上的内存由编译器自动管理当函数调用结束时栈帧被销毁内存也被释放。堆Heap堆是 无序的 内存区域内存分配和释放由程序员控制。堆通常用于动态分配内存。程序员可以在堆上分配任意大小的内存块例如通过 malloc() 或 new 等操作并手动释放内存如 free() 或 delete。堆内存的大小通常只受系统内存总量的限制。
1.2 内存管理
栈Stack栈内存的管理非常简单内存的分配和回收都由操作系统自动管理。当函数调用时栈上的数据会被自动压入栈中函数结束后栈上的数据会被自动弹出。内存是连续的且不需要显式释放因此栈内存的使用更加高效。堆Heap堆内存需要程序员显式地管理分配和释放。由于堆内存的分配和回收没有明确的顺序可能会导致内存碎片。操作系统需要维护堆的分配情况比如通过垃圾回收或手动调用内存管理函数。
1.3 内存大小
栈Stack栈的大小通常较小通常在几 MB通常操作系统为每个线程分配 1MB 至 8MB 的栈空间。栈的内存空间是有限的因此递归深度过大或者局部变量过多可能导致栈溢出Stack Overflow。堆Heap堆的大小通常较大可以通过操作系统动态分配更多的内存。堆的大小只受限于系统的可用内存因此它能够用于大规模的动态内存分配。
1.4 访问速度
栈Stack由于栈的内存分配遵循“后进先出”原则它的内存分配和回收都非常简单因此栈的访问速度较快。栈内存的分配是由 CPU 在硬件级别支持的通常会非常高效。堆Heap堆的分配比较复杂因为它需要寻找合适的空闲空间这可能涉及到内存碎片整理等操作因此堆的分配速度较慢。此外堆的内存管理通常比栈要复杂可能导致额外的开销。
1.5 生命周期
栈Stack栈上的变量如函数的局部变量在函数调用时被创建在函数退出时销毁。栈内存的生命周期通常与函数的执行周期相同。堆Heap堆上的内存块的生命周期由程序员控制。程序员需要显式地释放内存如 free() 或 delete否则会导致内存泄漏。
1.6 内存分配方式
栈Stack栈内存的分配是连续的内存分配和回收非常快速因此没有内存碎片问题。每次分配和回收都是固定大小的内存区域。堆Heap堆内存的分配不是连续的因为它根据需要分配大小不等的内存块。随着程序运行堆内存可能会出现碎片化问题。特别是在频繁的分配和释放内存后堆可能会出现较多的空闲块导致性能下降。
堆和栈的效率对比
特性栈Stack堆Heap内存分配自动按顺序分配不需要显式释放。需要程序员显式分配和释放。访问速度更快CPU 对栈的支持通常较高。较慢堆的分配和回收过程较为复杂。内存大小相对较小有限制适合存储临时数据。相对较大适合存储动态数据。内存管理简单由操作系统自动管理。复杂由程序员手动管理。生命周期与函数调用生命周期相同自动销毁。由程序员控制需要显式释放易导致内存泄漏。内存碎片不会出现内存碎片问题。可能出现内存碎片降低效率。分配与回收效率分配和回收都很快主要是栈顶指针的移动。分配和回收较慢需要查找合适的内存块。
堆和栈的应用场景
栈Stack栈适用于存储函数的局部变量、函数调用的信息例如函数调用栈。栈的操作速度非常快且内存管理简单因此非常适合短生命周期的局部数据。 适用场景函数调用、局部变量、递归调用。优点快速、自动管理、不容易出错。缺点内存空间有限不能用于动态内存分配。 堆Heap堆适用于存储需要动态分配内存的数据尤其是数据量较大或者生命周期较长的数据。堆的内存分配灵活允许任意大小的内存块动态分配。 适用场景动态数据结构如链表、树、图等、动态数组、需要长期存活的数据如全局数据、长时间存在的对象。优点灵活可以分配任意大小的内存适合处理大规模数据。缺点内存管理复杂容易造成内存泄漏效率相对较低。
总结
栈 适用于那些生命周期较短、内存需求固定的数据结构访问速度快、分配与销毁非常高效适合用于函数调用时的临时变量。堆 适用于需要动态分配和释放内存、生命周期不确定的数据结构具有更大的灵活性但性能较低且需要手动管理内存。
在实际应用中我们通常会根据程序的需求来选择合适的内存管理方式。例如如果我们需要处理大量动态分配的数据可以使用堆如果数据的生命周期较短且内存需求固定可以使用栈。