百度Linux C++开发岗面试题解析(附答案讲解)
一、C++ 基础
1. 指针和引用的区别
问题:请简述指针和引用的区别。
答案:
定义:指针是一个实体,它存储了一个内存地址,通过这个地址可以间接访问该地址所指向的对象。例如:int* ptr = new int(5); 这里 ptr 就是一个指向 int 类型对象的指针。引用则是一个变量的别名,它和被引用的变量共享同一块内存空间,定义时必须初始化。例如:int num = 10; int& ref = num; 这里 ref 就是 num 的引用。
内存分配:指针本身需要分配内存空间来存储所指向对象的地址,在 32 位系统中,指针通常占用 4 个字节;在 64 位系统中,指针通常占用 8 个字节。而引用不需要额外的内存空间来存储,它和被引用的变量在内存中是同一位置。
初始化:指针在定义时可以不初始化,之后再指向其他对象,如 int* ptr; ptr = #。但引用在定义时必须初始化,并且一旦初始化后就不能再引用其他对象,例如 int& ref = num;,不能再让 ref 引用其他变量。
可空性:指针可以为空指针,即 ptr = nullptr;,表示不指向任何有效对象。而引用不能为 nullptr,因为它必须是某个已存在对象的别名。
操作:访问指针所指向的值需要使用解引用操作符 *,如 int value = *ptr;。而引用直接使用其名称就可以访问所引用的值,例如 int value = ref;。对指针进行自增(++)或自减(--)操作时,指针会根据其指向的数据类型移动相应的字节数,如 char* 类型的指针自增时会移动 1 个字节,int* 类型的指针自增时会移动 4 个字节(假设 int 占 4 个字节)。而对引用进行自增或自减操作,实际上是对被引用的变量进行自增或自减操作。
传递:在函数参数传递中,传指针的实质是传值,传递的值是指针的地址。这意味着在函数内部对指针的修改不会影响到函数外部的指针本身,但可以通过指针修改其所指向的对象的值。例如:
void modifyPointer(int* ptr) {
ptr = new int(10); // 这里修改的是函数内部的ptr指针,不会影响外部的ptr
}
传引用的实质是传地址,传递的是变量的地址。在函数内部对引用的修改会直接影响到函数外部的变量。例如:
void modifyReference(int& ref) {
ref = 20; // 这里修改的是外部传入的ref所引用的变量
}
2. const 关键字的用法
问题:C++ 中的 const 关键字有哪些不同的用法?
答案:
修饰变量:声明常量,变量的值在初始化后不能被修改。例如:const int num = 10; 这里 num 就是一个常量,后续代码中不能对 num 进行赋值操作,如 num = 20; 会导致编译错误。
修饰指针:
- const int* ptr:指向常量的指针,即指针所指向的值不能通过该指针被修改,但指针本身可以指向其他地址。例如:
int num1 = 10; int num2 = 20; const int* ptr = &num1; // *ptr = 30; // 错误,不能通过ptr修改其所指向的值 ptr = &num2; // 正确,ptr可以指向其他地址
- int* const ptr:指针常量,指针本身的地址不能被修改,但可以通过该指针修改其所指向的值。例如:
int num = 10; int* const ptr = # ptr = &num2; // 错误,ptr不能指向其他地址 *ptr = 20; // 正确,可以通过ptr修改其所指向的值
- const int* const ptr:指向常量的指针常量,指针本身的地址不能被修改,且指针所指向的值也不能通过该指针被修改。例如:
const int num = 10; const int* const ptr = # // *ptr = 20; // 错误,不能通过ptr修改其所指向的值 // ptr = &num2; // 错误,ptr不能指向其他地址
修饰成员函数:保证函数不修改对象的状态。例如:
class MyClass {
public:
int value;
void printValue() const {
// value = 20; // 错误,在const成员函数中不能修改对象的成员变量
std::cout << value << std::endl;
}
};
这里 printValue 函数被声明为 const,表示在调用该函数时,对象的状态不会被改变。
3. 函数重载和运算符重载
问题:什么是函数重载和运算符重载?
答案:
函数重载:
定义:在同一作用域内,可以声明几个功能类似的同名函数,这些同名函数的参数列表(参数的类型、个数、顺序)不同,这就是函数重载。编译器会根据调用函数时传入的参数的具体情况,自动匹配调用合适的函数。例如:
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
int add(int a, int b, int c) {
return a + b + c;
}
这里定义了三个 add 函数,它们参数列表不同,构成了函数重载。当调用 add(2, 3); 时,编译器会调用第一个 add 函数;调用 add(2.5, 3.5); 时,会调用第二个 add 函数;调用 add(2, 3, 4); 时,会调用第三个 add 函数。
作用:函数重载可以让程序员使用相同的函数名来实现不同的功能,提高了代码的可读性和可维护性,避免了为类似功能的函数取不同名字而带来的记忆负担。
运算符重载:
定义:对于 C++ 中的一些运算符,如 +、-、*、/、==、<< 等,它们在处理内置数据类型时已经有了默认的行为。而运算符重载就是为自定义类型(如类)重新定义这些运算符的行为,使得自定义类型的对象也能像内置类型一样使用这些运算符进行操作。例如,为自定义的 Complex 类重载 + 运算符:
class Complex {
public:
double real;
double imag;
Complex(double r = 0, double i = 0) : real(r), imag(i) {}
Complex operator+(const Complex& other) const {
return Complex(real + other.real, imag + other.imag);
}
};
这样就可以使用 + 运算符来对 Complex 类的对象进行加法运算,如 Complex c1(1, 2); Complex c2(3, 4); Complex result = c1 + c2;。
作用:运算符重载可以使代码更加直观和自然,符合人们对数学运算和逻辑运算的习惯。它增强了自定义类型的易用性,使得代码在处理自定义类型时更加简洁和易读。
4. 虚函数和纯虚函数
问题:C++ 中的虚函数和纯虚函数有什么区别?
答案:
虚函数:
定义:在基类中使用 virtual 关键字声明的成员函数称为虚函数。虚函数在派生类中可以被重新定义(重写),以实现多态行为。例如:
class Base {
public:
virtual void print() {
std::cout << "This is Base class" << std::endl;
}
};
class Derived : public Base {
public:
void print() override {
std::cout << "This is Derived class" << std::endl;
}
};
这里 Base 类中的 print 函数被声明为虚函数,Derived 类重写了 print 函数。当通过基类指针或引用调用 print 函数时,会根据实际指向的对象类型来决定调用哪个类的 print 函数,从而实现多态。例如:
Base* ptr = new Derived(); ptr->print(); // 输出 "This is Derived class"
特点:基类可以为虚函数提供默认实现。虚函数的调用依赖于对象的实际类型,而不是指针或引用的类型,这就是动态绑定(运行时多态)。虚函数表指针(vptr)用于实现动态绑定,每个包含虚函数的类对象都有一个 vptr,它指向该类的虚函数表,虚函数表中存储了虚函数的地址。
纯虚函数:
定义:纯虚函数是在声明时被初始化为 0 的虚函数,即 virtual void func() = 0;。包含纯虚函数的类称为抽象类,抽象类不能被实例化,它主要用于为派生类提供一个统一的接口规范。例如:
class Shape {
public:
virtual double area() = 0; // 纯虚函数
};
class Circle : public Shape {
public:
double radius;
Circle(double r) : radius(r) {}
double area() override {
return 3.14 * radius * radius;
}
};
class Rectangle : public Shape {
public:
double length;
double width;
Rectangle(double l, double w) : length(l), width(w) {}
double area() override {
return length * width;
}
};
这里 Shape 类是抽象类,它的 area 函数是纯虚函数。Circle 和 Rectangle 类继承自 Shape 类,并实现了 area 函数。
特点:基类不提供纯虚函数的实现,派生类必须重写纯虚函数,否则派生类也会成为抽象类。纯虚函数主要用于定义接口,使得不同的派生类可以根据自身的特点实现该接口,从而实现多态。
5. 拷贝构造函数与移动构造函数
问题:C++ 中拷贝构造函数与移动构造函数的区别是什么?
答案:
拷贝构造函数:
定义:拷贝构造函数是一种特殊的构造函数,用于用一个已存在的对象来初始化另一个同类型的对象。其参数为 const 引用,形式为 ClassName(const ClassName& other);。例如:
class MyClass {
public:
int* data;
MyClass(int value) {
data = new int(value);
}
MyClass(const MyClass& other) {
data = new int(*other.data); // 深拷贝
}
~MyClass() {
delete data;
}
};
这里 MyClass 类的拷贝构造函数会为新对象分配新的内存,并将 other 对象中 data 所指向的值复制到新分配的内存中,这是深拷贝。如果不定义拷贝构造函数,编译器会生成一个默认的浅拷贝构造函数,对于包含指针成员的类,浅拷贝可能会导致多个对象共享同一块内存,在对象析构时出现多次释放同一内存的错误。
作用:在对象传递和赋值时,如果需要创建一个与已有对象完全相同的新对象,就会调用拷贝构造函数。例如,当函数参数为对象类型时,实参传递给形参就会调用拷贝构造函数;当使用一个对象初始化另一个对象时,也会调用拷贝构造函数。
移动构造函数:
定义:移动构造函数用于将一个对象的资源所有权转移到另一个对象,而不是进行复制。其参数为右值引用,形式为 ClassName(ClassName&& other);。例如:
class MyClass {
public:
int* data;
MyClass(int value) {
data = new int(value);
}
MyClass(const MyClass& other) {
data = new int(*other.data); // 深拷贝
}
MyClass(MyClass&& other) noexcept {
data = other.data;
other.data = nullptr;
}
~MyClass() {
delete data;
}
};
这里移动构造函数将 other 对象的 data 指针直接赋值给新对象,然后将 other 对象的 data 指针置为 nullptr,这样就完成了资源的转移,避免了不必要的内存分配和复制操作,提高了效率。
作用:在临时对象的场景下,移动构造函数非常有用。例如,当函数返回一个对象时,如果使用移动构造函数,就可以将临时对象的资源直接转移到接收返回值的对象中,而不是进行复制。在 C++11 及之后的标准中,移动语义使得在处理临时对象时能够更加高效地利用资源。
校招想进大厂的同学不知道学什么?不知道做什么项目?可以参考下面这个视频的讲解
二、内存管理
1. C++ 内存分区
问题:C++ 内存分为几部分?请分别介绍。
答案:C++ 内存通常分为以下几个部分:
代码段(text segment):
存储内容:存放程序执行的代码,即 CPU 执行的机器指令。同时,也可能包含一些常量,如字符串常量等。例如:
const char* str = "Hello, World!";
这里的 "Hello, World!" 字符串常量就存储在代码段。
特性:代码段是只读的,某些架构可能允许修改,但在大多数情况下,程序在运行过程中不会对代码段进行写入操作,以保证程序的稳定性和安全性。代码段是静态分配的,在程序加载到内存时就确定了其位置和大小。
数据段(data segment):
存储内容:用于存放程序中已经初始化的非零全局变量和静态变量。数据段又可细分为读写(RW)区域和只读(RO)区域。只读区域(.constdata)用于保存常量,例如:
const int globalConst = 10;
这里的 globalConst 就存储在数据段的只读区域。读写区域用于存放普通的非常量全局变量和静态变量,例如:
int globalVar = 20; static int staticVar = 30;
globalVar 和 staticVar 都存储在数据段的读写区域。
特性:数据段也是静态分配的,在程序开始运行之前就已经分配好内存空间,并且在程序的整个生命周期内都存在。数据段中的变量在程序启动时被初始化,其值在程序运行过程中可以被修改(对于读写区域的变量)。
BSS 段(Block Started by Symbol):
存储内容:存放程序中未初始化的全局变量和初始值为零的全局变量。例如:
int uninitGlobalVar; static int zeroInitStaticVar = 0;
uninitGlobalVar 和 zeroInitStaticVar 都会存储在 BSS 段。
特性:BSS 段同样是静态分配的,在程序加载时,系统会自动将 BSS 段中的变量初始化为零。BSS 段不占用可执行文件的实际磁盘空间,因为它只需要记录变量的类型和大小等信息,而不需要存储变量的初始值(因为都是零),这样可以减少可执行文件的大小。
堆(heap):
存储内容:由程序员动态分配和释放的内存区域。当使用 new 或 malloc 等函数分配内存时,内存就从堆中获取。例如:
int* heapArray = new int[10];
这里通过 new 操作符在堆上分配了一个包含 10 个 int 类型元素的数组,heapArray 指向这块在堆上分配的内存。
特性:堆的大小是动态变化的,可以根据程序的运行需求在运行时进行扩展或收缩。堆的内存分配和释放由程序员手动控制,这就要求程序员必须确保在不再使用堆内存时及时释放,否则会导致内存泄漏。例如,如果在上述代码之后没有使用 delete[] heapArray; 来释放内存,那么这块内存就会一直被占用,直到程序结束,这就是内存泄漏。
栈(stack):
存储内容:用于存放函数的参数值、局部变量以及函数调用时的返回地址等临时数据。例如在函数内部定义的局部变量:
void func() {
int localVar = 5; // localVar 存储在栈中
char ch = 'a'; // ch 也存储在栈中
}
当函数 func 被调用时,localVar 和 ch 会被压入栈中;当函数执行结束返回时,这些局部变量会随着栈帧的销毁而自动释放,无需程序员手动操作。
特性:栈遵循 “先进后出”(FILO)的访问规则,就像叠盘子一样,最后放入的元素最先被取出。栈的内存分配和释放是由编译器自动管理的,效率非常高。栈的大小是固定的(在程序运行前通常由操作系统或编译器确定),如果在函数中递归调用过深或定义过多大的局部变量,可能会导致栈溢出(stack overflow)错误。例如无限递归函数:
void infiniteRecursion() {
infiniteRecursion(); // 不断递归调用,栈帧持续压入,最终导致栈溢出
}
2. 内存泄漏及解决方案
问题:什么是内存泄漏?在 C++ 中如何避免内存泄漏?
答案:
内存泄漏定义:内存泄漏是指程序在动态分配内存后,由于某种原因(如忘记释放、释放逻辑错误等),导致已分配的内存无法被程序再次使用,也无法被操作系统回收,直到程序结束。长期运行的程序(如服务器程序)若存在内存泄漏,会逐渐耗尽系统内存,最终导致程序崩溃或系统性能严重下降。
常见的内存泄漏场景包括:
1.使用 new 或 malloc 分配内存后,未使用 delete 或 free 释放。例如:
void leakExample1() {
int* ptr = new int(10); // 分配内存
// 未执行 delete ptr; 导致内存泄漏
}
2.程序逻辑错误导致释放语句无法执行。例如在释放前提前返回:
void leakExample2() {
int* ptr = new int[20];
if (someCondition) {
return; // 提前返回,delete[] ptr; 语句未执行
}
delete[] ptr;
}
3.复杂数据结构(如链表、树)销毁时,仅释放了根节点,未递归释放所有子节点的内存。
避免内存泄漏的方案:
1.手动管理优化:养成 “谁分配谁释放” 的习惯,确保每一处 new/malloc 都对应唯一的 delete/free,且释放语句在所有代码路径中都能执行(可使用 goto 或 RAII 思想避免提前返回导致的泄漏)。例如:
void safeRelease() {
int* ptr = new int(5);
if (someCondition) {
delete ptr; // 提前返回前释放内存
return;
}
delete ptr; // 正常路径释放内存
}
2.使用智能指针:C++11 及以后标准提供的智能指针(unique_ptr、shared_ptr、weak_ptr)可自动管理内存,当智能指针生命周期结束时,会自动调用析构函数释放所指向的内存,从根本上减少手动释放的错误。例如:
#include <memory>
void useSmartPointer() {
std::unique_ptr<int> uptr(new int(10)); // 独占所有权,生命周期结束自动释放
std::shared_ptr<int> sptr = std::make_shared<int>(20); // 共享所有权,最后一个引用释放时自动释放
}
3.使用内存检测工具:开发阶段借助工具检测内存泄漏,如 Linux 下的 valgrind(通过 memcheck 工具检测泄漏)、addresssanitizer(编译器内置工具,编译时添加 -fsanitize=address 选项),以及 IDE 自带的内存分析工具(如 CLion 的内存检测器)。例如使用 valgrind 检测:
valgrind --leak-check=full ./your_program
工具会输出详细的泄漏位置和泄漏内存大小,帮助定位问题。
4. 采用 RAII 设计模式:RAII(Resource Acquisition Is Initialization,资源获取即初始化)思想将资源(如内存、文件句柄)的管理与对象的生命周期绑定,对象创建时获取资源,对象销毁时自动释放资源。智能指针本质就是 RAII 模式的实现,自定义类也可遵循该模式,例如:
class RAIIMemory {
private:
int* data;
public:
RAIIMemory(int value) : data(new int(value)) {}
~RAIIMemory() {
delete data; // 析构时自动释放内存
}
// 禁止拷贝和赋值,避免浅拷贝导致的二次释放(也可实现深拷贝)
RAIIMemory(const RAIIMemory&) = delete;
RAIIMemory& operator=(const RAIIMemory&) = delete;
};
3. 智能指针的种类及区别
问题:C++ 中的智能指针有哪些?它们的区别和适用场景是什么?
答案:C++ 标准库(<memory> 头文件)提供了三种常用智能指针:unique_ptr、shared_ptr 和 weak_ptr,均用于自动管理动态内存,避免内存泄漏。
1. unique_ptr(独占式智能指针):
核心特性:独占内存所有权,即同一时间只能有一个 unique_ptr 指向同一块内存,不支持拷贝和赋值操作(拷贝构造函数和赋值运算符被删除),但支持移动语义(通过 std::move 转移所有权)。
代码示例:
#include <memory>
void testUniquePtr() {
std::unique_ptr<int> u1(new int(10));
// std::unique_ptr<int> u2 = u1; // 错误,不支持拷贝
std::unique_ptr<int> u2 = std::move(u1); // 正确,转移所有权,u1 此后变为空
if (!u1) {
std::cout << "u1 is empty after move" << std::endl;
}
} // u2 生命周期结束,自动释放内存
适用场景:管理单个对象的内存,且不需要共享所有权的场景,如函数返回动态分配的对象、作为容器元素(避免拷贝开销)、管理局部动态内存等。unique_ptr 还可用于管理自定义资源(如文件句柄、网络连接),通过指定自定义删除器实现:
// 自定义删除器:关闭文件句柄
auto fileDeleter = [](FILE* fp) {
if (fp) {
fclose(fp);
std::cout << "File closed" << std::endl;
}
};
std::unique_ptr<FILE, decltype(fileDeleter)> filePtr(fopen("test.txt", "r"), fileDeleter);
2. shared_ptr(共享式智能指针):
核心特性:支持多个 shared_ptr 共享同一块内存的所有权,内部通过 “引用计数” 实现:当新的 shared_ptr 指向该内存时,引用计数加 1;当 shared_ptr 生命周期结束(或重置)时,引用计数减 1;当引用计数变为 0 时,自动释放内存。支持拷贝和赋值操作,拷贝时引用计数同步增加。
代码示例:
#include <memory>
void testSharedPtr() {
std::shared_ptr<int> s1 = std::make_shared<int>(20); // 推荐使用 make_shared 构建,内存分配更高效
std::cout << "Reference count: " << s1.use_count() << std::endl; // 输出 1
std::shared_ptr<int> s2 = s1; // 拷贝,引用计数变为 2
std::cout << "Reference count: " << s1.use_count() << std::endl; // 输出 2
s1.reset(); // s1 重置,引用计数变为 1
std::cout << "Reference count: " << s2.use_count() << std::endl; // 输出 1
} // s2 生命周期结束,引用计数变为 0,内存释放
注意事项:避免循环引用问题,即两个 shared_ptr 互相指向对方,导致引用计数无法变为 0,内存泄漏。例如:
class A;
class B;
class A {
public:
std::shared_ptr<B> bPtr;
~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
std::shared_ptr<A> aPtr;
~B() { std::cout << "B destroyed" << std::endl; }
};
void cycleReference() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->bPtr = b;
b->aPtr = a;
// 函数结束时,a 和 b 的引用计数均为 1,无法释放,导致内存泄漏
}
适用场景:需要多个对象共享同一块内存的场景,如多个模块访问同一个动态分配的配置对象、容器中存储需要共享的对象等。解决循环引用需配合 weak_ptr 使用。
3. weak_ptr(弱引用智能指针):
核心特性:弱引用,不拥有内存所有权,仅用于观察 shared_ptr 所管理的内存,不会影响 shared_ptr 的引用计数。weak_ptr 不能直接访问所指向的对象,需先通过 lock() 方法升级为 shared_ptr(若内存未释放,lock() 返回非空 shared_ptr;若已释放,返回空 shared_ptr),避免悬空指针问题。
代码示例(解决循环引用):
class A;
class B;
class A {
public:
std::weak_ptr<B> bPtr; // 改为 weak_ptr
~A() { std::cout << "A destroyed" << std::endl; }
};
class B {
public:
std::weak_ptr<A> aPtr; // 改为 weak_ptr
~B() { std::cout << "B destroyed" << std::endl; }
};
void solveCycle() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->bPtr = b;
b->aPtr = a;
// 函数结束时,a 和 b 的引用计数均为 1,释放后引用计数变为 0,内存正常释放
}
// 访问 weak_ptr 指向的对象
void accessWeakPtr(std::weak_ptr<int> wp) {
if (auto sp = wp.lock()) { // 升级为 shared_ptr,判断是否有效
std::cout << "Value: " << *sp << std::endl;
} else {
std::cout << "Memory has been released" << std::endl;
}
}
适用场景:解决 shared_ptr 的循环引用问题;观察对象是否存活(如缓存场景,判断缓存对象是否已释放);在不影响对象生命周期的前提下访问对象。
简历没有项目,这里推荐几个项目:
C++Linux项目推荐-Web多人聊天+MySQL+Redis+Websocket+Json,可以写简历的C++项目
C++项目推荐-真正可以媲美redis的kv存储项目-包括性能如何逐步优化
C++校招项目防雷同之:高性能RPC框架-支持json/protobuf等多种序列化方式
三、Linux 系统编程
1. 进程与线程的区别
问题:在 Linux 系统中,进程和线程的区别是什么?分别适用于哪些场景?
答案:进程和线程是操作系统中调度和资源管理的基本单位,二者在资源占用、调度开销、通信方式等方面存在显著差异。
适用场景:
1.进程适用场景:
- 任务间独立性要求高,需避免一个任务崩溃影响其他任务(如浏览器的每个标签页作为独立进程);
- 任务需要独立的资源隔离(如不同的用户权限、独立的文件描述符集合);
- 利用多 CPU 核心实现并行计算(但进程切换开销大,适合粗粒度任务)。
2.线程适用场景:
- 任务间需要频繁通信、共享数据(如服务器处理多个客户端连接,每个连接用线程处理,共享监听套接字);
- 任务切换频繁,需要低调度开销(如实时系统中的高频任务);
- 利用多 CPU 核心实现细粒度并行计算(如数据并行处理,线程共享数据区,减少通信开销)。
2. Linux 下的进程间通信(IPC)方式
问题:Linux 系统提供了哪些进程间通信(IPC)方式?请简述每种方式的特点和适用场景。
答案:Linux 系统提供了多种 IPC 机制,适用于不同的通信需求,常见方式如下:
1. 管道(Pipe)与命名管道(FIFO):
管道(匿名管道):
特点:基于文件描述符的半双工通信(数据只能单向流动),仅支持父子进程或兄弟进程间通信(通过 fork 继承文件描述符);管道内的数据是字节流,无数据结构,需通信双方约定数据格式;数据写入后被读取即删除,不持久化。
代码示例(父子进程通信):
#include <unistd.h>
#include <stdio.h>
int main() {
int pipefd[2];
pipe(pipefd); // 创建管道,pipefd[0] 读端,pipefd[1] 写端
pid_t pid = fork();
if (pid == 0) { // 子进程:写数据
close(pipefd[0]); // 关闭读端
const char* msg = "Hello from child";
write(pipefd[1], msg, sizeof(msg));
close(pipefd[1]);
} else { // 父进程:读数据
close(pipefd[1]); // 关闭写端
char buf[1024] = {0};
read(pipefd[0], buf, sizeof(buf));
printf("Parent received: %s\n", buf);
close(pipefd[0]);
}
return 0;
}
适用场景:简单的父子进程间单向数据传输(如命令行中的管道 cmd1 | cmd2)。
命名管道(FIFO):
特点:与匿名管道类似,也是半双工字节流通信,但通过文件系统中的 “文件名” 标识(可通过 mkfifo 命令或函数创建),支持任意进程间通信(只要知道 FIFO 文件名);FIFO 文件仅用于标识通信通道,数据仍在内存中传输,不持久化。
代码示例(创建 FIFO):
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
const char* fifoName = "my_fifo";
mkfifo(fifoName, 0666); // 创建FIFO,权限为可读可写
// 写进程:打开FIFO写端
int fd = open(fifoName, O_WRONLY);
const char* msg = "Hello via FIFO";</doubaocanvas>
write (fd, msg, sizeof (msg));
close (fd);
// 读进程(可在另一个程序中):打开 FIFO 读端
int readFd = open (fifoName, O_RDONLY);
char readBuf [1024] = {0};
read (readFd, readBuf, sizeof (readBuf));
printf ("Received via FIFO: % s\n", readBuf);
close (readFd);
unlink (fifoName); // 删除 FIFO 文件
return 0;
}
适用场景:无亲缘关系的进程间单向或双向数据传输(如两个独立的服务程序间通信)。
2. 消息队列(Message Queue):
特点:基于内核的消息链表,按消息类型(整数)分类存储,支持任意进程间通信(只要知道消息队列的键值 key_t);通信为非实时,消息存入队列后可被多个进程读取(但每个消息仅被一个进程读取后删除);消息有固定格式(包含类型和数据),无需通信双方额外约定格式;消息队列独立于进程存在,进程退出后消息仍可保留在队列中(除非显式删除)。
核心函数:
- msgget():创建或获取消息队列,返回消息队列ID;
- msgsnd():向消息队列发送消息;
- msgrcv():从消息队列接收消息;
- msgctl():控制消息队列(如删除、获取状态)。
代码示例(发送消息):
#include <sys/msg.h>
#include <stdio.h>
#include <string.h>
// 消息结构体(首成员必须是long类型的消息类型)
struct Msg {
long type;
char data[1024];
};
int main() {
key_t key = ftok(".", 100); // 生成唯一键值(路径和项目ID)
int msgId = msgget(key, IPC_CREAT | 0666); // 创建消息队列
struct Msg msg;
msg.type = 1; // 设置消息类型
strcpy(msg.data, "Message from queue sender");
msgsnd(msgId, &msg, sizeof(msg.data), 0); // 发送消息(阻塞模式)
return 0;
}
代码示例(接收消息):
#include <sys/msg.h>
#include <stdio.h>
struct Msg {
long type;
char data[1024];
};
int main() {
key_t key = ftok(".", 100);
int msgId = msgget(key, IPC_CREAT | 0666);
struct Msg msg;
msgrcv(msgId, &msg, sizeof(msg.data), 1, 0); // 接收类型为1的消息
printf("Received from queue: %s\n", msg.data);
msgctl(msgId, IPC_RMID, NULL); // 删除消息队列
return 0;
}
适用场景:进程间需要按类型传递结构化数据的场景(如多生产者 - 多消费者模型,按消息优先级处理),但消息大小有上限(通常由内核参数 MSGMAX 限制),不适合传输大量数据。
3. 共享内存(Shared Memory):
特点:最高效的 IPC 方式,直接在多个进程的地址空间中映射同一块物理内存,进程通过读写该内存实现通信,无需内核中转数据;共享内存本身不提供同步机制,需配合信号量(Semaphore)或互斥锁(Mutex)避免数据竞争;共享内存独立于进程存在,进程退出后内存仍需显式删除。
核心函数:
- shmget():创建或获取共享内存,返回共享内存 ID;
- shmat():将共享内存映射到当前进程的地址空间;
- shmdt():解除共享内存与进程地址空间的映射;
- shmctl():控制共享内存(如删除、修改权限)。
代码示例(写入共享内存):
#include <sys/shm.h>
#include <stdio.h>
#include <string.h>
int main() {
key_t key = ftok(".", 200);
int shmId = shmget(key, 1024, IPC_CREAT | 0666); // 创建1024字节的共享内存
char* shmAddr = (char*)shmat(shmId, NULL, 0); // 映射到进程地址空间(NULL表示由内核分配地址)
strcpy(shmAddr, "Data in shared memory"); // 写入数据
printf("Written to shared memory: %s\n", shmAddr);
// 等待读进程读取(实际场景需同步机制)
getchar();
shmdt(shmAddr); // 解除映射
shmctl(shmId, IPC_RMID, NULL); // 删除共享内存
return 0;
}
代码示例(读取共享内存):
#include <sys/shm.h>
#include <stdio.h>
int main() {
key_t key = ftok(".", 200);
int shmId = shmget(key, 1024, IPC_CREAT | 0666);
char* shmAddr = (char*)shmat(shmId, NULL, 0);
printf("Read from shared memory: %s\n", shmAddr); // 读取数据
shmdt(shmAddr);
return 0;
}
适用场景:进程间需要高频、大量传输数据的场景(如视频流传输、大数据计算任务间数据共享),需注意必须搭配同步机制,否则会出现数据错乱。
4. 信号量(Semaphore):
特点:本质是内核维护的计数器,用于实现进程或线程间的同步与互斥,而非直接传输数据;信号量的值表示可用资源的数量,支持两种核心操作:P 操作(申请资源,计数器减 1,若为负则阻塞)和 V 操作(释放资源,计数器加 1,若有阻塞进程则唤醒);分为匿名信号量(仅支持线程间或父子进程间同步)和命名信号量(支持任意进程间同步)。
核心函数(System V 信号量):
- semget():创建或获取信号量集,返回信号量 ID;
- semop():执行 P/V 操作;
- semctl():控制信号量(如初始化、删除)。
代码示例(使用信号量同步共享内存访问):
// 写进程:先P操作申请资源,写入后V操作释放
#include <sys/shm.h>
#include <sys/sem.h>
#include <stdio.h>
#include <string.h>
union semun { // semctl所需的联合体
int val;
struct semid_ds* buf;
unsigned short* array;
};
void semP(int semId) { // P操作
struct sembuf sb = {0, -1, SEM_UNDO}; // 第0个信号量,减1,进程退出时恢复
semop(semId, &sb, 1);
}
void semV(int semId) { // V操作
struct sembuf sb = {0, 1, SEM_UNDO};
semop(semId, &sb, 1);
}
int main() {
key_t shmKey = ftok(".", 300);
int shmId = shmget(shmKey, 1024, IPC_CREAT | 0666);
char* shmAddr = (char*)shmat(shmId, NULL, 0);
key_t semKey = ftok(".", 400);
int semId = semget(semKey, 1, IPC_CREAT | 0666);
union semun su;
su.val = 1; // 初始化信号量为1(互斥锁)
semctl(semId, 0, SETVAL, su);
semP(semId); // 申请资源(加锁)
strcpy(shmAddr, "Synchronized shared data");
printf("Written (synchronized): %s\n", shmAddr);
semV(semId); // 释放资源(解锁)
getchar();
shmdt(shmAddr);
shmctl(shmId, IPC_RMID, NULL);
semctl(semId, 0, IPC_RMID, su); // 删除信号量
return 0;
}
适用场景:实现进程或线程间的互斥(如保护共享内存、临界资源访问)和同步(如生产者 - 消费者模型中控制数据读写顺序)。
5. 信号(Signal):
特点:最基础的 IPC 方式,用于通知进程发生了某种事件(如中断、异常、进程间通知);Linux 提供了 31 种标准信号(如 SIGINT 表示键盘中断,SIGTERM 表示进程终止请求),支持自定义信号处理函数;信号是异步的,进程无法预测信号何时到达,且信号队列长度有限,可能出现信号丢失。
核心函数:
- signal()/sigaction():注册信号处理函数;
- kill():向指定进程发送信号;
- sigqueue():向指定进程发送带附加数据的信号;
- sigprocmask():屏蔽或解除屏蔽指定信号。
代码示例(信号处理):
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void sigHandler(int sig) { // 信号处理函数
if (sig == SIGINT) {
printf("\nReceived SIGINT (Ctrl+C), exiting...\n");
_exit(0);
}
}
int main() {
signal(SIGINT, sigHandler); // 注册SIGINT的处理函数
printf("Running, press Ctrl+C to exit\n");
while (1) {
sleep(1); // 等待信号
}
return 0;
}
适用场景:进程间简单的事件通知(如父进程通知子进程退出、监控进程通知工作进程重启),不适合传输大量数据,仅能传递 “事件发生” 的信号。
3. 线程同步机制
问题:在 Linux C++ 中,常用的线程同步机制有哪些?请简述它们的原理和适用场景。
答案:线程同步的核心目的是避免多个线程同时访问临界资源(如共享内存、全局变量)导致的数据竞争和逻辑错误,常用的同步机制包括互斥锁、条件变量、读写锁、信号量等。
1. 互斥锁(Mutex):
原理:通过 “加锁” 和 “解锁” 操作实现临界资源的独占访问。当一个线程成功获取互斥锁后,其他尝试获取该锁的线程会被阻塞,直到持有锁的线程释放锁。互斥锁保证了同一时间只有一个线程能进入临界区(访问临界资源的代码段)。
C++ 标准库实现(<mutex>):
- std::mutex:基础互斥锁,支持 lock()(阻塞获取)、try_lock()(非阻塞获取,失败返回 false)、unlock()(释放锁);
- std::lock_guard:RAII 风格的锁管理类,构造时自动加锁,析构时自动解锁,避免忘记解锁导致死锁;
- std::unique_lock:更灵活的锁管理类,支持延迟加锁、非阻塞加锁、锁转移等,功能比 std::lock_guard 更丰富。
代码示例:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 全局互斥锁
int sharedCount = 0;
void increment() {
for (int i = 0; i < 10000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // 自动加锁,作用域结束自动解锁
sharedCount++; // 临界区:安全访问共享变量
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final sharedCount: " << sharedCount << std::endl; // 预期输出20000
return 0;
}
适用场景:多个线程需要互斥访问临界资源的场景(如共享变量修改、单例模式实例创建、设备文件读写),确保操作的原子性。
2. 条件变量(Condition Variable):
原理:用于线程间的 “等待 - 通知” 同步,解决线程间的时序依赖问题(如生产者线程需等待消费者线程消费数据后再生产,消费者线程需等待生产者线程生产数据后再消费)。条件变量需配合互斥锁使用:线程在等待条件时会释放互斥锁,避免阻塞其他线程;当条件满足时,被通知的线程会重新获取互斥锁并继续执行。
C++ 标准库实现(<condition_variable>):
- std::condition_variable:需配合 std::unique_lock<std::mutex> 使用,支持 wait()(等待条件)、notify_one()(唤醒一个等待线程)、notify_all()(唤醒所有等待线程);
- std::condition_variable_any:可配合任意满足 “可锁定” 特性的锁(如 std::shared_mutex),灵活性更高,但性能略差。
代码示例(生产者 - 消费者模型):
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
const int MAX_QUEUE_SIZE = 5;
std::queue<int> dataQueue;
std::mutex mtx;
std::condition_variable cv;
void producer() { // 生产者线程:生产数据
for (int i = 1; i <= 10; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 等待队列未满
cv.wait(lock, []() { return dataQueue.size() < MAX_QUEUE_SIZE; });
dataQueue.push(i);
std::cout << "Produced: " << i << ", Queue size: " << dataQueue.size() << std::endl;
cv.notify_one(); // 通知消费者有数据可用
}
}
void consumer() { // 消费者线程:消费数据
for (int i = 1; i <= 10; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 等待队列非空
cv.wait(lock, []() { return !dataQueue.empty(); });
int data = dataQueue.front();
dataQueue.pop();
std::cout << "Consumed: " << data << ", Queue size: " << dataQueue.size() << std::endl;
cv.notify_one(); // 通知生产者有空间可用
}
}
int main() {
std::thread prod(producer);
std::thread cons(consumer);
prod.join();
cons.join();
return 0;
}
适用场景:线程间存在时序依赖的场景(如生产者 - 消费者、读者 - 写者中的等待逻辑),避免线程通过 “忙等”(循环检查条件)浪费 CPU 资源。
3. 读写锁(Read-Write Lock):
原理:针对 “读多写少” 的场景优化,区分读操作和写操作:多个读线程可同时获取读锁(共享访问),提高并发效率;写线程需独占获取写锁(此时不允许其他读线程或写线程获取锁),确保写操作的原子性。读写锁遵循 “写优先” 或 “读优先” 策略(具体由实现决定),避免写线程饥饿。
C++ 标准库实现(C++17 及以后,<shared_mutex>):
- std::shared_mutex:读写锁的基础类,支持 lock()(写锁,独占)、unlock()(释放锁)、lock_shared()(读锁,共享)、unlock_shared()(释放读锁);
- std::shared_lock:RAII 风格的读锁管理类,构造时获取读锁,析构时释放;
- std::unique_lock:可配合 std::shared_mutex 管理写锁。
代码示例:
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>
std::shared_mutex rwMutex;
int sharedData =</doubaocanvas>
0; // 共享数据
// 读线程:获取读锁,读取共享数据
void reader (int id) {
for (int i = 0; i < 5; ++i) {
std::shared_lockstd::shared_mutex readLock (rwMutex); // 获取读锁(共享)
std::cout << "Reader" << id << "read sharedData:" << sharedData << std::endl;
readLock.unlock (); // 可提前释放锁,也可依赖析构自动释放
std::this_thread::sleep_for (std::chrono::milliseconds (100)); // 模拟其他操作
}
}
// 写线程:获取写锁,修改共享数据
void writer (int id) {
for (int i = 0; i < 3; ++i) {
std::unique_lockstd::shared_mutex writeLock (rwMutex); // 获取写锁(独占)
sharedData++;
std::cout << "Writer" << id << "updated sharedData to:" << sharedData << std::endl;
writeLock.unlock ();
std::this_thread::sleep_for (std::chrono::milliseconds (300)); // 模拟其他操作
}
}
int main() {
std::vectorstd::thread threads;
// 创建 3 个读线程
for (int i = 0; i < 3; ++i) {
threads.emplace_back (reader, i + 1);
}
// 创建 2 个写线程
for (int i = 0; i < 2; ++i) {
threads.emplace_back (writer, i + 1);
}
// 等待所有线程结束
for (auto& t : threads) {
t.join ();
}
return 0;
}
适用场景:“读多写少”的高并发场景(如缓存系统、配置文件读取、数据库查询结果缓存),在读操作频繁时能显著提升并发效率,避免互斥锁导致的串行化瓶颈。
4. 线程信号量(Semaphore):
原理:与进程间信号量原理一致(基于计数器的 P/V 操作),但用于线程间同步,通常由用户态库实现(而非内核维护),开销比进程信号量更低。C++标准库未直接提供线程信号量,可通过 <semaphore.h>(POSIX 信号量)或自定义实现。
POSIX 信号量实现(Linux 下):
- sem_init():初始化信号量(pshared=0 表示线程间使用);
- sem_wait():P操作(申请资源,计数器减1,阻塞直到计数器≥0);
- sem_post():V 操作(释放资源,计数器加1);
- sem_destroy():销毁信号量。
代码示例(线程同步):
#include <iostream>
#include <thread>
int taskCount = 0;
void worker(int id) {
sem_wait(&sem); // P操作:申请任务名额
taskCount++;
std::cout << "Worker " << id << " is processing task " << taskCount << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // 模拟任务处理
std::cout << "Worker " << id << " finished task " << taskCount << std::endl;
sem_post(&sem); // V操作:释放任务名额
}
int main() {
sem_init(&sem, 0, 2); // 初始化:线程间使用,初始计数器为2(最多2个线程同时处理任务)
std::vector<std::thread> workers;
for (int i = 0; i < THREAD_NUM; ++i) {
workers.emplace_back(worker, i + 1);
}
for (auto& w : workers) {
w.join();
}
sem_destroy(&sem);
return 0;
}
适用场景:控制并发线程数量(如线程池的最大并发任务数)、生产者 - 消费者模型中的资源计数(如限制队列最大长度),比条件变量更直观地实现 “计数型同步”。
4. 死锁及避免方法
问题:什么是死锁?在 Linux C++ 中如何避免死锁?
答案:
死锁定义:多个线程(或进程)因互相等待对方持有的资源(如锁、文件句柄),而永久阻塞的状态。死锁需满足四个必要条件:
- 互斥条件:资源只能被一个线程独占(如互斥锁);
- 持有并等待条件:线程持有部分资源,同时等待其他线程的资源;
- 不可剥夺条件:线程持有的资源不能被强制剥夺,只能主动释放;
- 循环等待条件:多个线程形成资源等待循环(如线程 A 等线程 B 的锁,线程 B 等线程 A 的锁)。
常见死锁场景:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtxA;
std::mutex mtxB;
// 线程1:先加锁A,再尝试加锁B
void threadFunc1() {
mtxA.lock();
std::cout << "Thread 1 locked mtxA" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 让线程2有时间加锁B
mtxB.lock(); // 等待线程2释放mtxB,形成死锁
std::cout << "Thread 1 locked mtxB" << std::endl;
// 业务逻辑...
mtxB.unlock();
mtxA.unlock();
}
// 线程2:先加锁B,再尝试加锁A
void threadFunc2() {
mtxB.lock();
std::cout << "Thread 2 locked mtxB" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 让线程1有时间加锁A
mtxA.lock(); // 等待线程1释放mtxA,形成死锁
std::cout << "Thread 2 locked mtxA" << std::endl;
// 业务逻辑...
mtxA.unlock();
mtxB.unlock();
}
int main() {
std::thread t1(threadFunc1);
std::thread t2(threadFunc2);
t1.join();
t2.join();
return 0;
}
上述代码中,线程 1 持有 mtxA 等待 mtxB,线程 2 持有 mtxB 等待 mtxA,满足死锁的四个条件,导致程序永久阻塞。
死锁避免方法:
1.固定锁的获取顺序:确保所有线程按相同顺序获取多个锁,打破 “循环等待条件”。例如将上述代码中两个线程的锁获取顺序统一为 “先 mtxA 后 mtxB”:
// 线程2修改为与线程1相同的锁顺序
void threadFunc2() {
mtxA.lock(); // 先加锁A
std::cout << "Thread 2 locked mtxA" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mtxB.lock(); // 再加锁B
std::cout << "Thread 2 locked mtxB" << std::endl;
// 业务逻辑...
mtxB.unlock();
mtxA.unlock();
}
2.使用非阻塞锁获取(try_lock):通过 std::mutex::try_lock() 尝试获取锁,若失败则释放已持有的锁并重试(或延时后重试),打破 “持有并等待条件”。例如:
void safeLockFunc() {
while (true) {
// 尝试获取mtxA
if (mtxA.try_lock()) {
std::cout << "Got mtxA" << std::endl;
// 尝试获取mtxB,失败则释放mtxA
if (mtxB.try_lock()) {
std::cout << "Got mtxB" << std::endl;
break; // 成功获取所有锁,退出循环
} else {
mtxA.unlock(); // 释放已持有的mtxA
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 延时重试
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
// 业务逻辑...
mtxB.unlock();
mtxA.unlock();
}
3.使用 std::lock 批量获取锁:C++ 标准库的 std::lock 函数可原子性获取多个锁,避免部分锁获取成功导致的死锁,其内部通过固定顺序或重试机制保证安全。例如:
void threadFuncSafe() {
std::lock(mtxA, mtxB); // 原子性获取两个锁,要么都成功,要么都失败
std::lock_guard<std::mutex> lockA(mtxA, std::adopt_lock); // 接管已获取的锁,自动释放
std::lock_guard<std::mutex> lockB(mtxB, std::adopt_lock);
std::cout << "Successfully locked both mutexes" << std::endl;
// 业务逻辑...
}
4.限制锁的持有时间:通过定时机制(如 std::condition_variable::wait_for),若超时未获取所需资源则释放已持有的锁,打破 “不可剥夺条件”。
5.使用死锁检测工具:开发阶段通过工具定位潜在死锁,如 Linux 下的 pstack(查看线程调用栈)、valgrind --tool=helgrind(检测线程同步错误)、gdb(调试线程状态)。例如使用 pstack 查看死锁线程:
pstack <pid> # pid为死锁程序的进程ID
工具会输出线程的调用栈,显示线程阻塞在 pthread_mutex_lock 等函数,帮助定位死锁位置。
四、网络编程
1. TCP 三次握手与四次挥手
问题:请简述 TCP 协议的三次握手和四次挥手过程,以及每个阶段的核心目的。
答案:TCP 是面向连接、可靠的字节流协议,通过 “三次握手” 建立连接,通过 “四次挥手” 关闭连接,确保通信双方状态同步。
1. 三次握手(建立连接):
假设通信双方为 客户端(Client) 和 服务器(Server),服务器已处于 LISTEN 状态(监听端口),三次握手过程如下:
第一次握手(Client → Server):
- 客户端发送 SYN 报文(同步序列编号),报文段中包含 客户端初始序列号(ISN_c),并将 SYN 标志位设为 1。
- 目的:客户端向服务器发起连接请求,告知服务器 “我要建立连接,请确认你的接收能力”。
- 客户端状态变化:从 CLOSED → SYN_SENT。
第二次握手(Server → Client):
- 服务器收到 SYN 报文后,若同意连接,发送 SYN+ACK 报文:
- SYN 标志位设为 1,包含 服务器初始序列号(ISN_s);
- ACK 标志位设为 1,确认号为 ISN_c + 1(表示已收到客户端的 ISN_c,下一次期望接收 ISN_c + 1 及以后的数据)。
- 目的:服务器确认客户端的连接请求,并向客户端发起连接请求(双向连接)。
- 服务器状态变化:从 LISTEN → SYN_RCVD。
第三次握手(Client → Server):
- 客户端收到 SYN+ACK 报文后,发送 ACK 报文:
- ACK 标志位设为 1,确认号为 ISN_s + 1(表示已收到服务器的 ISN_s);
- 可携带数据(若客户端有数据需立即发送)。
- 目的:客户端确认服务器的连接请求,完成双向连接建立。
- 客户端状态变化:从 SYN_SENT → ESTABLISHED;服务器收到 ACK 后状态变化:从 SYN_RCVD → ESTABLISHED。
三次握手的核心目的:确保双方的 “发送能力” 和 “接收能力” 均正常,避免 “失效连接请求报文” 导致的错误(如客户端发送的连接请求延迟到达服务器,服务器误建立无效连接)。
2. 四次挥手(关闭连接):
TCP 连接是双向的,关闭连接需双方分别关闭各自的 “发送通道”,因此需要四次交互:
第一次挥手(Client → Server):
- 客户端主动关闭连接,发送 FIN 报文(结束标志),FIN 标志位设为 1,序列号为 Seq_c(客户端当前已发送数据的最后一个字节的序号 + 1)。
- 目的:客户端告知服务器 “我已无数据要发送,请准备关闭我的发送通道”。
- 客户端状态变化:从 ESTABLISHED → FIN_WAIT_1。
第二次挥手(Server → Client):
- 服务器收到 FIN 报文后,发送 ACK 报文,确认号为 Seq_c + 1,序列号为 Seq_s(服务器当前已发送数据的最后一个字节的序号 + 1)。
- 目的:服务器确认收到客户端的关闭请求,但此时服务器可能仍有数据未发送,需继续发送。
- 服务器状态变化:从 ESTABLISHED → CLOSE_WAIT;客户端收到 ACK 后状态变化:从 FIN_WAIT_1 → FIN_WAIT_2。
第三次挥手(Server → Client):
- 服务器完成所有数据发送后,发送 FIN+ACK 报文:
- FIN 标志位设为 1,序列号为 Seq_s'(服务器最终已发送数据的最后一个字节的序号 + 1);
- ACK 标志位设为 1,确认号仍为 Seq_c + 1(保持与第二次挥手一致)。
- 目的:服务器告知客户端 “我已无数据要发送,请准备关闭我的发送通道”。
- 服务器状态变化:从 CLOSE_WAIT → LAST_ACK。
第四次挥手(Client → Server):
- 客户端收到 FIN+ACK 报文后,发送 ACK 报文,确认号为 Seq_s' + 1,序列号为 Seq_c + 1。
- 目的:客户端确认收到服务器的关闭请求,完成双向发送通道关闭。
- 客户端状态变化:从 FIN_WAIT_2 → TIME_WAIT(等待 2MSL 时间,确保服务器收到 ACK,避免服务器重发 FIN);服务器收到 ACK 后状态变化:从 LAST_ACK → CLOSED。
- 客户端等待 2MSL(MSL 为报文段最大生存时间,通常为 1-2 分钟)后,状态从 TIME_WAIT → CLOSED,彻底释放连接。
四次挥手的核心目的:确保双方均已完成数据发送,避免数据丢失(如服务器在第二次挥手后仍需发送剩余数据,因此不能合并为三次挥手)。
2. TCP 与 UDP 的区别
问题:TCP 和 UDP 是 TCP/IP 协议族中的两种传输层协议,请简述它们的核心区别及适用场景。
答案:TCP 和 UDP 在可靠性、连接性、数据传输方式等方面存在显著差异,适用于不同的业务场景:
3. Linux 下 TCP Socket 编程流程
问题:请简述 Linux C++ 中基于 TCP 的 Socket 客户端和服务器端的编程流程,并给出核心代码示例。
答案:TCP Socket 编程基于 “客户端 - 服务器” 模型,服务器端需先启动监听,客户端主动发起连接,双方建立连接后进行数据交互。
(1)服务器端流程
- 创建 Socket:调用 socket() 函数创建 TCP Socket(流式 Socket),返回文件描述符。
- 绑定地址:调用 bind() 函数将 Socket 与服务器的 IP 地址和端口号绑定。
- 监听连接:调用 listen() 函数将 Socket 设置为监听状态,等待客户端连接。
- 接受连接:调用 accept() 函数阻塞等待客户端连接,成功后返回新的 Socket 文件描述符(用于与该客户端通信)。
- 数据交互:通过 read()/recv() 读取客户端数据,通过 write()/send() 向客户端发送数据。
- 关闭连接:通信结束后,调用 close() 关闭通信 Socket 和监听 Socket。
(2)客户端流程
- 创建 Socket:调用 socket() 函数创建 TCP Socket。
- 连接服务器:调用 connect() 函数向服务器的 IP 地址和端口号发起连接。
- 数据交互:通过 write()/send() 向服务器发送数据,通过 read()/recv() 读取服务器响应。
- 关闭连接:通信结束后,调用 close() 关闭 Socket。
(3)核心代码示例
服务器端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUF_SIZE 1024
int main() {
int listenFd, connFd;
struct sockaddr_in serverAddr, clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
char buf[BUF_SIZE] = {0};
// 1. 创建TCP Socket(AF_INET:IPv4,SOCK_STREAM:流式Socket,0:默认TCP协议)
if ((listenFd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket create failed");
exit(EXIT_FAILURE);
}
// 2. 设置服务器地址结构
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET; // IPv4
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网卡IP
serverAddr.sin_port = htons(PORT); // 端口号转换为网络字节序(大端)
// 3. 绑定Socket与地址
if (bind(listenFd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
perror("bind failed");
close(listenFd);
exit(EXIT_FAILURE);
}
// 4. 监听连接(第二个参数:等待队列最大长度)
if (listen(listenFd, 5) == -1) {
perror("listen failed");
close(listenFd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 5. 接受客户端连接(阻塞)
if ((connFd = accept(listenFd, (struct sockaddr*)&clientAddr, &clientAddrLen)) == -1) {
perror("accept failed");
close(listenFd);
exit(EXIT_FAILURE);
}
printf("Client connected: %s:%d\n",
inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
// 6. 数据交互:读取客户端数据并回复
ssize_t recvLen;
while ((recvLen = read(connFd, buf, BUF_SIZE)) > 0) {
buf[recvLen] = '\0'; // 确保字符串结束
printf("Received from client: %s\n", buf);
// 回复客户端
const char* reply = "Server received: ";
write(connFd, reply, strlen(reply));
write(connFd, buf, strlen(buf));
write(connFd, "\n", 1);
memset(buf, 0, BUF_SIZE); // 清空缓冲区
}
// 7. 关闭连接
close(connFd); // 关闭与客户端的通信Socket
close(listenFd); // 关闭监听Socket
printf("Client disconnected, server closed\n");
return 0;
}
客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SERVER_IP "127.0.0.1" // 服务器IP(本地测试用回环地址)
#define SERVER_PORT 8888
#define BUF_SIZE 1024
int main() {
int sockFd;
struct sockaddr_in serverAddr;
char buf[BUF_SIZE] = {0};
// 1. 创建TCP Socket
if ((sockFd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket create failed");
exit(EXIT_FAILURE);
}
// 2. 设置服务器地址结构
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP); // IP地址转换为网络字节序
serverAddr.sin_port = htons(SERVER_PORT);
// 3. 连接服务器(阻塞)
if (connect(sockFd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
perror("connect failed");
close(sockFd);
exit(EXIT_FAILURE);
}
printf("Connected to server %s:%d\n", SERVER_IP, SERVER_PORT);
// 4. 数据交互:输入数据并发送给服务器
printf("Enter message to send (enter 'exit' to quit): ");
while (fgets(buf, BUF_SIZE, stdin) != NULL) {
// 移除fgets读取的换行符
buf[strcspn(buf, "\n")] = '\0';
// 若输入"exit",退出循环
if (strcmp(buf, "exit") == 0) {
break;
}
// 发送数据给服务器
write(sockFd, buf, strlen(buf));
// 读取服务器回复
memset(buf, 0, BUF_SIZE);
ssize_t recvLen = read(sockFd, buf, BUF_SIZE);
if (recvLen > 0) {
printf("Server reply: %s", buf);
} else if (recvLen == 0) {
printf("Server closed the connection\n");
break;
} else {
perror("read failed");
break;
}
printf("Enter message to send (enter 'exit' to quit): ");
}
// 5. 关闭连接
close(sockFd);
printf("Client closed\n");
return 0;
}
4. Linux IO 模型(IO 多路复用)
问题:Linux 下有哪些常见的 IO 模型?请重点讲解 IO 多路复用(select/poll/epoll)的原理、区别及适用场景。
答案:Linux 下 IO 模型分为 5 类:阻塞 IO、非阻塞 IO、IO 多路复用、信号驱动 IO、异步 IO,其中 IO 多路复用是高并发网络编程(如服务器处理大量客户端连接)的核心技术,支持同时监控多个 IO 事件。
(1)基础 IO 模型简介
- 阻塞 IO:默认 IO 模型,调用 read()/write() 等函数后,线程阻塞直到 IO 事件完成(如数据到达、连接建立),效率低,无法处理多连接。
- 非阻塞 IO:通过 fcntl() 将 Socket 设置为非阻塞模式,调用 IO 函数后立即返回,若 IO 未就绪则返回错误(EAGAIN/EWOULDBLOCK),需通过循环轮询检查 IO 状态,CPU 开销大。
- 信号驱动 IO:通过 sigaction() 注册信号处理函数,当 IO 事件就绪时,内核发送 SIGIO 信号通知进程,进程在信号处理函数中处理 IO,避免轮询,但信号处理逻辑复杂,难以处理大量连接。
- 异步 IO:进程调用 aio_read()/aio_write() 后立即返回,内核完成 IO 操作后(如数据从内核态拷贝到用户态),通过信号或回调通知进程,全程无阻塞,是真正的 “异步”,但 Linux 下实现不完善,使用较少。
- IO 多路复用:通过 select()/poll()/epoll() 等函数,同时监控多个 IO 文件描述符(Socket),当某个 IO 事件就绪时,函数返回并通知进程处理该 IO,支持高并发,是服务器开发的主流选择。
(2)IO 多路复用:select/poll/epoll 对比
(3)epoll 核心原理与代码示例
epoll 是 Linux 下最高效的 IO 多路复用技术,核心通过三个函数实现:
- epoll_create():创建 epoll 实例,返回 epoll 文件描述符。
- epoll_ctl():向 epoll 实例添加、删除、修改监控的 fd 和事件类型。
- epoll_wait():阻塞等待 IO 事件就绪,返回就绪的 fd 和事件。
水平触发(LT)vs 边缘触发(ET):
- LT(Level Triggered):默认模式,只要 fd 处于就绪状态(如内核缓冲区有数据),每次调用 epoll_wait() 都会返回该 fd,适合初学者,编程简单,但可能导致重复通知。
- ET(Edge Triggered):仅在 fd 状态从 “未就绪” 变为 “就绪” 时通知一次(如数据首次到达内核缓冲区),需一次性读取 / 写入所有数据,编程复杂,但效率更高,避免重复通知。
epoll 代码示例(LT 模式,服务器处理多客户端):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#define PORT 8888
#define BUF_SIZE 1024
#define MAX_EVENTS 1024 // 最大就绪事件数
int main() {
int listenFd, epollFd;
struct sockaddr_in serverAddr, clientAddr;
socklen_t clientAddrLen = sizeof(clientAddr);
struct epoll_event ev, events[MAX_EVENTS]; // ev:添加事件,events:就绪事件数组
// 1. 创建监听Socket
if ((listenFd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket create failed");
exit(EXIT_FAILURE);
}
// 2. 绑定地址(复用端口,避免TIME_WAIT状态占用端口)
int opt = 1;
setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = htonl(INADDR_ANY);
serverAddr.sin_port = htons(PORT);
if (bind(listenFd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
perror("bind failed");
close(listenFd);
exit(EXIT_FAILURE);
}
// 3. 监听连接
if (listen(listenFd, 5) == -1) {
perror("listen failed");
close(listenFd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d (epoll LT mode)...\n", PORT);
// 4. 创建epoll实例
if ((epollFd = epoll_create1(0)) == -1) {
perror("epoll_create1 failed");
close(listenFd);
exit(EXIT_FAILURE);
}
// 5. 向epoll添加监听Socket的读事件(等待客户端连接)
ev.events = EPOLLIN; // 读事件(连接请求或数据到达)
ev.data.fd = listenFd; // 关联监听Socket
if (epoll_ctl(epollFd, EPOLL_CTL_ADD, listenFd, &ev) == -1) {
perror("epoll_ctl add listenFd failed");
close(listenFd);
close(epollFd);
exit(EXIT_FAILURE);
}
// 6. 循环等待IO事件
while (1) {
// epoll_wait阻塞等待,返回就绪事件数(timeout=-1:永久阻塞)
int nfds = epoll_wait(epollFd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait failed");
break;
}
// 遍历就绪事件
for (int i = 0; i < nfds; ++i) {
int fd = events[i].data.fd;
// 情况1:监听Socket就绪(有新客户端连接)
if (fd == listenFd) {
int connFd = accept(listenFd, (struct sockaddr*)&clientAddr, &clientAddrLen);
if (connFd == -1) {
perror("accept failed");
continue;
}
printf("Client connected: %s:%d\n",
inet_ntoa</doubaocanvas>
(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
// 将新连接的客户端 Socket 添加到 epoll 监控(LT 模式,默认阻塞,无需额外设置)
ev.events = EPOLLIN;
ev.data.fd = connFd;
if (epoll_ctl (epollFd, EPOLL_CTL_ADD, connFd, &ev) == -1) {
perror ("epoll_ctl add connFd failed");
close (connFd);
continue;
}
}
// 情况 2:客户端 Socket 就绪(有数据到达或连接关闭)
else {
char buf [BUF_SIZE] = {0};
ssize_t recvLen = read (fd, buf, BUF_SIZE - 1); // 留 1 字节存 '\0'
if (recvLen > 0) {
buf [recvLen] = '\0';
printf ("Received from % d: % s\n", fd, buf);
// 回复客户端(简单回显)
const char* prefix = "Server echo:";
write (fd, prefix, strlen (prefix));
write (fd, buf, strlen (buf));
write (fd, "\n", 1);
}
//recvLen == 0:客户端主动关闭连接
else if (recvLen == 0) {
printf ("Client % d disconnected\n", fd);
// 从 epoll 中删除该 Socket,避免后续继续监控
epoll_ctl (epollFd, EPOLL_CTL_DEL, fd, NULL);
close (fd);
}
//recvLen < 0:读取错误
else {
perror ("read failed");
epoll_ctl (epollFd, EPOLL_CTL_DEL, fd, NULL);
close (fd);
}
}
}
}
}
// 关闭资源
close (listenFd);
close (epollFd);
return 0;
}
ET模式关键实现要点:
若需将上述代码改为ET模式,需注意以下两点:
1.设置Socket为非阻塞:ET模式依赖非阻塞IO,确保一次性读取/写入所有数据,避免因阻塞导致后续数据无法处理。通过 fcntl() 设置非阻塞:
// 新增函数:设置Socket为非阻塞模式
int setNonBlocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL failed");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL failed");
return -1;
}
return 0;
}
// 在accept后调用,将客户端Socket设为非阻塞
int connFd = accept(...)
if (connFd != -1) {
setNonBlocking(connFd); // 关键:ET模式必须配合非阻塞IO
ev.events = EPOLLIN | EPOLLET; // 添加EPOLLET标志,启用ET模式
ev.data.fd = connFd;
epoll_ctl(epollFd, EPOLL_CTL_ADD, connFd, &ev);
}
2.循环读取数据:ET 模式仅通知一次数据到达,需循环调用 read() 直到返回 EAGAIN/EWOULDBLOCK(表示内核缓冲区已无数据):
// 读取客户端数据部分改为循环读取(ET模式)
else {
char buf[BUF_SIZE] = {0};
ssize_t recvLen;
// 循环读取,直到内核缓冲区无数据
while ((recvLen = read(fd, buf, BUF_SIZE - 1)) > 0) {
buf[recvLen] = '\0';
printf("Received from %d: %s\n", fd, buf);
// 回复逻辑...
memset(buf, 0, BUF_SIZE);
}
// 处理读取结果
if (recvLen == 0) {
// 客户端关闭连接...
} else if (recvLen < 0) {
// 若错误为EAGAIN/EWOULDBLOCK,说明数据已读完,无需处理
if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read failed");
epoll_ctl(epollFd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
}
}
5. TCP 粘包问题及解决方案
问题:什么是 TCP 粘包?产生原因是什么?如何在 Linux C++ 中解决粘包问题?
答案:
粘包定义:TCP 是字节流协议,无数据边界,发送方多次发送的小数据包可能被内核合并为一个大数据包发送,或接收方一次读取到多个发送方的数据包,这种 “数据包边界模糊” 的现象称为粘包。例如:
发送方依次发送 ["Hello", "World"],接收方可能一次读取到 ["HelloWorld"] 或 ["Hel", "loWorld"]。
粘包产生原因:
- 发送方原因:TCP 的 Nagle 算法(默认开启)会合并小数据包(小于 MSS,最大分段大小),等待一定时间或积累到足够大小后再发送,减少网络开销,导致粘包。
- 接收方原因:接收方内核缓冲区有数据时,若应用层未及时读取,后续到达的数据会存入缓冲区,应用层下次读取时可能一次性读取多个数据包。
- 网络原因:数据包在网络中可能被分片或合并(如路由器转发时的 MTU 限制),导致接收方收到的数据包边界与发送方不一致。
解决方案:
核心思路是在应用层定义数据边界,让接收方能够正确分割多个数据包,常见方案如下:
(1)固定长度数据包
发送方每次发送固定长度的数据(如 1024 字节),接收方每次读取固定长度的数据,若数据不足则补空字符,读取后截取有效数据。
代码示例(发送方):
void sendFixedLength(int sockFd, const char* data) {
char buf[1024] = {0}; // 固定长度1024字节
strncpy(buf, data, sizeof(buf) - 1); // 数据不足补'\0'
write(sockFd, buf, sizeof(buf)); // 发送固定长度
}
代码示例(接收方):
void recvFixedLength(int sockFd) {
char buf[1024] = {0};
while (1) {
ssize_t recvLen = read(sockFd, buf, sizeof(buf)); // 每次读取固定长度
if (recvLen == sizeof(buf)) {
printf("Received: %s\n", buf); // 直接处理,无需分割
memset(buf, 0, sizeof(buf));
} else if (recvLen <= 0) {
break; // 连接关闭或错误
}
}
}
适用场景:数据长度固定的场景(如传感器固定周期发送的监测数据),缺点是灵活性差,数据长度不固定时会浪费带宽(如短数据需补空)。
(2)特殊分隔符
发送方在每个数据包末尾添加特殊分隔符(如 \n、\r\n 或自定义字符),接收方读取数据时,按分隔符分割数据包。
代码示例(接收方):
void recvWithDelimiter(int sockFd) {
char buf[1024] = {0};
int bufLen = 0; // 缓冲区中未处理的数据长度
while (1) {
// 读取数据到缓冲区剩余空间
ssize_t recvLen = read(sockFd, buf + bufLen, sizeof(buf) - bufLen - 1);
if (recvLen <= 0) break;
bufLen += recvLen;
buf[bufLen] = '\0'; // 确保字符串结束
// 按分隔符'\n'分割数据包
char* pos = strchr(buf, '\n');
while (pos != NULL) {
*pos = '\0'; // 将分隔符替换为'\0',分割出一个数据包
printf("Received: %s\n", buf); // 处理当前数据包
// 将剩余数据移到缓冲区开头
bufLen -= (pos - buf + 1);
memmove(buf, pos + 1, bufLen);
buf[bufLen] = '\0';
// 继续查找下一个分隔符
pos = strchr(buf, '\n');
}
}
}
适用场景:文本数据(如 HTTP 协议的 \r\n\r\n 分隔请求头),缺点是需确保数据中不包含分隔符(否则会误分割),二进制数据不适用。
(3)长度前缀 + 数据
最常用的方案:发送方先发送数据包的长度(固定字节数,如 4 字节 int),再发送数据;接收方先读取长度,再根据长度读取对应字节的数据。
代码示例(发送方):
void sendWithLengthPrefix(int sockFd, const char* data) {
int dataLen = strlen(data);
// 步骤1:发送长度(4字节,网络字节序)
int lenNet = htonl(dataLen); // 主机字节序转网络字节序(大端)
write(sockFd, &lenNet, sizeof(lenNet));
// 步骤2:发送数据
write(sockFd, data, dataLen);
}
代码示例(接收方):
void recvWithLengthPrefix(int sockFd) {
while (1) {
// 步骤1:读取长度(4字节)
int lenNet;
ssize_t recvLen = read(sockFd, &lenNet, sizeof(lenNet));
if (recvLen != sizeof(lenNet)) break; // 长度读取失败
int dataLen = ntohl(lenNet); // 网络字节序转主机字节序
// 步骤2:读取对应长度的数据
char* data = new char[dataLen + 1];
data[dataLen] = '\0';
int readTotal = 0;
// 可能需要多次读取(如数据被分片)
while (readTotal < dataLen) {
recvLen = read(sockFd, data + readTotal, dataLen - readTotal);
if (recvLen <= 0) {
delete[] data;
return;
}
readTotal += recvLen;
}
// 处理数据
printf("Received: %s\n", data);
delete[] data;
}
}
适用场景:通用场景(文本 / 二进制数据均可),灵活性高,无数据浪费,是工业界主流方案(如 RPC 框架、自定义 TCP 协议)。
五、综合编程与工程实践
1. 单例模式的实现(线程安全)
问题:在 C++ 中如何实现线程安全的单例模式?请给出至少两种实现方式。
答案:
单例模式的核心是确保类仅有一个实例,并提供全局访问点,线程安全的关键是避免多线程同时创建实例导致的 “重复初始化” 问题。
(1)饿汉模式(静态初始化)
原理:在程序启动时(静态变量初始化阶段)创建单例实例,利用 C++ 静态变量的初始化特性(全局 / 静态变量在 main 函数前初始化,且仅初始化一次),天然线程安全。
代码示例:
class Singleton {
private:
// 私有构造函数:禁止外部创建实例
Singleton() {}
// 私有拷贝构造和赋值运算符:禁止拷贝
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 静态单例实例(程序启动时初始化)
static Singleton instance;
public:
// 全局访问点
static Singleton& getInstance() {
return instance;
}
// 测试方法
void doSomething() {
printf("Singleton instance: %p\n", this);
}
};
// 静态实例初始化(类外初始化,程序启动时执行)
Singleton Singleton::instance;
// 使用示例
int main() {
Singleton& s1 = Singleton::getInstance();
Singleton& s2 = Singleton::getInstance();
s1.doSomething(); // 输出相同地址
s2.doSomething(); // 输出相同地址
return 0;
}
优点:实现简单,天然线程安全,无锁开销,访问效率高。
缺点:实例在程序启动时创建,若实例初始化开销大(如加载配置文件、初始化资源),会增加程序启动时间;若程序全程未使用该实例,会造成资源浪费。
(2)懒汉模式(双重检查锁定,DCLP)
原理:在第一次调用 getInstance() 时创建实例,通过 “双重检查锁定”(先判断实例是否存在,存在则直接返回;不存在则加锁,再次检查实例是否存在,不存在则创建)避免多线程竞争,兼顾延迟初始化和线程安全。
代码示例(C++11 及以后):
#include <mutex>
class Singleton {
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 静态指针(延迟初始化),volatile确保禁止编译器优化
static volatile Singleton* instance;
static std::mutex mtx; // 互斥锁
public:
static Singleton& getInstance() {
// 第一次检查:无锁,快速判断实例是否存在
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mtx); // 加锁
// 第二次检查:防止多线程同时进入第一次检查后,重复创建实例
if (instance == nullptr) {
// C++11后,new操作的内存分配和对象构造是原子的,无需额外内存屏障
instance = new Singleton();
// (可选)注册析构函数,程序退出时释放实例
static Destructor destructor;
}
}
return const_cast<Singleton&>(*instance);
}
void doSomething() {
printf("Singleton instance: %p\n", this);
}
private:
// 辅助析构类:程序退出时调用析构函数,释放单例实例
class Destructor {
public:
~Destructor() {
if (Singleton::instance != nullptr) {
delete Singleton::instance;
Singleton::instance = nullptr;
}
}
};
};
// 静态成员初始化
volatile Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mtx;
// 使用示例(多线程测试)
#include <thread>
void testSingleton() {
Singleton& s = Singleton::getInstance();
s.doSomething();
}
int main() {
std::thread t1(testSingleton);
std::thread t2(testSingleton);
t1.join();
t2.join();
return 0;
}
关键细节:
- volatile 关键字:禁止编译器对 instance 指针进行优化(如将指针缓存到寄存器,导致第二次检查时读取旧值);
- 辅助析构类:解决单例实例的内存释放问题(程序退出时,静态成员 destructor 会被销毁,其析构函数释放单例实例);
- C++11 兼容性:C++11 前 new 操作可能被拆分为 “分配内存”“构造对象”“赋值指针” 三步,存在重排序风险,需添加内存屏障;C++11 后 new 操作保证原子性,无需额外处理。
优点:延迟初始化,程序启动快,仅在需要时创建实例,资源利用率高。
缺点:实现复杂,存在锁开销(但仅第一次创建实例时加锁,后续访问无锁)。
(3)C++11 静态局部变量模式(推荐)
原理:利用 C++11 标准的特性 ——局部静态变量的初始化是线程安全的,即多线程同时调用 getInstance() 时,静态局部变量 instance 仅初始化一次,无需手动加锁,兼顾简洁性和线程安全。
代码示例:
class Singleton {
private:
</doubaocanvas>
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
// 全局访问点:局部静态变量自动实现线程安全初始化
static Singleton& getInstance () {
static Singleton instance; // C++11 后,此处初始化线程安全
return instance;
}
void doSomething() {
printf("Singleton instance: %p\n", this);
}
};
// 使用示例(多线程安全)
#include <thread>
void testSingleton() {
Singleton& s = Singleton::getInstance();
s.doSomething();
}
int main() {
std::thread t1(testSingleton);
std::thread t2(testSingleton);
std::thread t3(testSingleton);
t1.join();
t2.join();
t3.join();
return 0;
}
关键特性:
1. 线程安全:C++11标准明确规定,局部静态变量的初始化在多线程环境下是“魔法静态变量”(Magic Static),编译器会自动添加同步机制,确保仅初始化一次;
2. 自动析构:局部静态变量的生命周期从初始化开始,到程序结束时自动销毁,无需手动编写析构逻辑,避免内存泄漏;
3. 延迟初始化:实例仅在第一次调用 `getInstance()` 时创建,不影响程序启动速度。
优点:实现最简洁,兼顾线程安全、延迟初始化和自动析构,无锁开销(仅初始化阶段有编译器隐式同步,后续访问无开销),是C++11及以后版本的推荐方案。
缺点:依赖C++11及以上标准,若项目需兼容旧编译器(如GCC 4.3及以下)则无法使用。
2. STL容器底层原理(vector/list/map)
问题:请简述C++ STL中 `vector`、`list`、`map` 的底层数据结构、核心特性及适用场景。 答案:STL容器是C++开发的基础工具,不同容器的底层实现决定了其性能特性和适用场景,三者核心差异如下:
(1)vector(动态数组)
底层数据结构:连续的动态内存空间(数组),内部维护三个指针:
- begin():指向数组首元素;
- end():指向数组尾元素的下一个位置;
- capacity() 对应的指针:指向已分配内存的尾位置(即当前可容纳的最大元素数,超过则扩容)。
核心特性:
1. 随机访问:支持 [] 运算符和 at() 方法,访问任意元素的时间复杂度为 O(1);
2. 动态扩容:当 size() == capacity() 时,插入元素会触发扩容,扩容逻辑通常为:
- 分配新内存(新容量通常为原容量的2倍,不同编译器可能调整,如GCC为2倍,MSVC为1.5倍);
- 拷贝原数组元素到新内存;
- 释放原内存;
- 扩容过程时间复杂度为 O(n),但 amortized(均摊)到每次插入操作仍为 O(1);
3. 插入/删除效率:
- 尾部插入/删除(push_back()/pop_back()):时间复杂度 O(1)(无扩容时);
- 中间/头部插入/删除:需移动后续元素,时间复杂度 O(n);
4. 内存连续性:元素在内存中连续存储,缓存命中率高(CPU可预加载连续内存,减少缓存缺失)。 适用场景:
- 需要频繁随机访问元素(如通过索引访问);
- 插入/删除操作主要在尾部;
- 对缓存友好的场景(如数值计算、数据遍历);
注意事项:
- 扩容后,原有的迭代器、指针、引用会失效(因为内存地址已改变);
- 避免频繁在中间插入元素,否则性能低下;
- 可通过 reserve(n) 提前预留内存,减少扩容次数(如已知元素数量时)。
(2)list(双向链表)
底层数据结构:双向循环链表,每个节点包含三个部分:
- 前驱指针(prev):指向前一个节点;
- 数据域(data):存储元素值;
- 后继指针(next):指向后一个节点;
- 链表首尾节点相连,形成循环结构(部分实现为非循环,但核心是双向指针)。
核心特性:
1. 无随机访问:无法通过索引访问元素,需从表头/表尾遍历,访问任意元素时间复杂度 O(n);
2. 插入/删除效率:
- 已知节点位置时,插入/删除仅需修改指针,时间复杂度 O(1);
- 未知节点位置时,需先遍历找到节点,总时间复杂度 O(n);
3. 内存非连续:元素在内存中分散存储,每个节点单独分配内存,缓存命中率低;
4. 无扩容:插入元素时直接分配新节点,删除元素时释放节点内存,无需整体扩容;
5. 迭代器稳定性:插入/删除元素时,仅受影响节点的迭代器失效,其他节点的迭代器、指针、引用仍有效(不同于vector)。
适用场景:
- 频繁在中间插入/删除元素(如实现链表式队列、栈,或频繁修改的数据集);
- 无需随机访问元素的场景;
注意事项:
- 遍历效率低于vector(缓存命中率低);
- 内存开销高于vector(每个节点需额外存储两个指针);
- STL中无单向链表(slist 为非标准扩展,C++11后可使用 forward_list 实现单向链表)。
(3)map(有序关联容器)
底层数据结构:红黑树(一种自平衡的二叉搜索树),每个节点存储 std::pair<const Key, T>(键值对),且按键(Key)的升序(默认)排列。
红黑树特性:通过颜色规则(节点分为红/黑)确保树的高度始终为 O(log n),避免二叉搜索树退化为链表(最坏情况时间复杂度从O(n)变为O(log n))。
核心特性:
1. 有序性:元素按Key的默认排序规则(std::less<Key>)自动排序,支持通过 begin()/end() 遍历有序元素;
2. 查找效率:通过Key查找元素时,红黑树二分查找,时间复杂度 O(log n);
3. 插入/删除效率:插入/删除节点时,红黑树自动调整平衡,时间复杂度 O(log n);
4. 键唯一性:map 的Key不可重复,插入重复Key时会失败(insert() 返回失败,[] 会覆盖原有值);若需Key可重复,需使用 multimap;
5. 迭代器稳定性:插入/删除元素时,仅被删除节点的迭代器失效,其他节点的迭代器仍有效(红黑树结构调整不影响其他节点地址)。
适用场景:
- 需要按Key有序存储键值对(如按ID排序的用户信息、按时间排序的日志);
- 频繁按Key查找、插入、删除元素(如字典、索引表);
注意事项:
- 不支持随机访问,遍历需通过迭代器;
- Key需支持比较操作(默认 std::less<Key>,自定义类型需重载 < 运算符或提供比较函数);
- 若无需有序性,仅需快速查找,可使用 unordered_map(底层为哈希表,查找时间复杂度平均O(1),最坏O(n))。
3. Linux下性能优化手段(CPU/内存/IO)
问题:在Linux C++开发中,如何针对CPU、内存、IO三个维度进行性能优化?请给出具体手段和工具。 答案:Linux程序性能优化需先通过工具定位瓶颈,再针对性优化,三个维度的核心优化手段如下:
(1)CPU性能优化
核心目标:减少CPU计算开销,避免不必要的指令执行和上下文切换。 优化手段:
1. 减少计算冗余:
- 避免循环内重复计算(如将循环外可计算的表达式移到循环外);
- 使用高效算法(如将O(n²)的排序改为O(n log n)的快排/归并排序,用哈希表代替线性查找);
- 避免浮点数运算(浮点数运算比整数慢,可通过定点数或整数模拟替代)。
2. 提升缓存命中率:
- 数据对齐:结构体成员按CPU缓存行大小(通常64字节)对齐,避免跨缓存行访问(可通过 __attribute__((aligned(64))) 或 alignas 关键字实现);
- 数据局部性:按内存连续顺序访问数据(如使用vector代替list,遍历数组时按行优先而非列优先);
- 循环展开:减少循环次数和分支判断(如将 for (int i=0; i<4; i++) 展开为4次直接操作,编译器 -O3 通常会自动优化)。
3. 减少分支预测失败:
- 避免循环内复杂的条件判断(如用查表法代替多个 `if-else` 或 `switch`);
- 对有序数据使用二分查找(分支预测成功率高);
- 使用编译器内置函数(如 `__builtin_expect` 提示分支概率,帮助编译器优化分支预测)。
4. 减少上下文切换:
- 线程池化:避免频繁创建/销毁线程(线程创建销毁会触发内核上下文切换,线程池复用线程);
- 控制线程数量:线程数不宜超过CPU核心数(CPU密集型任务),IO密集型任务可适当增加,但需避免过度切换;
- 使用无锁编程:对简单共享数据,用原子操作(`std::atomic`)代替互斥锁,减少锁竞争导致的上下文切换。
常用工具:
- perf:Linux内核自带性能分析工具,可采样CPU指令执行、函数调用耗时、缓存命中率等(如 perf top 查看CPU占用最高的函数,perf record -g ./program 记录调用栈并分析);
- gprof:GCC自带性能分析工具,生成函数调用耗时报告(编译时需添加 -pg 选项,运行程序生成 gmon.out,再用 gprof ./program gmon.out 查看);
- valgrind --tool=cachegrind:模拟CPU缓存,分析缓存缺失率(定位缓存不友好的代码)。
(2)内存性能优化
核心目标:减少内存占用,避免内存泄漏和碎片化,提升内存访问效率。 优化手段:
1. 减少内存分配开销:
- 内存池化:预先分配一块连续内存,按需分配/释放(避免频繁调用 new/delete 或 malloc/free,减少内存碎片和系统调用开销);
- 使用合适的容器:如用 vector 代替 list(内存连续,减少节点内存开销),用 std::string 的 reserve() 提前预留内存;
- 避免内存泄漏:使用智能指针(unique_ptr/shared_ptr)、RAII模式,配合 valgrind --tool=memcheck 检测泄漏。
2. 减少内存碎片化:
- 避免频繁分配/释放小块内存(小块内存易导致内存碎片,可通过内存池合并小块内存);
- 使用大页内存(HugePages):将Linux默认4KB页改为2MB或1GB大页,减少页表项数量和TLB( Translation Lookaside Buffer)缺失率(需内核配置,适合内存密集型任务)。
3. 数据结构优化:
- 压缩数据:对冗余数据使用压缩算法(如字符串用LZ4压缩,数值用位域 bit-field 减少占用);
- 避免内存浪费:如用 uint8_t/uint16_t 代替 int 存储小范围数值,结构体成员按内存大小排序(减少内存空洞,如将 char 放在一起,避免因对齐产生的空洞)。
常用工具:
- valgrind --tool=memcheck:检测内存泄漏、野指针、越界访问;
- valgrind --tool=massif:分析内存使用峰值和分配情况,定位内存占用过高的代码;
- pmap:查看进程内存映射(如 pmap -x <pid>),分析内存段占用;
- free/top:查看系统/进程内存使用概况(如 top -p <pid> 查看进程RES(物理内存)、VIRT(虚拟内存)占用)。
(3)IO性能优化
核心目标:减少IO等待时间,提升IO吞吐量,避免IO成为瓶颈(Linux下IO通常指磁盘IO和网络IO)。
磁盘IO优化:
1. 减少IO次数:
- 批量读写:将多次小IO合并为一次大IO(如用 fread/fwrite 代替 getc/putc,设置更大的缓冲区);
- 预读/预写:使用 posix_fadvise 或 madvise 告知内核提前读取数据到缓存(预读)或延迟写入数据(预写),减少IO等待;
- 文件系统优化:使用ext4/xfs等支持日志模式的文件系统,关闭不必要的日志(如 mount -o noatime 关闭文件访问时间记录,减少写IO)。
2. 提升IO并行性:
- 使用异步IO(AIO):通过 io_setup/io_submit/io_getevents 等函数实现异步磁盘IO,避免线程阻塞在IO等待(适合高并发IO场景);
- 多磁盘RAID:将数据分散到多个磁盘,并行读写(如RAID 0提升吞吐量,RAID 5兼顾性能和可靠性)。
网络IO优化:
1. 减少网络交互次数:
- 数据批量发送:合并多次小数据包(如TCP关闭Nagle算法 setsockopt(sockFd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt)),但需结合业务场景,避免频繁小数据包);
- 长连接复用:使用TCP长连接代替短连接(如HTTP/2、WebSocket),减少TCP三次握手/四次挥手的开销。
2. 提升IO处理效率:
- 使用IO多路复用:用 epoll 代替 select/poll,处理大量并发连接(如Nginx、Redis的网络模型);
- 零拷贝(Zero-Copy):通过 sendfile/mmap 减少数据在内核态和用户态之间的拷贝(如文件服务器发送文件时,`sendfile` 直接从内核缓冲区发送数据,无需用户态拷贝);
- 调整TCP参数:如增大TCP接收/发送缓冲区(SO_RCVBUF/SO_SNDBUF),减少TCP重传(调整 tcp_retries2、tcp_syn_retries 等内核参数)。
常用工具:
- iostat:查看磁盘IO使用率、吞吐量(如 iostat -x 1 每秒输出一次磁盘IO详情);
- iotop:按IO使用率排序进程,定位磁盘IO密集型进程;
- tcpdump/wireshark:抓取网络数据包,分析网络交互(如是否有大量小数据包、重传);
- netstat/ss:查看网络连接状态(如 ss -tulnp 查看TCP/UDP连接,netstat -s 查看TCP统计信息如重传次数);
- iftop:实时查看网络带宽使用情况,定位带宽占用高的连接。

查看1道真题和解析