文章目录
- 一、虚函数实现原理
- 二、虚函数表在什么阶段生成
- 三、哪些函数不能是虚函数
- 四、malloc和new区别
- 五、malloc申请的内存能用delete释放吗
- 六、强制转换操作符
- 七、怎么禁止拷贝构造
- 八、vector的reserve和resize区别
- 九、map插入新数据会影响之前find的指针吗
- 十、如何查看哪些端口被监听
- 十一、如何查看系统32位还是64位
- 十二、怎么写一个非阻塞的服务端
- 十三、怎么改变socket的滑动窗口大小
- 十四、多路复用
- 十五、应用层的多路复用
- 十六、多线程的锁
- 十七、互斥锁和自旋锁区别、效率方面
- 十八、动静态库区别
- 十九、堆栈区别
- 二十、malloc和new区别、free一个new出来的对象的结果
- 二十一、在函数内定义一个数组,该数组是在堆上还是栈上
- 二十二、定义这个数组在大小上有什么限制吗(栈溢出)
- 二十三、在函数里定义一个vector对象,哪些在栈上或者堆上
- 二十四、STL容器unordered map/set、map/set、priority queue原理
- 二十五、vector日常使用,查询、插入和扩容原理和理解
- 二十六、多态原理
- 二十七、编译时多态,多个实例化的编译结果!
- 二十八、智能指针
- 二十九、进程间通信
- 三十、具体介绍系统IPC
- 三十一、socket通信,不使用协议栈的socket通信
- 三十二、操作系统如何实现共享内存(虚拟地址空间)
- 三十三、主流Linux如何管理虚拟内存(分页)
- 三十四、进程线程区别
- 三十五、线程间通信
一、虚函数实现原理
虚函数是C++中实现多态的重要机制,其实现依赖于虚函数表(vtable)和虚函数指针(vptr)。以下是虚函数实现原理的详细解释:
1.1. 虚函数的基本概念
- 虚函数:在基类中用关键字
virtual
声明的成员函数,允许在运行时根据实际对象类型来调用对应的派生类实现。 - 多态:虚函数支持动态绑定(运行时决定调用哪个函数),与非虚函数的静态绑定(编译时决定调用哪个函数)不同。
1.2. 虚函数表(vtable)
虚函数表是编译器为每个包含虚函数的类创建的一个静态数组,表中存储了指向该类虚函数实现的函数指针。
-
虚函数表内容:
-
每个类有一个虚函数表(派生类会覆盖基类的虚函数表)。
-
表中每个条目对应一个虚函数,存储的是该类或派生类中该虚函数的实现地址。
-
表的结构:
-
若基类声明了虚函数,派生类继承后可以覆盖这些虚函数,则虚函数表会更新为派生类的实现。
-
若派生类未覆盖某个虚函数,则继续指向基类的实现。
1.3. 虚函数指针(vptr)
-
每个对象(实例)中会有一个隐藏的指针
vptr
,它指向对应类的虚函数表。 -
如何工作:
-
在对象的构造函数中,
vptr
被初始化为指向当前类的虚函数表。 -
如果是派生类对象,构造派生类时会将
vptr
重新指向派生类的虚函数表。
1.4. 调用流程
当通过基类指针或引用调用虚函数时,实际的调用过程如下:
- 根据对象实例的
vptr
找到对应的虚函数表。 - 根据虚函数表中的条目找到对应的函数实现地址。
- 调用该函数。
1.5. 代码示例
#include <iostream>
using namespace std;
class Base {
public:
virtual void func() { // 虚函数
cout << "Base::func()" << endl;
}
virtual ~Base() {} // 虚析构函数
};
class Derived : public Base {
public:
void func() override { // 覆盖虚函数
cout << "Derived::func()" << endl;
}
};
int main() {
Base* obj = new Derived(); // 基类指针指向派生类对象
obj->func(); // 调用 Derived::func() 而不是 Base::func()
delete obj;
return 0;
}
1.6. 虚函数实现的内存布局
假设上面的Base
和Derived
类:
- Base类的虚函数表:
vtable_Base:
[0]: &Base::func()
- Derived类的虚函数表:
vtable_Derived:
[0]: &Derived::func()
- 对象布局:
- Base对象:
[vptr] -> vtable_Base
- Derived对象:
[vptr] -> vtable_Derived
1.7. 构造函数和析构函数中的虚函数
- 在构造函数或析构函数中,虚函数的调用是基于当前构造/析构阶段的类,而非派生类。
- 这是因为在构造函数和析构函数的执行过程中,
vptr
指向正在构造或销毁的类的虚函数表。
1.8. 虚函数的性能开销
-
额外的内存开销:
-
每个包含虚函数的类会有一个虚函数表。
-
每个包含虚函数的对象会有一个
vptr
指针。 -
运行时开销:
-
虚函数调用需要通过
vptr
查找函数指针,多了一次间接寻址。
尽管虚函数引入了一定的开销,但其实现的多态性使代码更加灵活和易于扩展。
二、虚函数表在什么阶段生成
虚函数表(vtable)****是在编译阶段生成的,具体来说,是在编译器对类进行编译时创建的静态数据结构。以下是虚函数表生成阶段及其相关过程的详细解释:
2.1. 虚函数表生成的阶段
-
编译阶段:
-
编译器在处理含有虚函数的类时,会为该类创建对应的虚函数表。
-
虚函数表是一个静态的数据结构,与类相关联(不是对象),因此在程序加载时就可以被使用。
-
链接阶段:
-
链接器会将不同编译单元中涉及虚函数表的符号链接在一起,并生成可执行文件。
2.2. 虚函数表生成的步骤
2.2.1. (1)编译器识别虚函数
-
当类中定义了虚函数(即使用了
virtual
关键字),编译器会: -
为该类生成一个虚函数表。
-
在虚函数表中存储该类中所有虚函数的入口地址。
2.2.2. (2)为派生类更新虚函数表
- 如果派生类继承了基类的虚函数且未覆盖,虚函数表中仍保留基类的实现地址。
- 如果派生类覆盖了基类的虚函数,虚函数表中的对应条目会更新为派生类的实现地址。
2.2.3. (3)在每个对象中设置虚函数指针(vptr
)
- 对象的
vptr
不会在编译阶段创建,而是在程序运行时初始化(对象构造函数中)。 vptr
指向该对象所属类的虚函数表。
2.3. 虚函数表的生命周期
- 生成时:编译阶段生成。
- 初始化时:在运行时,创建对象时,
vptr
指针被初始化为指向虚函数表。 - 销毁时:虚函数表是类级别的静态数据结构,与对象的生存周期无关。即使对象销毁,虚函数表仍然存在。
2.4. 虚函数表的内容与布局
虚函数表是与类绑定的,它的内容由类的虚函数定义决定。例如:
- 如果一个类有一个虚函数表,表中存储了该类及其继承层次中覆盖的虚函数地址。
- 若派生类覆盖了基类的虚函数,表中的对应条目会被更新为派生类的实现。
2.5. 示例分析
#include <iostream>
using namespace std;
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
virtual ~Base() {}
};
class Derived : public Base {
public:
void func1() override { cout << "Derived::func1" << endl; }
};
int main() {
Base* obj = new Derived();
obj->func1(); // 调用 Derived::func1
obj->func2(); // 调用 Base::func2
delete obj;
return 0;
}
2.5.1. 编译时(生成虚函数表)
- Base类的虚函数表:
vtable_Base:
[0]: &Base::func1
[1]: &Base::func2
- Derived类的虚函数表:
vtable_Derived:
[0]: &Derived::func1 // 覆盖了Base::func1
[1]: &Base::func2 // 继承了Base::func2
2.5.2. 运行时(设置vptr指针)
-
当
Derived
对象创建时: -
对象的
vptr
指向vtable_Derived
。 -
当通过
Base*
调用虚函数时: -
使用
vptr
找到vtable_Derived
,然后根据虚函数表调用正确的实现。
2.6. 总结
- 虚函数表生成阶段:在编译阶段由编译器生成。
- 虚函数指针初始化:在运行时对象构造过程中设置。
- 虚函数表是静态的,与类绑定,而
vptr
是动态的,与对象绑定。
三、哪些函数不能是虚函数
在C++中,并不是所有类型的成员函数都可以声明为虚函数(使用 virtual
关键字)。以下是不能是虚函数的函数类型及原因:
3.1. 构造函数
-
不能是虚函数的原因:
-
构造函数用于初始化对象,包括设置虚函数指针(
vptr
)。在构造函数执行时,虚函数机制尚未完全建立。 -
在构造函数中调用虚函数时,只能调用当前类的实现,而不是派生类的实现。
-
解决方法:
-
如果需要多态行为,通常使用工厂方法模式或在构造完成后调用虚函数。
3.2. 静态成员函数
-
不能是虚函数的原因:
-
静态成员函数是与类相关联的,而不是与具体对象相关联。
-
虚函数依赖于对象的
vptr
指针来动态绑定函数,而静态函数没有对象实例,因此无法通过vptr
调用。
3.3. 内联函数
-
理论上可以是虚函数,但矛盾:
-
虚函数通常通过动态绑定调用,而动态绑定会导致函数地址在运行时才确定,违背了内联函数必须在编译时展开的要求。
-
结果:
-
编译器可能忽略虚函数的内联请求。
-
如果在明确调用基类的虚函数实现(如
Base::func()
),它可以被内联。
3.4. 友元函数
-
不能是虚函数的原因:
-
友元函数不是类的成员,而是定义在类外的普通函数。
-
虚函数是类的成员属性,因此友元函数不具备声明为虚函数的条件。
3.5. 全局函数
-
不能是虚函数的原因:
-
虚函数是类的成员函数,全局函数与类无关,无法具有虚函数的特性。
3.6. 模板函数
-
理论上可以是虚函数,但复杂性高:
-
虚函数依赖于固定的函数表结构,而模板函数在实例化时可能生成多个不同的函数实现,导致虚函数表的结构复杂且难以维护。
-
因此,模板函数通常避免作为虚函数使用。
3.7. 总结
以下类型的函数不能是虚函数:
- 构造函数:初始化对象时不能动态绑定。
- 静态成员函数:与对象无关,无法通过
vptr
调用。 - 友元函数:不是类的成员。
- 全局函数:与类无关。
- 内联函数:动态绑定与编译时内联矛盾。
- 某些模板函数:复杂的实例化过程使其不适合作为虚函数。
虚函数的使用场景是运行时多态,因此只能出现在特定与对象绑定的成员函数中。
四、malloc和new区别
malloc
和 new
都是用来在 C++ 中分配内存的,但它们有显著的区别,主要涉及语法、功能、效率和错误处理等方面。
4.1. 语法和使用方式
-
malloc
: -
函数调用,来自 C 语言标准库
<stdlib.h>
。 -
语法:
void* malloc(size_t size)
-
返回值是
void*
,需要显式类型转换。 -
例子:
int* ptr = (int*)malloc(sizeof(int));
-
new
: -
C++ 提供的操作符。
-
不需要指定大小,编译器会根据类型计算分配内存。
-
返回指定类型的指针,无需显式类型转换。
-
语法:
int* ptr = new int;
4.2. 初始化
-
malloc
: -
仅分配内存,不调用构造函数,也不会对分配的内存进行初始化。
-
若需要初始化,需手动调用函数(如
memset
)。 -
例子:
int* arr = (int*)malloc(10 * sizeof(int)); // 未初始化
-
new
: -
分配内存后,会调用对象的构造函数(如果是类对象),并可以直接初始化基本类型。
-
例子:
int* arr = new int[10]; // 默认初始化为随机值
int* num = new int(5); // 初始化为5
4.3. 释放内存
-
malloc
: -
需要用
free
来释放分配的内存。 -
释放时不会调用对象的析构函数。
-
例子:
free(ptr);
-
new
: -
必须使用
delete
或delete[]
来释放内存。 -
会调用对象的析构函数(如果是类对象)。
-
例子:
delete ptr;
delete[] arr;
4.4. 类型安全
-
malloc
: -
返回
void*
,需要显式地进行类型转换,否则会报错(在 C++ 中)。 -
例子:
int* ptr = (int*)malloc(sizeof(int)); // 显式类型转换
-
new
: -
不需要显式类型转换,返回的是分配类型的指针,符合类型安全的要求。
-
例子:
int* ptr = new int; // 自动类型转换
4.5. 功能支持
-
malloc
: -
只分配内存,不执行初始化或调用构造函数。
-
适合分配纯内存块(如处理 POD 类型、动态数组等)。
-
new
: -
除了分配内存,还会调用构造函数并初始化。
-
适合用来处理类对象的动态内存分配。
4.6. 错误处理
-
malloc
: -
分配失败时返回
NULL
。 -
例子:
int* ptr = (int*)malloc(sizeof(int));
if (!ptr) {
// 分配失败
}
-
new
: -
分配失败时会抛出
std::bad_alloc
异常。 -
若需要返回
nullptr
而不是抛出异常,可以使用nothrow
。 -
例子:
int* ptr = new(std::nothrow) int;
if (!ptr) {
// 分配失败
}
4.7. 性能
-
malloc
: -
仅分配内存,没有初始化或调用构造函数,性能通常稍快。
-
new
: -
因为会调用构造函数,性能略低于
malloc
。
4.8. 扩展性
-
malloc
: -
只负责内存分配,不关心对象的生命周期。
-
无法满足复杂对象(带构造函数和析构函数)的需求。
-
new
: -
能自动处理对象的构造和析构,更符合 C++ 面向对象的设计。
4.9. 总结对比表
4.10. 使用建议
- 使用
malloc
:适用于需要分配简单的内存块(如 C 风格的数组或 POD 类型),或者在混合 C/C++ 编程时。 - 使用
new
:更推荐在 C++ 中使用,因为它支持对象的构造和析构,符合 C++ 的语义。
五、malloc申请的内存能用delete释放吗
不可以用 delete
来释放由 malloc
分配的内存。malloc
和 delete
的内存管理机制不同,混用会导致未定义行为,可能引发程序崩溃或内存泄漏。
5.1. 原因分析
- 分配与释放的机制不同:
malloc
和free
是 C 标准库中的函数,直接调用底层内存分配系统(如malloc()
和free()
在堆上分配和释放内存)。new
和delete
是 C++ 提供的操作符,不仅分配内存,还管理对象的构造和析构。
- 内存分配元信息不同:
malloc
和free
只关注内存的分配和释放,它们在堆上分配内存时会添加一些元信息,但与对象的类型和生命周期无关。new
和delete
除了分配内存,还负责调用对象的构造和析构函数,因此它们管理的内存布局与malloc
/free
不同。
- 运行时行为的差异:
- 使用
malloc
分配的内存没有调用构造函数。 - 使用
delete
时,C++ 运行时会尝试调用析构函数,但这在malloc
分配的内存上是未定义行为。
5.2. 错误示例
#include <iostream>
#include <cstdlib> // for malloc, free
int main() {
int* ptr = (int*)malloc(sizeof(int)); // 使用 malloc 分配内存
*ptr = 42;
delete ptr; // 错误!不能用 delete 释放 malloc 分配的内存
return 0;
}
5.3. 正确的做法
- 如果使用
malloc
分配内存,必须用free
释放。 - 如果使用
new
分配内存,必须用delete
释放。
// 使用 malloc 和 free
int* ptr1 = (int*)malloc(sizeof(int));
*ptr1 = 42;
free(ptr1); // 正确
// 使用 new 和 delete
int* ptr2 = new int(42);
delete ptr2; // 正确
5.4. 混用的后果
-
未定义行为:
-
用
delete
释放malloc
的内存,或用free
释放new
分配的内存,都会引发未定义行为。 -
可能导致的问题:
-
程序崩溃(
segmentation fault
)。 -
内存泄漏(内存未正确释放)。
-
未初始化的析构函数调用(如果
delete
在malloc
分配的对象上调用)。
5.5. 总结
-
不要混用
malloc/free
和new/delete
。 -
遵循以下规则:
-
malloc
和free
配对使用。 -
new
和delete
配对使用。 -
如果需要兼容 C 和 C++,尽量避免
malloc
,优先使用new
和智能指针(如std::unique_ptr
或std::shared_ptr
)。
六、强制转换操作符
C++ 提供了四种强制类型转换操作符,它们是为了替代 C 风格的强制转换(如 (int)x
),提供更明确、更安全的类型转换方式。这些操作符是:
static_cast
dynamic_cast
const_cast
reinterpret_cast
6.1. 1. static_cast
-
用途:
-
用于编译时已知、类型安全的转换。
-
支持以下场景:
-
基本类型之间的转换(如
int
转换为float
)。 -
父类与子类之间的转换(基类到派生类需要确保安全性)。
-
枚举类型与整型之间的转换。
-
移除或添加函数指针的
void*
类型。 -
特点:
-
编译期检查类型安全。
-
无法用于跨层次的多态类型转换。
-
无法进行危险的指针强制转换。
-
示例:
int a = 42;
float b = static_cast<float>(a); // 基本类型转换
class Base {};
class Derived : public Base {};
Base* base = new Derived;
Derived* derived = static_cast<Derived*>(base); // 基类转派生类,必须确保安全
6.2. 2. dynamic_cast
-
用途:
-
用于多态类型的安全向下转换(即基类指针或引用向派生类转换)。
-
依赖于类型信息(RTTI, 运行时类型识别)。
-
特点:
-
必须有一个虚函数表(类必须是多态类型,含有虚函数)。
-
在转换失败时:
-
对指针类型返回
nullptr
。 -
对引用类型抛出
std::bad_cast
异常。 -
示例:
class Base {
public:
virtual ~Base() {}
};
class Derived : public Base {};
Base* base = new Derived;
Derived* derived = dynamic_cast<Derived*>(base); // 安全转换
if (derived) {
// 转换成功
} else {
// 转换失败
}
Base* base2 = new Base;
Derived* derived2 = dynamic_cast<Derived*>(base2); // 返回 nullptr,转换失败
6.3. 3. const_cast
-
用途:
-
用于移除或添加
const
或volatile
限定符。 -
常用于需要修改本应不可变的对象(不推荐,可能破坏代码逻辑)。
-
特点:
-
不改变对象本身的底层类型。
-
如果试图修改真正
const
的变量,行为未定义。 -
示例:
void print(const int& num) {
int& modifiableNum = const_cast<int&>(num);
modifiableNum = 100; // 仅限非真正 const 对象,安全性需自行保证
}
const int x = 42;
// int& y = const_cast<int&>(x); // 错误,修改真正 const 对象是未定义行为
6.4. 4. reinterpret_cast
-
用途:
-
用于类型之间的低级别、危险的转换。
-
支持以下场景:
-
指针类型之间的转换。
-
整数与指针之间的转换。
-
特点:
-
不安全,直接按位解释内存。
-
不进行任何类型检查。
-
示例:
int a = 42;
int* ptr = &a;
void* voidPtr = reinterpret_cast<void*>(ptr); // int* 转为 void*
int* intPtr = reinterpret_cast<int*>(voidPtr); // void* 转回 int*
long long addr = reinterpret_cast<long long>(ptr); // 指针转整型
int* ptrFromAddr = reinterpret_cast<int*>(addr); // 整型转指针
6.5. 总结表
6.6. 注意事项
- 如果可能,尽量避免使用
reinterpret_cast
,除非确实需要进行底层内存操作。 - 使用
const_cast
修改本应不可变的对象需要非常小心,优先通过逻辑改造避免这种需求。 - 优先选择
static_cast
或dynamic_cast
,因为它们提供了更好的类型检查和安全性。
七、怎么禁止拷贝构造
在 C++ 中,如果希望禁止类对象的拷贝构造(以及赋值操作),可以通过以下方法实现:
7.1. 使用 delete
禁止拷贝构造函数
C++11 引入了 = delete
,可以显式禁止特定函数的使用,包括拷贝构造函数和拷贝赋值运算符。
- 示例代码:
class NonCopyable {
public:
NonCopyable() = default; // 默认构造函数
~NonCopyable() = default; // 默认析构函数
NonCopyable(const NonCopyable&) = delete; // 禁止拷贝构造
NonCopyable& operator=(const NonCopyable&) = delete; // 禁止拷贝赋值
};
int main() {
NonCopyable a;
// NonCopyable b = a; // 编译错误:拷贝构造被删除
// NonCopyable c;
// c = a; // 编译错误:拷贝赋值被删除
return 0;
}
-
优点:
-
明确表达意图,禁止拷贝的行为在编译期被捕获。
-
易于理解和维护。
7.2. 将拷贝构造函数声明为 private
(适用于 C++98 和 C++03)
在 C++98/03 中,可以通过将拷贝构造函数和拷贝赋值运算符声明为 private
,并且不提供实现,来禁止拷贝行为。
- 示例代码:
class NonCopyable {
public:
NonCopyable() {}
~NonCopyable() {}
private:
NonCopyable(const NonCopyable&); // 声明为 private,禁止拷贝构造
NonCopyable& operator=(const NonCopyable&); // 声明为 private,禁止拷贝赋值
};
int main() {
NonCopyable a;
// NonCopyable b = a; // 编译错误:拷贝构造不可访问
return 0;
}
-
缺点:
-
错误在链接时可能无法明确,尤其当函数未定义时。
-
对新开发的代码不推荐使用,但在需要兼容旧代码时有用。
7.3. 使用继承方式,通过基类禁止拷贝
如果需要多个类禁止拷贝,可以创建一个基类,专门用于禁止拷贝,并让其他类继承。
- 示例代码:
class NonCopyable {
protected:
NonCopyable() = default; // 允许子类构造
~NonCopyable() = default;
public:
NonCopyable(const NonCopyable&) = delete; // 禁止拷贝构造
NonCopyable& operator=(const NonCopyable&) = delete; // 禁止拷贝赋值
};
class MyClass : public NonCopyable {
public:
MyClass() {}
};
int main() {
MyClass a;
// MyClass b = a; // 编译错误:拷贝构造被删除
return 0;
}
-
优点:
-
避免每个类都重复实现禁止拷贝的代码。
-
提供了统一的禁止拷贝的机制。
7.4. 总结
7.5. 推荐
在现代 C++(C++11 及以上)中,推荐使用 = delete
来禁止拷贝构造函数和赋值运算符,因为它更清晰、更直观,并且可以有效避免误用。
八、vector的reserve和resize区别
C++ 中,std::vector
的 reserve
和 resize
都是用来调整容器的大小或容量的,但它们的作用和用途完全不同。以下是两者的详细对比:
8.1. reserve
-
作用:
-
仅调整
vector
的容量(capacity),不改变当前的大小(size)。 -
主要用于预分配内存,避免频繁的内存分配,提高性能。
-
特点:
-
vector
的size
不变。 -
只能增加容量,不能减少容量。
-
不会初始化任何新元素。
-
示例:
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
std::cout << "Initial size: " << vec.size() << "\n"; // 输出 0
std::cout << "Initial capacity: " << vec.capacity() << "\n"; // 通常也是 0
vec.reserve(10); // 预留 10 个元素的容量
std::cout << "After reserve, size: " << vec.size() << "\n"; // 仍然是 0
std::cout << "After reserve, capacity: " << vec.capacity() << "\n"; // 输出 10
}
8.2. resize
-
作用:
-
调整
vector
的大小(size),同时可能会影响容量(capacity)。 -
如果新的大小大于当前大小,会创建并初始化新元素(默认值初始化或指定值)。
-
如果新的大小小于当前大小,会删除多余的元素。
-
特点:
-
改变
vector
的size
。 -
如果
size
增加,新增的元素会被初始化(例如,基本类型为默认值0
)。 -
如果
size
减小,多余的元素会被销毁。 -
示例:
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec(5, 1); // 创建一个大小为 5 的 vector,所有元素初始化为 1
std::cout << "Initial size: " << vec.size() << "\n"; // 输出 5
std::cout << "Initial capacity: " << vec.capacity() << "\n"; // 可能 >=5
vec.resize(10, 2); // 扩大到 10 个元素,新增元素初始化为 2
std::cout << "After resize, size: " << vec.size() << "\n"; // 输出 10
for (int x : vec) std::cout << x << " "; // 输出:1 1 1 1 1 2 2 2 2 2
vec.resize(3); // 缩小到 3 个元素
std::cout << "\nAfter resize, size: " << vec.size() << "\n"; // 输出 3
for (int x : vec) std::cout << x << " "; // 输出:1 1 1
}
8.3. reserve
和 resize
的区别对比
8.4. 常见误区与建议
- 误解
reserve
会增加大小:
reserve
只影响容量,不会增加元素,也不会改变size
。- 如果需要增加元素,请使用
resize
或push_back
。
- 混用场景:
- 如果需要频繁添加大量元素,先用
reserve
分配足够的容量。 - 如果需要明确控制
size
或初始化新元素,用resize
。
- 避免不必要的内存分配:
- 使用
reserve
预分配容量,可以避免多次内存重新分配,从而提高性能。
8.5. 综合示例
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
vec.reserve(10); // 仅预留容量,size 仍为 0
std::cout << "After reserve, size: " << vec.size() << "\n"; // 输出 0
std::cout << "After reserve, capacity: " << vec.capacity() << "\n"; // 输出 10
vec.resize(5, 1); // 增加大小到 5,元素初始化为 1
std::cout << "After resize, size: " << vec.size() << "\n"; // 输出 5
std::cout << "After resize, capacity: " << vec.capacity() << "\n"; // 仍然是 10
for (int x : vec) std::cout << x << " "; // 输出:1 1 1 1 1
return 0;
}
九、map插入新数据会影响之前find的指针吗
在 C++ 的 std::map
中,插入新数据不会使之前通过 find
获得的迭代器失效。这是因为 std::map
的底层实现基于平衡二叉树(通常是红黑树),插入新元素时会调整树的结构,但不会改变已经存在的节点的地址。
9.1. 详细分析
std::map
的特点:
std::map
的底层结构是平衡二叉树(通常是红黑树)。- 插入元素时,如果没有触发重新分配内存(比如从外部管理的节点池分配新节点),原有的节点位置不会改变。
- 迭代器指向的是树中的节点,而不是整个树的结构,因此迭代器是稳定的。
- 影响迭代器有效性的操作:
-
不会失效的操作:
-
插入(
insert
、emplace
):
新元素插入后,只会增加树的一个节点,不会影响已有节点。 -
删除(
erase
):
删除某个特定节点的迭代器后,其他节点的迭代器仍然有效,但指向被删除节点的迭代器将失效。 -
会失效的操作:
-
对整个
map
调用成员函数clear
,所有迭代器都会失效。
9.2. 示例代码
#include <iostream>
#include <map>
int main() {
std::map<int, std::string> myMap;
// 插入一些初始数据
myMap[1] = "One";
myMap[2] = "Two";
myMap[3] = "Three";
// 使用 find 获取迭代器
auto it = myMap.find(2);
if (it != myMap.end()) {
std::cout << "Found: " << it->first << " -> " << it->second << std::endl;
}
// 插入新的数据
myMap[4] = "Four";
myMap[5] = "Five";
// 检查之前的迭代器是否仍然有效
if (it != myMap.end()) {
std::cout << "Iterator still valid: " << it->first << " -> " << it->second << std::endl;
}
return 0;
}
输出:
Found: 2 -> Two
Iterator still valid: 2 -> Two
9.3. 结论
- 在
std::map
中,插入新数据不会使已有节点的迭代器失效。 - 如果在操作中需要保留迭代器,可以放心使用
find
后的迭代器,除非显式删除了该节点或清空了整个容器。
十、如何查看哪些端口被监听
在操作系统中,可以使用不同的方法查看哪些端口正在被监听,具体取决于你的操作系统(Windows、Linux、macOS)。以下是常用的方法:
10.1. 在 Windows 系统中
10.1.1. 方法 1:使用 netstat
命令
- 打开命令提示符(
cmd
)。 - 输入以下命令查看监听的端口:
netstat -ano | findstr LISTENING
-
参数说明:
-
-a
:显示所有连接和监听的端口。 -
-n
:以数字形式显示地址和端口。 -
-o
:显示每个连接所属的进程 ID(PID)。 -
findstr LISTENING
:筛选出状态为LISTENING
的行。
- 结合 PID 查看具体的进程:
tasklist | findstr <PID>
替换 <PID>
为具体的进程 ID。
10.1.2. 方法 2:使用 PowerShell
- 打开 PowerShell。
- 输入以下命令:
Get-NetTCPConnection -State Listen | Format-Table -Property LocalAddress,LocalPort,OwningProcess
- 该命令列出所有正在监听的端口及其进程。
10.2. 在 Linux/macOS 系统中
10.2.1. 方法 1:使用 netstat
命令
- 在终端中输入以下命令:
netstat -tuln
-
参数说明:
-
-t
:显示 TCP 协议的连接。 -
-u
:显示 UDP 协议的连接。 -
-l
:显示监听状态的端口。 -
-n
:以数字形式显示地址和端口。
- 示例输出:
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
udp 0 0 0.0.0.0:123 0.0.0.0:*
10.2.2. 方法 2:使用 ss
命令
ss
是 netstat
的现代替代工具,速度更快,信息更详尽。
ss -tuln
-
参数说明:
-
-t
:显示 TCP 连接。 -
-u
:显示 UDP 连接。 -
-l
:显示监听端口。 -
-n
:以数字形式显示地址和端口。
10.2.3. 方法 3:使用 lsof
命令
lsof
可以列出打开的文件和网络连接:
sudo lsof -i -P -n
-
参数说明:
-
-i
:显示网络连接信息。 -
-P
:显示端口号而不是服务名称。 -
-n
:不解析主机名以加快查询速度。
筛选出监听端口:
sudo lsof -i -P -n | grep LISTEN
10.3. 输出解析
无论是使用哪种工具,监听的端口信息通常包括以下字段:
- 协议(Proto):
tcp
或udp
。 - 本地地址(Local Address):监听的 IP 地址和端口号。
- 状态(State):对于 TCP 通信,监听状态通常为
LISTEN
。 - 进程 ID(PID):使用该端口的进程。
10.4. 常见用途
- 检测冲突端口:如果某个端口被占用,可以根据 PID 找到对应进程。
- 安全审查:检查不必要的监听端口,避免暴露安全隐患。
- 服务检查:确认服务是否正确启动并监听了预期的端口。
如果需要进一步操作(如关闭进程),可以根据 PID 查找并终止进程。
十一、如何查看系统32位还是64位
要查看系统是 32 位还是 64 位,可以根据操作系统不同,使用不同的方法。以下是常用的查看方法:
11.1. 在 Windows 系统中
11.1.1. 方法 1:使用 系统信息
- 按
Win + R
打开运行对话框。 - 输入
msinfo32
并按回车,打开系统信息
。 - 在
系统摘要
中,查找 “系统类型”:
- 如果显示 “x64-based PC”,表示操作系统是 64 位。
- 如果显示 “x86-based PC”,表示操作系统是 32 位。
11.1.2. 方法 2:使用命令行
- 打开命令提示符(
cmd
)或 PowerShell。 - 输入以下命令:
wmic os get osarchitecture
- 输出会显示 “64-bit” 或 “32-bit”。
11.1.3. 方法 3:通过控制面板
- 打开
控制面板
>系统和安全
>系统
。 - 查看 “系统类型”,会显示是 “64 位操作系统” 还是 “32 位操作系统”。
11.2. 在 Linux 系统中
11.2.1. 方法 1:使用 uname
命令
- 打开终端。
- 输入以下命令:
uname -m
- 如果输出是
x86_64
,表示系统是 64 位。 - 如果输出是
i686
或i386
,表示系统是 32 位。
11.2.2. 方法 2:查看 file
命令
- 输入以下命令:
file /bin/ls
- 如果输出包含
64-bit
,表示系统是 64 位。 - 如果输出包含
32-bit
,表示系统是 32 位。
11.3. 在 macOS 系统中
11.3.1. 方法 1:使用 uname
命令
- 打开终端。
- 输入以下命令:
uname -m
- 如果输出是
x86_64
,表示系统是 64 位。 - 如果输出是
i386
,表示系统是 32 位。
11.3.2. 方法 2:使用 system_profiler
- 打开终端。
- 输入以下命令:
system_profiler SPHardwareDataType
- 查看 “64-bit Kernel and Extensions” 项目,若为 Yes,则是 64 位系统。
11.4. 总结
- Windows:通过
msinfo32
或命令行wmic os get osarchitecture
查看。 - Linux:使用
uname -m
或file /bin/ls
命令。 - macOS:使用
uname -m
或system_profiler
。
这些方法可以帮助你快速了解系统是 32 位还是 64 位。
十二、怎么写一个非阻塞的服务端
写一个非阻塞的服务端,通常涉及到 非阻塞 I/O 和 多路复用,这样服务端可以同时处理多个客户端的请求,而不需要等待某个请求完成再处理下一个。
在 C++ 中,你可以使用 select
或 poll
等系统调用来实现多路复用,或者使用 epoll
(在 Linux 系统上)来实现高效的非阻塞 I/O。以下是一个使用 select
实现非阻塞服务端的基本示例。你也可以根据需求选择适合的方法。
12.1. 1. 使用 select
实现非阻塞服务端
select
可以让你在多个 I/O 操作(例如网络连接)上同时监听,不会在某个连接阻塞时停止整个进程。
12.1.1. 示例:基于 select
的非阻塞 TCP 服务端
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/select.h>
#define PORT 8080
#define MAX_CLIENTS 10
// 设置套接字为非阻塞模式
void setNonBlocking(int sockfd) {
fcntl(sockfd, F_SETFL, O_NONBLOCK);
}
// 处理客户端连接的函数
void handleClient(int clientSock) {
char buffer[1024];
int bytesReceived;
while (true) {
bytesReceived = recv(clientSock, buffer, sizeof(buffer), 0);
if (bytesReceived == -1) {
// EWOULDBLOCK 表示非阻塞模式下没有数据可读
if (errno == EWOULDBLOCK) {
break; // 没有数据可以读取,退出循环
}
perror("recv");
break;
} else if (bytesReceived == 0) {
// 客户端关闭了连接
std::cout << "Client disconnected" << std::endl;
break;
}
// 回显收到的数据
send(clientSock, buffer, bytesReceived, 0);
}
close(clientSock);
}
int main() {
int serverSock, clientSock, maxFd;
struct sockaddr_in serverAddr, clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
fd_set readfds;
// 创建服务器套接字
if ((serverSock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
return 1;
}
// 设置服务器地址
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
// 绑定套接字到指定地址和端口
if (bind(serverSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
perror("bind");
return 1;
}
// 开始监听
if (listen(serverSock, MAX_CLIENTS) == -1) {
perror("listen");
return 1;
}
// 设置 serverSock 为非阻塞模式
setNonBlocking(serverSock);
std::cout << "Server is listening on port " << PORT << "..." << std::endl;
// 主循环
while (true) {
FD_ZERO(&readfds);
FD_SET(serverSock, &readfds);
maxFd = serverSock;
// 添加所有客户端套接字到 `readfds`
// 此处假设你有维护客户端套接字的机制,暂时跳过具体实现
// 使用 select 等待事件
int activity = select(maxFd + 1, &readfds, nullptr, nullptr, nullptr);
if (activity == -1) {
perror("select");
continue;
}
// 检查 serverSock 是否准备好接收新的连接
if (FD_ISSET(serverSock, &readfds)) {
clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &clientAddrLen);
if (clientSock == -1) {
perror("accept");
continue;
}
// 设置新连接为非阻塞模式
setNonBlocking(clientSock);
std::cout << "New connection from " << inet_ntoa(clientAddr.sin_addr) << std::endl;
// 在这里添加新连接到 `readfds` 中以便监听
FD_SET(clientSock, &readfds);
maxFd = std::max(maxFd, clientSock);
}
// 遍历所有的客户端,处理 I/O
for (int i = 0; i <= maxFd; ++i) {
if (FD_ISSET(i, &readfds) && i != serverSock) {
handleClient(i);
FD_CLR(i, &readfds); // 移除已处理的客户端套接字
}
}
}
close(serverSock);
return 0;
}
12.1.2. 代码说明:
- 非阻塞套接字:我们使用
fcntl
将服务器套接字和客户端套接字设置为非阻塞模式,这样当没有数据时,recv
函数会返回-1
并设置errno
为EWOULDBLOCK
,表示没有数据可读。 select
多路复用:使用select
监听多个套接字(如服务器套接字和所有客户端套接字),它会阻塞,直到某个套接字准备好进行 I/O 操作。FD_SET
和FD_ISSET
:通过FD_SET
将需要监听的套接字添加到文件描述符集readfds
,使用FD_ISSET
来检查哪个套接字可读。
12.2. 2. 使用 epoll
实现非阻塞服务端(Linux 特有)
在 Linux 上,epoll
是更高效的多路复用机制,特别适合于大规模并发连接。相较于 select
,epoll
能够处理更多的并发连接且效率更高。
以下是一个基本的 epoll
示例:
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <netinet/in.h>
#define PORT 8080
#define MAX_EVENTS 10
// 设置套接字为非阻塞模式
void setNonBlocking(int sockfd) {
fcntl(sockfd, F_SETFL, O_NONBLOCK);
}
int main() {
int serverSock, clientSock, epollFd;
struct sockaddr_in serverAddr, clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
struct epoll_event ev, events[MAX_EVENTS];
// 创建服务器套接字
if ((serverSock = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket");
return 1;
}
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
if (bind(serverSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
perror("bind");
return 1;
}
if (listen(serverSock, 10) == -1) {
perror("listen");
return 1;
}
setNonBlocking(serverSock);
// 创建 epoll 实例
epollFd = epoll_create1(0);
if (epollFd == -1) {
perror("epoll_create");
return 1;
}
ev.events = EPOLLIN;
ev.data.fd = serverSock;
if (epoll_ctl(epollFd, EPOLL_CTL_ADD, serverSock, &ev) == -1) {
perror("epoll_ctl");
return 1;
}
while (true) {
int nfds = epoll_wait(epollFd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == serverSock) {
clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &clientAddrLen);
if (clientSock == -1) {
perror("accept");
continue;
}
setNonBlocking(clientSock);
ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
ev.data.fd = clientSock;
if (epoll_ctl(epollFd, EPOLL_CTL_ADD, clientSock, &ev) == -1) {
perror("epoll_ctl");
continue;
}
} else {
char buffer[1024];
int bytesReceived = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
if (bytesReceived == -1) {
if (errno != EAGAIN) {
perror("recv");
}
} else if (bytesReceived == 0) {
close(events[i].data.fd);
} else {
send(events[i].data.fd, buffer, bytesReceived, 0);
}
}
}
}
十三、怎么改变socket的滑动窗口大小
在网络编程中,滑动窗口(Sliding Window) 是 TCP 协议中的一个重要概念,它用于控制数据传输的流量。滑动窗口大小决定了在接收到确认之前,发送方可以发送的最大字节数。在操作系统中,TCP 滑动窗口大小 的调整通常是通过操作系统的 TCP 接收窗口 和 TCP 发送窗口 来管理的。
对于 socket 编程,你可以使用 setsockopt
函数来改变套接字的缓冲区大小,从而间接影响 TCP 的滑动窗口大小。这个调整不仅限于滑动窗口本身,还影响了发送和接收缓冲区的大小。
13.1. 如何调整 TCP 滑动窗口大小
你可以通过调整套接字的 发送缓冲区大小 和 接收缓冲区大小 来影响滑动窗口。具体的缓冲区调整方式如下:
13.1.1. 1. 调整接收缓冲区大小
接收缓冲区大小决定了接收方可以接收多少数据。可以通过设置套接字选项 SO_RCVBUF
来调整。
int rcvbuf_size = 65536; // 设置接收缓冲区大小为 64KB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf_size, sizeof(rcvbuf_size));
13.1.2. 2. 调整发送缓冲区大小
发送缓冲区大小决定了发送方可以缓存多少数据。可以通过设置套接字选项 SO_SNDBUF
来调整。
int sndbuf_size = 65536; // 设置发送缓冲区大小为 64KB
setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &sndbuf_size, sizeof(sndbuf_size));
13.1.3. 3. 获取当前缓冲区大小
如果你想查询当前的接收和发送缓冲区的大小,可以使用 getsockopt
来读取。
int rcvbuf_size;
socklen_t optlen = sizeof(rcvbuf_size);
getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf_size, &optlen);
std::cout << "Receive buffer size: " << rcvbuf_size << std::endl;
int sndbuf_size;
getsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &sndbuf_size, &optlen);
std::cout << "Send buffer size: " << sndbuf_size << std::endl;
13.2. 详细说明
- SO_RCVBUF 和 SO_SNDBUF:分别用来设置接收和发送缓冲区的大小。虽然这两者并不直接设置滑动窗口的大小,但它们与滑动窗口有着密切的关系,因为这些缓冲区用于存放待接收和待发送的数据。
- 调整 TCP 滑动窗口大小:TCP 的滑动窗口大小(即允许未确认数据的最大数量)通常会受到系统内核的限制,并且与
SO_RCVBUF
和SO_SNDBUF
设置的大小相匹配。系统会根据这些缓冲区的大小自动调整滑动窗口。
13.2.1. 4. 调整 TCP 滑动窗口的其他方式(Linux 特有)
在 Linux 系统中,你还可以通过修改 /proc/sys/net/ipv4/tcp_rmem
和 /proc/sys/net/ipv4/tcp_wmem
文件来调整默认的接收和发送窗口大小。这些文件控制了 TCP 接收和发送的最小、默认和最大缓冲区大小。
例如:
cat /proc/sys/net/ipv4/tcp_rmem
# 输出类似:4096 87380 174760
# 这表示最小、默认和最大接收缓冲区的大小
cat /proc/sys/net/ipv4/tcp_wmem
# 输出类似:4096 65536 16777216
# 这表示最小、默认和最大发送缓冲区的大小
你可以通过以下命令动态修改这些值:
echo "4096 87380 174760" > /proc/sys/net/ipv4/tcp_rmem
echo "4096 65536 16777216" > /proc/sys/net/ipv4/tcp_wmem
13.3. 注意事项
- 调整缓冲区大小和滑动窗口大小需要注意网络带宽、延迟等因素,过大的缓冲区可能会导致内存消耗过多,过小的缓冲区可能会导致网络吞吐量低。
- 操作系统通常会根据网络状况和内存限制自动调整窗口大小,因此手动调整的效果可能会有所不同,特别是在高性能系统中。
通过上述方法,你可以在应用程序中影响 TCP 滑动窗口 的大小,从而提高网络应用的性能。
十四、多路复用
多路复用(Multiplexing) 是一种技术,允许在单一的通信通道上并行传输多个数据流。在网络编程中,多路复用通常指的是通过单一的线程或进程来处理多个网络连接的技术,这对于提升性能、减少资源消耗以及增加程序的并发性非常重要。
在操作系统中,多路复用 被广泛应用于 I/O 操作中,尤其是在 网络编程 中,使得一个进程可以同时监控多个文件描述符(例如多个套接字连接),而不必每次都等待某一个连接的 I/O 操作。
常见的多路复用技术有:
select
poll
epoll
(在 Linux 上)kqueue
(在 BSD 系统上)- IOCP (I/O Completion Ports)(在 Windows 上)
下面详细介绍这几种多路复用技术。
14.1. 1. select
多路复用
select
是最早的 I/O 多路复用机制,允许进程监视多个文件描述符,以便在任何一个文件描述符准备好进行读写操作时通知进程。select
是一种阻塞式的 I/O 复用机制,通常用于处理多个客户端连接。
14.1.1. select
的工作原理:
select
调用会监视一个或多个文件描述符。- 如果某个文件描述符准备好进行 I/O(例如可读、可写、出现异常),
select
会返回,并且进程可以对该文件描述符进行读写操作。 - 进程可以监视多个套接字,通过
FD_SET
、FD_ISSET
和FD_CLR
操作集来管理监听的文件描述符。
14.1.2. 示例:select
实现网络服务
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/select.h>
#define PORT 8080
int main() {
int serverSock, clientSock;
struct sockaddr_in serverAddr;
fd_set readfds;
struct timeval timeout;
// 创建服务器套接字
serverSock = socket(AF_INET, SOCK_STREAM, 0);
if (serverSock < 0) {
perror("socket");
return 1;
}
// 设置服务器地址
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
// 绑定套接字
if (bind(serverSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
perror("bind");
return 1;
}
// 监听连接
if (listen(serverSock, 5) < 0) {
perror("listen");
return 1;
}
std::cout << "Server listening on port " << PORT << std::endl;
while (true) {
FD_ZERO(&readfds);
FD_SET(serverSock, &readfds);
timeout.tv_sec = 5;
timeout.tv_usec = 0;
// 使用 select 等待事件
int activity = select(serverSock + 1, &readfds, NULL, NULL, &timeout);
if (activity < 0) {
perror("select");
return 1;
}
if (FD_ISSET(serverSock, &readfds)) {
clientSock = accept(serverSock, NULL, NULL);
if (clientSock < 0) {
perror("accept");
return 1;
}
std::cout << "New connection accepted" << std::endl;
// 处理客户端数据
char buffer[1024];
int bytesRead = recv(clientSock, buffer, sizeof(buffer), 0);
if (bytesRead > 0) {
send(clientSock, buffer, bytesRead, 0);
}
close(clientSock);
}
}
close(serverSock);
return 0;
}
14.2. 2. poll
多路复用
poll
是对 select
的改进,克服了 select
的一些限制,如最大文件描述符的数量。poll
允许动态管理文件描述符的集合,且没有 select
的文件描述符数量上限。
14.2.1. poll
的工作原理:
poll
监视一个文件描述符数组中的每个文件描述符,并等待某些事件发生。- 与
select
相比,poll
使用pollfd
结构体数组来管理监听的文件描述符,避免了select
最大文件描述符数量的限制。
14.2.2. poll
示例
#include <iostream>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <unistd.h>
#include <poll.h>
#include <cstring>
#define PORT 8080
#define MAX_CLIENTS 10
int main() {
int serverSock, clientSock;
struct sockaddr_in serverAddr;
struct pollfd fds[MAX_CLIENTS];
int nfds = 1;
// 创建服务器套接字
serverSock = socket(AF_INET, SOCK_STREAM, 0);
if (serverSock < 0) {
perror("socket");
return 1;
}
// 设置服务器地址
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
// 绑定套接字
if (bind(serverSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
perror("bind");
return 1;
}
// 监听连接
if (listen(serverSock, 5) < 0) {
perror("listen");
return 1;
}
fds[0].fd = serverSock;
fds[0].events = POLLIN;
while (true) {
int ret = poll(fds, nfds, -1);
if (ret < 0) {
perror("poll");
return 1;
}
// 处理 serverSock 是否有新连接
if (fds[0].revents & POLLIN) {
clientSock = accept(serverSock, NULL, NULL);
if (clientSock < 0) {
perror("accept");
return 1;
}
fds[nfds].fd = clientSock;
fds[nfds].events = POLLIN;
nfds++;
}
// 处理每个客户端的数据
for (int i = 1; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
char buffer[1024];
int bytesRead = recv(fds[i].fd, buffer, sizeof(buffer), 0);
if (bytesRead > 0) {
send(fds[i].fd, buffer, bytesRead, 0);
} else {
close(fds[i].fd);
fds[i] = fds[--nfds]; // 移除关闭的客户端
}
}
}
}
close(serverSock);
return 0;
}
14.3. 3. epoll
多路复用(Linux 特有)
epoll
是 Linux 提供的高效 I/O 多路复用机制。与 select
和 poll
相比,epoll
提供了更高效的性能,尤其是在大量并发连接的场景下。它避免了每次调用时遍历文件描述符集合的开销,改为基于事件驱动的方式。
14.3.1. epoll
的工作原理:
epoll
通过epoll_create
创建一个epoll
实例,使用epoll_ctl
注册文件描述符,并通过epoll_wait
等待文件描述符的事件。- 它支持 边缘触发(ET) 和 水平触发(LT) 模式,提供了更高的效率。
14.3.2. epoll
示例
#include <iostream>
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <netinet/in.h>
#define PORT 8080
#define MAX_EVENTS 10
void setNonBlocking(int sockfd) {
fcntl(sockfd, F_SETFL, O_NONBLOCK);
}
int main() {
int serverSock, clientSock, epollFd;
struct sockaddr_in serverAddr, clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
struct epoll_event ev, events[MAX_EVENTS];
// 创建服务器套接字
serverSock = socket(AF_INET, SOCK_STREAM, 0);
if (serverSock < 0) {
perror("socket");
return 1;
}
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = INADDR_ANY;
serverAddr.sin_port = htons(PORT);
if (bind(serverSock, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
perror("bind");
return 1;
}
if (
listen(serverSock, 5) < 0) {
perror("listen");
return 1;
}
setNonBlocking(serverSock);
// 创建 epoll 实例
epollFd = epoll_create1(0);
if (epollFd == -1) {
perror("epoll_create1");
return 1;
}
// 注册 serverSock 到 epoll
ev.events = EPOLLIN;
ev.data.fd = serverSock;
epoll_ctl(epollFd, EPOLL_CTL_ADD, serverSock, &ev);
while (true) {
int numEvents = epoll_wait(epollFd, events, MAX_EVENTS, -1);
if (numEvents == -1) {
perror("epoll_wait");
return 1;
}
for (int i = 0; i < numEvents; i++) {
if (events[i].data.fd == serverSock) {
clientSock = accept(serverSock, (struct sockaddr*)&clientAddr, &clientAddrLen);
if (clientSock == -1) {
perror("accept");
continue;
}
setNonBlocking(clientSock);
// 注册 clientSock 到 epoll
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = clientSock;
epoll_ctl(epollFd, EPOLL_CTL_ADD, clientSock, &ev);
} else {
char buffer[1024];
int bytesRead = recv(events[i].data.fd, buffer, sizeof(buffer), 0);
if (bytesRead > 0) {
send(events[i].data.fd, buffer, bytesRead, 0);
} else {
close(events[i].data.fd);
}
}
}
}
close(serverSock);
close(epollFd);
return 0;
}
14.4. kqueue
(BSD 系统特有)
kqueue
是 BSD 系统提供的一种高效的事件通知机制,类似于 epoll
,但它是 BSD 系统特有的。
14.5. I/O Completion Ports(Windows)
Windows 提供了 I/O 完成端口(IOCP),它是 Windows 平台下的一种高效多路复用机制,专门用于处理大量并发 I/O 操作。
14.6. 总结
select
和poll
是经典的 I/O 多路复用机制,适用于较少连接的情况,但效率较低。epoll
提供了高效的事件通知,适合大规模并发连接,尤其是在 Linux 上。kqueue
在 BSD 系统上类似于epoll
,而 IOCP 是 Windows 平台的高效机制。
十五、应用层的多路复用
应用层的多路复用 是指在应用层通过程序逻辑来实现对多个数据流或多个网络连接的管理和复用,通常是为了提高程序的并发性和效率,避免频繁的阻塞操作。与操作系统提供的多路复用机制(如 select
、poll
、epoll
等)不同,应用层的多路复用可以通过一些框架或设计模式来实现,以满足应用层的需求。
常见的 应用层多路复用 技术和方法包括:
- I/O 多路复用
- 事件驱动编程
- 线程池或协程模型
- 消息队列
- 非阻塞 I/O 与回调机制
接下来,分别介绍这些方法。
15.1. 1. I/O 多路复用(事件驱动模型)
在应用层,我们可以利用操作系统提供的多路复用技术(如 select
、poll
或 epoll
)来同时监控多个文件描述符或套接字。这种方法在网络服务中非常常见,特别是当你需要处理多个客户端连接时,事件驱动编程 是一个非常有效的模式。
在这种模型中:
- 单线程 或 少数线程 可以同时处理多个网络连接。
- 使用
select
、poll
或epoll
等 I/O 多路复用函数监听多个套接字,待其中一个或多个连接可读/可写时通知应用程序进行相应的操作。
15.1.1. 事件驱动编程模型:
- 事件循环:在事件驱动模型中,应用程序通常会启动一个事件循环,持续监听 I/O 事件、用户输入、定时任务等。
- 回调函数:当某个事件发生时,相关的回调函数会被触发,进行相应的处理。
这种方法避免了使用大量线程和进程,从而提高了性能和资源的利用率。
15.2. 2. 线程池与协程模型
15.2.1. 线程池(Thread Pool)
线程池是一种常见的多路复用技术,通过线程池可以实现对多个请求的复用和管理。线程池模型的优势是可以重用现有的线程,减少频繁创建和销毁线程的开销。
- 多线程:每当有新的请求或任务需要处理时,从线程池中获取一个空闲线程来处理。
- 任务队列:请求通过任务队列发送到线程池中的线程进行处理,线程池对请求的执行顺序进行管理。
15.2.2. 协程(Coroutine)
协程是一种比线程更轻量级的多任务处理方式,它通常由应用程序通过协作式的方式控制任务的执行。协程的优势是能够在单线程中高效地处理多个任务,避免了线程切换的开销。
- 异步 I/O:通过协程的方式管理 I/O 操作,使得一个线程能够在等待某个 I/O 操作完成时执行其他任务。
- 事件循环:协程也通常通过事件循环的方式来调度任务,在事件循环中,可以挂起当前任务,等待 I/O 操作完成后再恢复执行。
Python 的 asyncio
模块、Go 的 goroutines 等都是基于协程的并发编程模型。
15.3. 3. 消息队列(Message Queues)
消息队列是一种常用于解耦和异步任务处理的机制。在应用层,消息队列可以作为多路复用的一种实现方式,尤其适用于需要多个服务或组件之间进行通信的场景。
- 生产者/消费者模型:多个生产者将任务放入队列,消费者从队列中取出任务并执行。通过异步消息处理,多个任务能够高效地复用系统资源。
- 缓冲与负载均衡:消息队列可以帮助缓冲请求并对任务进行负载均衡,避免过载。
- 异步处理:消息队列可以帮助实现异步处理,允许生产者和消费者之间的解耦和并发。
常见的消息队列包括 RabbitMQ、Kafka、ActiveMQ 等。
15.4. 4. 非阻塞 I/O 与回调机制
非阻塞 I/O 和回调机制结合了多路复用和异步处理的优势。通过设置 I/O 操作为非阻塞模式,应用程序可以在等待 I/O 完成的过程中执行其他任务,而不需要阻塞当前的线程。
- 非阻塞 I/O:通过设置文件描述符(如套接字)为非阻塞模式,应用程序在调用 I/O 操作时如果数据没有准备好,不会被阻塞,而是直接返回错误或状态,应用程序可以继续处理其他任务。
- 回调机制:当 I/O 操作完成时,系统会触发相应的回调函数,告知应用程序可以继续处理数据。
例如,在 Node.js 中,应用层的多路复用通常依赖事件驱动机制,通过 非阻塞 I/O 和 回调函数 的结合,实现高效的并发处理。
15.5. 5. 应用场景:高性能 Web 服务器(如 Nginx)
Nginx 是一个典型的高性能 Web 服务器,它广泛使用应用层多路复用技术来处理大量的并发连接。Nginx 基于事件驱动和非阻塞 I/O 模型,通过使用 epoll(在 Linux 上)来监控多个客户端连接的 I/O 状态,而每个连接的处理则由事件循环和回调机制管理。
- 单线程:Nginx 使用单线程处理大量并发连接,通过事件驱动机制来处理多个客户端请求。
- 高效资源利用:通过非阻塞 I/O 和事件循环,Nginx 能在高并发情况下仍能保持较低的资源消耗。
- 模块化设计:Nginx 通过模块化的架构处理不同的任务,例如请求解析、负载均衡、反向代理等。
15.6. 总结
应用层多路复用的核心目的是通过 高效的任务管理和资源复用 来提高应用程序的并发处理能力,避免传统的每个连接都需要一个独立线程/进程的高资源消耗。常见的应用层多路复用技术包括:
- 事件驱动编程(通过
select
、poll
、epoll
等操作系统提供的 I/O 多路复用技术)。 - 线程池与协程,通过任务调度来管理多个并发操作。
- 消息队列,用于解耦和异步任务处理。
- 非阻塞 I/O 与回调机制,通过非阻塞方式执行多个任务。
这些技术使得现代的高并发应用能够在有限的资源下处理大量的并发请求,减少阻塞、提高吞吐量,是构建高性能网络应用和服务的基础。
十六、多线程的锁
在多线程编程中,锁(Lock) 是一种用于控制多个线程对共享资源的访问的同步机制。由于多个线程可能会并发地访问共享资源,可能会导致数据竞争和不一致的情况。为了防止这种情况,通常使用锁来确保一次只有一个线程能够访问某个资源,从而避免并发冲突。
以下是几种常见的 多线程锁 类型和机制:
16.1. 1. 互斥锁(Mutex)
互斥锁(Mutual Exclusion Lock, Mutex) 是最常用的一种锁,它确保在同一时刻只有一个线程可以访问受保护的代码或数据。当一个线程加锁时,其他线程必须等待直到这个线程释放锁。
-
基本操作:
-
加锁(lock):线程尝试获取锁,如果锁已经被其他线程占用,则该线程会阻塞,直到锁被释放。
-
解锁(unlock):线程释放锁,使其他线程能够获取锁。
-
实现:在大多数操作系统和编程语言中,
mutex
是通过操作系统的内核机制实现的。
16.1.1. 示例(C++ std::mutex
):
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void printNumbers(int id) {
mtx.lock(); // 加锁
std::cout << "Thread " << id << " is printing numbers" << std::endl;
mtx.unlock(); // 解锁
}
int main() {
std::thread t1(printNumbers, 1);
std::thread t2(printNumbers, 2);
t1.join();
t2.join();
return 0;
}
16.2. 2. 读写锁(Read-Write Lock)
读写锁是一种允许多个线程同时读共享资源,但在写资源时,只有一个线程能够访问资源的锁。它有两个模式:
- 读锁(shared lock):允许多个线程同时获取读锁,只要没有线程持有写锁。
- 写锁(exclusive lock):如果一个线程持有写锁,则其他线程不能获取读锁或写锁。
读写锁适用于读操作远多于写操作的场景,可以提高系统的并发性。
16.2.1. 示例(C++ std::shared_mutex
):
#include <iostream>
#include <thread>
#include <shared_mutex>
std::shared_mutex rwMutex;
void readData(int id) {
std::shared_lock<std::shared_mutex> lock(rwMutex); // 读锁
std::cout << "Thread " << id << " is reading data" << std::endl;
}
void writeData(int id) {
std::unique_lock<std::shared_mutex> lock(rwMutex); // 写锁
std::cout << "Thread " << id << " is writing data" << std::endl;
}
int main() {
std::thread t1(readData, 1);
std::thread t2(readData, 2);
std::thread t3(writeData, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
16.3. 3. 自旋锁(Spinlock)
自旋锁是一种轻量级的锁,它在等待获取锁时不会阻塞线程,而是通过不断检查锁是否释放来"自旋"等待。它适用于锁竞争非常短暂的场景。
- 自旋锁的缺点:如果锁长时间被占用,自旋锁会浪费 CPU 时间,因此不适合长时间持有锁的操作。
16.3.1. 示例(C++ std::atomic_flag
):
#include <iostream>
#include <thread>
#include <atomic>
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void criticalSection(int id) {
while (lock.test_and_set(std::memory_order_acquire)) { // 自旋,直到锁被释放
// 等待
}
std::cout << "Thread " << id << " is in critical section" << std::endl;
lock.clear(std::memory_order_release); // 释放锁
}
int main() {
std::thread t1(criticalSection, 1);
std::thread t2(criticalSection, 2);
t1.join();
t2.join();
return 0;
}
16.4. 4. 条件变量(Condition Variable)
条件变量是一种用于线程间同步的机制,允许一个线程在某个条件成立时通知其他线程。它通常与互斥锁一起使用,用于实现线程之间的等待和通知。
- 等待(wait):线程在某个条件未满足时,可以进入等待状态,直到条件满足。
- 通知(notify):当条件满足时,线程可以通知等待的线程继续执行。
条件变量常用于生产者-消费者问题等场景。
16.4.1. 示例(C++ std::condition_variable
):
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void printNumbers(int id) {
std::unique_lock<std::mutex> lock(mtx);
while (!ready) { // 等待条件变量
cv.wait(lock);
}
std::cout << "Thread " << id << " is printing numbers" << std::endl;
}
void setReady() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::unique_lock<std::mutex> lock(mtx);
ready = true;
cv.notify_all(); // 通知所有等待线程
}
int main() {
std::thread t1(printNumbers, 1);
std::thread t2(printNumbers, 2);
std::thread t3(setReady);
t1.join();
t2.join();
t3.join();
return 0;
}
16.5. 5. 递归锁(Recursive Lock)
递归锁是一种特殊类型的互斥锁,允许同一个线程多次加锁而不会发生死锁。当一个线程获得递归锁后,它可以再次获得该锁而不会被阻塞。递归锁通常用于避免在递归函数中发生死锁的情况。
16.5.1. 示例(C++ std::recursive_mutex
):
#include <iostream>
#include <thread>
#include <mutex>
std::recursive_mutex rmtx;
void recursiveFunction(int depth) {
if (depth == 0) return;
rmtx.lock();
std::cout << "Depth " << depth << std::endl;
recursiveFunction(depth - 1); // 递归调用
rmtx.unlock();
}
int main() {
std::thread t1(recursiveFunction, 3);
t1.join();
return 0;
}
16.6. 6. 死锁与避免
死锁是指两个或更多的线程因互相等待对方持有的锁而导致的僵局。常见的死锁发生情形有:
- 互斥条件:资源是独占的。
- 请求与保持条件:一个线程保持某些资源的同时,等待其他资源。
- 不剥夺条件:资源一旦被分配给某个线程,它就不能被强制剥夺。
- 循环等待条件:线程之间形成环状等待。
避免死锁的一些策略包括:
- 锁的顺序:确保所有线程按照相同的顺序获取锁,避免循环等待。
- 超时机制:在获取锁时设置超时,如果无法获取锁则释放已获得的锁并重试。
16.7. 总结
多线程的锁机制是为了确保线程在访问共享资源时的同步性和一致性。常见的锁类型包括:
- 互斥锁(Mutex):最常用的锁,保证同一时间只有一个线程访问资源。
- 读写锁(Read-Write Lock):适用于读操作多、写操作少的场景,允许多个读操作并发执行。
- 自旋锁(Spinlock):适用于锁持有时间非常短的场景,避免了线程挂起。
- 条件变量(Condition Variable):允许线程等待某个条件的满足,常用于生产者-消费者问题。
- 递归锁(Recursive Lock):允许同一个线程多次加锁,避免死锁问题。
选择合适的锁类型对于提升程序性能、避免死锁等并发问题至关重要。
十七、互斥锁和自旋锁区别、效率方面
互斥锁(Mutex)和自旋锁(Spinlock)是两种常见的同步机制,它们都用于防止多个线程并发访问共享资源,但它们的工作原理和效率方面有所不同。下面将从 工作机制、效率、使用场景 等方面对它们进行对比。
17.1. 1. 工作机制
17.1.1. 互斥锁(Mutex)
- 互斥锁确保同一时间只有一个线程可以访问某个资源。
- 阻塞操作:当一个线程试图获得已被另一个线程占用的互斥锁时,线程会进入阻塞状态,直到锁被释放。操作系统会将该线程挂起,直到锁被其他线程释放。
- 互斥锁的管理通常是由操作系统内核来进行的,操作系统负责调度线程的挂起和恢复。
17.1.2. 自旋锁(Spinlock)
- 自旋锁也确保同一时间只有一个线程可以访问资源,但当一个线程尝试获取已被占用的自旋锁时,它不会阻塞自己,而是忙等待(spin),即持续检查锁是否释放。
- 自旋操作:如果锁已经被占用,线程会不断检查锁的状态并循环等待,直到锁被释放为止。
- 自旋锁通常是轻量级的,且没有涉及线程调度,它通常由原子操作来实现。
17.2. 2. 效率对比
17.2.1. 互斥锁的效率
- 线程阻塞与唤醒:当互斥锁被占用时,线程会被操作系统挂起,并进入阻塞状态。操作系统会将该线程从 CPU 中移除,直到锁可用时再唤醒该线程。线程的上下文切换、挂起和唤醒操作是有开销的,特别是在锁争用严重时,效率会下降。
- 适用于长时间锁定的情况:由于操作系统调度的介入,互斥锁适用于需要持有锁的时间较长的情况。线程可以被挂起,而不浪费 CPU 时间。
17.2.2. 自旋锁的效率
- 无线程阻塞:自旋锁不会涉及到线程的挂起和唤醒,因此没有上下文切换的开销。线程只是忙等锁释放,持续执行忙等待的循环。
- 适用于短时间锁定的情况:当锁持有的时间非常短时,自旋锁非常高效,因为它避免了线程的上下文切换和调度,能够迅速获得锁。特别是在锁竞争不激烈时,自旋锁的效率非常高。
- CPU资源浪费:然而,当锁持有的时间较长,或者锁竞争较为激烈时,自旋锁会导致线程不断忙等,这会浪费 CPU 资源,导致系统性能下降,甚至导致 CPU 资源的浪费。
17.3. 3. 使用场景
17.3.1. 互斥锁适用场景:
- 长时间锁定的场景:例如,访问数据库、文件系统等,需要长时间持有锁的操作。互斥锁适用于锁持有时间较长的场景,避免了自旋锁在长时间等待时的 CPU 资源浪费。
- 锁竞争较多的场景:当多个线程频繁访问共享资源时,互斥锁的阻塞机制适合管理这些情况,减少了过多的自旋等待。
17.3.2. 自旋锁适用场景:
- 短时间锁定的场景:当锁的持有时间非常短,线程争用较少时,自旋锁能够避免线程阻塞和上下文切换,提供更高的效率。
- 低延迟要求的场景:对于一些实时性要求高的场景,自旋锁能够避免系统的调度延迟,因为它不依赖操作系统进行线程的挂起和唤醒。
- 锁竞争较低的场景:当多个线程之间的锁竞争较少时,自旋锁的效率会比较高,因为不需要等待线程的上下文切换。
17.4. 4. 性能比较(具体情况)
- 锁争用低时:在低争用情况下(即大多数线程都能快速获取锁),自旋锁的性能会优于互斥锁,因为自旋锁不会涉及线程阻塞和唤醒的开销,CPU 只需要忙等待,不需要进行上下文切换。
- 锁争用高时:在高争用的情况下,尤其是当多个线程频繁争夺锁时,自旋锁会带来大量的 CPU 时间浪费,反而降低了系统性能。而互斥锁会将线程挂起,不会浪费 CPU 时间,因此在高争用的情况下,互斥锁效率较高。
17.5. 5. 死锁与饥饿问题
- 死锁:无论是互斥锁还是自旋锁,都可能引发死锁。如果多个线程在相反的顺序获取多个锁,可能会造成死锁。为了避免死锁,可以通过设定锁的顺序来规避,或者使用 递归锁 等特殊锁。
- 饥饿问题:自旋锁可能会导致某些线程因锁持续被占用而长时间无法获取锁(即饥饿)。这是因为自旋锁本质上并不考虑线程的优先级,而是持续忙等待,可能会忽略一些线程的需求。
17.6. 6. 代码示例
17.6.1. 互斥锁(Mutex)示例(C++):
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
void printNumbers(int id) {
mtx.lock(); // 获取锁
std::cout << "Thread " << id << " is printing numbers" << std::endl;
mtx.unlock(); // 释放锁
}
int main() {
std::thread t1(printNumbers, 1);
std::thread t2(printNumbers, 2);
t1.join();
t2.join();
return 0;
}
17.6.2. 自旋锁(Spinlock)示例(C++):
#include <iostream>
#include <atomic>
#include <thread>
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void printNumbers(int id) {
while (lock.test_and_set(std::memory_order_acquire)) { // 自旋锁,直到锁被释放
// 等待
}
std::cout << "Thread " << id << " is printing numbers" << std::endl;
lock.clear(std::memory_order_release); // 释放锁
}
int main() {
std::thread t1(printNumbers, 1);
std::thread t2(printNumbers, 2);
t1.join();
t2.join();
return 0;
}
17.7. 总结
- 互斥锁(Mutex):适用于需要长时间持有锁的情况,操作系统负责线程的阻塞和唤醒,避免了自旋锁的 CPU 资源浪费。适用于锁争用较多的场景。
- 自旋锁(Spinlock):适用于锁持有时间较短且锁竞争较少的场景,它的优势在于避免了线程的阻塞和上下文切换,能够提高效率,但在锁争用高或锁持有时间长的情况下,会浪费大量 CPU 资源。
根据实际应用中的锁竞争情况,选择合适的锁类型对系统性能至关重要。
十八、动静态库区别
静态库和动态库**静态库(Static Library)和动态库(Dynamic Library)**是两种常见的程序库(library)形式,它们在编译、链接、运行时的行为和性能上有所不同。以下是对它们的详细比较:
18.1. 1. 静态库(Static Library)
18.1.1. 定义:
静态库是一种在编译期间将代码链接到程序中的库。它的文件通常具有 .a
(在 UNIX-like 系统中)或 .lib
(在 Windows 系统中)扩展名。静态库文件包含了一组预编译的目标文件(object files),这些目标文件包含了函数和数据的实现,编译器将这些库的内容直接链接到最终的可执行文件中。
18.1.2. 特点:
- 链接方式:静态库在编译期间被链接到程序中。当你编译程序时,静态库的代码会被复制到最终的可执行文件中。
- 文件大小:由于静态库的代码会嵌入到可执行文件中,因此可执行文件通常会变得较大。
- 独立性:静态库不需要依赖于外部的库文件。编译后生成的可执行文件是独立的,不依赖于静态库的存在。
- 更新:如果静态库被更新,你需要重新编译整个程序,以便使用新的库版本。
- 执行速度:静态链接的程序通常启动速度较快,因为所有的代码都已编译进可执行文件,程序不需要额外加载外部库。
18.1.3. 优缺点:
-
优点:
-
可执行文件是独立的,不依赖外部库文件。
-
程序的加载速度较快,因为所有需要的代码已包含在可执行文件中。
-
不容易受到外部动态库版本不匹配等问题的影响。
-
缺点:
-
可执行文件较大,因为库文件的代码被复制到每个程序中。
-
如果库需要更新,必须重新编译所有依赖该库的程序。
-
不便于多个程序共享同一份库,浪费存储空间。
18.1.4. 示例:
静态库的创建和使用示例:
# 编译源文件为目标文件
g++ -c mylib.cpp
# 创建静态库
ar rcs libmylib.a mylib.o
# 使用静态库链接程序
g++ -o myprogram main.cpp -L. -lmylib
18.2. 2. 动态库(Dynamic Library)
18.2.1. 定义:
动态库是一种在程序运行时加载和链接的库。动态库通常具有 .so
(在 UNIX-like 系统中)或 .dll
(在 Windows 系统中)扩展名。动态库并不直接链接到可执行文件中,而是在程序运行时由操作系统加载。
18.2.2. 特点:
- 链接方式:动态库在运行时加载,而不是在编译时链接到可执行文件中。程序和动态库通过符号表(Symbol Table)进行链接。
- 文件大小:由于动态库的代码不包含在可执行文件中,动态库的文件较小。
- 依赖性:程序在运行时依赖于外部的动态库文件。如果运行时找不到相关动态库,程序将无法启动。
- 更新:如果动态库更新,只需要替换库文件,而无需重新编译程序,前提是接口(API)保持不变。
- 执行速度:动态链接的程序启动时需要加载动态库,因此启动速度比静态链接慢,但运行时可能会共享同一个库的多个实例,从而节省内存。
18.2.3. 优缺点:
-
优点:
-
可执行文件较小,因为库的代码不被嵌入到程序中。
-
动态库可以被多个程序共享,节省了内存空间。
-
更新库文件时不需要重新编译程序,只需替换库文件即可。
-
支持按需加载,可以在运行时动态加载和卸载库,灵活性更高。
-
缺点:
-
依赖于外部库文件,程序启动时需要加载库文件,可能会增加启动时间。
-
如果库文件不存在或者版本不兼容,程序可能无法运行。
-
运行时如果找不到所需的动态库,可能导致崩溃或错误。
18.2.4. 示例:
动态库的创建和使用示例:
# 编译源文件为目标文件
g++ -fPIC -c mylib.cpp
# 创建动态库
g++ -shared -o libmylib.so mylib.o
# 使用动态库链接程序
g++ -o myprogram main.cpp -L. -lmylib -Wl,-rpath=.
18.3. 3. 静态库和动态库的主要区别
18.4. 4. 总结
-
静态库:
-
在编译时被链接到程序中,生成的可执行文件较大,且程序是独立的,不依赖外部库。
-
适合那些程序依赖较少、库更新不频繁的场景。
-
动态库:
-
在运行时加载,生成的可执行文件较小,程序依赖外部库文件。
-
适合那些需要共享库、更新频繁或需要按需加载库的场景。
在选择使用静态库还是动态库时,可以根据程序的需求、维护的复杂性、运行时效率和库的更新频率等因素来决定。
十九、堆栈区别
“堆栈”通常指的是计算机内存管理中的两种不同的内存结构:栈(Stack) 和 堆(Heap)。它们的主要区别在于内存的分配和管理方式。
- 栈(Stack)
- 内存分配方式:栈内存是由操作系统自动分配和释放的。当函数调用时,会将该函数的局部变量、参数以及返回地址等信息压入栈中,函数调用结束后,这些信息会被弹出栈外,释放内存。
- 内存管理:栈是基于“先进后出”(LIFO,Last In First Out)原理进行管理的。
- 生命周期:栈上的数据在作用域结束时自动销毁。
- 大小:栈的大小是有限的,通常较小,操作系统为每个线程分配固定大小的栈空间。
- 性能:栈内存的分配和回收效率较高,因为它遵循简单的“入栈”和“出栈”操作。
- 堆(Heap)
- 内存分配方式:堆内存由程序员通过动态分配(如使用
malloc
、new
等)进行分配,程序员需要手动管理内存的释放(例如通过free
、delete
)。 - 内存管理:堆内存是非连续的,内存的分配和回收过程较为复杂,可能导致内存碎片化。
- 生命周期:堆上的数据直到程序员手动释放前都不会被销毁,可能会导致内存泄漏。
- 大小:堆的大小通常较大,可以根据系统和需求动态调整。
- 性能:堆内存的分配和回收较慢,因为它需要复杂的管理和碎片处理。
总结区别
在实际使用中,栈通常用于存储函数调用信息和局部变量,而堆用于存储动态分配的数据。
二十、malloc和new区别、free一个new出来的对象的结果
malloc
和 new
都用于动态分配内存,但它们之间有一些重要的区别。还有,free
用于释放通过 malloc
或 calloc
分配的内存,而不能直接用于释放通过 new
分配的内存。
malloc
和new
的区别
free
一个new
出来的对象的结果
错误:不能使用 free
释放通过 new
分配的内存。这是因为 malloc
和 new
的内存分配机制不同:
malloc
分配内存时,不会调用构造函数,因此它分配的内存仅仅是原始的字节块。new
分配内存时,不仅分配内存,还会调用相应类型的构造函数进行初始化。
因此,如果你使用 free()
释放通过 new
分配的内存,会导致未定义的行为,通常会导致程序崩溃,因为 free()
只是释放内存,而没有调用析构函数来清理对象的资源,可能还会破坏内存管理的内部结构。
正确的做法:
- 对于通过
new
分配的内存,应该使用delete
来释放内存。 - 对于通过
new[]
分配的数组,应该使用delete[]
来释放内存。
示例
int* ptr = new int(10); // 使用 new 分配内存并初始化为10
delete ptr; // 正确:使用 delete 来释放
int* arr = new int[10]; // 使用 new[] 分配内存
delete[] arr; // 正确:使用 delete[] 来释放
如果错误地使用 free
:
int* ptr = new int(10);
free(ptr); // 错误:不能使用 free 释放通过 new 分配的内存
结果:这种做法会导致运行时错误、内存泄漏或者程序崩溃。
总结:
malloc
和new
都用于动态内存分配,但new
会调用构造函数,而malloc
不会。free
用于释放通过malloc
或calloc
分配的内存,而不能用于释放通过new
分配的内存。- 对于通过
new
分配的内存,使用delete
来释放;对于通过new[]
分配的数组,使用delete[]
来释放。
二十一、在函数内定义一个数组,该数组是在堆上还是栈上
在函数内定义一个数组时,数组是在栈上还是堆上,取决于你如何声明和分配这个数组。下面分别讨论不同情况下数组的分配:
- 栈上分配数组
当你在函数内部声明一个普通的数组时,它是在栈上分配的。栈内存由操作系统自动管理,函数调用结束时,栈上的数组会自动销毁。
示例:
void myFunction() {
int arr[10]; // 在栈上分配一个包含10个整数的数组
// arr[0] 到 arr[9] 可以使用
}
- 内存分配:
arr
数组是在栈上分配的。 - 生命周期:
arr
的生命周期由函数的作用域控制,函数执行完后,数组自动销毁。 - 大小限制:栈的大小是有限的,因此栈上的数组通常不能太大,否则可能会导致栈溢出。
- 堆上分配数组
当你使用 new
或 malloc
动态分配内存时,数组是分配在堆上的。堆内存是动态分配的,程序员需要手动管理堆内存的释放。
示例:
void myFunction() {
int* arr = new int[10]; // 在堆上分配一个包含10个整数的数组
// arr[0] 到 arr[9] 可以使用
delete[] arr; // 必须手动释放堆上的内存
}
- 内存分配:
arr
数组是在堆上分配的。 - 生命周期:
arr
数组的生命周期由程序员管理,必须使用delete[]
来释放内存,防止内存泄漏。 - 大小限制:堆内存的大小通常比较大,只受限于操作系统的内存总量。
总结:
- 栈上数组:通过直接声明(如
int arr[10];
)创建,内存分配由操作系统自动管理,生命周期由作用域控制。 - 堆上数组:通过
new[]
或malloc
等动态内存分配函数创建,内存由程序员手动管理,需要显式释放。
因此,只有在使用 new
、malloc
等动态分配内存的方式时,数组才会在堆上分配。而在函数内直接定义的普通数组,默认是在栈上分配的。
二十二、定义这个数组在大小上有什么限制吗(栈溢出)
在栈上定义数组时,确实存在大小限制,主要取决于栈的大小。如果数组太大,可能会导致栈溢出。栈溢出是指当栈空间不足时,程序会尝试使用超出栈内存范围的空间,从而导致程序崩溃或行为异常。
栈大小限制
- 栈的大小是有限的,并且通常由操作系统设置。例如,在大多数操作系统中,每个线程都有一个独立的栈空间,栈空间的大小通常在几个百 KB 到几 MB 之间(例如,Linux 上的默认栈大小一般是 8 MB)。
- 栈溢出的风险:如果你在函数内部声明一个非常大的数组(例如几 MB 大小的数组),就有可能导致栈溢出。栈溢出后,程序通常会崩溃。
例子:
-
小数组(栈上分配):
void myFunction() { int arr[1000]; // 1000个整数,大约4000字节(4KB) // 可以正常使用 }
在大多数系统上,这种大小的数组是没问题的,因为栈通常有足够的空间。
-
大型数组(栈上分配,可能导致栈溢出):
void myFunction() { int arr[1000000]; // 100万个整数,大约4000000字节(4MB) // 可能会导致栈溢出,取决于栈的大小 }
这里的数组大小可能会接近或超过栈的限制,导致栈溢出。
如何避免栈溢出?
-
避免定义过大的栈数组:如果你需要的数组很大,可以考虑将其定义为堆数组(使用
new
或malloc
)而不是栈数组,这样就可以避免栈溢出的问题。void myFunction() { int* arr = new int[1000000]; // 在堆上分配一个100万个整数的数组 // 使用完后需要记得 delete[] delete[] arr; }
-
使用动态内存分配:对于较大的数组,可以使用
new[]
或malloc
等动态分配方式,这些方式在堆上分配内存,通常不受栈大小限制,但需要手动管理内存。 -
调整栈大小(系统设置):在某些情况下,如果你控制程序的运行环境,可以尝试增加栈的大小。比如在 Linux 上可以使用
ulimit -s
命令调整栈大小,或者在编译时指定栈大小。
栈和堆的适用场景:
- 栈:适合小规模的局部数据结构(如局部数组),因为它们的生命周期是短暂的,而且栈的分配和回收效率非常高。
- 堆:适合大规模的数据结构或对象(如大型数组、动态分配的对象),因为堆内存较大且不会受到栈大小的限制。
总结:
- 栈数组大小有限,具体限制取决于操作系统和编译器的栈大小设置。过大的栈数组会导致栈溢出。
- 如果需要较大的数组或动态大小的数组,最好在堆上分配内存,即使用
new[]
或malloc
,这样就不受栈大小限制。
二十三、在函数里定义一个vector对象,哪些在栈上或者堆上
在 C++ 中,std::vector
是一个动态数组容器,它会根据需要动态分配内存,因此 vector
对象本身以及它存储的元素内存位置的分配方式存在一些区别。具体来说,std::vector
的内存分配情况如下:
vector
对象本身:
-
std::vector
本身是一个对象,它是一个类类型,通常在栈上分配,除非它是通过动态内存分配(new
)创建的。 -
栈分配:如果你在函数内声明一个
std::vector
对象,那么该对象的内存通常是分配在栈上的,属于局部变量。void myFunction() { std::vector<int> v; // v对象在栈上分配 }
这里
v
是栈上的一个局部变量,它的构造和析构由栈的作用域控制。
vector
存储的元素内存:
-
std::vector
内部会维护一个动态数组来存储它的元素,这个数组的内存是在堆上分配的,尤其是在vector
被填充了数据时。 -
当
vector
扩容时,它会重新分配更大的堆内存并将现有元素复制到新内存块。void myFunction() { std::vector<int> v(1000); // v的元素数组在堆上分配 }
在上述代码中,
std::vector<int> v(1000);
创建了一个vector
对象v
,它包含 1000 个int
元素。虽然v
本身是在栈上分配的,但vector
内部的元素(即 1000 个int
)会被分配到堆上。
总结:
vector
对象本身:如果vector
是一个局部变量,它通常在栈上分配。vector
存储的元素:vector
内部存储的元素数组总是分配在堆上,栈上只保存对这些堆内存的指针(即vector
的内部实现)。
例子说明:
void myFunction() {
// 1. `v` 是栈上分配的对象
std::vector<int> v(1000); // 2. v 的元素数组在堆上分配
// 3. `v` 的析构会释放堆内存
}
在上面的例子中:
v
对象本身是在栈上分配的。v
内部存储的 1000 个int
元素是分配在堆上的。- 当函数返回时,
v
被销毁,vector
的析构函数会释放堆上的内存。
其他注意事项:
- 元素的内存管理:
std::vector
会在需要扩容时自动管理堆内存的分配和释放。它会定期进行重新分配,以便支持更大的容器。你无需手动管理这些内存。 - 避免栈溢出:虽然
vector
对象本身在栈上分配,但如果你在栈上创建一个非常大的vector
对象,可能会由于栈空间不足而导致栈溢出。为避免此问题,确保vector
对象本身的大小适合栈空间。
二十四、STL容器unordered map/set、map/set、priority queue原理
在 C++ 中,STL(标准模板库)提供了多种容器,其中 unordered_map/set
、map/set
和 priority_queue
是常用的关联容器和优先级队列。每个容器都有其独特的实现原理和适用场景。下面将详细讲解它们的原理。
unordered_map
和unordered_set
unordered_map
和 unordered_set
都是基于 哈希表(Hash Table) 实现的容器。
unordered_map
:存储键值对(key-value pairs),每个元素由一个键(key)和一个值(value)组成。unordered_set
:只存储键(key),没有与之相关的值(value)。
实现原理:
- 哈希表(Hash Table):
unordered_map
和unordered_set
都使用哈希表来存储元素。每个元素通过哈希函数映射到一个桶(bucket)中。哈希表的关键操作是通过哈希函数快速定位元素,因此它们提供了平均常数时间复杂度的查找、插入和删除操作(O(1)
)。 - 哈希冲突处理:由于多个元素可能被哈希到相同的桶中(即哈希冲突),通常使用链表、链式哈希(separate chaining)或开放定址法(open addressing)等技术来解决冲突。
- 无序存储:
unordered_map
和unordered_set
内部的元素并不保持任何特定的顺序,因此,它们在遍历时的顺序是不可预测的。
操作复杂度:
- 查找、插入和删除:平均时间复杂度为
O(1)
,最坏情况下(哈希函数不均匀、冲突过多)可能退化为O(n)
。 - 遍历:时间复杂度是
O(n)
,其中n
是容器中的元素数量。
示例:
#include <unordered_map>
#include <unordered_set>
#include <iostream>
int main() {
// unordered_map 示例
std::unordered_map<int, std::string> umap;
umap[1] = "one";
umap[2] = "two";
umap[3] = "three";
for (const auto& pair : umap) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// unordered_set 示例
std::unordered_set<int> uset = {1, 2, 3, 4, 5};
for (const auto& elem : uset) {
std::cout << elem << " ";
}
}
map
和set
map
和 set
都是基于 红黑树(Red-Black Tree) 实现的容器。
map
:存储键值对(key-value pairs),每个元素由一个键(key)和一个值(value)组成。键是唯一的。set
:只存储键(key),没有与之相关的值(value)。键是唯一的。
实现原理:
-
红黑树(Red-Black Tree)
:
map
和
set
都使用红黑树来存储元素。红黑树是一种自平衡的二叉搜索树,具有以下特点:
- 每个节点都有一个颜色(红色或黑色)。
- 根节点是黑色。
- 红色节点的子节点必须是黑色(即没有两个连续的红色节点)。
- 从任意节点到其叶子节点的路径中,黑色节点的数量是相同的。
- 红黑树的高度是对数级别的,因此它提供了
O(log n)
的查找、插入和删除操作。
操作复杂度:
- 查找、插入和删除:时间复杂度是
O(log n)
,其中n
是容器中的元素数量。 - 遍历:时间复杂度是
O(n)
,并且元素是按照升序(或自定义的排序规则)遍历的。
示例:
#include <map>
#include <set>
#include <iostream>
int main() {
// map 示例
std::map<int, std::string> m;
m[1] = "one";
m[2] = "two";
m[3] = "three";
for (const auto& pair : m) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// set 示例
std::set<int> s = {1, 2, 3, 4, 5};
for (const auto& elem : s) {
std::cout << elem << " ";
}
}
priority_queue
priority_queue
是一种优先队列,它通常使用 堆(Heap) 实现,堆是一种完全二叉树。C++ STL 中的 priority_queue
是一个最大堆(默认情况下),即堆顶元素是当前队列中的最大元素。它也可以配置为最小堆。
实现原理:
- 堆(Heap):
priority_queue
基于堆数据结构实现,具体地说,它是一个完全二叉树,满足堆的性质:- 最大堆:每个节点的值都不小于其子节点的值。
- 最小堆:每个节点的值都不大于其子节点的值(可以通过自定义比较器来实现)。
- 堆操作:堆提供了快速的插入和删除操作(插入和删除堆顶元素的时间复杂度都是
O(log n)
),但它不支持中间元素的直接访问。- 插入:通过
push()
将元素插入堆中。 - 删除堆顶元素:通过
pop()
移除堆顶元素。 - 访问堆顶元素:通过
top()
获取堆顶元素,但不移除它。
- 插入:通过
操作复杂度:
- 插入:
O(log n)
。 - 删除堆顶元素:
O(log n)
。 - 访问堆顶元素:
O(1)
。
示例:
#include <queue>
#include <iostream>
int main() {
std::priority_queue<int> pq;
pq.push(10);
pq.push(30);
pq.push(20);
while (!pq.empty()) {
std::cout << pq.top() << " "; // 输出堆顶元素(最大元素)
pq.pop(); // 移除堆顶元素
}
}
总结
这些容器各自有不同的性能特点和使用场景,选择适合的容器可以大大提升程序的效率和可读性。
二十五、vector日常使用,查询、插入和扩容原理和理解
在 C++ 中,std::vector
是一个动态数组容器,具有灵活的内存管理和高效的操作。它的底层实现基于动态数组,因此在查询、插入、扩容等操作时有其独特的原理。以下是 std::vector
日常使用中的主要操作原理和如何理解这些操作的详细内容。
- 查询(Accessing Elements)
std::vector
提供了多种方式来访问容器中的元素。
-
随机访问:
vector
提供了常数时间复杂度O(1)
的随机访问操作。你可以通过索引访问元素,或者使用迭代器遍历。示例:
std::vector<int> v = {1, 2, 3, 4, 5}; std::cout << v[2]; // 通过索引访问,第 3 个元素,输出 3 std::cout << v.at(2); // 使用 at() 方法访问,返回 3,也会检查越界
-
注意:
v[2]
和v.at(2)
的区别在于:at()
会在访问越界时抛出std::out_of_range
异常,而[]
操作符不会进行边界检查,直接访问越界元素会导致未定义行为。
- 插入(Insertion)
插入操作会影响 vector
的性能,特别是在容器的前面或中间插入元素时。
-
在末尾插入:
vector
提供了push_back()
方法将元素添加到末尾。这个操作是常数时间复杂度O(1)
,除非需要扩容(扩容时可能是O(n)
)。示例:
std::vector<int> v = {1, 2, 3}; v.push_back(4); // 向末尾插入元素,v 变成 {1, 2, 3, 4}
-
在指定位置插入:如果你在
vector
中间插入元素(如使用insert()
),这会导致插入位置后的元素需要向后移动,时间复杂度是O(n)
,其中n
是插入点之后的元素数量。示例:
std::vector<int> v = {1, 2, 4}; v.insert(v.begin() + 2, 3); // 在索引 2 处插入元素 3,v 变成 {1, 2, 3, 4}
- 扩容(Resizing and Reallocation)
std::vector
是一个动态数组容器,它的容量会随着元素的增加而自动扩展。当 vector
的元素数量超过当前分配的内存容量时,vector
会触发 扩容(reallocation),分配新的更大的内存块,并将原有元素复制到新内存中。
-
扩容机制:
- 容量(capacity)和大小(size):
vector
的大小是当前存储的元素数量,容量是分配给vector
的内存块大小(即可以存储的最大元素数量)。 - 扩容策略:当
vector
的大小超过当前容量时,vector
通常会将其容量翻倍。这是为了减少频繁的内存重新分配。扩容时,vector
会申请一块更大的内存空间,并将原有元素逐个复制到新的内存区域。扩容通常是O(n)
操作,其中n
是当前的元素数量。
示例:
std::vector<int> v = {1, 2, 3}; std::cout << "Size: " << v.size() << ", Capacity: " << v.capacity() << std::endl; v.push_back(4); // 如果 capacity 不足,会触发扩容
- 容量(capacity)和大小(size):
-
扩容的代价:扩容会带来较高的性能开销,因为所有元素都必须重新复制到新的内存空间。为了优化性能,
vector
通常会分配比当前需要更多的内存,这样减少了扩容的频率,但也可能会导致内存浪费。 -
reserve()
方法:如果你知道vector
将存储一定数量的元素,可以提前调用reserve()
来为vector
分配足够的内存,以避免多次扩容。例如:std::vector<int> v; v.reserve(1000); // 提前为 vector 分配足够的空间,避免多次扩容
调用
reserve()
不会改变vector
的大小,它只是增加了容器的容量。 -
shrink_to_fit()
方法:当你删除大量元素时,vector
可能会保留原来的内存容量,这会浪费内存。调用shrink_to_fit()
可以将vector
的容量调整为与当前元素数量相匹配,尽管这不是强制性的,也不一定会执行(取决于实现)。std::vector<int> v = {1, 2, 3, 4, 5}; v.resize(2); // v 变成 {1, 2} v.shrink_to_fit(); // 尝试将容量调整为 2
- 理解
vector
的内存模型
- 大小(size):
vector
的大小是当前存储的元素个数,随着元素的增加或删除动态变化。 - 容量(capacity):
vector
当前分配的内存空间的大小,容量总是大于或等于大小。 - 内存分配:每次扩容时,
vector
会分配更大的内存,并将原有元素复制到新内存块中,原内存会被释放。
扩容示例:
假设有一个 vector
初始化时容量为 2,然后依次插入元素:
std::vector<int> v;
v.push_back(1); // 容量为 2,大小为 1
v.push_back(2); // 容量为 2,大小为 2
v.push_back(3); // 扩容,容量变为 4,大小为 3
v.push_back(4); // 容量为 4,大小为 4
- 在
push_back(3)
之前,vector
容量是 2,当元素数超过容量时,vector
扩容为 4,并将原有元素移动到新的内存区域。 - 这一次扩容的代价是
O(n)
,其中n
是原有元素的个数,vector
需要分配新内存并复制原有元素。
- 总结
vector
操作的性能
-
查询(通过索引或迭代器):
O(1)
,快速。 -
插入
:
- 末尾插入(push_back):
O(1)
(摊销),但如果需要扩容时,扩容的代价是O(n)
。 - 中间插入(insert):
O(n)
,因为需要移动后续元素。
- 末尾插入(push_back):
-
扩容:当
vector
达到当前容量时,插入会触发扩容,通常是容量翻倍,扩容的时间复杂度是O(n)
,其中n
是当前元素个数。
- 使用
vector
的最佳实践
- 如果你知道会存储大量元素,提前调用
reserve()
来减少不必要的扩容操作。 - 对于经常进行插入和删除的操作,
vector
可能不是最优选择,考虑使用std::deque
或std::list
。 - 在内存使用上,
vector
的容量可能比实际大小多出一部分,如果内存较紧张,可以使用shrink_to_fit()
来优化内存使用。
通过理解这些 vector
的操作原理,可以更高效地使用它,并避免在使用中遇到性能瓶颈或内存浪费的问题。
二十六、多态原理
多态(Polymorphism) 是面向对象编程(OOP)的一个重要特性,它允许不同的类以统一的接口来执行不同的操作。多态让我们可以使用相同的函数接口处理不同的对象类型,而不需要关心对象的具体类型。
多态的原理
多态通常可以分为两种类型:
- 编译时多态(静态多态)
- 函数重载(Function Overloading)
- 运算符重载(Operator Overloading)
- 运行时多态(动态多态)
- 虚函数(Virtual Function)
在 C++ 中,运行时多态是最常见的多态实现方式,通常通过 继承 和 虚函数 实现。
- 编译时多态(静态多态)
编译时多态通过函数重载和运算符重载来实现,这些都是在编译时就确定的,而不依赖于运行时的类型。
函数重载
函数重载是指多个函数具有相同的名称,但参数列表不同。编译器根据函数的参数类型或数量来决定调用哪个版本的函数。
#include <iostream>
using namespace std;
class Printer {
public:
void print(int x) {
cout << "Printing integer: " << x << endl;
}
void print(double x) {
cout << "Printing double: " << x << endl;
}
};
int main() {
Printer p;
p.print(10); // 调用 print(int)
p.print(10.5); // 调用 print(double)
}
运算符重载
运算符重载允许自定义类型使用标准的运算符(如 +
, -
, *
等)。
#include <iostream>
using namespace std;
class Complex {
public:
int real, imag;
Complex(int r, int i) : real(r), imag(i) {}
// 重载 + 运算符
Complex operator + (const Complex& other) {
return Complex(real + other.real, imag + other.imag);
}
};
int main() {
Complex c1(2, 3), c2(4, 5);
Complex c3 = c1 + c2; // 使用重载的 + 运算符
cout << "Sum: (" << c3.real << ", " << c3.imag << ")\n";
}
- 运行时多态(动态多态)
运行时多态是通过虚函数(virtual
)和继承来实现的,最常见的应用场景是父类指针或引用指向子类对象时,调用虚函数时表现出的不同功能。
虚函数的原理
虚函数是通过 虚函数表(vtable) 和 虚函数指针(vptr) 实现的。
- 虚函数表(vtable):每个包含虚函数的类会有一个虚函数表。虚函数表是一个指针数组,存储类中所有虚函数的地址。当通过指向基类的指针或引用调用虚函数时,程序会根据指针的实际对象类型来调用对应的虚函数,而不是基类中的版本。
- 虚函数指针(vptr):每个对象中会有一个指向其类的虚函数表的指针。
vptr
用来决定调用哪个类的虚函数。
虚函数的示例:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { // 虚函数
cout << "Base class show function" << endl;
}
};
class Derived : public Base {
public:
void show() override { // 重写虚函数
cout << "Derived class show function" << endl;
}
};
int main() {
Base* bptr;
Derived d;
bptr = &d;
// 调用的将是 Derived 类中的 show 方法,而不是 Base 类中的
bptr->show(); // 输出: Derived class show function
}
虚函数表的工作原理
- 对象创建时,每个对象都会有一个
vptr
指针,它指向该对象的类的虚函数表(vtable)。 - vtable 是一个数组,存储了该类的所有虚函数的地址。
- 当你通过基类指针或引用调用虚函数时,程序会通过
vptr
查找对象的实际类的虚函数表,进而调用正确的虚函数。
虚函数和动态绑定
- 静态绑定(或早绑定):编译时确定调用哪个函数。普通的非虚函数使用静态绑定。
- 动态绑定(或晚绑定):运行时确定调用哪个函数。当我们通过基类指针或引用调用虚函数时,程序会根据对象的实际类型动态选择适当的函数(此过程称为动态绑定)。
- 虚函数的多态性
虚函数允许我们通过父类指针或引用调用子类的重写方法,这种方式被称为运行时多态。在 C++ 中,只有虚函数能够实现这种多态性。
例子:
#include <iostream>
using namespace std;
class Animal {
public:
virtual void sound() { // 基类中的虚函数
cout << "Animal sound" << endl;
}
};
class Dog : public Animal {
public:
void sound() override { // 重写基类的虚函数
cout << "Woof!" << endl;
}
};
class Cat : public Animal {
public:
void sound() override { // 重写基类的虚函数
cout << "Meow!" << endl;
}
};
int main() {
Animal* a1 = new Dog();
Animal* a2 = new Cat();
a1->sound(); // 输出: Woof!
a2->sound(); // 输出: Meow!
delete a1;
delete a2;
}
- 在上述代码中,
Dog
和Cat
都重写了基类Animal
中的sound
函数。 - 当我们通过
Animal
类型的指针调用sound()
函数时,程序会根据对象的实际类型(Dog
或Cat
)来选择合适的函数,这就是动态多态。
- 虚函数的实现细节
虚函数机制是通过 虚函数表(vtable) 和 虚函数指针(vptr) 实现的。每个包含虚函数的类都会有一个虚函数表。这个表包含了该类中所有虚函数的地址。
-
虚函数表:每个类在编译时会自动生成一个虚函数表,它存储了类中所有虚函数的指针。如果一个类继承了另一个类并覆盖了基类的虚函数,那么派生类的虚函数表将会更新为新的虚函数指针。
-
虚函数指针:每个对象都有一个指向虚函数表的指针(vptr)。通过该指针,程序在运行时可以根据对象的实际类型来选择正确的虚函数。
-
虚函数的性能开销
使用虚函数和运行时多态会带来一些性能开销:
- 虚函数表的存储:每个包含虚函数的类都需要一个虚函数表(
vtable
),这会消耗一些内存。 - 动态绑定的时间开销:每次通过指针或引用调用虚函数时,程序必须通过
vptr
查找虚函数表,进而确定调用哪个函数。这个查找过程比静态绑定要稍慢。
然而,这种开销通常是微不足道的,除非在需要频繁调用虚函数的性能敏感的代码中。
总结
- 多态 使得不同类型的对象可以通过相同的接口进行操作,增强了代码的灵活性和可扩展性。
- 编译时多态 通过函数重载和运算符重载实现,主要是在编译时通过函数签名来决定调用哪个函数。
- 运行时多态 通过虚函数和继承实现,依赖于虚函数表(vtable)和虚函数指针(vptr),在运行时决定调用哪个类的函数。
运行时多态是面向对象编程的一个核心概念,使得通过父类指针或引用能够调用不同派生类的实现,从而支持了面向接口编程和代码的高度复用。
二十七、编译时多态,多个实例化的编译结果!
编译时多态的原理与多个实例化的编译结果
在 C++ 中,编译时多态 是通过 函数重载 和 模板(模板特化、模板实例化) 来实现的。编译时多态的关键在于,类型信息 是在编译时已经确定的,而不是在运行时动态决定的。
当你使用 函数重载 或 模板 时,编译器会根据提供的参数类型或模板类型推导来生成不同的函数或类实例,这些不同的实例化可以看作是编译时多态的体现。
- 函数重载
函数重载是编译时多态的经典示例。当多个函数拥有相同的名称,但参数类型或数量不同,编译器会根据函数调用时传递的参数来选择具体的函数。
函数重载的编译结果
编译器在编译阶段就会根据函数调用的上下文来选择正确的重载版本,避免了运行时的开销。
#include <iostream>
using namespace std;
class Printer {
public:
void print(int x) {
cout << "Printing integer: " << x << endl;
}
void print(double x) {
cout << "Printing double: " << x << endl;
}
};
int main() {
Printer p;
p.print(10); // 调用 print(int)
p.print(10.5); // 调用 print(double)
}
编译结果:编译器会根据传递的参数类型(int
和 double
)在编译时选择对应的 print
函数。在生成的机器码中,编译器为每个重载的函数生成了不同的调用路径。
p.print(10)
会直接调用print(int)
。p.print(10.5)
会调用print(double)
。
这些选择是在编译时静态决定的。
- 模板编译时多态
C++ 中的模板是实现编译时多态的强大工具,模板会在编译时生成不同类型的代码实例化。模板可以根据不同的类型来生成对应的函数或类实例,这也是 模板实例化 的过程。
模板实例化
C++ 编译器会根据传递给模板的类型,生成相应的代码实例。每次模板被调用时,编译器会生成一个特定类型的函数或类实例。这是通过 模板实例化 实现的。
#include <iostream>
using namespace std;
template <typename T>
void print(T x) {
cout << "Printing: " << x << endl;
}
int main() {
print(10); // 会生成 print<int>
print(10.5); // 会生成 print<double>
print("Hello"); // 会生成 print<const char*>
}
编译结果:编译器会在每次调用 print()
时生成相应类型的模板函数实例:
print<int>(10)
:会生成print
函数的int
类型版本。print<double>(10.5)
:会生成print
函数的double
类型版本。print<const char\*>(“Hello”)
:会生成print
函数的const char*
类型版本。
这里,模板实例化 在编译时完成,不同类型的模板实例化会生成不同的代码,这样编译器就根据调用的模板类型生成了多个不同的编译结果。
- 模板特化
C++ 还支持模板特化,即根据特定类型为模板提供不同的实现。模板特化可以帮助你为某些特殊类型(例如整数类型、指针类型等)提供自定义的实现。
模板特化示例
#include <iostream>
using namespace std;
// 通用模板
template <typename T>
void print(T x) {
cout << "General template: " << x << endl;
}
// 特化模板用于整数类型
template <>
void print<int>(int x) {
cout << "Specialized template for int: " << x << endl;
}
int main() {
print(10); // 调用特化的 print<int>
print(10.5); // 调用通用模板
print("Hello"); // 调用通用模板
}
编译结果:
print(10)
:调用模板特化的print<int>
,输出Specialized template for int: 10
。print(10.5)
和print("Hello")
:调用通用模板print(T)
,输出General template: 10.5
和General template: Hello
。
- 模板元编程与编译时决策
模板元编程(Template Metaprogramming, TMP)是一个更高级的技术,它允许在编译时通过模板实现一些计算或条件判断。模板元编程通常通过 std::enable_if
、std::is_same
等工具来在编译时做类型推导和选择。
std::enable_if
示例
#include <iostream>
#include <type_traits>
using namespace std;
template <typename T>
typename std::enable_if<std::is_integral<T>::value>::type
print(T x) {
cout << "Printing integral value: " << x << endl;
}
template <typename T>
typename std::enable_if<!std::is_integral<T>::value>::type
print(T x) {
cout << "Printing non-integral value: " << x << endl;
}
int main() {
print(10); // 调用 print<int>(整数)
print(10.5); // 调用 print<double>(非整数)
print("Hello"); // 调用 print<const char*>(非整数)
}
编译结果:
print(10)
:根据模板enable_if
判断类型是否是整数,输出Printing integral value: 10
。print(10.5)
和print("Hello")
:输出Printing non-integral value: 10.5
和Printing non-integral value: Hello
,因为10.5
和"Hello"
都不是整数。
- 多个实例化的编译结果
当编译器处理模板时,每次模板实例化都会生成特定的类型版本。如果模板参数类型不同,编译器会为每种类型生成一个不同的实例。这就意味着你可以通过不同的模板参数生成多个实例化版本的代码,体现了编译时多态。
假设你有一个模板函数 foo
,并且你调用它三次,分别使用不同的类型参数:
template <typename T>
void foo(T x) {
// 函数体
}
int main() {
foo(10); // 使用 int 类型
foo(3.14); // 使用 double 类型
foo("hello"); // 使用 const char* 类型
}
编译结果:
-
编译器会生成三种不同的
foo
函数实例:
foo<int>(10)
foo<double>(3.14)
foo<const char*>("hello")
每个不同的类型参数会产生一个不同的函数实例,这些实例化的代码在编译时就已经生成,且每个版本的代码针对不同的类型进行了优化。
总结
- 编译时多态 是通过函数重载和模板实现的。函数重载基于参数类型的不同来选择函数,而模板通过类型推导来生成不同类型的代码实例。
- 模板实例化 是编译时根据不同类型生成不同函数或类的过程,这是编译时多态的一个核心特性。
- 模板特化 可以帮助我们为特定类型提供定制的实现,这也是编译时多态的一种方式。
- 通过模板元编程,可以实现更复杂的编译时决策,在编译阶段进行类型选择和计算。
二十八、智能指针
智能指针(Smart Pointer) 是 C++ 中用来管理动态分配内存的一种类,它通过对原始指针进行封装,自动管理内存的分配和释放,从而避免了手动管理内存所带来的复杂性和风险(例如内存泄漏、双重释放等问题)。
智能指针的核心思想是将内存的管理交给智能指针对象来负责,而不是由程序员手动释放。这通常通过 RAII(Resource Acquisition Is Initialization)技术来实现,即资源的生命周期和对象的生命周期绑定在一起,当智能指针超出作用域时,资源会自动释放。
C++11 中的智能指针
C++11 引入了三种主要的智能指针类型:
std::unique_ptr
std::shared_ptr
std::weak_ptr
这些智能指针通常位于 <memory>
头文件中。
std::unique_ptr
std::unique_ptr
是 独占式的智能指针,意味着每个 unique_ptr
对象只能拥有一个原始指针的所有权。它不允许多个 unique_ptr
指向同一个对象。unique_ptr
会在超出作用域时自动释放所管理的内存。
特点:
- 独占所有权:一个对象只能有一个
unique_ptr
来管理,不能拷贝,只能通过move
转移所有权。 - 自动释放:当
unique_ptr
离开作用域时,它会自动释放管理的内存。
使用示例:
#include <iostream>
#include <memory>
class MyClass {
public:
void sayHello() {
std::cout << "Hello from MyClass!" << std::endl;
}
};
int main() {
// 创建一个 unique_ptr 管理 MyClass 对象
std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
ptr1->sayHello(); // 调用 MyClass 方法
// 不允许复制 unique_ptr,只能移动
// std::unique_ptr<MyClass> ptr2 = ptr1; // 错误:不能拷贝
std::unique_ptr<MyClass> ptr2 = std::move(ptr1); // 移动所有权
// ptr1 现在为空,不能再使用
// ptr1->sayHello(); // 错误,ptr1 已被转移
ptr2->sayHello(); // 正常使用 ptr2
// 当 ptr2 超出作用域时,自动释放 MyClass 对象
}
注意:std::move
用来转移 unique_ptr
的所有权。转移之后,ptr1
变为空指针(nullptr
),不能再访问该对象。
std::shared_ptr
std::shared_ptr
是 共享所有权的智能指针。多个 shared_ptr
可以共享同一个对象的所有权。只有最后一个指向该对象的 shared_ptr
被销毁时,才会释放该对象。shared_ptr
通过引用计数来管理对象的生命周期。
特点:
- 共享所有权:多个
shared_ptr
可以指向同一个对象,引用计数会增加。 - 自动释放:当最后一个
shared_ptr
被销毁时,管理的对象会自动释放。 - 线程安全:对引用计数的修改是线程安全的,但对象的销毁操作并不是线程安全的。
使用示例:
#include <iostream>
#include <memory>
class MyClass {
public:
void sayHello() {
std::cout << "Hello from MyClass!" << std::endl;
}
};
int main() {
// 创建 shared_ptr 管理 MyClass 对象
std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
ptr1->sayHello();
// 创建另一个 shared_ptr,指向相同的对象
std::shared_ptr<MyClass> ptr2 = ptr1; // 引用计数增加
std::cout << "Use count: " << ptr1.use_count() << std::endl; // 输出引用计数
// 当 ptr1 和 ptr2 都超出作用域时,MyClass 对象被自动释放
}
注意:shared_ptr
会自动增加引用计数,每当有一个新的 shared_ptr
被赋值为一个已有对象时,引用计数会增加;当一个 shared_ptr
被销毁或重新赋值时,引用计数会减少。
std::weak_ptr
std::weak_ptr
是 不改变引用计数的智能指针,它用于解决 shared_ptr
中的循环引用问题。weak_ptr
允许访问 shared_ptr
管理的对象,但不影响该对象的引用计数,因此它不会阻止对象的销毁。
特点:
- 避免循环引用:
weak_ptr
不增加引用计数,因此不会导致循环引用问题。 - 临时访问:
weak_ptr
不能直接解引用,需要先通过lock()
获取一个有效的shared_ptr
。 - 适用于缓存:当需要缓存对象或暂时引用对象时,使用
weak_ptr
比较合适。
使用示例:
#include <iostream>
#include <memory>
class MyClass {
public:
void sayHello() {
std::cout << "Hello from MyClass!" << std::endl;
}
};
int main() {
std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
// 创建 weak_ptr,指向 shared_ptr,但不增加引用计数
std::weak_ptr<MyClass> weakPtr = ptr1;
// 通过 weak_ptr 获取 shared_ptr
if (auto ptr2 = weakPtr.lock()) {
ptr2->sayHello();
} else {
std::cout << "Object has been destroyed!" << std::endl;
}
// ptr1 超出作用域,MyClass 对象被销毁
if (auto ptr2 = weakPtr.lock()) {
ptr2->sayHello(); // 此时 ptr2 将为空
} else {
std::cout << "Object has been destroyed!" << std::endl; // 输出: Object has been destroyed!
}
}
注意:weak_ptr
不能直接访问对象。如果需要访问对象,必须通过 lock()
方法转换成 shared_ptr
,如果对象已经被销毁,lock()
会返回空指针。
智能指针与资源管理的关系
智能指针本质上是一种 RAII(Resource Acquisition Is Initialization)技术的应用。RAII 的核心思想是:对象的生命周期与资源的生命周期绑定,当对象被销毁时,资源也会被释放。因此,智能指针的作用不仅仅是避免内存泄漏,还能帮助开发者避免因手动管理内存带来的错误。
智能指针优势:
- 自动管理内存:智能指针会在超出作用域时自动释放资源,避免了内存泄漏和双重释放的问题。
- 异常安全:在发生异常时,智能指针会确保对象被正确释放,避免因异常导致的资源泄漏。
- 避免手动释放:不再需要显式地调用
delete
或delete[]
,减少了因忘记释放资源或误用delete
的风险。 - 共享资源管理:
shared_ptr
允许多个对象共享资源,而weak_ptr
可以避免因引用计数的增加而导致的循环引用。
智能指针的限制与注意事项
- 循环引用:虽然
shared_ptr
可以共享对象,但如果两个shared_ptr
彼此引用对方,会导致循环引用,导致对象无法被销毁。此时应使用weak_ptr
来打破循环引用。 - 性能开销:智能指针通常会有一定的性能开销,尤其是
shared_ptr
的引用计数管理和线程安全相关的操作。需要权衡性能和易用性。 - 资源管理的特殊场景:对于某些特定资源,如文件句柄、网络连接等,可能需要更精细的资源管理策略,智能指针可能不是最佳选择。
总结
std::unique_ptr
:独占所有权,适用于一个对象只有一个管理者的场景。std::shared_ptr
:共享所有权,多个shared_ptr
可以指向同一个对象,适用于对象被多个地方共享的场景。std::weak_ptr
:不改变引用计数,用于避免循环引用,适用于缓存或临时引用的场景。
通过使用智能指针,可以大大简化资源管理,减少因手动管理内存带来的错误,尤其在现代 C++ 中,智能指针已经成为了推荐的内存管理方式。
二十九、进程间通信
进程间通信(IPC, Inter-Process Communication) 是指在多个进程之间传递数据和信息的机制。由于操作系统中的进程拥有独立的内存空间,它们无法直接访问彼此的内存。因此,为了实现进程间的数据交换和协调,必须通过特定的通信机制。
在操作系统中,有多种进程间通信的方式。每种方式都有自己的特性、优势和适用场景。常见的进程间通信方式包括:
- 管道(Pipe)
- 命名管道(Named Pipe)
- 消息队列(Message Queue)
- 共享内存(Shared Memory)
- 信号量(Semaphore)
- 套接字(Socket)
- 信号(Signal)
- 内存映射文件(Memory-Mapped Files)
下面详细介绍这些进程间通信机制的工作原理、特点和使用场景。
- 管道(Pipe)
管道是一种最简单的进程间通信方式,通常用于父子进程之间的通信。
- 匿名管道(Unnamed Pipe):只能用于父子进程或有亲缘关系的进程之间。
- 命名管道(Named Pipe):用于没有亲缘关系的进程间通信,可以跨网络进行通信。
管道的特点:
- 单向通信:数据只能从管道的一个端口流入,另一个端口流出。
- 流式通信:数据以字节流的方式传输。
使用示例:
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
int main() {
int pipefds[2];
char buffer[128];
if (pipe(pipefds) == -1) {
std::cerr << "Pipe creation failed!" << std::endl;
return -1;
}
pid_t pid = fork();
if (pid == 0) { // 子进程
close(pipefds[1]); // 关闭写端
read(pipefds[0], buffer, sizeof(buffer));
std::cout << "Received in child process: " << buffer << std::endl;
close(pipefds[0]);
} else { // 父进程
close(pipefds[0]); // 关闭读端
const char* msg = "Hello from parent!";
write(pipefds[1], msg, strlen(msg) + 1);
close(pipefds[1]);
}
return 0;
}
限制:
- 仅限于父子进程。
- 数据传输是字节流形式,不支持复杂数据类型的传输。
- 命名管道(Named Pipe)
命名管道是一种特殊的文件,可以在不同的进程间进行通信,适用于没有父子关系的进程间通信。
特点:
- 双向通信:支持全双工通信(需要两个管道)。
- 有名字:可以通过文件路径进行访问。
- 跨进程通信:不同的进程通过管道的路径名来访问数据。
使用示例:
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
int main() {
const char* pipePath = "/tmp/my_pipe";
// 创建命名管道
mkfifo(pipePath, 0666);
pid_t pid = fork();
if (pid == 0) { // 子进程
int fd = open(pipePath, O_RDONLY);
char buffer[128];
read(fd, buffer, sizeof(buffer));
std::cout << "Child received: " << buffer << std::endl;
close(fd);
} else { // 父进程
sleep(1); // 等待子进程打开管道
int fd = open(pipePath, O_WRONLY);
write(fd, "Hello from parent!", 18);
close(fd);
}
// 删除管道
unlink(pipePath);
return 0;
}
限制:
- 需要手动创建和删除管道文件。
- 数据传输是字节流,适合简单的数据通信。
- 消息队列(Message Queue)
消息队列是一种先进先出(FIFO)的数据结构,允许进程以消息的形式进行通信。
特点:
- 支持消息的发送和接收:消息队列允许一个进程将消息放入队列中,另一个进程从队列中读取消息。
- 异步通信:消息队列实现了进程间的解耦,发送者和接收者不需要在时间上同步。
- 支持消息优先级:消息队列通常可以设置消息的优先级。
使用示例:
#include <iostream>
#include <sys/ipc.h>
#include <sys/msg.h>
struct msgbuf {
long mtype;
char mtext[100];
};
int main() {
key_t key = ftok("msgqueue", 65);
int msgid = msgget(key, 0666 | IPC_CREAT);
pid_t pid = fork();
if (pid == 0) { // 子进程,接收消息
struct msgbuf rcv_msg;
msgrcv(msgid, &rcv_msg, sizeof(rcv_msg.mtext), 1, 0);
std::cout << "Child received: " << rcv_msg.mtext << std::endl;
} else { // 父进程,发送消息
struct msgbuf send_msg;
send_msg.mtype = 1;
strcpy(send_msg.mtext, "Hello from parent!");
msgsnd(msgid, &send_msg, sizeof(send_msg.mtext), 0);
}
return 0;
}
限制:
- 消息队列的大小是有限制的,必须考虑消息的大小和队列的容量。
- 需要系统提供对消息队列的支持(如 UNIX 系统中的
msgget
)。
- 共享内存(Shared Memory)
共享内存是将一段内存区域映射到多个进程的地址空间中,这样不同的进程可以直接读写这段内存,从而实现高效的进程间通信。
特点:
- 高效的通信:直接共享内存,不需要数据复制,适用于大数据量的通信。
- 同步机制:为了避免并发冲突,需要配合信号量(semaphore)等同步机制来管理共享内存。
使用示例:
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <cstring>
int main() {
key_t key = ftok("shmfile", 65);
int shmid = shmget(key, 1024, 0666 | IPC_CREAT);
char* str = (char*) shmat(shmid, nullptr, 0);
pid_t pid = fork();
if (pid == 0) { // 子进程
std::cout << "Child reads: " << str << std::endl;
} else { // 父进程
strcpy(str, "Hello from parent!");
wait(nullptr); // 等待子进程
}
// 分离共享内存
shmdt(str);
return 0;
}
限制:
- 需要同步机制(如信号量)来保证多进程访问的安全性。
- 共享内存是有限的,需要正确管理资源。
- 信号量(Semaphore)
信号量是一种用于进程间同步的机制,它通常与共享内存配合使用。信号量用于控制对共享资源的访问,防止多个进程并发修改共享数据时发生冲突。
特点:
- 用于同步和互斥:信号量通常用于控制多个进程对共享资源的并发访问。
- 计数信号量:可以用于控制对多个资源的访问数量。
使用示例:
#include <iostream>
#include <semaphore.h>
#include <pthread.h>
sem_t semaphore;
void* thread_function(void* arg) {
sem_wait(&semaphore);
std::cout << "Thread has entered critical section" << std::endl;
sem_post(&semaphore);
return nullptr;
}
int main() {
sem_init(&semaphore, 0, 1);
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread_function, nullptr);
pthread_create(&t2, nullptr, thread_function, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
sem_destroy(&semaphore);
return 0;
}
限制:
- 主要用于进程或线程的同步,不能直接传递数据。
- 需要处理死锁问题。
- 套接字(Socket)
套接字是一种网络通信机制,可以用于不同机器上的进程之间的通信。套接字
提供了基于 TCP/IP 协议的通信能力,广泛应用于分布式系统中。
特点:
- 跨机器通信:支持网络通信,可以跨机器、跨平台进行数据交换。
- 全双工通信:支持双向通信,进程可以同时接收和发送数据。
- 信号(Signal)
信号是一种较为轻量的进程间通信方式,通常用于通知进程某个事件的发生。例如,当某个进程需要向其他进程发送中断或停止信号时,可以使用信号。
特点:
- 异步通信:信号通常用于发送事件通知。
- 简单的传递机制:只能传递一个小的信息,无法传递复杂的数据。
- 内存映射文件(Memory-Mapped Files)
内存映射文件可以将文件或其他对象映射到进程的地址空间。不同进程可以通过映射相同的文件来共享内存,从而实现高效的进程间通信。
特点:
- 直接共享内存:将文件映射到内存中,允许进程通过内存访问文件。
- 适用于大文件处理:非常适合处理大文件,因为它避免了文件读取和写入的重复操作。
总结
不同的进程间通信方式适用于不同的场景:
- 管道和命名管道:简单的父子进程或同一机器内的进程通信。
- 消息队列:异步消息传递,适合消息驱动的通信。
- 共享内存:高效的跨进程数据共享,适合大数据量传输。
- 信号量:进程同步和互斥,防止资源冲突。
- 套接字:跨机器的进程间通信,适合网络应用。
- 信号:轻量的进程通知机制,适用于事件驱动的通信。
选择适当的通信方式可以显著提高程序的效率和可维护性。
三十、具体介绍系统IPC
在操作系统中,进程间通信(IPC,Inter-Process Communication) 是指不同进程之间交换数据和信息的机制。由于操作系统中的进程有各自独立的内存空间,它们不能直接共享数据,因此需要通过某些特殊的机制来进行数据的传递与同步。进程间通信的方式多种多样,每种方式都适用于不同的应用场景。
在具体的操作系统中(如 Linux、Windows 等),会提供一些标准的 IPC 机制。下面将详细介绍几种常见的 IPC 方式及其实现原理,特别是 Unix/Linux 系统中的实现。
- 管道(Pipe)
管道 是一种最基本的 IPC 机制,它用于将数据从一个进程传递到另一个进程。管道是一种 半双工通信 方式,数据流只能单向传输。管道的原理类似于数据流经一根管道,数据从一端写入,通过管道传输后从另一端读取。
- 匿名管道:通常用于具有亲缘关系的进程(如父子进程)之间通信。父进程通过管道向子进程传递数据,或子进程通过管道向父进程传递数据。
- 命名管道(FIFO):命名管道是一种特殊类型的管道,它在文件系统中有一个路径名,允许没有亲缘关系的进程进行通信。
实现原理:
管道基于内存缓冲区实现,操作系统为管道分配一个缓冲区。当一个进程写入管道时,数据首先进入缓冲区,然后另一个进程从管道读取数据。管道具有阻塞特性:如果缓冲区满了,写入会阻塞;如果缓冲区为空,读取会阻塞。
示例代码(Linux):
#include <unistd.h>
#include <iostream>
#include <cstring>
int main() {
int pipefds[2];
char buffer[128];
if (pipe(pipefds) == -1) {
std::cerr << "Pipe creation failed!" << std::endl;
return -1;
}
pid_t pid = fork();
if (pid == 0) { // 子进程
close(pipefds[1]); // 关闭写端
read(pipefds[0], buffer, sizeof(buffer));
std::cout << "Received in child process: " << buffer << std::endl;
close(pipefds[0]);
} else { // 父进程
close(pipefds[0]); // 关闭读端
const char* msg = "Hello from parent!";
write(pipefds[1], msg, strlen(msg) + 1);
close(pipefds[1]);
}
return 0;
}
优缺点:
-
优点
:
- 简单且高效,适用于同一机器上具有亲缘关系的进程间通信。
- 不需要显式的资源管理。
-
缺点
:
- 仅支持单向通信。
- 只能用于父子进程或具有亲缘关系的进程。
- 对于大量数据或复杂数据结构的传输较为困难。
- 消息队列(Message Queue)
消息队列 是一种先进先出(FIFO)队列,它允许一个进程将数据(消息)写入队列,另一个进程从队列中读取消息。消息队列允许多个进程之间进行异步通信,且每个消息都可以携带一定量的数据。
实现原理:
消息队列通过内核维护一个队列,每当一个进程需要发送消息时,它将消息放入队列。接收方可以从队列中读取消息。消息队列提供了阻塞和非阻塞的读写操作,且可以设置消息的优先级。
优缺点:
-
优点
:
- 支持多进程间的异步通信。
- 可以在不同进程间传递复杂的数据结构(通过将数据封装为消息)。
- 可以设置消息优先级。
-
缺点
:
- 队列的容量有限,可能会导致队列满。
- 消息队列管理较为复杂,需要同步机制来保证数据一致性。
示例代码(Linux):
#include <sys/ipc.h>
#include <sys/msg.h>
#include <iostream>
#include <cstring>
struct msgbuf {
long mtype;
char mtext[100];
};
int main() {
key_t key = ftok("msgqueue", 65); // 生成消息队列的键值
int msgid = msgget(key, 0666 | IPC_CREAT); // 获取消息队列ID
pid_t pid = fork();
if (pid == 0) { // 子进程,接收消息
struct msgbuf rcv_msg;
msgrcv(msgid, &rcv_msg, sizeof(rcv_msg.mtext), 1, 0);
std::cout << "Child received: " << rcv_msg.mtext << std::endl;
} else { // 父进程,发送消息
struct msgbuf send_msg;
send_msg.mtype = 1;
strcpy(send_msg.mtext, "Hello from parent!");
msgsnd(msgid, &send_msg, sizeof(send_msg.mtext), 0);
}
return 0;
}
- 共享内存(Shared Memory)
共享内存 是一种进程间直接共享内存区域的通信方式。多个进程可以将同一块内存区域映射到各自的地址空间,从而实现高效的数据共享。共享内存的主要优势是速度快,因为进程间不需要复制数据。
实现原理:
共享内存的实现方式是通过将一段物理内存映射到多个进程的虚拟地址空间,所有的进程都可以直接读写这块内存。为了防止数据竞争,通常需要使用 信号量 或 互斥锁 等同步机制来协调进程对共享内存的访问。
优缺点:
-
优点
:
- 速度非常快,适合需要频繁访问共享数据的场景。
- 不需要拷贝数据,节省了内存和 CPU 时间。
-
缺点
:
- 需要额外的同步机制来避免数据竞争。
- 共享内存的管理较为复杂,容易出现内存泄漏问题。
示例代码(Linux):
#include <sys/ipc.h>
#include <sys/shm.h>
#include <iostream>
#include <cstring>
int main() {
key_t key = ftok("shmfile", 65); // 生成共享内存键值
int shmid = shmget(key, 1024, 0666 | IPC_CREAT); // 获取共享内存ID
char* str = (char*) shmat(shmid, nullptr, 0); // 将共享内存映射到进程地址空间
pid_t pid = fork();
if (pid == 0) { // 子进程
std::cout << "Child reads: " << str << std::endl;
} else { // 父进程
strcpy(str, "Hello from parent!");
wait(nullptr); // 等待子进程
}
// 分离共享内存
shmdt(str);
return 0;
}
- 信号量(Semaphore)
信号量 是用于控制多个进程对共享资源的访问,常与共享内存结合使用。信号量通过维护一个计数器来控制进程对资源的访问,进程可以使用 sem_wait
操作申请资源,使用 sem_post
操作释放资源。信号量常用于进程同步,避免并发冲突。
实现原理:
信号量通常在内核中维护一个计数器,每次进程请求资源时,信号量的值会减少。如果值为 0,进程会被阻塞,直到信号量值大于 0。进程释放资源时,信号量值会增加。
优缺点:
-
优点
:
- 用于进程间同步,避免共享资源的冲突。
- 支持多进程/多线程的并发控制。
-
缺点
:
- 使用不当可能导致死锁或资源饥饿问题。
示例代码(Linux):
#include <semaphore.h>
#include <pthread.h>
#include <iostream>
sem_t semaphore;
void* thread_function(void* arg) {
sem_wait(&semaphore);
std::cout << "Thread has entered critical section" << std::endl;
sem_post(&semaphore);
return nullptr;
}
int main() {
sem_init(&semaphore, 0, 1);
pthread_t t1, t2;
pthread_create(&t1, nullptr, thread_function, nullptr);
pthread_create(&t2, nullptr, thread_function, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
sem_destroy(&semaphore);
return 0;
}
- 套接字(Socket)
套接字是一种跨机器通信的 IPC 机制,允许在不同机器或不同网络上的进程进行通信。套接字使用 TCP/IP 协议族,可以在本地或远程进程之间传递数据。
实现原理:
套接字通过使用 TCP/IP 协议提供了全双工通信的能力。一个进程可以通过套接字发送数据,另一个进程通过同样的套接字接收数据。套接字可以是流式(TCP)或数据报(UDP)的,适用于不同的网络通信需求。
优缺点:
-
优点
:
- 支持跨机器的进程间通信。
- 提供了可靠的传输机制。
-
缺点
:
- 网络通信需要更高的配置和开销,适用于大规模、分布式应用。
总结
在操作系统中,IPC 是非常重要的功能,它让不同进程之间能够高效、安全地共享信息。常见的 IPC 机制如管道、消息队列、共享内存、信号量和套接字各自有不同的特点和适用场景。具体选择哪种 IPC 方式,取决于应用的需求,诸如数据传输量、速度要求、是否需要跨机器通信、是否需要同步等因素都会影响选择。
三十一、socket通信,不使用协议栈的socket通信
在传统的 Socket 通信中,网络协议栈(如 TCP/IP 协议栈)承担了很多通信的基础功能,包括数据分割、重组、数据传输的可靠性保证等。一般来说,开发人员通过操作系统提供的 Socket API(如 socket()
, bind()
, send()
, recv()
等)进行数据传输,这些操作会通过操作系统的协议栈处理。
然而,有时开发者可能希望绕过操作系统的 协议栈,直接操作底层硬件或自定义协议。这类通信通常称为 裸数据套接字通信(Raw Socket)或 绕过协议栈的 Socket 通信。
- 什么是裸数据套接字(Raw Socket)
Raw Socket 是一种允许应用程序直接访问网络层或链路层的套接字。使用 Raw Socket,开发者可以选择自定义协议来进行通信,或者直接构造网络协议的报文(如 IP 层、ICMP 协议等),从而绕过了 TCP/IP 协议栈的许多功能。
通过 Raw Socket,程序可以构造自定义的 IP 数据包,并将其发送到网络中。这种通信方式通常用于:
- 网络调试、网络监控工具(如
ping
,traceroute
) - 实现自定义协议(如自定义传输层协议)
- 攻击或漏洞分析(如构造恶意数据包进行测试)
- 原理
在操作系统中,通常会提供 Raw Socket 接口,允许开发者直接向网络层发送和接收数据包。开发者需要手动构造网络协议的各个层(例如,IP 头部、传输层协议头部等),并进行校验和的计算。具体来说,Raw Socket 允许应用程序直接:
- 生成自定义的协议数据单元(PDU)
- 发送和接收 IP 数据包(而不是上层的 TCP/UDP 数据包)
- 操控数据链路层和网络层的部分内容
Raw Socket 与传统的 流式套接字(TCP/UDP) 的不同之处在于,它不会自动为数据包添加头部信息(如 TCP/IP 头部、校验和等),这些都需要开发者手动处理。
- 裸数据套接字的使用限制
- 权限要求:在大多数操作系统(如 Linux 和 Windows)中,使用 Raw Socket 通常需要管理员权限或根权限(例如,需要
root
权限在 Linux 中创建 Raw Socket)。 - 无法直接使用 TCP/UDP:Raw Socket 允许使用 IP 层直接通信,但不能直接使用 TCP 或 UDP 协议。开发者需要自己编写协议处理代码。
- 安全性问题:Raw Socket 给了开发者更多控制权,但也带来了潜在的安全风险,恶意程序可以使用 Raw Socket 进行数据包嗅探、伪造、DOS 攻击等。
-
裸数据套接字通信流程
-
创建 Raw Socket:使用操作系统提供的 Raw Socket 接口来创建一个原始套接字,通常通过
socket()
函数,指定协议类型为SOCK_RAW
,并选择协议类型(例如 IP 或 ICMP)。 -
构造数据包:由于协议栈不再处理数据包的头部和校验和,开发者需要手动构造报文头部,指定 IP 头、传输层头(如 TCP/UDP),以及应用数据部分。还需要自己计算校验和(例如 IP 和 TCP 的校验和)。
-
发送数据包:使用
sendto()
或类似的函数发送构造好的数据包。需要手动指定目标地址和端口号。 -
接收数据包:使用
recvfrom()
或类似函数接收数据包。接收到的原始数据包将包含所有协议头部,应用程序需要解析这些头部并提取有用的数据。 -
解析数据包:Raw Socket 返回的通常是完整的数据包(包括头部和数据部分),因此需要开发者手动解析数据包,并处理各个层(如 IP、TCP、UDP)的协议头。
-
裸数据套接字编程示例(Linux)
下面是一个使用 Raw Socket 实现 ICMP “ping” 操作的简单示例。该程序直接使用 Raw Socket 创建并发送 ICMP 数据包,绕过了 TCP/IP 协议栈。
示例:使用 Raw Socket 发送 ICMP 请求
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
// 计算校验和
unsigned short checksum(void *b, int len) {
unsigned short *buf = (unsigned short *)b;
unsigned int sum = 0;
unsigned short result;
for (sum = 0; len > 1; len -= 2) {
sum += *buf++;
}
if (len == 1) {
sum += *(unsigned char *)buf;
}
sum = (sum >> 16) + (sum & 0xFFFF);
sum += (sum >> 16);
result = ~sum;
return result;
}
// ICMP请求报文构造
void send_ping(int sockfd, const char *ip_address) {
struct sockaddr_in dest;
struct icmphdr icmp_hdr;
struct iphdr ip_hdr;
char packet[4096];
// 设置目的地
dest.sin_family = AF_INET;
dest.sin_port = 0;
dest.sin_addr.s_addr = inet_addr(ip_address);
// 构造ICMP报文头
memset(&icmp_hdr, 0, sizeof(icmp_hdr));
icmp_hdr.type = ICMP_ECHO;
icmp_hdr.un.echo.id = getpid();
icmp_hdr.un.echo.sequence = 1;
icmp_hdr.checksum = checksum(&icmp_hdr, sizeof(icmp_hdr));
// 构造IP头部
memset(&ip_hdr, 0, sizeof(ip_hdr));
ip_hdr.ihl = 5;
ip_hdr.version = 4;
ip_hdr.tot_len = sizeof(ip_hdr) + sizeof(icmp_hdr);
ip_hdr.id = htonl(54321); // 唯一标识符
ip_hdr.frag_off = 0;
ip_hdr.ttl = 64;
ip_hdr.protocol = IPPROTO_ICMP;
ip_hdr.check = checksum(&ip_hdr, sizeof(ip_hdr)); // IP头部校验和
ip_hdr.saddr = inet_addr("192.168.1.1"); // 本地IP(可以替换)
// 将IP头和ICMP头组合成一个完整的包
memcpy(packet, &ip_hdr, sizeof(ip_hdr));
memcpy(packet + sizeof(ip_hdr), &icmp_hdr, sizeof(icmp_hdr));
// 发送数据包
if (sendto(sockfd, packet, sizeof(ip_hdr) + sizeof(icmp_hdr), 0,
(struct sockaddr*)&dest, sizeof(dest)) < 0) {
perror("Send failed");
return;
}
printf("Ping packet sent to %s\n", ip_address);
}
int main() {
int sockfd;
const char *ip_address = "8.8.8.8"; // 目标IP
// 创建Raw Socket
sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (sockfd < 0) {
perror("Socket creation failed");
exit(1);
}
// 发送ICMP请求
send_ping(sockfd, ip_address);
close(sockfd);
return 0;
}
-
说明
-
Raw Socket 创建:
socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)
表示创建一个原始套接字,使用 IP 协议族(IPv4),协议类型是ICMP
。这里的IPPROTO_ICMP
表示使用 ICMP 协议。 -
校验和计算:因为原始套接字不会自动计算校验和,因此需要手动为 IP 和 ICMP 头部计算校验和。
checksum()
函数用于计算校验和。 -
构造数据包:首先构造 IP 头部,然后构造 ICMP 请求头部,最后将 IP 头和 ICMP 头部组合成完整的报文。
-
发送数据包:使用
sendto()
函数发送数据包。 -
总结
不使用协议栈的 Socket 通信,主要是通过 Raw Socket 进行的,允许直接操作 IP 层和传输层协议,绕过了操作系统的 TCP/IP 协议栈。Raw Socket 需要手动构建协议头和处理校验和,通常用于底层网络编程、网络调试工具、以及需要高定制化协议的场景。不过,这种方式通常需要更高的权限,并且存在较高的复杂性和安全风险。
三十二、操作系统如何实现共享内存(虚拟地址空间)
在操作系统中,共享内存(Shared Memory)是一种允许多个进程访问相同物理内存区域的机制,进程之间可以通过共享内存进行高效的数据交换。共享内存的实现需要依赖于操作系统的虚拟内存管理系统,它通过内存映射和虚拟地址空间管理来实现进程之间的内存共享。
- 虚拟内存和地址空间
操作系统使用虚拟内存来为每个进程提供独立的地址空间。每个进程认为自己有一个连续的虚拟地址空间,操作系统则负责将这些虚拟地址映射到物理内存(RAM)中的实际地址。
- 虚拟地址:每个进程有自己的虚拟地址空间,它看起来是连续的,但实际上虚拟地址通过操作系统的内存管理单元(MMU,Memory Management Unit)映射到物理内存的不同位置。
- 物理地址:物理内存的实际位置,存储了程序运行时的数据和代码。
通过虚拟内存,操作系统可以实现多进程的内存隔离,即每个进程的虚拟地址空间是独立的,但操作系统允许通过特定的机制让多个进程共享某一块物理内存区域。
- 共享内存的实现
共享内存的核心思想是让多个进程映射同一块物理内存区域到各自的虚拟地址空间中,从而实现内存共享。操作系统通过以下几种机制来实现共享内存:
2.1 内存映射(Memory Mapping)
内存映射是共享内存实现的基础,它允许将文件或内存区域映射到进程的虚拟地址空间中。在共享内存的场景中,操作系统通常使用 内存映射文件(memory-mapped file)或专门的共享内存区域来实现多个进程之间的内存共享。
在操作系统中,内存映射通常通过系统调用来实现。例如,在 Linux 系统中,mmap()
系统调用可以将一个物理内存区域映射到进程的虚拟地址空间。如果多个进程调用 mmap()
将相同的物理内存区域映射到各自的虚拟地址空间,它们就可以访问共享的内存。
2.2 共享内存对象(Shared Memory Objects)
操作系统通过 共享内存对象(如 Linux 中的 shmget()
和 shmat()
)来管理共享内存。这些共享内存对象是在内核中分配的一块物理内存区域,这块内存可以被多个进程映射到它们各自的虚拟地址空间中。通常,操作系统会在内存管理子系统中创建一个共享内存段,并为每个进程维护虚拟地址的映射关系。
2.3 内存保护和同步
共享内存的实现通常还涉及内存保护和同步机制。多个进程同时访问同一块共享内存时,需要保证:
- 内存一致性:多个进程修改共享内存时,必须保证对共享内存的读写操作不会导致数据冲突或破坏。
- 进程同步:如果多个进程同时读写共享内存,操作系统需要提供同步机制(如信号量、互斥锁等),避免并发访问时出现竞争条件。
- 具体实现方式
3.1 Linux中的共享内存
在 Linux 中,操作系统使用 System V 共享内存(IPC)或 POSIX 共享内存 来实现进程间的共享内存。
3.1.1 System V 共享内存
System V 共享内存提供了一组系统调用来创建、访问和管理共享内存:
shmget()
:创建共享内存段或获取现有的共享内存段的标识符。shmat()
:将共享内存段映射到进程的虚拟地址空间。shmdt()
:将共享内存段从进程的虚拟地址空间中分离。shmctl()
:用于控制共享内存段的操作(如删除共享内存段等)。
示例代码:
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <string.h>
int main() {
key_t key = ftok("shmfile", 65); // 创建共享内存的键值
int shmid = shmget(key, 1024, 0666 | IPC_CREAT); // 获取共享内存标识符
char *str = (char*) shmat(shmid, NULL, 0); // 映射共享内存到进程地址空间
printf("Write data to shared memory: ");
fgets(str, 1024, stdin); // 从标准输入读取数据到共享内存
printf("Data written to shared memory: %s\n", str);
shmdt(str); // 分离共享内存
return 0;
}
3.1.2 POSIX 共享内存
POSIX 共享内存是另一种更现代、更灵活的共享内存实现,使用 shm_open()
创建共享内存对象,使用 mmap()
映射到进程的虚拟地址空间,使用 ftruncate()
设置共享内存的大小,使用 close()
和 unlink()
进行资源释放。
示例代码:
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>
int main() {
const char *name = "/my_shm";
const int SIZE = 4096;
// 创建共享内存对象
int shm_fd = shm_open(name, O_CREAT | O_RDWR, 0666);
if (shm_fd == -1) {
perror("shm_open failed");
return 1;
}
// 调整共享内存的大小
ftruncate(shm_fd, SIZE);
// 映射共享内存
void *ptr = mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
if (ptr == MAP_FAILED) {
perror("mmap failed");
return 1;
}
// 写入共享内存
sprintf(ptr, "Hello, shared memory!");
// 访问共享内存
printf("Data from shared memory: %s\n", (char *)ptr);
// 解除映射
munmap(ptr, SIZE);
// 删除共享内存
shm_unlink(name);
return 0;
}
3.2 Windows中的共享内存
在 Windows 中,共享内存的实现通常使用 内存映射文件,它允许多个进程将同一个文件映射到各自的地址空间,从而共享文件内容。Windows 提供了 CreateFileMapping()
和 MapViewOfFile()
等 API 来实现共享内存。
示例代码:
#include <windows.h>
#include <iostream>
int main() {
const char* sharedMemoryName = "Global\\MySharedMemory";
const int SIZE = 4096;
// 创建共享内存对象
HANDLE hMapFile = CreateFileMapping(
INVALID_HANDLE_VALUE, // 使用系统虚拟内存
NULL, // 默认安全设置
PAGE_READWRITE, // 读写权限
0, 0, // 最大大小
SIZE, // 共享内存的大小
sharedMemoryName); // 共享内存对象的名称
if (hMapFile == NULL) {
std::cerr << "Could not create file mapping." << std::endl;
return 1;
}
// 映射共享内存
void* ptr = MapViewOfFile(hMapFile, FILE_MAP_ALL_ACCESS, 0, 0, SIZE);
if (ptr == NULL) {
std::cerr << "Could not map view of file." << std::endl;
return 1;
}
// 向共享内存写数据
strcpy((char*)ptr, "Hello from shared memory!");
// 输出共享内存中的数据
std::cout << "Shared memory data: " << (char*)ptr << std::endl;
// 解除映射
UnmapViewOfFile(ptr);
// 关闭文件映射对象
CloseHandle(hMapFile);
return 0;
}
- 总结
操作系统通过虚拟内存管理来实现共享内存。具体来说,操作系统会通过内存映射机制将同一块物理内存映射到多个进程的虚拟地址空间,从而允许这些进程共享这块内存区域。在实现时,操作系统会提供一些系统调用或 API(如 mmap()
、shmget()
、CreateFileMapping()
等)来创建和管理共享内存,并通过内存保护、同步机制保证数据的一致性和进程间的
三十三、主流Linux如何管理虚拟内存(分页)
在主流的 Linux 操作系统中,虚拟内存管理是一个非常重要的系统功能,它使得操作系统能够高效、灵活地利用物理内存,并提供进程隔离、内存保护和扩展的能力。Linux 使用 分页(Paging)技术来管理虚拟内存,将虚拟地址空间划分为固定大小的页面(pages),并将它们映射到物理内存中的页面框(page frames)。
- 虚拟内存概念
虚拟内存是一种允许操作系统提供每个进程一个独立的地址空间的技术。虚拟地址空间将进程的内存访问隔离开,避免了一个进程直接访问或篡改另一个进程的内存。虚拟内存的主要优点包括:
- 进程隔离:每个进程拥有独立的虚拟地址空间,互不干扰。
- 内存扩展:即使物理内存不足,操作系统也可以通过交换空间(swap space)将不活跃的内存页面存储到磁盘,从而扩展内存。
- 内存保护:操作系统可以通过内存页的保护机制(如只读、可写)来避免进程无意间访问非法内存区域。
- 分页机制
分页是虚拟内存管理的一种实现方式。其基本思想是将虚拟地址空间划分为固定大小的块(页面),物理内存也划分为相同大小的块(页框)。然后,操作系统使用一个页表(Page Table)来维护虚拟页到物理页框的映射关系。
2.1 分页基本原理
虚拟地址空间中的每个进程都通过虚拟页(Virtual Pages)来划分自己的地址空间。每个虚拟页被映射到物理内存中的一个页框(Page Frame)。虚拟内存和物理内存的大小通常是成比例的,而操作系统通过页表来维护虚拟页到物理页框的映射。
- 虚拟页(Virtual Page):虚拟地址空间中的最小单位,通常为 4KB(也可以更大,取决于硬件架构)。
- 页框(Page Frame):物理内存中的最小单位,与虚拟页大小相同,通常也是 4KB。
- 页表(Page Table):操作系统维护的一个数据结构,用于存储虚拟地址到物理地址的映射。每个进程有自己的页表。
2.2 分页工作过程
当一个进程访问一个虚拟地址时,CPU 将虚拟地址分为两部分:
- 虚拟页号(VPN,Virtual Page Number):用于在页表中查找。
- 页内偏移(Page Offset):在页内的偏移量。
虚拟地址被分为如下部分:
虚拟地址 = 虚拟页号 + 页内偏移
操作系统根据虚拟页号在进程的页表中查找对应的物理页框号,将其与页内偏移结合,得到物理地址。若该虚拟页尚未映射到物理页框,操作系统将触发一个 缺页中断(Page Fault),并加载对应的页到物理内存中。
- 页表结构
页表是操作系统用来管理虚拟内存的核心数据结构,它维护了每个虚拟页与物理页框的映射关系。每个进程都有自己的页表,页表通过虚拟页号(VPN)查找对应的物理页框号(PFN,Physical Frame Number)。
3.1 页表项(Page Table Entry, PTE)
每个页表项(PTE)包含了虚拟页与物理页之间的映射信息。一个典型的页表项可能包括以下字段:
- 物理页框号(PFN):映射到物理内存的页框号。
- 有效位(Valid bit):指示该页表项是否有效。
- 读/写位(Read/Write):指示该页是否可读/写。
- 访问位(Accessed bit):指示该页是否被访问过。
- 修改位(Dirty bit):指示该页是否被修改过。
- 权限位(Protection bits):指示该页的访问权限(如只读、可写、可执行等)。
3.2 多级页表
由于虚拟地址空间和物理内存空间通常非常大,单级页表可能会非常庞大,因此操作系统通常使用多级页表来减少内存消耗。Linux 采用了 多级页表(Multi-level Page Tables)来减少页表的大小。
在多级页表中,页表本身也被划分为多个层级,每个层级负责一部分地址的映射。常见的多级页表有二级页表、三级页表和四级页表,具体使用多少级取决于硬件架构。例如:
- x86 架构通常使用 四级页表。
- x64 架构(如 64 位 Linux)使用四级页表来管理虚拟地址空间。
具体的映射过程如下:
- L1 页表:处理虚拟地址的高 9 位,找到第二级页表(L2)。
- L2 页表:处理虚拟地址的中 9 位,找到物理页框。
- L3 页表:进一步细化映射。
- L4 页表:在 64 位系统中,最后一层页表是 L4。
3.3 TLB(Translation Lookaside Buffer)
由于每次访问虚拟内存时都需要查询页表,查询速度可能较慢。为提高效率,现代 CPU 使用 TLB(Translation Lookaside Buffer)缓存最近使用的页表项。TLB 是一种高速缓存,存储了虚拟页到物理页的映射,减少了多次查询页表的开销。
- 缺页中断和页面置换
当进程访问的虚拟页没有映射到物理内存时,操作系统会触发 缺页中断,并进行以下操作:
-
页面调入:操作系统会从硬盘的交换空间(swap space)或文件系统中加载缺失的页面到物理内存中。
-
页面置换:如果物理内存已满,操作系统必须选择一个页面进行置换(将其写回磁盘并将其从内存中移除),将缺失页面加载到该页面的物理内存位置。
-
虚拟内存分页的性能优化
为了提高分页的效率和性能,操作系统采用了以下技术:
- 局部性原理:操作系统和硬件利用数据的时间局部性和空间局部性,优化内存页的加载和交换策略。
- 预取(Prefetching):操作系统可能会预加载某些页面到内存,以提高访问速度。
- 页面大小:为了降低页表的开销和减少缺页中断的次数,操作系统可能使用大页面(如 2MB、1GB 页面),这种技术称为 大页面支持(HugePages)。
- 总结
在 Linux 中,虚拟内存是通过 分页机制 管理的,虚拟地址空间被划分为固定大小的页面,物理内存被划分为页框,页表用于维护虚拟页和物理页框之间的映射关系。操作系统通过缺页中断、页面置换和内存保护等机制来确保内存的高效管理,并利用多级页表和 TLB 来优化性能。通过分页,Linux 实现了进程隔离、内存保护和高效的内存管理,为现代操作系统的稳定性和性能提供了坚实的基础。
三十四、进程线程区别
进程和线程是操作系统中用于并发执行的两个基本概念,它们在很多方面有所不同。理解进程与线程的区别对于掌握操作系统的基础概念非常重要。下面是它们之间的主要区别:
- 基本概念
-
进程(Process)
:
- 进程是操作系统进行资源分配和调度的基本单位。
- 每个进程都有独立的虚拟地址空间、代码、数据、堆栈等资源。
- 进程之间相互隔离,一个进程不能直接访问另一个进程的内存空间。
- 进程具有独立性,一个进程的崩溃不会影响其他进程的运行。
-
线程(Thread)
:
- 线程是进程中的一个执行单位,一个进程可以包含多个线程,线程共享进程的资源(如内存、文件描述符等)。
- 线程是比进程更小的执行单元,线程之间共享同一进程的虚拟地址空间、全局变量等。
- 每个线程有自己的执行栈和局部变量,但共享其他线程的数据和资源。
- 线程之间的切换比进程切换更轻量,开销更小。
- 资源管理
-
进程
:
- 进程有独立的资源,操作系统为每个进程分配自己的虚拟地址空间、文件描述符、堆栈等资源。
- 进程之间的通信需要使用进程间通信(IPC)机制,如管道、消息队列、共享内存、信号量等。
-
线程
:
- 线程共享同一个进程的资源,包括内存地址空间、全局变量、文件描述符等。
- 线程之间的通信相对简单,因为它们可以直接访问共享内存,但这也带来了线程同步的问题。
- 调度和切换
- 进程:
- 操作系统调度进程时,会进行上下文切换,即保存当前进程的状态(如寄存器值)并加载另一个进程的状态。
- 进程间的切换代价较高,因为涉及到内存映射的切换和更多的资源管理。
- 线程:
- 线程切换的开销较小,因为线程共享进程的资源,只需要保存和恢复少量的上下文信息(如程序计数器和寄存器)。
- 线程之间的调度比进程之间的调度更高效,线程切换比进程切换更轻量。
- 并发与并行
-
进程
:
- 进程可以并行执行,但由于它们互相独立,通常在多核处理器上运行不同的进程时,进程之间的并行度较高。
- 进程之间的并行性更强,但开销较大。
-
线程
:
- 线程在多核处理器上也可以并行执行,因为多个线程可以在多个处理器上并发执行。
- 线程是比进程更小的调度单位,所以它们可以实现更细粒度的并发,线程之间的通信和切换更高效。
- 创建和销毁
- 进程:
- 进程的创建和销毁相对较慢,因为需要操作系统为每个进程分配独立的资源,如内存、文件描述符等。
- 创建进程的操作通常涉及系统调用,如
fork()
和exec()
(在 UNIX/Linux 系统中),以及操作系统为进程分配内存、文件等资源。
- 线程:
- 创建和销毁线程的成本较低,因为线程共享进程的资源,不需要为每个线程分配独立的虚拟地址空间。
- 创建线程通常使用系统调用,如
pthread_create()
(在 POSIX 系统中),或其他语言的线程库。
- 崩溃与隔离
- 进程:
- 进程之间相互隔离,一个进程崩溃不会直接影响其他进程。操作系统通过进程隔离确保一个进程的内存访问不会破坏其他进程。
- 进程崩溃时,操作系统可以通过进程终止、回收资源等方式进行处理。
- 线程:
- 线程之间共享内存空间,因此一个线程的崩溃或异常可能会影响到整个进程的稳定性。特别是一个线程访问了非法内存或发生了死锁,可能导致整个进程崩溃。
- 线程崩溃时,通常需要通过异常处理和线程同步机制来确保系统稳定。
- 适用场景
- 进程:
- 适用于需要独立运行、隔离性强的应用场景。例如,运行不同服务的应用程序,多个浏览器标签,每个进程都运行一个独立的服务。
- 进程适用于并行任务,但会带来较大的资源消耗和上下文切换开销。
- 线程:
- 适用于需要大量计算、需要频繁通信的并发任务,且多个任务之间共享数据或资源的场景。常见的如 Web 服务器、多线程计算任务、GUI 程序(如游戏、图形设计软件等)。
- 线程适用于需要高效、低开销的并发,但需要小心线程同步和竞争条件的问题。
- 例子
- 进程:在 Linux 系统中,你可以通过
fork()
系统调用创建一个子进程,这个子进程会得到一个独立的虚拟内存空间。 - 线程:使用 POSIX 线程库(pthread),你可以在同一个进程中创建多个线程,这些线程共享进程的虚拟内存空间。
总结表格
简而言之,进程是操作系统资源管理和调度的最基本单位,适合用于需要独立资源和隔离的应用;线程则是执行单位,多个线程共享同一个进程的资源,适合于轻量级、需要并发执行的任务。
三十五、线程间通信
线程间通信(Inter-Thread Communication, ITC)是指多个线程在同一个进程中交换数据或信息的过程。由于线程共享同一进程的内存空间,它们可以通过直接读写共享内存来进行通信,但这种直接共享内存的方式会带来线程同步的问题。因此,在多线程编程中,除了直接共享内存外,还需要使用一些同步机制来保证数据的一致性和避免竞态条件。
线程间通信的方式主要可以分为以下几类:
- 共享内存
线程共享进程的虚拟地址空间,因此它们可以通过共享内存来交换数据。具体的方式是通过共享一个或多个变量,所有线程都可以直接访问和修改这些共享的变量。
问题:
- 竞态条件:多个线程同时访问同一内存区域时,可能会发生冲突,导致数据不一致。
- 同步机制:为了避免竞态条件,需要使用同步原语(如互斥锁、条件变量等)来控制对共享内存的访问。
- 互斥锁(Mutex)
互斥锁是最常见的线程同步工具,它用来确保同一时刻只有一个线程能够访问共享资源。通过互斥锁,线程可以控制对共享数据的访问,从而避免竞态条件的发生。
用法:
- 线程在访问共享资源之前先获得互斥锁,访问完成后释放锁。
- 如果一个线程已经获得了互斥锁,其他线程会被阻塞,直到锁被释放。
示例:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 创建一个互斥锁
int shared_data = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx); // 自动锁定和解锁
++shared_data; // 访问共享资源
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Shared data: " << shared_data << std::endl; // 输出2,确保没有竞态条件
return 0;
}
注意:
std::lock_guard
是一个 RAII(Resource Acquisition Is Initialization)类,它会在作用域结束时自动释放锁,避免因忘记释放锁而导致死锁。
- 条件变量(Condition Variable)
条件变量用于线程之间的等待和通知机制。当一个线程需要等待某个条件成立时,它会等待条件变量;当另一个线程修改了条件并希望通知等待的线程时,它会通知条件变量。
用法:
- 条件变量通常与互斥锁一起使用,互斥锁保护共享数据,而条件变量用于在特定条件下让线程等待或唤醒。
wait()
:使线程进入等待状态,直到收到通知。notify_one()
:通知一个等待的线程。notify_all()
:通知所有等待的线程。
示例:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void print_id(int id) {
std::unique_lock<std::mutex> lck(mtx);
while (!ready) cv.wait(lck); // 等待条件为true
std::cout << "Thread " << id << '\n';
}
void go() {
std::unique_lock<std::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;
}
- 信号量(Semaphore)
信号量是一种用于控制对共享资源的访问的同步机制,尤其是在多线程环境中。信号量可以控制某个特定资源的访问数量,比如限制同时访问共享资源的线程数。
用法:
- 二值信号量:只允许一个线程访问资源,类似于互斥锁。
- 计数信号量:允许多个线程访问资源。
示例:
#include <iostream>
#include <thread>
#include <semaphore.h>
std::counting_semaphore<2> sem(2); // 初始计数为2,最多允许两个线程访问
void access_resource(int id) {
sem.acquire(); // 获取信号量,控制访问
std::cout << "Thread " << id << " accessing the resource\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Thread " << id << " releasing the resource\n";
sem.release(); // 释放信号量
}
int main() {
std::thread t1(access_resource, 1);
std::thread t2(access_resource, 2);
std::thread t3(access_resource, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
- 管道(Pipe)和队列(Queue)
管道是一个用于线程之间传输数据的常见机制。它允许一个线程将数据写入管道,另一个线程从管道中读取数据。队列(如 std::queue
)是线程安全的共享数据结构,常用于线程间的生产者-消费者模型。
用法:
- 管道:常见于进程间通信,但也可用于线程间通信。数据可以按顺序从管道中传输。
- 队列:常用于生产者-消费者模型,生产者线程将数据放入队列,消费者线程从队列中取出数据。
示例:
#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
std::queue<int> q;
std::mutex mtx;
std::condition_variable cv;
void producer() {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock(mtx);
q.push(i);
std::cout << "Produced: " << i << "\n";
cv.notify_all(); // 通知消费者线程
}
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !q.empty(); }); // 等待队列有数据
int value = q.front();
q.pop();
std::cout << "Consumed: " << value << "\n";
if (value == 4) break; // 如果消耗完所有数据,则退出
}
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
- 共享内存与原子操作(Atomic Operations)
- 原子操作:对于一些简单的共享数据(如计数器),可以使用原子操作进行线程间通信和同步。原子操作可以保证在多线程环境中对共享数据的操作是不可分割的,避免了竞态条件。
- 共享内存:通过共享内存区域(如
mmap
、shmget
等),线程可以高效地交换数据,但需要注意同步问题。
总结
线程间通信的方式有很多,选择适当的通信方式取决于应用场景和需求。常用的线程间通信方法包括:
- 共享内存:线程共享内存区域,直接读写共享数据。
- 互斥锁:通过互斥锁控制对共享资源的访问,避免竞态条件。
- 条件变量:用于线程之间的等待和通知机制。
- 信号量:控制同时访问共享资源的线程数。
- 管道/队列:用于数据的生产者-消费者模型。
- 原子操作:用于对共享数据的简单同步操作。
每种方式都有其优点和使用场景,正确地使用线程间通信和同步机制,可以避免多线程编程中的许多常见问题,如竞态条件、死锁等。