基础知识
main 函数之前之后
在C或C++中,main函数之前会执行一些初始化操作,例如:
- 静态变量的初始化。
- 标准库的初始化。
- 运行时库的初始化。
- 堆栈的初始化。
- 虚拟存储管理的初始化。
- 设备驱动的加载。
在main函数执行完成后,会执行一些清理操作,例如:
- 程序退出时,各个静态变量和全局变量的析构函数被调用。
- 释放堆栈所占用的内存和操作系统所占用的资源。
- 关闭打开的文件和网络连接等。
- 关闭操作系统所提供的调试信息输出。
- 调用_exit()函数来结束进程。
总结
- 预处理器指令:在编译过程中,预处理器会处理以#开头的指令,例如#include,#define等等。
- 静态变量初始化:编译器会对全局或静态变量进行初始化,非静态变量不会被初始化。但是,在C++11之后,可以使用以下方式显式地初始化非静态全局变量:
int a = 0;
- 导入其他库:如果程序需要使用其他库的函数或变量,那么需要使用#include指令将这些库包含进来。
- 函数原型声明:在main函数之前,可以声明其他函数的原型,包括函数名、参数类型和返回类型。这样可以允许程序在调用函数前检查函数的声明是否可以被正确识别。
- 程序入口点:在main函数被调用之前,程序会先执行操作系统指定的程序入口点。这个入口点是程序启动时第一个被执行的函数,它通常在C运行时库中定义。
- 函数调用:当程序调用一个函数时,会将当前函数的返回地址和所有的参数压入栈中。被调用函数执行完后,将返回值压入栈中,并跳回到返回地址,继续执行调用函数。
- 对全局对象的构造:在C++中,全局对象的构造函数会在main函数之前执行。这些对象的创建顺序是根据它们在程序中的定义顺序。
- 程序退出:当main函数执行结束之后,程序会执行一个清理过程,包括清理内存和关闭打开的文件等。在C++中,全局对象的析构函数会在此进行调用。
深拷贝浅拷贝
深拷贝和浅拷贝是C++中的两种常见的拷贝方式,它们的区别主要在于拷贝的内容和拷贝的方式。
- 浅拷贝:浅拷贝只是复制了指针的值,而不是复制指针指向的内容。这意味着原始对象和拷贝对象共享同一块内存,如果其中一个对象更改了指向内存的值,那么另一个对象也会受到影响。
- 深拷贝:深拷贝会复制指针指向的内容。这意味着原始对象和拷贝对象拥有各自的独立的内存,彼此之间不会受到影响。
区别:
- 浅拷贝不会创建新的内存空间,只能用于传递指针或引用,不适用于需要独立拷贝的对象。
- 深拷贝会创建新的内存空间,适用于需要独立拷贝的对象。但是,由于需要分配新的内存空间,深拷贝比浅拷贝更耗费资源。
总之,使用深拷贝和浅拷贝最主要的区别在于是否拷贝了指针指向的内容,而不是指针本身。在使用时需要根据实际情况选择。
面向对象的三大特征
封装,继承,多态
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
继承:可以使用现有类的所有功能,并在不需要重新编写原来的类的情况下对这些功能仅从扩展
三种继承方式
继承方式 private protected public 基类的private成员 不可见 不可见 不可见 基类的protected成员 变为private成员 仍为protected成员 仍为protected成员 基类的public成员 变为private成员 变为protected成员 仍为public成员 多态:用父类型的指针指向子类型的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有两种方式,重写,重载。
展开讲讲重写、重载
- 重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
- 重写:是指子类重新定义父类虚函数的方法。
虚函数
C++中的虚函数是用于实现多态的一种机制。它允许在派生类中重写基类中的函数实现。当通过基类指针或引用来调用虚函数时,会根据对象的实际类型来调用相应的函数实现,而不是在编译时确定。这种特性使得程序可以在运行时表现出不同的行为,从而提高了程序的灵活性和可扩展性。
在C++中,虚函数(virtual function)是实现多态性(polymorphism)的一种关键机制。多态性允许对象通过父类的指针或引用来调用子类的函数实现,这在面向对象编程中非常有用,特别是在需要动态行为的情况下。
基本概念
虚函数:在基类中,使用virtual关键字声明的成员函数称为虚函数。虚函数允许在派生类中重写(override)该函数的实现。
虚函数表(vtable):编译器为每个包含虚函数的类生成一个虚函数表,该表存储了类中所有虚函数的地址。当通过基类指针或引用调用虚函数时,程序会查找虚函数表来确定要调用的具体函数。
虚析构函数:如果基类包含虚函数,通常建议将基类的析构函数也声明为虚函数,以确保当通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,避免资源泄漏。
关键点
virtual 关键字:在基类中使用 virtual 关键字声明函数,使其成为虚函数。
函数重写:在派生类中使用相同的函数签名来重写虚函数。C++11 引入了 override 关键字,显式表明一个函数是用来重写基类中的虚函数的。
多态性:通过基类指针或引用调用虚函数时,会根据对象的实际类型来调用相应的函数实现。
虚析构函数:确保派生类对象在通过基类指针删除时能够正确析构,避免资源泄漏。
注意事项
虚函数有一定的性能开销,因为需要通过虚函数表来查找函数地址。
虚函数在运行时解析,而不是编译时。
虚函数必须是类的成员函数,不能是静态函数或全局函数。
构造函数不能是虚函数,因为对象在构造过程中,其类型还未完全确定。
虚函数是如何实现的?
虚函数是通过虚函数表(vtable)来实现的。编译器为每个包含虚函数的类生成一个虚函数表,该表存储了类中所有虚函数的地址。当通过基类指针或引用来调用虚函数时,程序会查找虚函数表来确定要调用的具体函数。虚函数表的使用使得程序可以在运行时确定要调用的函数,从而实现了多态性。
析构函数可以是虚函数吗?为什么?
析构函数可以是虚函数,而且通常建议将基类的析构函数声明为虚函数。这是因为当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这会导致派生类对象中的资源无法被正确释放,从而造成内存泄漏。将基类的析构函数声明为虚函数可以确保在删除对象时能够正确调用派生类的析构函数,从而避免资源泄漏的问题。
构造函数可以是虚函数吗?为什么?
构造函数不能是虚函数。这是因为对象在构造过程中,其类型还未完全确定,因此无法确定要调用的虚函数是哪个。此外,构造函数的主要作用是初始化对象,而虚函数表是在对象构造完成后才建立的,因此在构造函数中无法使用虚函数表来查找和调用虚函数。因此,构造函数不能是虚函数。
纯虚函数和抽象类是什么?它们之间的关系是什么?
纯虚函数是在基类中声明的虚函数,但在基类中并没有给出具体的实现,而是用“=0”来标识。含有纯虚函数的类被称为抽象类。抽象类不能实例化,但可以作为基类供其他类继承。派生类必须实现基类中所有的纯虚函数,否则派生类也将成为抽象类,无法实例化。纯虚函数和抽象类是实现多态性的一种重要手段,它们允许程序员定义一种接口或规范,然后让不同的派生类来实现这些接口或规范,从而实现不同的功能。
为什么基类的析构要设置成虚析构
基类的析构函数应当设置成虚析构函数,是为了确保在子类对象被销毁时,能够正确的调用子类对象的析构函数和基类对象的析构函数。如果基类的析构函数不是虚析构函数,则在删除子类对象时只会调用子类的析构函数,而不会调用基类的析构函数,这会导致内存泄漏和对象状态不一致的问题。
当一个类有虚函数时,编译器会在该类中自动生成一个虚函数表(vtable),该虚函数表记录了虚函数的地址。在派生类中,如果重新定义了基类的虚函数,则该虚函数表中的对应项将被更新为派生类的虚函数地址。因此,当删除派生类对象时,会先调用派生类的析构函数,然后再调用基类的析构函数,确保所有的资源被正确释放。
在实际使用中,如果一个类被设计成可派生的,其析构函数应当总是设置为虚析构函数,以确保派生类对象的正确销毁。
虚继承
虚继承是面向对象编程中的一种技术,特别是在C++中,它用于解决多重继承中可能产生的二义性和数据冗余问题。当一个类从多个基类继承,而这些基类又有一个共同的基类时,就可能会产生菱形继承结构。在菱形继承中,如果不使用虚继承,那么派生类中将会有多份从共同基类继承来的数据成员和成员函数,这会导致数据冗余和二义性。
虚继承通过在继承声明中使用virtual
关键字来指定一个基类为虚基类。这样,无论这个基类在继承体系中被继承了多少次,派生类中只会有一份该基类的数据成员和成员函数。虚继承在派生类中引入了一个虚基类表指针(virtual base table pointer),该指针指向一个虚基类表(virtual table),表中记录了虚基类与本类的偏移地址。通过偏移地址,派生类可以访问到虚基类的成员,从而避免了数据冗余和二义性。
菱形继承
菱形继承是一种特殊的继承结构,它描述了两个子类继承自同一个父类,而又有另一个子类同时继承这两个子类的情况。这种继承结构形成了一个菱形的形状,因此得名菱形继承。
在菱形继承中,如果不使用虚继承,那么最底层的派生类将包含多份从共同基类继承来的数据成员和成员函数。这会导致以下问题:
- 数据冗余:最底层的派生类中会有多份从共同基类继承来的数据成员,这浪费了存储空间。
- 二义性:当最底层的派生类试图访问从共同基类继承来的成员函数时,编译器无法确定应该调用哪一个版本的函数,因为存在多个版本的函数可供选择。
为了解决这些问题,可以使用虚继承来将共同基类指定为虚基类。这样,最底层的派生类中只会有一份从共同基类继承来的数据成员和成员函数,从而避免了数据冗余和二义性。
struct
和 class
的区别
C++ 中,struct
和 class
是两种定义类的方式。它们的主要区别在于默认的访问权限和默认的继承方式。
- 访问权限的区别:
struct
的默认访问权限为public
,而class
的默认访问权限为private
。因此,如果使用struct
定义一个类,那么该类的成员变量和成员函数默认是公共的,可以被外部访问。而如果使用class
定义一个类,那么该类的成员变量和成员函数默认是私有的,不能被外部访问。 - 继承方式的区别:使用
struct
定义的类的默认继承方式是public
,而使用class
定义的类的默认继承方式是private
。因此,当使用struct
继承另一个类时,该类的公共成员和保护成员都会被继承,而当使用class
继承另一个类时,该类的公共和受保护成员都将被继承为私有成员。
除了上述的两个区别外,struct
和 class
的其他方面基本相同。在实际编程中,可以根据需要灵活选择使用 struct
或 class
定义类。
内存相关
内存管理
在C++中,内存管理是程序员负责的重要任务之一,它们包括内存分配和释放操作,以确保应用程序能够正确和高效地使用系统的内存资源。
C++提供了几种内存管理方式:
- 栈内存管理:C++中的自动变量(局部变量)通常在函数的栈帧上分配和释放,由编译器负责管理。当函数执行完毕时,栈上的自动变量会自动被释放,无需手动释放内存。
- 堆内存管理:通过new运算符在堆上分配动态内存,通过delete运算符释放已分配的内存。堆内存的生命周期必须手动管理,否则可能导致内存泄漏或者使用已释放的内存。
- 智能指针:C++11引入了智能指针,如std::shared_ptr和std::unique_ptr,它们可以自动管理堆内存,避免了手动释放内存的工作。智能指针使用引用计数技术来追踪指针的引用数量,当引用计数为零时,自动释放相关的内存。
- RAII(资源获取即初始化):RAII是一种C++编程技术,通过在对象的构造函数中获取资源,在析构函数中释放资源,确保资源的正确分配和释放。RAII可用于管理任何资源,包括内存、文件、网络连接等。
- 自定义的内存管理:C++还允许通过重载new和delete运算符来自定义内存管理方式。可以自定义内存分配算法、内存池等以满足特定需求。
在进行内存管理时,需要注意以下几点:
- 内存泄漏:未释放已分配的内存,导致内存无法再被使用。应确保在不再需要时及时释放内存。
- 悬空指针和野指针:使用已释放的内存或未初始化的指针,可能导致程序崩溃或产生不可预料的行为。应确保指针的有效性,避免使用无效指针。
- 内存越界:当使用指针访问超出其所指向内存范围的数据时,可能会访问到未分配的内存或者破坏其他数据。应确保指针的有效性,避免越界访问。
- 数据竞争:多线程环境下,对共享内存的访问操作需要进行同步,以避免数据竞争和未定义行为的发生。
内存分区
在C++中,内存分为五个区,它们分别是堆,栈,自由存储区,全局/静态存储区和常量存储区
- 堆,由程序员进行分配释放,就是那些由 new 分配的内存块,一般一个 new 对应一个 delete
- 栈,由操作系统自动分配释放,存放函数的参数值,局部变量的值等,函数结束时这些存储单元被自动的释放
- 自由存储区,就是由 malloc 分配的内存块,一般一个 malloc 对应一个 free
- 全局/静态存储区:全局变量和静态变量被分配在同一块内存中
- 常量存储区:这是一块比较特殊的存储区,里面存储的是常量,不允许修改
编译
C/C++ 编译时和运行时的内存区域分配
先来说下 C 程序编译内存分配:
- 栈区(stack):存放局部变量和参数,申请和释放都由编译器自动完成。
- 堆区(heap):动态内存分配,申请和释放都是由程序员控制。
- 静态区/全局区(static):存放全局变量和静态变量。下面存放了未初始化的静态/全局变量。
- 文字常量区:存放字符串常量的。比如,char *p = “my name is”。“my....is!”字符串常量就存放那个区域。
- 代码区(code):用来存放代码的。
第二种是程序在运行时内存分配,程序在进程中的内存分配区域。 从高地址到地址:
- 环境变量(Unix/Linux中全局环境变量)
- stack区,存放内容和上文同。
- heap区,存放内容和上文同。值得说明的是:stack区起始地址是在高地址,即是从高地址向低地址延伸。而heap区起始地址是在低地址,即是从低地址向高地址延伸。总结:stack起始地址固定在高地址,heap起始地址固定在低地址,然后两个区都向中间延伸。直到stack区和heap区的结束地址重合则表示没有stack和heap内存空间了。
- data区,分为bss未初始化的数据区和初始化的数据区。
- 文本(text)区,存放代码的区域。
编译时与运行时的内存情况
- 编译时不分配内存
编译时是不分配内存的。此时只是根据声明时的类型进行占位,到以后程序执行时分配内存才会正确。所以声明是给编译器看的,聪明的编译器能根据声明帮你识别错误。 - 运行时必分配内存
运行时程序是必须调到“内存”的。因为CPU(其中有多个寄存器)只与内存打交道的。程序在进入实际内存之前要首先分配物理内存。 - 编译过程
只能简单说一下,因为如果要详细的话,就是一本书了《编译原理》。编译器能够识别语法,数据类型等等。然后逐行逐句检查编译成二进制数据的obj文件,然后再由链接程序将其链接成一个EXE文件。此时的程序是以EXE文件的形式存放在磁盘上。 - 运行过程
当执行这个EXE文件以后,此程序就被加载到内存中,成为进程。此时一开始程序会初始化一些全局对象,然后找到入口函数(main()或者WinMain()),就开始按程序的执行语句开始执行。此时需要的内存只能在程序的堆上进行动态增加/释放了。
C++ 编译流程
C++的编译流程是一个将源代码转换为可执行文件的过程,它主要包括以下四个阶段:预处理、编译、汇编和链接。以下是对每个阶段的详细解释:
预处理(Preprocessing)
预处理是编译过程的第一阶段,主要处理源代码中的预处理指令。这些指令通常以“#”开头,如#include
、#define
、#ifdef
等。预处理器会执行以下操作:
- 文件包含(头文件处理):处理
#include
指令,将指定的头文件内容插入到#include
指令所在的位置。这通常用于引入库文件或自定义的头文件。 - 宏替换:处理
#define
指令,定义宏并在后续代码中进行宏替换。这允许程序员在代码中使用简短的符号来表示复杂的常量或表达式。 - 条件编译:处理条件编译指令(如
#ifdef
、#ifndef
、#else
、#endif
等),根据条件编译特定的代码块。这适用于在不同平台或配置下包含不同的代码。
预处理后,源代码将被转换为预处理过的源代码,通常以.i
或.ii
为扩展名,然后进入下一阶段。
编译(Compilation)
编译阶段是将预处理过的源代码转换为汇编代码的过程。编译器会执行以下操作:
- 词法分析:将源代码拆分为一系列的单词(Token),包括关键字(如
int
、class
、return
)、标识符(如变量名、函数名)、常量(如数字、字符串)和运算符(如+
、-
、*
)等。 - 语法分析:根据C++语法规则,将单词组织成语法树(Parse Tree)。语法树是源代码的一种树状表示形式,它反映了源代码的结构和逻辑层次。
- 语义分析:检查语法树中的类型、作用域等语义信息,确保代码符合C++语言规范。这包括类型检查、作用域分析、符号表构建等。
- 代码生成:将语法树转换为目标平台的汇编代码。这个过程是整个程序构建的核心部分之一,也是最复杂的部分之一。生成的汇编代码通常以
.s
或.asm
为扩展名。
汇编(Assembly)
汇编阶段是将汇编代码转换为目标文件(Object File)的过程。汇编器会将汇编代码翻译为机器指令,并生成目标文件。目标文件是一种二进制文件,包含了编译后的机器指令和符号信息(如变量名、函数名等)。汇编后,源代码将被转换为目标文件,通常以.o
或.obj
为扩展名,然后进入下一阶段。
链接(Linking)
链接阶段是将多个目标文件和库文件链接为一个可执行文件的过程。链接器会执行以下操作:
- 符号解析:解析目标文件中的符号引用,找到对应的符号定义。这涉及到查找和匹配不同目标文件中的符号。
- 重定位:调整代码和数据的地址,使它们在内存中具有正确的相对位置。这涉及到更新目标文件中的重定位信息,以确保符号在最终的可执行文件或库文件中具有正确的地址。
- 库文件链接:
- 静态库链接:将静态库中被引用的目标文件合并到可执行文件中。静态库是目标文件的集合,链接时直接将库中的代码复制到可执行文件中。
- 动态库链接:在可执行文件中添加对动态库的引用。动态库在程序运行时被加载,链接时只记录库名和符号信息,不将库中的代码复制到可执行文件中。
链接后,源代码将被转换为可执行文件,通常以.exe
(Windows)或无扩展名(Linux和macOS)为扩展名。程序员可以运行可执行文件来执行C++程序。
综上所述,C++的编译流程包括预处理、编译、汇编和链接四个阶段。每个阶段都有其特定的任务和作用,共同完成了从源代码到可执行文件的转换过程。
new delete, malloc free
malloc
和 new
的区别:
malloc
是 C/C++ 中的函数,用于动态分配内存,返回void*
类型的指针。需要手动指定内存大小,并且不会自动调用构造函数。new
是 C++ 中的运算符,用于动态分配内存,返回指定类型的指针。可以自动调用构造函数,不需要手动指定内存大小。
free
和 delete
的区别:
free
是 C/C++ 中的函数,用于释放malloc
动态分配的内存。delete
是 C++ 中的运算符,用于释放new
动态分配的内存,同时会自动调用析构函数。
需要注意的是,如果使用 new 来分配内存,必须使用 delete 来释放;如果使用 malloc 来分配内存,必须使用 free 来释放。否则会出现内存泄漏或者段错误等问题。同时,使用 new 分配内存时还需要注意对于数组类型需要使用 delete[] 来释放,而不是单独的 delete 运算符。
静态成员函数可以直接访问非静态数据成员吗?
不可以,静态成员函数只是和类实现了绑定,而没有和任何对象绑定在一起,不包含this指针,无法访问静态成员。(静态成员函数所需内存在程序执行前就分配好了,给静态成员必须要等到这个类在堆/栈上分配内存才能使用,所以如果静态成员函数访问非静态,可能非静态成员还没有内存)
网络相关
socket 编程了解吗
服务器端函数:
- socket创建一个套接字
- bind绑定ip和端口
- listen使套接字变为可以被动链接
- accept等待客户端的连接
- read/write接收发送数据
- close关闭连接
客户端函数:
- 创建一个socket,用socket()
- 连接服务器用connect()
- 收发数据用read/write()
- close关闭连接
TCP UDP 区别
TCP(传输控制协议)和 UDP(用户数据报协议)是两种协议,它们用于在计算机网络中传输数据。它们有以下不同点:
- 可靠性:TCP是一种可靠的协议,它在数据传输过程中会监控数据是否到达目的地;而UDP是无连接的,不提供保证,数据包可能会丢失或到达顺序会被打乱。
- 速度:UDP比TCP快,因为UDP不需要额外的时间去确认数据是否到达目的地,也不用等待重新发送数据。
- 连接性:TCP是面向连接的协议,需要在发送数据之前进行连接,当连接建立后,数据才能够传输。而UDP是无连接的协议,不需要进行连接,数据包可以直接发送到目的地。
- 包头大小:TCP的包头比UDP大,需要占用一定的网络带宽,因此在特定情况下,UDP可能需要更少的带宽。
综上所述,TCP适合要求数据可靠传输的场景,例如网页浏览、文件传输等;而UDP适合用于要求快速传输的实时应用,例如在线游戏、音频视频传输等。
TCP(Transmission Control Protocol,传输控制协议)和UDP(User Datagram Protocol,用户数据报协议)是OSI模型中运输层(或称为传输层)的两个重要协议,它们在网络通信中扮演着不同的角色。以下是TCP和UDP的主要区别以及TCP的粘包问题详解:
TCP和UDP的区别
- 面向连接与无连接:
- TCP是面向连接的协议,这意味着在数据传输之前,需要建立一条可靠的连接通道。这种连接通过三次握手过程建立,并在数据传输结束后通过四次挥手过程释放。
- UDP是无连接的协议,它在数据传输之前不需要建立连接。每个UDP数据报都是独立的,不会保存连接状态。
- 可靠性:
- TCP提供可靠的数据传输服务。它使用确认、重传和流量控制等机制来确保数据的可靠传递。如果数据包丢失或损坏,TCP会重新发送丢失的数据包,直到数据成功传输到接收端。
- UDP则不提供可靠的数据传输服务。它不保证数据包能够成功到达接收端,也不提供确认、重传或流量控制等机制。因此,在数据传输过程中可能会丢失数据包或数据包的顺序可能会错乱。
- 有序性:
- TCP能够保证数据的有序传输。它使用序号和确认机制来确保数据按照发送的顺序正确地接收和组装。即使数据包乱序到达,TCP也会按照序号进行重组,保证数据传输的有序性。
- UDP则不关心数据包的顺序。接收端会按照接收到的顺序将数据包传递给上层应用,因此数据包可能会乱序到达。
- 传输效率:
- TCP的传输效率相对较低,因为它需要建立连接、进行确认和重传等操作,这些操作都会增加传输的延迟和开销。
- UDP的传输效率较高,因为它不需要建立连接和进行确认等操作,可以直接发送数据包。这使得UDP在实时通信和流媒体等应用中更为适用,因为这些应用对实时性要求较高,而不太在乎数据的有序性和可靠性。
- 资源需求:
- TCP要求系统资源较多,因为它需要维护连接状态、进行确认和重传等操作。
- UDP则要求系统资源较少,因为它不需要维护连接状态和进行确认等操作。
- 使用模式:
- TCP采用流模式进行数据传输,将数据看作一连串无结构的字节流。
- UDP则采用数据报模式进行数据传输,将数据划分为独立的报文进行传输。
TCP的粘包问题
TCP是基于字节流的协议,在传输过程中可能会出现粘包问题。粘包问题指的是发送方在传输数据时,TCP协议把多个发送的小数据包“粘”在一起,形成一个大的数据包发送;或者接收方在接收数据时,多个小的数据包被“粘”在一起,形成一个大的数据包接收。这种现象的发生是由于TCP协议的工作机制导致的。
TCP粘包问题的解决方案包括:
- 消息边界标记:在发送的消息中加入特定的消息边界标记(如换行符),接收端根据消息边界标记来分割接收到的数据,从而识别出完整的消息。但这种方法效率低,需要逐个字节接收并判断。
- 消息长度固定:发送端将每个消息的长度固定,接收端根据固定长度来分割接收到的数据。但这种方法灵活性差,不适用于消息长度不固定的情况。
- 消息头部长度字段:发送端在每个消息前加入一个固定长度的消息头部,包含消息的长度信息。接收端根据头部长度字段来读取对应长度的消息数据。这种方法较为灵活和高效,适用于大多数情况。
- 使用标准的应用层协议:如HTTP、HTTPS等,这些协议已经封装了不定长度的数据包,并提供了相应的解析方法。
TCP 丢包原因
如何确定是网络问题导致的丢包 如何抓包 如何排查
TCP丢包的原因可能有多种,比如网络拥塞、网络延迟、网络故障等等。要确定是网络问题导致的丢包,可以使用网络分析工具对网络质量进行测试,如ping
命令、traceroute
命令、网络性能监视器等。
抓包是通过网络分析工具对网络通信数据进行截取和分析,以获取网络通信中的详细信息。一般常用的网络抓包工具有Wireshark
、Tcpdump
等,在抓包时可以选择过滤条件,只捕获感兴趣的数据包,如指定IP地址、协议类型等。
排查TCP丢包可以采用如下步骤:
- 使用
ping
命令测试网络连通性,确认网络是否正常。 - 使用
traceroute
命令查看网络到目标主机的路径和延迟情况,判断是否存在路由故障或网络拥塞。 - 使用
Wireshark
或Tcpdump
等工具抓取网络数据包,分析丢包的原因并排除故障。 - 针对特定的应用程序或服务进行监控,查看哪些数据包丢失,分析丢包原因并解决问题。
TCP 网络丢包排查
网络丢包是指在数据传输过程中部分或全部数据丢失或损坏。针对 TCP 协议下的网络丢包,可以按以下步骤进行排查:
- 检查网络连接 首先要检查网络连接是否正常,包括网线或无线连接是否稳定,并尝试重新连接网络。
- 使用 ping 命令检查网络质量 使用 ping 命令检查网络是否畅通。如果 ping 的延迟很高或丢包率很高,那么网络质量就不好。
- 检查防火墙设置 网络防火墙会对网络通信进行限制。在排查网络丢包问题时,需要检查防火墙设置是否阻止了数据传输。
- 检查传输数据量 检查传输的数据量是否过大,导致网络拥堵和丢包。
- 检查发送和接收端的缓存区 如果发送和接收端的缓存区设置不当,会引起数据包的丢失,需要检查缓存区大小是否合适。
- 选择合适的网络协议 TCP 协议是面向连接的协议,因此它具有更好的可靠性和容错能力。但是在一些特定的场景下,选择 UDP 协议等其他协议可能更加适合。
- 找到丢包的原因并采取相应的措施 在找到丢包原因后,可以采取相应的措施,例如增加网络带宽,优化网络设置等。
三次握手 四次挥手
三次握手和四次挥手是TCP(传输控制协议)中用于建立和断开连接的重要过程。
三次握手是TCP协议中用于建立可靠连接的过程。它确保客户端和服务器之间的通信信道是可靠的,并且双方都已准备好进行数据传输。具体过程如下:
- 第一次握手:客户端向服务器发送一个带有SYN(synchronize,同步)标志的数据包,表示希望建立TCP连接。此时,客户端进入SYN_SENT状态,等待服务器的确认。
- 第二次握手:服务器收到客户端的SYN数据包后,会回传一个带有SYN/ACK(acknowledge,确认)标志的数据包,表示已接收到客户端的连接请求,并同意建立连接。此时,服务器进入SYN_RECV状态。
- 第三次握手:客户端收到服务器的SYN/ACK数据包后,会再次发送一个带有ACK标志的数据包,表示已确认服务器的连接请求。此时,客户端和服务器都进入ESTABLISHED(已建立连接)状态,表示TCP连接已经成功建立,双方可以开始进行数据传输。
在三次握手过程中,双方通过交换带有特定标志的数据包来确认对方的接收能力和发送能力,以及同步连接双方的序列号和确认号。这些步骤确保了连接的可靠性和准确性。
四次挥手是TCP协议中用于断开连接的过程。由于TCP连接是全双工的,因此每个方向都需要单独进行关闭。具体过程如下:
- 第一次挥手:客户端向服务器发送一个带有FIN(finish,结束)标志的数据包,表示希望关闭客户端到服务器的数据传送。此时,客户端进入FIN_WAIT_1状态,等待服务器的确认。
- 第二次挥手:服务器收到客户端的FIN数据包后,会发送一个带有ACK标志的数据包进行确认,表示已接收到客户端的关闭请求。此时,服务器进入CLOSE_WAIT状态,等待自己的数据传送完毕后再进行关闭。
- 第三次挥手:服务器在确认所有数据都已传送完毕后,会向客户端发送一个带有FIN标志的数据包,表示希望关闭服务器到客户端的数据传送。此时,服务器进入LAST_ACK状态,等待客户端的确认。
- 第四次挥手:客户端收到服务器的FIN数据包后,会发送一个带有ACK标志的数据包进行确认,表示已接收到服务器的关闭请求。此时,客户端进入TIME_WAIT状态,等待一段时间(通常为2MSL,即两倍的最大报文段生存时间)以确保服务器收到自己的确认报文后,才最终进入CLOSED状态,完成TCP连接的断开。
在四次挥手过程中,双方通过交换带有FIN和ACK标志的数据包来逐步关闭连接,并确保所有数据都已成功传输。这些步骤确保了连接的可靠断开和资源的正确释放。
TCP四次挥手的close_wait状态是在什么时候?
- 客户端打算关闭连接,此时会发送一个TCP报文,FIN标志被置为1,之后客户端进入FIN_wait_1状态
- 服务端收到该报文后,向客户端发送ACK报文,接着服务器进入CLOSED_WAIT状态
- 客户端收到服务端的ACK报文之后,进入FIN_wait_2状态
- 等待客户端处理完数据后,也向客户端发送FIN报文,之后服务端进入LAST_ACK状态
- 客户端收到服务器的FIN报文后,回一个ACK应答报文,之后进入TIME_WAIT状态
- 服务器接收到ACK应答报文后,就进入CLOSED状态,至此服务端已经完成连接的关闭
- 客户端在经过2MSL等待时间之后,自动进入CLOSED状态,至此客户端也完成连接的关闭
出现大量close_wait有什么影响,怎么排查?
出现大量CLOSE_WAIT的原因及解决办法:
如果一直保持在CLOSE_WAIT状态,那么只有一种情况,就是在对方关闭连接之后服务器程序自己没有进一步发出ack信号。换句话说,就是在对方连接关闭之后,程序里没有检测到,或者程序压根就忘记了这个时候需要关闭连接,于是这个资源就一直被程序占着。这种情况通过服务器内核参数也没办法解决,服务器对于程序抢占的资源没有主动回收的权利,除非终止程序运行。
所以如果将大量CLOSE_WAIT的解决办法总结为一句话那就是:查代码。因为问题出在程序里头啊。
异步/同步、阻塞/非阻塞 四个概念的关系与区别
异步与同步指的是进程或线程之间的执行顺序关系,而阻塞与非阻塞则指的是进程或线程在等待外部资源时的状态。
异步执行是指进程或线程发出某个操作后,不需要立即等待其结果,可以继续执行其他操作,待操作完成后会通过回调函数或事件通知的方式获取结果。同步执行是指进程或线程发出某个操作后,必须等待其结果返回,然后才能继续执行下一个操作。
阻塞是指进程或线程在等待某个操作完成时,暂时无法执行其他操作,会一直处于等待状态。非阻塞是指进程或线程在等待某个操作时,可以继续执行其他操作,不会被阻塞。
可以将这四个概念放入一个二维矩阵中进行对比和分类:
同步 | 异步 | |
---|---|---|
阻塞 | 阻塞同步 | 阻塞异步 |
非阻塞 | 非阻塞同步 | 非阻塞异步 |
- 阻塞同步:进程或线程发出操作后,必须等待操作完成才能继续执行下一个操作。
- 阻塞异步:进程或线程发出操作后,不需要立即等待操作结果,但执行其他操作时会被阻塞,直到获取到操作结果。
- 非阻塞同步:进程或线程发出操作后,可以立即执行其他操作,但必须在适当的时机主动查询操作结果。
- 非阻塞异步:进程或线程发出操作后,不需要立即等待操作结果,可以继续执行其他操作,待操作完成后会通过回调函数或事件通知的方式获取结果。
总结来说,异步/同步与阻塞/非阻塞是两个不同维度上的概念,前者是描述操作的执行顺序,后者是描述等待资源时的状态。在实际应用中,可以根据具体情况选择不同的方式来提高程序的效率和响应性能。
IO 多路复用了解吗
IO多路复用是一种通过一种机制,使得一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作的技术。Linux支持IO多路复用的系统调用主要有select、poll、epoll,以下是这三种调用的详细介绍:
select
- 跨平台性:select可以跨平台使用。
- 工作方式:select能够同时监视多个文件描述符是否就绪,只有监听的文件描述符就绪了或者超时了相应进程/线程才会被唤醒。
- 限制:
- select能够检测的连接数是有上限的,默认的是1024,超过这个数值就无法使用。这主要是由于bitmap有大小限制,32位操作系统一般为1024,64位操作系统一般为2048。
- 每次调用select时,都需要将用户态的文件描述符集合拷贝到内核态,这会带来一定的开销。
- select函数返回的是有几个描述符已经就绪了,用户需要再次遍历以找出具体哪些描述符就绪,时间复杂度为O(n)。
poll
- 平台限制:poll只能在Linux平台使用。
- 工作方式:poll的工作方式和select类似,但它在文件描述符集合的表示上做了优化,使用pollfd结构体来表示一个文件描述符及其感兴趣的事件。
- 改进:
- poll解决了select中bitmap大小限制的问题,理论上可以监视的文件描述符数量取决于操作系统的配置。
- 然而,poll仍然需要每次调用时都将文件描述符集合从用户态拷贝到内核态。
- 和select一样,poll函数返回后也需要用户再次遍历以找出具体哪些描述符就绪。
epoll
- 平台限制:epoll也只能在Linux平台使用。
- 工作方式:epoll是select和poll的增强版本,它使用红黑树来管理待检测的文件描述符集合,并使用了回调机制来通知用户哪些文件描述符已经就绪。
- 优势:
- epoll没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制。
- epoll使用红黑树来管理文件描述符,检测效率更高,且不会随着检测集合的变大而下降。
- epoll提供了epoll_wait函数,可以直接返回已就绪的文件描述符集合,无需用户再次遍历。
- epoll将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤分开,提高了效率。
总结
综上所述,select、poll、epoll都是Linux支持的IO多路复用机制,它们都可以使得一个进程能够同时监视多个文件描述符的状态。然而,它们在实现方式、性能限制和适用场景上有所不同。在选择使用哪种机制时,需要根据具体的应用场景和需求进行权衡。例如,如果需要跨平台使用,可以选择select;如果需要在Linux平台上处理大量并发连接,且希望获得更好的性能,那么epoll是更好的选择。
三者的原型如下所示:
int select(int nfds, fd_set *readfds,
fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout
);
int poll(struct pollfd *fds, nfds_t nfds,
int timeout
);
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout
);
线程与进程
线程与进程的区别
进程和线程是操作系统中用于并发执行任务的两种基本单位,它们之间存在显著的区别:
- 资源拥有情况:
- 进程是拥有资源的最小单位。每启动一个进程,系统会为其分配独立的地址空间,并建立数据段、堆栈段、代码段等,以维护其运行环境。
- 线程不拥有独立的地址空间,而是共享所属进程的地址空间。这意味着线程之间可以方便地共享数据,但也需要注意同步问题。
- 切换开销:
- CPU在切换进程时,需要保存和恢复进程的上下文,包括地址空间、堆栈指针、寄存器状态等,因此开销较大。
- 线程切换时,由于线程共享进程的地址空间,只需保存和恢复线程的上下文(如线程堆栈指针、寄存器状态等),因此开销相对较小。
- 通信与同步:
- 进程间通信(IPC)通常需要通过操作系统提供的通信机制(如管道、信号、消息队列、共享内存等)来实现,这些机制往往涉及内核态和用户态的切换,因此效率较低。
- 线程间通信则相对简单,因为它们共享进程的地址空间,可以直接访问共享变量、全局变量等。然而,这也带来了同步问题,需要使用互斥锁、读写锁、条件变量等同步机制来避免数据竞争。
- 独立性:
- 进程是独立的执行实体,拥有自己的地址空间和系统资源。一个进程的崩溃不会影响其他进程的运行。
- 线程则依赖于其所属的进程,一个线程的崩溃可能导致整个进程的崩溃(取决于操作系统的线程实现和线程的崩溃处理机制)。
- 创建与销毁:
- 创建一个新进程需要分配独立的地址空间、建立数据段等,因此开销较大。
- 创建一个新线程则只需在进程的地址空间中分配线程堆栈等少量资源,因此开销较小。同样地,销毁线程的开销也小于销毁进程。
多线程多进程区别
多线程和多进程都可以提高计算机的并发性能,但它们的技术实现和应用场景有所不同:
多线程:
- 线程是程序执行流的最小单元,一个进程可以包含多个线程;
- 多线程共享进程的内存空间,因此数据交换和通信比多进程更加方便高效;
- 多线程易于实现和调试,因此常用于并发度比较低的任务。
多进程:
- 进程是操作系统进行资源划分、调度的最小单位,一个进程包含一个或多个线程;
- 不同进程之间的内存空间是相互隔离的,数据交换和通信需要借助 IPC(进程间通信)技术,效率较低;
- 多进程相对于多线程更加稳定可靠,因此适用于需要高度并发的任务。
综上所述,多线程适用于并发度比较低、需要高效、简单易用的场景,多进程适用于需要高度并发、稳定可靠的场景。但并不是说它们是互斥的,实际上在很多场景下,多线程和多进程也可以结合使用,以提高计算机的并发性能。
详细说明
多线程和多进程是操作系统中用于实现并发执行的两种主要方式,它们之间存在一些关键的区别。以下是对多线程和多进程区别的详细阐述:
资源分配与独立性
- 资源分配:
- 多进程:进程是操作系统资源分配的基本单位。每个进程拥有独立的内存空间和系统资源(如文件句柄、网络连接等),这些资源在进程间是相互隔离的。
- 多线程:线程是CPU调度的基本单位,且线程并不拥有独立的资源。同一进程内的线程共享进程的资源,包括内存、文件句柄等。
- 独立性:
- 多进程:进程间相互独立,一个进程的崩溃不会影响其他进程的运行,提供了较高的安全性和隔离性。
- 多线程:线程是进程内的并发单位,一个线程的崩溃可能导致整个进程崩溃(尽管这取决于操作系统的线程实现和崩溃处理机制),从而影响同一进程内的其他线程。
开销与性能
- 创建与销毁开销:
- 多进程:由于需要分配独立的资源(如内存和文件句柄),创建和销毁进程的开销较大。
- 多线程:线程共享进程的资源,避免了重复的分配过程,因此创建和销毁线程的开销相对较小。
- 上下文切换开销:
- 多进程:进程间的上下文切换需要切换独立的地址空间,可能触发内存页表的切换,因此开销较大。
- 多线程:线程共享同一地址空间,无需切换内存页表,因此上下文切换开销较小,切换速度更快。
通信与同步
- 通信方式:
- 多进程:进程间通信(IPC)通常依赖操作系统提供的机制,如管道、消息队列、共享内存或套接字。这些机制较为安全,但通信效率相对较低。
- 多线程:线程共享同一进程的内存空间,可以直接访问和操作共享内存,因此通信更加简单和高效。然而,这也带来了同步问题,需要使用互斥锁、条件变量等同步机制来避免数据竞争和死锁等问题。
- 同步复杂性:
- 多进程:由于进程间资源相互隔离,同步问题相对简单。但进程间通信和同步机制的使用可能增加系统的复杂性。
- 多线程:由于线程共享进程的资源,同步问题变得复杂。需要仔细设计同步机制来确保数据的一致性和避免竞争条件。
适用场景
- 多进程:
- 适用于需要高隔离性、稳定性的场景,如独立的服务(如数据库服务、后台守护进程)和需要并发执行的独立任务。
- 适用于多核处理器环境,可以充分利用多核CPU的并行处理能力。
- 多线程:
- 适用于计算密集型或需要资源共享的场景,如需要快速响应用户请求的Web服务器、数据分析、游戏引擎和图形界面应用。
- 适用于需要高并发、快速响应的应用,因为线程间的上下文切换开销较小,可以更快地响应外部事件。
综上所述,多线程和多进程在资源分配、开销、通信与同步以及适用场景等方面存在显著差异。在选择使用多线程还是多进程时,需要根据具体的应用场景和需求进行权衡。
进程通信
进程间通信(IPC)是指不同进程之间传递信息或数据的过程。常见的进程通信方式包括:
- 管道:匿名管道只能用于父子进程之间的通信,而命名管道则可以在不相关的进程之间使用。
- 信号:信号是一种异步通信方式,用于通知进程某个事件的发生。信号可以携带少量信息,但不能用于大量数据的传输。
- 消息队列:消息队列克服了信号传递信息少的缺点,允许进程之间传递具有类型的数据。
- 共享内存:共享内存是一种高效的进程间通信方式,因为它允许进程直接访问同一块内存区域。然而,共享内存也带来了同步问题,需要使用信号量等同步机制来避免数据竞争。
- 套接字:套接字不仅可以用于本地进程之间的通信,还可以用于不同主机之间的网络通信。
线程通信
线程间通信通常通过共享内存来实现,因为线程共享进程的地址空间。常见的线程通信方式包括:
- 共享变量:线程可以直接访问共享变量来进行通信。然而,这需要注意同步问题,以避免数据竞争和死锁等问题。
- 消息队列:与进程间的消息队列类似,线程也可以使用消息队列来传递数据。这种方式可以实现线程之间的解耦和灵活通信。
- 管道:虽然管道通常用于进程间通信,但在某些情况下(如使用线程库提供的管道机制时),线程也可以使用管道来进行通信。
- 信号:在某些操作系统中,线程也可以接收和发送信号来进行通信。然而,由于信号的异步性和不可预测性,它通常不是线程通信的首选方式。
- 同步机制:互斥锁、读写锁、条件变量等同步机制也可以用于线程之间的通信和协调。例如,一个线程可以等待某个条件变量的触发来继续执行,而另一个线程则可以通过设置条件变量来通知等待的线程。
进程间同步
进程间同步是指多个进程在执行次序上的协调,以确保它们能够正确地访问共享资源或完成相互合作的任务。常见的进程间同步机制包括:
- 互斥锁:用于保护临界资源,防止多个进程同时访问导致数据竞争。
- 信号量:一种计数器类型的同步机制,用于控制多个进程对资源的访问。信号量可以是二元的(即0或1),也可以是计数的(表示可用资源的数量)。
- 条件变量:用于进程之间的协调和通信。一个进程可以等待某个条件的出现(如某个资源的可用性),而另一个进程可以触发该条件来通知等待的进程。
- 读写锁:一种特殊的锁机制,允许多个进程同时读取共享资源,但只允许一个进程写入资源。读写锁可以提高并发性,同时保证数据的一致性。
- 事件:在某些操作系统中,事件也可以用于进程间的同步。一个进程可以等待某个事件的发生来继续执行,而另一个进程则可以触发该事件来通知等待的进程。
线程间同步
线程间同步与进程间同步类似,但也有一些特殊之处。常见的线程间同步机制包括:
- 互斥锁:同样用于保护临界资源,防止多个线程同时访问导致数据竞争。线程库通常提供轻量级的互斥锁实现,以提高性能。
- 条件变量:与进程间的条件变量类似,用于线程之间的协调和通信。线程可以等待某个条件的出现或触发某个条件来通知其他线程。
- 读写锁:同样允许多个线程同时读取共享资源,但只允许一个线程写入资源。读写锁在多线程环境中非常有用,可以提高并发性和性能。
- 信号量:虽然信号量通常用于进程间同步,但在某些线程库中也可以用于线程间同步。信号量可以用于控制多个线程对资源的访问数量或顺序。
- 原子操作:在某些情况下,可以使用原子操作来实现线程间的同步。原子操作是不可分割的操作,它们在执行过程中不会被其他线程打断。通过使用原子操作来更新共享变量或执行其他关键操作,可以确保线程间的正确同步和数据一致性。
综上所述,线程与进程在资源拥有、切换开销、通信与同步等方面存在显著差异。进程间通信和同步通常涉及操作系统提供的通信机制和同步机制,而线程间通信和同步则更多地依赖于共享内存和线程库提供的同步机制。
生产者消费者线程池
生产者消费者模型是一种经典的线程同步模型,适用于多线程共享资源的情况。在这种模型中,生产者线程负责生产资源,消费者线程负责消费资源,线程池则负责管理这些线程。
线程池是一种重用线程的机制,它可以避免因频繁创建和销毁线程而产生的性能开销。在生产者消费者模型中,线程池可以管理生产者和消费者线程,确保它们按照预期的方式工作。线程池通常包括一个任务队列,当需要执行任务时,线程从任务队列中取出任务并执行。
在生产者消费者模型中,生产者线程将资源放入任务队列中,消费者线程从任务队列中取出资源进行消费。线程池负责管理这些线程的工作,保证生产者和消费者线程能够同步工作。线程池还可以控制线程的数量,以适应不同的负载情况。
在实现生产者消费者线程池时,需要考虑以下几个方面:
- 任务队列的设计:任务队列需要支持线程安全的操作,以确保多个线程可以同时访问它。可以使用锁或信号量等同步机制来实现线程安全。
- 线程池的实现:线程池可以使用线程池库来实现,也可以自行开发。线程池需要具备管理线程、调度任务、控制线程数量和维护任务队列等功能。
- 生产者和消费者的实现:生产者线程负责将资源放入任务队列中,消费者线程负责从任务队列中取出资源进行消费。生产者和消费者线程需要与任务队列进行交互,确保线程同步。
生产者消费者线程池模型是一种优秀的多线程协作模型,可以有效地处理多线程共享资源的情况。在实际应用中,可以根据需要对其进行细分和扩展。
在同一个进程中实现生产者消费者线程池
在同一个进程中,可以通过线程池来创建生产者和消费者线程。
首先,需要定义一个任务队列作为生产者和消费者之间的缓冲区,生产者可以向队列中添加任务,消费者可以从队列中取出任务进行处理。可以使用标准库中的队列容器来实现任务队列。
接着,定义一个线程池,可以使用C++11中的线程池库来实现,如ThreadPool库。
在生产者线程中,不断获取数据并将其添加到任务队列中;在消费者线程中,从任务队列中取出数据进行处理。可以在初始化线程池时指定生产者和消费者线程的数量,以控制并发度。
下面是一个简单的示例代码:
#include <queue>
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include "ThreadPool.h"
using namespace std;
queue<int> task_queue; // 任务队列
mutex mtx; // 互斥量
condition_variable cv; // 条件变量
void producer_func()
{
for (int i = 0; i < 10; i++)
{
unique_lock<mutex> lk(mtx);
task_queue.push(i); // 添加数据到任务队列
cv.notify_one(); // 通知消费者线程
}
}
void consumer_func()
{
while (true)
{
unique_lock<mutex> lk(mtx);
if (!task_queue.empty())
{
int task = task_queue.front(); // 取出队头任务
task_queue.pop();
lk.unlock();
cout << "Consumer thread " << this_thread::get_id() << " process task " << task << endl; // 处理任务
}
else
{
// 队列为空,等待生产者线程通知
cv.wait(lk);
}
}
}
int main()
{
ThreadPool thread_pool(2); // 创建线程池,包含2个线程
thread producer(producer_func); // 生产者线程
thread consumer1(consumer_func); // 消费者线程1
thread consumer2(consumer_func); // 消费者线程2
producer.join();
consumer1.join();
consumer2.join();
return 0;
}
需要注意的是,在多线程编程中,要注意线程安全问题,如加锁保护共享数据,避免竞争条件等问题。
多线程有哪些锁
互斥锁,自旋锁,读写锁,乐观锁,悲观锁
- 互斥锁:mutex,保证在任何时刻,都只有一个线程访问该资源,当获取锁操作失败时,线程进入阻塞,等待锁释放。
- 读写锁:rwlock,分为读锁和写锁,处于读操作时,可以运行多个线程同时读。但写时同一时刻只能有一个线程获得写锁。 互斥锁和读写锁的区别:
- 读写锁区分读锁和写锁,而互斥锁不区分
- 互斥锁同一时间只允许一个线程访问,无论读写;读写锁同一时间只允许一个线程写,但可以多个线程同时读。
- 自旋锁:spinlock,在任何时刻只能有一个线程访问资源。但获取锁操作失败时,不会进入睡眠,而是原地自旋,直到锁被释放。这样节省了线程从睡眠到被唤醒的时间消耗,提高效率。
- 条件锁:就是所谓的条件变量,某一个线程因为某个条件未满足时可以使用条件变量使该程序处于阻塞状态。一旦条件满足了,即可唤醒该线程(常和互斥锁配合使用)
- 信号量。
各个锁的具体实现
在C++中锁的实现通常通过操作系统提供的原语来实现。以下是常用锁的具体实现:
锁 | Windows | Linux |
---|---|---|
互斥锁(Mutex Lock) | CRITICAL_SECTION | pthread_mutex_t |
自旋锁(Spin Lock) | InterlockedExchange | pthread_spinlock_t |
读写锁(Read-Write Lock) | SRWLOCK | pthread_rwlock_t |
条件变量(Condition Variable) | CONDITION_VARIABLE | pthread_cond_t |
信号量(Semaphore) | Semaphore | sem_t |
屏障(Barrier) | InterlockedIncrement 和 InterlockedExchangeAdd | pthread_barrier_t |
以上是常见锁的实现方式,但实际上,每个操作系统和编译器中可能会有不同的具体实现方式。
什么情况会出现死锁,如何排查
死锁是指两个或多个进程,互相持有对方所需的资源,而导致它们都无法继续执行的状态。这种状态下,所有进程都会阻塞,无法向前推进,只有通过外部干预才能解决。
下面是几种情况可能会导致死锁:
- 互斥条件:至少有一个资源必须处于非共享模式,即每次只能被一个进程使用,这时若有多个进程要求使用资源,便会发生死锁。
- 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而这些新资源已被其他进程占有,导致进程无法释放它原本占有的资源,也无法获得新的资源,最终导致死锁。
- 不剥夺条件:某些资源不可被抢占,只能由占有它的进程自行释放。
- 循环等待条件:存在一种进程资源等待环路,即进程集合
{P0,P1,P2,…,Pn}
中,P0等待一种资源,同时正在被P1占用,P1等待另一种资源,同时正在被P2占用,P2等待P0占用的资源,这时候就会形成环路,导致死锁。
排查死锁的方法有以下几步:
- 确认是否发生死锁,可以通过系统日志或其他工具检查。
- 分析死锁的原因,可以通过查看进程或线程的资源占用情况,确认是否存在资源竞争或循环等待等情况。
- 解除死锁,可以通过释放资源或者强制终止占用资源的进程来解除死锁。
- 预防死锁,可以通过合理的资源分配和调度策略,以及避免循环等待等措施来预防死锁的发生。
互斥锁与自旋锁
加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
自旋锁是通过CPU提供的CAS(Compare And Swap)函数,在用户态完成加锁和解锁的操作,不会主动产生线程上下文切换,所以相比互斥锁,会快一点,开销也小一点
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。
自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
读写锁
读写锁由【读锁】和【写锁】组成,只读取共享资源1用【读锁】加锁,如果要修改共享资源则用【写锁】加锁。
读写锁的工作原理是:
- 当【写锁】没有被线程占用时,【读锁】可以多线程并发持有
- 当【写锁】被线程占用时,读线程的获取读锁的操作会被阻塞,其他写线程的获取写锁的操作也会被阻塞
乐观锁和悲观锁
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
分布式有了解吗
STL
STL 容器简单介绍,以及其使用场景
STL(Standard Template Library)是C++标准库中的一部分,提供了丰富且高效的容器类模板,用于存储和操作不同类型的数据。
STL容器可以分为以下几类:
序列容器(Sequence Containers):包括vector、deque、list、forward_list和array,以及C++11新增的string。序列容器以线性的方式存储元素,可以在任意位置插入和删除元素。
- vector:可变大小的数组,支持快速的随机访问和尾部插入/删除操作。适用于需要频繁随机访问的情况。
- deque:双端队列,支持在头部和尾部进行插入/删除操作。适用于频繁在头部和尾部插入/删除元素的情况。
- list:双向链表,支持在任意位置进行插入/删除操作。适用于频繁在任意位置插入/删除元素的情况。
- forward_list:单向链表,只支持在头部进行插入/删除操作。适用于频繁在头部插入/删除元素的情况。
- array:固定大小的数组,大小在编译时确定,不支持增删元素操作。适用于大小固定且需要在编译时确定的情况。
- string:C++中的字符串类,继承自vector,提供了更多与字符串相关的功能。
关联容器(Associative Containers):包括set、multiset、map、multimap,在C++11中还新增了unordered_set、unordered_multiset、unordered_map、unordered_multimap。关联容器使用二叉搜索树或哈希表来存储元素,可以实现快速的查找、插入和删除操作。
- set:有序集合,存储不重复的元素。适用于需要有序且元素不重复的情况。
- multiset:有序集合,可以存储重复的元素。适用于需要有序且元素可以重复的情况。
- map:键值对映射,根据键快速查找对应的值。适用于需要根据键查找值的情况。
- multimap:键值对映射,可以存储多个相同键的值。适用于需要键可以重复的情况。
- unordered_set:无序集合,存储不重复的元素,使用哈希表实现。适用于不需要有序的情况。
- unordered_multiset:无序集合,可以存储重复的元素,使用哈希表实现。适用于不需要有序且元素可以重复的情况。
- unordered_map:键值对映射,根据键快速查找对应的值,使用哈希表实现。适用于不需要有序的情况。
- unordered_multimap:键值对映射,可以存储多个相同键的值,使用哈希表实现。适用于不需要有序且键可以重复的情况。
容器适配器(Container Adapters):包括stack、queue和priority_queue。容器适配器是对底层容器的封装,提供了特定功能的接口。
- stack:栈,后进先出(LIFO)的数据结构。底层容器默认是deque。
- queue:队列,先进先出(FIFO)的数据结构。底层容器默认是deque。
- priority_queue:优先队列,每次取出的元素都是最大(或最小)的元素。底层容器默认是vector。
STL容器提供了丰富的功能和算法,适用于不同的使用场景,可以根据具体需求进行选择。例如,vector适用于需要频繁访问元素的情况;map适用于根据键查找值的情况;stack适用于后进先出的操作等等。
各种容器的实现方式
以下是一些常见C++容器的底层实现概述:
std::vector
动态数组:std::vector底层实现是一个连续的动态数组。
内存管理:通过一块连续的内存存储元素,当容量不足时,会分配更大的内存块(通常是当前容量的两倍)并将元素移动到新内存。
快速随机访问:由于元素存储在连续的内存中,可以通过索引实现O(1)时间复杂度的快速随机访问。
增长策略:vector利用“几何增长”策略减少内存重分配的频率。
std::map
红黑树:std::map通常使用红黑树(一种自平衡的二叉搜索树)来实现。
自动排序:元素以键排序,支持按键快速查找。
时间复杂度:插入、删除、查找操作平均和最坏时间复杂度为O(log n)。
迭代顺序:以键的升序迭代。
std::unordered_map
哈希表:底层采用哈希表实现。
快速访问:平均时间复杂度为O(1)。
无序存储:不保证键值对存储的顺序。
冲突解决:通常使用链地址法或开放地址法。
std::set
红黑树:类似于std::map,std::set使用红黑树存储唯一元素。
自动排序:按照元素的自然顺序或者自定义比较函数排序。
时间复杂度:查找、插入和删除操作平均和最坏时间复杂度为O(log n)。
std::unordered_set
哈希表:使用哈希表实现。
快速访问:类似于unordered_map。
无序存储:不保证顺序,平均时间复杂度为O(1)。
std::deque
双端队列:类似于vector,但支持在两端进行高效的插入和删除。
分段连续数组:由多个固定大小的块组成,各个块集中管理。
std::list
双向链表:每个节点包含指向前一个和后一个节点的指针。
高效插入/删除:在任意节点位置实现O(1)插入和删除。
这些实现方式使得每种容器都适合于不同的任务,根据时间和空间性能需求选择合适的数据结构来优化代码。
vector 具体实现
vector 空间不够怎么办,怎么分配128k的空间
在 std::vector 的底层实现中,当空间不够时,会进行以下步骤来重新分配内存:
容量检查:当你添加新的元素并且 vector 当前的容量已满时,vector 会触发扩展机制。
空间扩展:通常vector会按照一定的增长策略增加容量,以减少频繁的内存分配成本。常见的策略是将当前容量加倍,即 new_capacity = current_capacity * 2
。
分配新内存:分配新容量大小的内存。如果需要特定大小,比如128k内存,可以手动通过 reserve()
方法设置。例如,vec.reserve(128 * 1024 / sizeof(T))
,这里的 T 是 vector 的元素类型。
移动元素:将旧内存的所有元素移动到新内存中。大多数情况下,这需要逐个复制元素,不过C++11后如果元素支持移动语义,可以进行更高效的移动操作。
释放旧内存:释放原有内存。
这样,通过 reserve() 方法, std::vector 就会确保有足够的空间来存储指定数量的元素。
stl 模板库中 sort 的时间复杂度
STL 中的 std::sort 通常使用的是一种混合排序算法,通常为快速排序(Quicksort)、堆排序(Heapsort)和插入排序(Insertion Sort)的组合。
平均时间复杂度:O(n log n)
最坏时间复杂度:O(n log n),通过使用堆排序(或类似方式)保障最坏情况下退化到O(n log n)。
空间复杂度:通常为O(log n)用于递归调用栈(主要在递归实现中)。
这种组合方法旨在提供快速排序的平均性能,并确保在最坏情况下的性能不会退化。
指针的具体实现方式
数据互斥
在多线程存在的环境中,除了堆栈中的临时数据之外,所有的数据都是共享的。如果我们需要线程之间正确地运行,那么务必需要保证公共数据的执行和计算是正确的。简单一点说,就是保证数据在执行的时候必须是互斥的。否则,如果两个或者多个线程在同一时刻对数据进行了操作,那么后果是不可想象的。
todo todo todo
那么,有什么办法可以保证在某一时刻只有一个线程对数据进行操作呢?四个基本方法:
- 关中断
- 数学互斥方法
- 操作系统提供的互斥方法
- cpu原子操作
关中断
要让数据在某一时刻只被一个线程访问,方法之一就是停止线程调度就可以了。那么怎样停止线程调度呢?那么关掉时钟中断就可以了啊。在X86里面的确存在这样的两个指令
#include <stdio.h>
int main () {
__asm {
cli
sti
}
return 1;
}
其中cli是关中断,sti是开中断。这段代码没有什么问题,可以编过,当然也可以生成执行文件。但是在执行的时候会出现一个异常告警:Unhandled exception in test.exe: 0xC0000096: Privileged Instruction。告警已经说的很清楚了,这是一个特权指令。只有系统或者内核本身才可以使用这个指令。
数学互斥方法
假设有两个线程(a、b)正要对一个共享数据进行访问,那么怎么做到他们之间的互斥的呢?其实我们可以这么做
unsigned flag[2] = {0};
unsigned turn = 0;
void process (unsigned index) {
flag[index] = 1;
turn = index;
while (flag[1 - index] && (turn == index)) {
// do something
}
}
操作系统提供的互斥方法
cpu原子操作
红黑树简介
红黑树(Red-Black Tree)自平衡二叉搜索树(self-balancing binary search tree)
红黑树是一种特殊的二叉搜索树,它在二叉搜索树的基础上增加了一些额外的性质,以确保树的高度保持在对数级别,从而保证了基本操作(如查找、插入、删除等)的时间复杂度为
红黑树的性质包括:
- 节点是红色或黑色:每个节点都有一个颜色属性,可以是红色或黑色。
- 根节点是黑色:这确保了树不会退化为链表结构。
- 所有叶子节点都是黑色:在红黑树中,叶子节点通常指的是树中的空节点(NIL节点),它们被视为黑色。
- 红色节点的子节点必须是黑色(即从根到叶子的所有路径上不能有两个连续的红色节点):这有助于保持树的平衡。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点:这被称为“黑色平衡”性质,它确保了树的高度是对数级别的。
红黑树的旋转操作
红黑树通过旋转操作来维护其性质,特别是在插入和删除节点时。旋转操作有两种:左旋(left rotation)和右旋(right rotation)。
左旋
左旋操作涉及三个节点:一个父节点
- 将
的左子节点指针指向 (如果 存在)。 - 将
的右子节点指针指向 。 - 将
设置为新的父节点,而 成为 的左子节点。
左旋通常用于调整树的形状,以维持红黑树的性质。例如,在插入一个红色节点后,如果它的父节点也是红色,则可能需要进行左旋操作。
右旋
右旋操作与左旋类似,但方向相反。它涉及三个节点:一个父节点
- 将
的右子节点指针指向 (如果 存在)。 - 将
的左子节点指针指向 。 - 将
设置为新的父节点,而 成为 的右子节点。
右旋也用于调整树的形状,特别是在删除节点或调整红色节点和黑色节点的分布时。
旋转不会影响二叉搜索树的中序遍历顺序,从而不会破坏排序的性质,而是通过结构调整实现平衡。
红黑树的插入和删除
在插入新节点时,红黑树可能会违反其性质(如红色节点的连续出现或黑色节点的不平衡)。为了恢复红黑树的性质,可能需要进行一系列的旋转和重新着色操作。
类似地,在删除节点时(特别是删除黑色节点时),红黑树也可能失去平衡。为了恢复平衡,可能需要进行旋转和/或重新着色操作,并可能需要从树的其他部分“借用”黑色节点来保持黑色平衡性质。
设计模式
如何实现单例模式
单例模式是一种常用的设计模式,它确保一个类在应用程序中只有一个实例,并提供一个全局访问点。以下是几种常见的单例模式实现方式:
- 懒汉式(线程不安全): 这是最基本的实现方式,但在多线程环境下会出现问题。由于没有加锁,多个线程可能同时创建实例。
- 懒汉式(线程安全): 通过在
getInstance()
方法上加synchronized
关键字,可以确保在多线程环境下只有一个线程能进入创建实例的代码块。但这种方式每次获取实例都需要同步,性能较低。 - 饿汉式: 在类加载时就创建实例,因此它天生是线程安全的。但这种方式在类加载时就创建实例,如果实例初始化非常耗时或占用大量资源,而应用程序并不一定需要这个实例,可能会造成资源浪费。
- 双检锁(双重检查锁定): 这是一种优化的线程安全单例实现方式。它第一次检查实例是否为空时不进行同步,只有在第一次检查通过之后再进行同步检查。这种方式减少了同步开销,提高了性能。但实现稍显复杂,代码可读性不如简单的同步方法高。
- 静态内部类: 这是一种非常优雅和高效的方式。它利用了类加载的特点来实现线程安全的懒加载。静态内部类的实例只有在
getInstance()
方法第一次调用时才会被加载,且JVM会确保类的加载过程是线程安全的。 - 枚举: 这是实现单例模式的最佳方法。它更简洁,自动支持序列化机制,绝对防止多次实例化。枚举类型的特点决定了它天生是单例的,JVM保证枚举实例的唯一性。
如何避免发生对象的用户复制行为
要避免对象的用户复制行为,可以采取以下几种措施:
- 不提供复制构造函数和赋值运算符: 通过将复制构造函数和赋值运算符声明为私有或删除它们,可以防止对象被复制。
- 使用不可变对象: 如果对象是不可变的(即对象的状态在创建后不能被修改),那么复制对象就不会引起问题。可以通过将所有字段声明为
final
并确保所有字段都是不可变对象来实现这一点。 - 使用设计模式: 例如,可以使用单例模式来确保一个类只有一个实例,从而避免复制。
- 安全管理: 通过安全管理策略,限制用户对对象的访问和修改权限,从而防止复制行为。
如何实现线程安全的单例模式
实现线程安全的单例模式需要确保在多线程环境下只有一个线程能创建实例。以下是几种实现线程安全单例模式的方法:
- 使用
synchronized
关键字: 如懒汉式(线程安全)所示,通过在getInstance()
方法上加synchronized
关键字来确保线程安全。 - 双检锁(双重检查锁定):如前所述,这种方式通过减少同步开销来提高性能,同时保持线程安全。
- 静态内部类:利用类加载的线程安全性来实现线程安全的懒加载。
- 枚举:枚举类型天生是单例的,且JVM保证枚举实例的唯一性,因此枚举单例是线程安全的。
DCLP是什么,有什么问题
DCLP是Double-Checked Locking Pattern(双重检查锁定模式)的缩写。它的目的是在共享资源(如单例模式)初始化时添加高效的线程安全检查功能。然而,DCLP存在一些问题:
- 指令重排序: 在没有
volatile
修饰符的情况下,JVM和CPU可能会对指令进行重排序,导致线程看到未初始化完成的实例。通过使用volatile
修饰符可以避免这种问题。 - 编译器和硬件优化: 编译器和硬件可能会对代码进行优化,导致双重检查锁定模式失效。例如,编译器可能会交换某些指令的执行顺序,从而破坏双重检查锁定的正确性。
- 实现复杂性:双重检查锁定模式的实现相对复杂,需要仔细编写检查和同步代码,以确保正确的同步。这增加了代码的可读性和维护难度。
- 跨平台差异: 在不同的硬件架构和操作系统上,双重检查锁定模式的行为可能会有所不同。这增加了跨平台开发和测试的难度。
因此,在使用双重检查锁定模式时需要谨慎考虑其潜在的问题和复杂性。如果可能的话,可以考虑使用其他更简单、更可靠的线程安全单例模式实现方式(如静态内部类或枚举)。