一个安全的c++产品所需的基本工具包
需求和挑战
需求
- 应用程序在运行时必须是内存静态的
- 应用程序在运行时必须是内存静态的,意味着应用程序在运行时所使用的内存空间是固定的,不会发生动态变化。这个要求主要是出于稳定性和安全性考虑。如果应用程序的内存使用过程中发生了动态变化,可能会导致内存泄漏或者内存溢出等问题,从而引起应用程序崩溃或者其他异常情况。因此,要保证应用程序在运行时内存静态,需要合理分配内存,并且保证内存释放的及时性和准确性。
- 不发生内存碎片
- 内存碎片是指内存中存在无法被有效利用的小块内存空间,这些内存空间虽然不能满足大块内存的需求,但又无法被释放,最终导致内存空间的浪费。不发生内存碎片的意思是,在应用程序运行期间,分配内存的方式要尽可能地不留下小的未分配空间,以减少内存浪费。这可以通过合理设计内存的分配和释放方式来达到。例如,使用内存池技术可以避免频繁的内存分配和释放,从而减少内存碎片的产生。另外,在 C/C++ 语言中,使用内存管理函数(如 malloc/free 或 new/delete)时需要注意内存的分配与释放情况,避免出现内存泄漏或者内存碎片的问题。
- 人们应该能够推断容器的资源使用和资源限制,
- 以确保在应用程序的整个运行期间这些都足够
- 在设计和开发容器时,需要考虑容器所需要的资源量和所能承受的资源限制,并将其明确地传递给应用程序,以确保应用程序在整个运行期间都能够正常地使用这些资源。这包括 CPU、内存、磁盘空间、网络带宽等各种资源。如果容器中资源的分配不合理或者容器资源限制不够,可能会导致应用程序的性能低下、运行缓慢、崩溃等问题。因此,在设计和开发容器的过程中,需要详细地考虑容器所需的资源和所能承受的限制,并在容器的文档或其他方式中让使用者清楚地知道这些限制,以避免出现不必要的问题。
挑战
-
标准c++容器使用不同的策略分配内存
标准 C++ 容器使用不同的策略分配内存是指,各种不同的容器在进行内存分配时,采用了不同的分配策略。- vector:采用动态数组的方式分配内存,当容器中的元素数量增加时,会重新分配更大的内存空间,并将原有元素复制到新的内存区域中。
- list:采用双向链表实现,每个元素在内存中都是独立分配的,新增元素时只需要分配一个新的节点,不需要移动其他元素。
- map/set:采用二叉搜索树或红黑树实现,每个节点在内存中都是独立分配的,新增节点时需要重新分配内存,并调整树的结构。
这些不同的容器分配内存的方式都有各自的优点和缺点,根据需要选择不同的容器。例如,如果应用程序需要频繁地在中间插入或删除元素,那么 list 往往比 vector 更加适合;如果需要高效地查找元素,map/set 则优于 vector 和 list 等容器。总之,在选择和使用容器时,需要仔细考虑容器的特性和需要解决的问题,以选取最合适的容器。
-
栈内存非常有限,栈耗尽后很难恢复
-
栈是一种在程序中暂时存储数据的内存区域,其分配和回收是由程序自动完成的。栈内存非常有限,通常只有几MB的空间,因此在程序中过度使用栈内存,很容易导致栈耗尽的问题。一旦栈内存耗尽,程序可能会出现诸如栈溢出等问题,而这些问题往往难以恢复。这是因为栈的回退是由程序自动完成的,当栈内存发生错误时,程序已经无法正确地恢复栈的状态,导致程序的崩溃或其他异常情况。
-
因此,在程序设计和开发中应该注意尽可能减少栈内存的使用,而将更多的数据存储在堆或静态存储区中。此外,合理使用函数递归、调整函数参数和局部变量等方式,也有助于减少栈内存的使用和优化程序性能。最重要的是,要对栈内存的使用进行严格把控,以确保程序的稳定和安全。
- 内存池可能会分片,具体取决于它们的使用方式
-
内存池是一种常用的内存管理方式,它会预先在程序中分配一定数量的内存空间,然后根据需要动态地分配、回收内存,以提高内存使用效率。内存池虽然可以有效地避免内存碎片的产生,但是在使用过程中仍然可能出现分片的情况,而具体取决于它们的使用方式。内存池的分片指的是,当程序需要申请内存大小较小的空间时,如果内存池中可用的内存块都比这个大小大,那么就会将一个大块内存块中的一部分返回给程序,而这部分内存可能比程序需要的大小更大,从而导致内存浪费和分片的产生。
-
具体而言,内存池的分片情况可能受到以下几个因素的影响:
- 内存池的分配策略,如分配内存的最小单位、何时释放多余内存等。
- 程序对内存的使用方式,如申请内存的大小、频率等。
- 程序的特性,如数据结构的设计等。
-
因此,在使用内存池时,需要针对具体的应用场景和数据特性进行合理的配置和使用,以避免产生内存分片等问题。
-
必须提供资源使用的可追溯性,以证明系统没有耗尽资源,从而引入安全隐患
- 内存池和分配器只是解决方案的一部分。
Solution
不同的容器类型需要不同的解决方案
字符串(std::string)
- 编译时使用两种堆栈存储的固定大小的字符串,一种是静默截断,另一种是在溢出时抛出
-
std::string 是 C++ STL 库中提供的一种字符串类型,用于管理字符串的分配、释放和操作等任务。在实际使用中,std::string 会根据不同的需求,在编译时使用两种不同的堆栈存储的固定大小的字符串:
-
静默截断:当字符串长度超过了预先分配的固定大小时,字符串会被自动截断并存储在固定空间内,不会对程序造成任何异常或错误提示。这种方式适用于一些不关注字符串长度的场合,比如在某些应用中,字符串可能仅仅用于显示或传递一些简短的信息。
-
溢出时抛出:当字符串长度超过了预先分配的固定大小时,std::string 会抛出 std::out_of_range 异常,通知程序出现了数组溢出错误。这种方式适用于一些对字符串长度有严格要求的场合,比如在加密解密、解析协议等领域中,字符串长度往往是必须受到严密限制的。
- 不分配内存
- std::string 可以在栈上或堆上分配一段空间,用于存储字符串的内容,这个空间可以称为 buffer(缓冲区),由 std::string 内部管理。当字符串长度小于 buffer 大小时,string 对象就可以直接使用 buffer,而不需要动态地分配内存。当字符串长度超过 buffer 大小时,string 对象将会重新分配新的内存,并将当前的字符串拷贝到新的内存中,从而保证字符串的完整性和正确性。
- 体积小,适合存储在栈上
- 简单易用
向量(std::vector)
- 在构造时从堆中分配的运行时固定大小的向量
- 在稳定时间之前为每个向量分配内存
- 满足连续内存保证
- 难以使用,因为在运行时不可构造或复制
基于节点的容器(std::map、std::set等)内存池/分配器框架(例如https://github.com/foonathan/memory)
- 在运行前分配池
- 每种类型对应一个内存池,以防止内存碎片
- 池可以是任意粒度的资源
- 隔离并使证明应用程序不会耗尽资源变得更容易
- 无序容器不适用于固定大小的池
内存静态操作
需求和挑战
需求
- 应用程序在运行时必须是内存静态的,因此异常也需要是内存静态的
2.人们应该能够推断异常管理的资源使用和资源限制,以确保在应用程序的整个运行期间这些都足够
异常处理通常会使用一定的计算机资源,例如内存、处理器时间和磁盘空间等等。如果异常处理机制不合理或者使用不当,就有可能导致系统的崩溃、性能下降或者资源发生枯竭等问题。因此,人们应该能够识别和评估异常处理的资源使用和系统资源限制,并在开发应用程序时做出相应的调整和改进,以提高程序的稳定性和可靠性。
在应用程序的整个生命周期中,资源的使用和限制都会发生变化,因此,人们需要持续地关注和调整异常处理机制,并根据实际的情况进行相应的优化和改进。通过合理的异常处理机制,应用程序可以更有效地处理错误和异常情况,以提高程序的健壮性和可用性。 - 例程的运行时需要有界限,并且需要被很好地理解
程序或例程在运行时需要受到一些限制,包括但不限于:
内存限制:程序在运行时需要分配一定的内存,但内存是有限制的,如果程序使用了过多的内存,就会发生内存泄漏或者 out of memory 等错误。
IO限制:程序在运行时需要读取或写入一些文件或者数据流,但是 IO 操作会受到一些限制,例如权限限制、磁盘空间限制等。
运行时间限制:程序在运行时需要在一定的时间内完成其任务,如果任务时间过长,就可能影响程序的正确性和性能。
程序员需要了解这些限制,并在程序设计和实现过程中,合理地考虑这些限制。程序员需要确保程序能够在所限定的范围内正确地运行,例如避免内存泄漏,控制 IO 操作的大小和频率,使用高效算法减少程序运行时间等等。
另外,程序员需要理解程序的功能和实现细节,并且通过准确的注释、命名和代码结构等方式,让其他程序员能够轻松地理解和维护代码。这样可以提高代码的可读性和可维护性,减少程序错误和缺陷的产生。
挑战
-
抛出异常尽量在编译器级别分配内存
在一些特定的情况下,编译器可能会通过静态分析来检测代码中可能会抛出异常的地方,并在编译期间对这些位置进行预处理,以便于在程序运行时能够更加高效地处理异常,减少内存的动态分配次数。
这种技术通常被称为“zero-cost exception handling”,它是一种编译器技术,旨在减少异常处理时的运行时开销,从而提高程序的运行效率。在使用这种技术时,编译器会利用语言特性和程序信息来进行静态分析,以判断哪些代码可能会抛出异常,然后在编译器级别进行预处理,以减少在运行时处理异常的开销和内存动态分配的次数。
但需要注意的是,并不是所有的编程语言都支持 zero-cost exception handling,并且即使支持也只是在一些特定的情况下才会被启用。因此,在编写程序时,应该尽量避免过度使用异常处理,以免对程序的性能造成负面影响。 -
标准异常分配内存来存Error Message
标准异常(Standard Exceptions)是指在编程语言或编程框架中预定义的一组异常类型,它们可以被程序员在代码中使用,用于标识特定的错误或异常情况。例如,在 C++ 中,标准异常包括 std::exception、std::runtime_error、std::bad_alloc 等;在 Java 中,标准异常包括 Exception、RuntimeException、IOException 等。
标准异常通常会带有一条错误信息(Error Message),用于描述异常的原因和具体信息。这些错误信息需要在抛出异常时进行分配内存,并在异常处理过程中进行传递和显示。因此,标准异常需要分配内存来存储错误字符串。
例如,在 C++ 中,标准异常类 std::exception 带有一个虚函数 what(),用于返回异常的错误信息。在使用 std::exception 异常时,需要在抛出异常时指定错误信息字符串。这个字符串需要在堆上进行动态内存分配,并将指针传递给异常对象。在调用异常处理程序时,需要从异常对象中获取错误信息,并在程序中显示该信息。
在 Java 中,标准异常类也提供了类似的机制。异常类通常继承自 java.lang.Exception 或者 java.lang.RuntimeException,并提供了一组构造函数和 getMessage() 方法,用于获取异常的详细信息。在抛出异常时,需要指定错误信息字符串,并在异常处理程序中获取和处理该信息。 -
异常处理程序查找可能没有确定的运行时。查找时间可能依赖于继承结构。
异常处理程序查找可能没有确定的运行时。查找时间可能依赖于继承结构。
异常处理程序是指在抛出异常时,程序会查找匹配该异常的处理程序,并执行该处理程序中的异常处理逻辑。在 C++ 中,异常处理程序通常使用 try-catch 块来定义;在 Java 中,则使用 try-catch-finally 块来定义。继承结构是指在面向对象编程中,类与类之间的继承关系。在继承结构中,子类继承父类的属性和方法,并可以对这些属性和方法进行扩展或重写。在异常处理中,异常类之间也可以存在继承关系。例如,在 C++ 中,std::exception 是所有标准异常类的基类,其他异常类都继承自它;在 Java 中,Exception 是所有异常类的基类,其他异常类从它派生出来。
当程序抛出异常时,异常处理程序需要根据异常对象的类型和继承关系,逐级匹配可能的处理程序。由于异常处理程序是在运行时才进行查找和执行的,因此查找的时间可能没有确定,取决于继承结构的层级和数量。
具体来说,如果异常类之间的继承层级较浅或者处理程序较少,查找时间就可能很短;如果继承层级较深或者处理程序较多,查找时间就可能很长。此外,如果异常处理程序中存在多个 catch 块,而且它们的异常类型之间存在继承关系,程序会根据继承结构中自底向上的顺序,从最特定的异常类型到最一般的异常类型逐级匹配。
总之,异常处理程序的查找时间可能没有确定,而且可能依赖于继承结构的层级和数量。在编写程序时,应该尽量避免过度使用异常处理,以避免查找时间过长和程序性能下降。同时,应该合理设计异常继承结构,并在处理程序中尽量采用特定的异常类型,以提高程序健壮性和可读性。
-
异常处理增加了跟踪和分析应用程序执行分支的难度。这使得运行时和测试覆盖率分析变得复杂。
这句话主要是在指出使用异常处理机制会增加应用程序的跟踪和分析的复杂度,尤其是对于运行时和测试覆盖率分析等方面。具体来说,异常处理机制是将运行时错误和异常转化为异常对象,并通过一系列的 try-catch-finally 块来实现处理和传递。这样,异常处理机制就会引入额外的程序执行分支和代码路径,增加应用程序的复杂度,从而增加了跟踪和分析的难度。另外,由于异常处理机制可以在不同的层次进行处理,从而使得运行时和测试覆盖率分析变得更加复杂。为了正确地量化代码覆盖率和测试质量,需要更加详细和全面地分析异常处理机制的使用情况,以及异常类型、原因、位置、传递等方面的信息。总之,异常处理机制的使用对于应用程序的跟踪和分析是一种双刃剑。其一方面可以提高程序的健壮性和稳定性,增强程序的容错能力;另一方面,也会增加程序执行分支和代码路径,增加程序的复杂度和跟踪分析的难度。因此,在使用异常处理机制时,需要根据具体的应用场景和需求,权衡其优缺点,合理使用,以提高程序的可维护性和可测试性。
内存静态和确定性异常
内存静态异常(Memory Static Faults),又称为硬件故障(Hardware Faults),是指由于芯片制造工艺问题或外部环境干扰(例如电磁场干扰)等因素导致的内存数据损坏。这种异常无法通过异常处理机制来处理,因为它们在运行时之前就已经发生,程序也无法预测和避免。对于这种异常,通常会采用硬件技术(例如ECC错误纠正码、硬件层检测与冗余等)来检测和修复内存数据异常。
内存静态异常(Memory Static Faults),又称为硬件故障(Hardware Faults),是指由于芯片制造工艺问题或外部环境干扰(例如电磁场干扰)等因素导致的内存数据损坏。这种异常无法通过异常处理机制来处理,因为它们在运行时之前就已经发生,程序也无法预测和避免。对于这种异常,通常会采用硬件技术(例如ECC错误纠正码、硬件层检测与冗余等)来检测和修复内存数据异常。
总之,内存静态和确定性异常是指在程序执行过程中可能出现的内存异常,在处理方式和引起原因上有所不同。理解这些异常可以帮助开发人员设计和编写更加健壮和可靠的程序,提高程序的容错性和稳定性。
异常处理会创建额外的分支,这些分支很难被测试覆盖,对于最高的安全级别来说,100%的分支覆盖率是必须的
Real Time & Threading and related architecture
需求
-
任务需要有一个确定的运行时
当任务需要有一个确定的运行时时,意味着任务需要在预设的时间内完成,因此需要有一个明确的开始时间和结束时间,以确保任务的完成。在这种情况下,任务的运行时必须是可控制和可预期的,并且需要具有一定的稳定性和可靠性,在执行过程中不能出现不可预知或不可控的因素干扰。
例如,在嵌入式系统中,有些任务需要在严格的时间限制内完成,例如控制器需要在每个周期内定时触发某些事件。在这种情况下,任务需要在确定的时间内完成,并且需要有一个稳定的运行时来保证任务的执行。
类似地,在实时应用程序中,例如音视频应用程序、游戏等,也需要有一个稳定的运行时,以确保应用程序能够按照用户的期望稳定地运行,并在规定的时间内响应用户的操作。
总之,需要有一个确定的运行时意味着任务需要在一个规定的时间内完成,并且需要有一个可控制和可预期的系统来保证任务的完成,这是在许多应用程序和系统中非常重要的一点。 -
一个任务的执行需要被一个更高优先级的任务中断
当一个任务的执行需要被一个更高优先级的任务中断时,意味着系统中有多个任务正在运行,并且每个任务都有一个相应的优先级。在这种情况下,如果一个任务正在执行,而同时一个高优先级的任务需要立即处理,那么系统会中断当前任务的执行,将CPU资源分配给更高优先级的任务执行。
这种情况通常被称为抢占(Preemption),是一种常见的操作系统调度机制。在抢占机制下,系统会根据任务的优先级来分配CPU资源,优先处理高优先级的任务。如果当前正在执行的任务的优先级低于某个即将到来的任务的优先级,那么系统会立即中断当前任务的执行,将CPU资源分配给更高优先级的任务执行。待高优先级任务完成之后,系统会恢复之前低优先级任务的执行。
例如,在实时系统中,控制任务通常会被赋予高优先级,以确保实时性。如果关键控制任务正在执行,但同时另一个更重要或更紧急的控制任务需要立即处理,那么系统会立即中断当前任务的执行,将CPU资源分配给更高优先级的任务执行,以确保高优先级任务的及时响应。
总之,当一个任务的执行需要被一个更高优先级的任务中断时,意味着系统中存在多个任务,并且每个任务都有一个相应的优先级。系统会按照任务的优先级来分配CPU资源,优先处理高优先级的任务,以确保高优先级任务的及时响应。 -
低优先级任务不能阻塞高优先级任务
低优先级任务不能阻塞高优先级任务,是指在多任务操作系统中,高优先级任务的运行不能由于低优先级任务的阻塞而受到影响,确保高优先级任务的实时性和稳定性。在一个多任务系统中,有多个任务在同时运行,每个任务都有一个对应的优先级。为了确保高优先级任务的执行和完成,低优先级任务不能阻塞高优先级任务的执行。如果低优先级任务需要等待某个资源,而该资源正被高优先级任务使用,则系统必须采取措施,使得高优先级任务能够继续执行,不受低优先级任务的影响。这可以通过让低优先级任务等待、阻塞或挂起,直到高优先级任务完成对该资源的使用,才继续执行低优先级任务。
例如,在一个实时系统中,对于高优先级的控制任务,不能因低优先级的数据处理任务的处理时间过长而造成控制系统的响应时间过长。为了避免这种情况,系统可以采取抢占机制,即当高优先级的控制任务需要处理时,可以中断当前正在执行的低优先级任务,让高优先级的任务先执行。这样,在实现高优先级和低优先级任务的协作时,高优先级任务可以及时完成,并且不会受到低优先级任务的阻塞。
总之,低优先级任务不能阻塞高优先级任务,是在多任务系统中保证高优先级任务实时性和稳定性的重要原则。对于具有严格实时要求的系统,正确地处理任务优先级和任务间的协作,尤为重要。这可以使得系统更加可靠、响应更快、稳定性更高。
挑战
-
标准c++线程库只提供了对线程优先级、优先级继承、CPU pinning等非常基本的控制。
标准C++线程库提供了基本的线程控制机制,如线程的创建、启动、等待、暂停、恢复等。但是,对于高级的线程控制需求,如线程优先级、优先级继承、CPU pinning等,标准C++线程库只提供了非常基本的控制功能
具体来说,标准C++线程库只提供了线程优先级的控制(通过std::thread::native_handle()获取底层线程的native_handle,再通过系统调用或库函数设置线程优先级)、优先级继承和CPU pinning等最基本的控制功能。这些基本的功能虽然能够满足一般性的需求,但对于高级的线程控制要求,可能就无法满足了。
对于更高级的线程控制需求,需要使用平台特定的线程控制库,如Windows下的Win32 API、UNIX/Linux下的pthread库等。这些库提供了更多的线程控制功能,如线程优先级、线程调度算法、线程调用顺序、内核级别的互斥量和信号量、定时器等高级功能。但是需要注意,使用平台特定的线程控制库会影响代码的可移植性,因此需要谨慎使用。
总之,标准C++线程库提供了最基本的线程控制功能,但对于高级的线程控制需求,需要使用平台特定的线程控制库来实现。在使用线程控制库时需要遵循一定的规范和标准,以确保代码的正确性和可移植性。 -
c++中的执行是不可抢占的,因此很难编写任何具有实时能力的执行器
在C++中,通常情况下是不能抢占执行的,即一个线程无法在另外一个线程执行期间进行强制性的中断。这是因为C++使用的线程模型是基于系统线程(system thread)的,而系统线程的调度是由操作系统提供的。C++程序并没有控制权,不能主动干预系统线程的调度。
由此可见,C++中的执行确实是不可抢占的。这种特性虽然保证了线程执行的安全性和稳定性,但对于实时应用的开发却带来了很大的挑战。因为在实时应用中,任务的执行时间必须得到快速响应和保障,需要及时而准确地处理数据和事件,而不能由于其他任务的执行而被拖延或阻塞。
因此,虽然C++并不是一个实时性强的编程语言,但是我们可以利用一些技巧和工具来提升程序的实时响应能力。例如使用多线程编程技术,让实时任务在单独的线程中执行,并且设定线程的优先级等参数,来确保实时任务得到优先执行。同时,还可以使用操作系统提供的实时任务调度器或者第三方的实时任务调度器来管理任务执行,以达到更高的实时性能。
总的来说,虽然C++中的执行是不可抢占的,但我们可以通过合理地运用多线程和实时任务调度等技术,来提升程序的实时响应能力。需要根据实际需求选择合适的工具和技术,并进行合理的组合和配置,以达到最佳的实时性能。
3,要确保多线程代码是正确的是非常困难的,因为代码以一种不确定的方式交错执行
确保多线程代码的正确性是非常困难的,因为在多线程环境下,由于线程的交错执行,代码的执行顺序和结果都是不确定的,很难对其进行准确的预测和控制。
具体来说,有如下几个原因:
竞态条件:当两个或多个线程同时访问共享的资源时,就会出现竞态条件。这种情况下,线程的交错执行可能会导致数据的不一致性和错误的结果。
死锁:当两个或多个线程在等待对方释放资源时,就会出现死锁。这种情况下,线程会被永久地阻塞,无法继续执行。
饥饿:当某个线程一直无法获取到共享资源时,就会出现饥饿。这种情况下,线程无法执行,导致程序不能正常运行。
由于多线程代码的不确定性,保证其正确性是非常困难的。为避免这些问题,需要编写充分测试的代码,并使用锁、条件变量、信号量等同步机制来协调线程之间的执行。此外,应该尽可能避免竞态条件,避免死锁和饥饿等问题的发生。
- 如果发生数据竞争,则程序的行为是未定义的
在多线程编程中,当多个线程并发地访问和修改共享数据时,就会发生数据竞争。如果未对竞争条件进行正确的同步和互斥处理,那么程序的行为就是不可预期的,这种情况称为未定义行为。
未定义行为的具体表现是,程序可能在不同的运行环境下产生不同的结果,而且这些结果往往是不可预期和不稳定的。例如,当多个线程一起修改同一个变量时,程序可能会出现诸如程序崩溃、数据损坏、错误输出等异常情况。这种情况下,由于程序的行为是未定义的,因此无法保证程序的正确性和稳定性。
为了避免数据竞争和未定义行为,必须采取合适的同步机制来协调线程之间的执行。例如,可以使用互斥锁和条件变量来保护和同步共享数据,防止多个线程并发修改产生数据竞争。此外,还可以使用原子变量和读写锁等其它同步机制来提高程序的并发执行效率和稳定性。
总之,如果程序存在数据竞争,则其行为是未定义的,必须采取合适的同步机制来避免这种情况的发生,确保程序的正确性和稳定性。
Solution
自定义标准线程库
自定义标准线程库可以理解为在使用线程时,不仅要使用标准线程库提供的功能,还可以根据特定需求自定义实现一些功能,并将其封装成一个新的线程库。这样的线程库具有自定义的特点,能够更好地满足用户的需求。它可以包括一些常用的线程操作、线程同步机制、线程结束处理、线程池等多个功能,同时也要遵循标准线程库的接口规范以保证跨平台性。使用自定义标准线程库,可以提高程序的可维护性和可移植性。
一个自定义的现代c++线程库抽象了POSIX接口,这使得创建多线程应用程序更容易,并隐藏了实现细节
- 线程是在初始化期间创建的,但在运行时启动之前会一直保持
在程序初始化期间创建的线程,其实际运行时间并不是在初始化期间。相反,这些线程在初始化之后,只有当特定的条件或事件发生时才会启动运行。在初始化之后,在线程被启动之前,它们一直保持等待状态。这种方式可以确保程序在需要时才会启动线程,从而实现更高效、更有效的程序运行。 - 线程优先级、CPU pinning等是可配置的
线程优先级、CPU pinning等是指在使用线程时,可以通过特定的设置来控制线程的行为。线程优先级是指操作系统在调度线程时,优先考虑哪些线程,因此它能够影响线程的执行顺序,从而影响程序的性能。通过设置线程的优先级可以让某些线程更加优先执行,以达到更好的程序性能。
CPU Pinning是将CPU核心与线程绑定,使其只能在特定的CPU核心上运行,从而避免线程的频繁切换和调度,从而提高程序的性能和可伸缩性。通过设置CPU Pinning,可以将线程和CPU核心绑定,从而确保线程在特定的核心上运行。
这些线程配置可以根据实际需求进行设置和调整,以满足不同的应用场景和程序性能需求。同时,根据不同的操作系统和硬件平台,这些设置的方法也可能有所不同。 - 互斥量支持优先级继承
互斥量是一种同步机制,用于保护共享资源不受多个线程同时访问和修改,避免出现竞态条件的问题。在多线程环境中,当多个线程同时竞争同一个共享资源时,互斥量可以确保只有一个线程可以访问该资源,其他线程必须等待互斥量的释放才能访问。
而优先级继承是一种解决优先级反转问题的技术,它通过提升低优先级线程的优先级以防止高优先级线程被低优先级线程阻塞的情况。具体来说,当低优先级线程持有一个高优先级线程需要的共享资源时,低优先级线程会被提升为高优先级线程的优先级,以确保高优先级线程能够及时获得所需要的资源,从而避免出现优先级反转问题。
互斥量支持优先级继承,意味着在使用互斥量时,可以自动提升低优先级线程的优先级,以便高优先级线程能够及时获得所需要的资源,从而保证程序的正确性和性能。这种技术通常使用操作系统的调度机制实现,能够确保在所有线程中都能够正常地工作。
Rely on the OS Scheduler
在多线程编程中,线程的调度是很重要的一部分。操作系统提供了调度器来协调各个线程和进程的执行,使得它们能够在不同的CPU上运行,而不会发生冲突和竞争。程序员可以通过一些手段来控制线程的执行顺序和优先级,但是在操作系统下,最终的决策还是由操作系统的调度器来做出。
采用“Rely on the OS Scheduler”的方式,就是让操作系统来管理线程的调度,而不是我们自己手动管理。这种做法的优点是可以利用操作系统提供的高效、稳定、可靠的调度算法,避免出现我们自己实现的调度算法带来的问题。此外,操作系统调度器还可以提供更好的抢占机制来确保高优先级线程的执行,从而保证程序的正确性和性能。
在实际的开发中,可以使用操作系统提供的线程库和相关API,以便让线程的调度和管理更加方便和高效,同时能够充分利用操作系统的资源管理能力。因此,这种编程方式是很常见的,并被广泛用于各种应用场景中。
- 很好地理解
- 良好的工具支持
- 缺点:与基于执行器的架构相比,需要更多的上下文切换
- 使用高级工具确保正确性
- Thread Sanitizer
Thread Sanitizer(TSan)是一种内存错误检测工具,用于帮助程序员发现和修复多线程程序中的数据竞争和死锁问题。它是一种静态分析工具,能够在程序的运行过程中检查多线程访问共享资源的情况,并提供相关报告以帮助我们了解问题出现的具体位置和原因。
具体来说,Thread Sanitizer会在程序运行期间,对所有线程进行监控,跟踪线程与线程之间、线程与共享资源之间的交互,并检测出数据竞争、死锁和其它类型的多线程错误。当发现问题时,它会通过报告或错误信息的形式告知程序员,帮助快速定位和修复这些问题。
使用Thread Sanitizer可以显著地提高多线程程序的可靠性和健壮性,减少潜在的错误和延迟,提高系统的性能和可维护性。同时,Thread Sanitizer也可以作为一个非常有用的辅助工具,帮助程序员在开发和测试阶段更加快速、准确地发现和调试多线程程序的问题,从而提高开发效率。
- Clang Thread Safety Analysis
Clang Thread Safety Analysis是Clang工具链中的一项功能,用于分析C++代码的线程安全性问题。它通过静态分析技术检查代码中的潜在线程安全问题,包括数据竞争、死锁和内存管理等问题。
Clang是一种编程语言,其线程安全性是指对于多线程并发操作的情况下,能否保证代码的正确性和可靠性。当多个线程同时读写相同的共享资源时,如果没有经过合理设计和实现的线程安全性,将会导致数据不一致、竞态条件和死锁等问题。
Clang线程安全性分析是对Clang编写的程序进行检测,确保程序在多线程环境下能够正确运行,不会出现线程竞争和数据不一致的问题。这种分析通常局限于代码的静态分析,可以分析出可能出现竞态条件或数据不一致的代码段,并给出相应的建议和修正方案,从而保障程序的正确性和可靠性。
Clang Thread Safety Analysis通过将C++代码中的线程安全性信息表示为特殊的注释或属性,使得代码中的线程安全问题更容易检测和修复。除此之外,它还能够检测代码中的不合理锁使用、初始化顺序问题、线程安全接口设计及其它方面的问题。
- Hellgrind是一款开源的线程错误检测工具,属于Valgrind工具包的一部分,可以检测和调试C和C++程序中的线程问题,包括死锁、数据竞争、内存泄漏和访问非法内存等问题。 运行Hellgrind时,它会创建一个模拟的执行环境(称为Sanbox),在该环境中运行程序,并对程序进行实时跟踪和分析。Hellgrind监控线程间的同步和通信,并在发现问题时给出详细的报告。Hellgrind使用的算法是基于动态二进制翻译技术,可以将程序的机器代码翻译成更容易分析的形式,以获取更准确的数据。
Hellgrind工具的优点是:
-
不需要对代码进行任何修改,也不需要编译器支持。
-
能够捕获动态行为,包括库函数调用和共享内存的访问。
-
提供详细的故障报告,从源代码的角度分析问题,并上传易于解决的上下文信息。
-
支持对多种线程和锁类型的检测和调试,包括POSIX线程(pthread)和OpenMP。
支持对多种线程和锁类型的检测和调试,包括POSIX线程(pthread)和OpenMP。 -
Thread Sanitizer
threadsantizer(又名TSan)是C/C++的数据竞争检测器。数据竞争是并发系统中最常见也是最难调试的bug类型之一。当两个线程并发访问同一个变量,并且其中至少有一个是写访问时,就会发生数据竞争。c++ 11标准正式禁止数据竞争,因为这是未定义的行为。
#include <pthread.h>
#include <stdio.h>
int Global;
void *Thread1(void *x) {
Global++;
return NULL;
}
void *Thread2(void *x) {
Global--;
return NULL;
}
int main() {
pthread_t t[2];
pthread_create(&t[0], NULL, Thread1, NULL);
pthread_create(&t[1], NULL, Thread2, NULL);
pthread_join(t[0], NULL);
pthread_join(t[1], NULL);
}
输出:
xiaqiu@xz:~/TDD/build$ clang++ ../day22.cpp -fsanitize=thread -fPIE -pie -g
xiaqiu@xz:~/TDD/build$ ./a.out
==================
WARNING: ThreadSanitizer: data race (pid=3704)
Write of size 4 at 0x560623132668 by thread T2:
#0 Thread2(void*) /home/xiaqiu/TDD/build/../day22.cpp:12:10 (a.out+0xd03c4) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
Previous write of size 4 at 0x560623132668 by thread T1:
#0 Thread1(void*) /home/xiaqiu/TDD/build/../day22.cpp:7:10 (a.out+0xd0374) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
Location is global 'Global' of size 4 at 0x560623132668 (a.out+0x14f7668)
Thread T2 (tid=3707, running) created by main thread at:
#0 pthread_create <null> (a.out+0x4f3bd) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
#1 main /home/xiaqiu/TDD/build/../day22.cpp:19:4 (a.out+0xd0422) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
Thread T1 (tid=3706, finished) created by main thread at:
#0 pthread_create <null> (a.out+0x4f3bd) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
#1 main /home/xiaqiu/TDD/build/../day22.cpp:18:4 (a.out+0xd040b) (BuildId: fbc1945f262ecdd3a2e46828c8c16129711e0c1a)
SUMMARY: ThreadSanitizer: data race /home/xiaqiu/TDD/build/../day22.cpp:12:10 in Thread2(void*)
==================
ThreadSanitizer: reported 1 warnings
xiaqiu@xz:~/TDD/build$
-
Thread Safety Analysis 工具
Clang线程安全分析是一个c++语言扩展,它可以警告代码中潜在的竞争条件。分析是完全静态的(即编译时);没有运行时开销。该分析仍在积极开发中,但已经足够成熟,可以在工业环境中部署。它由谷歌与CERT/SEI合作开发,并广泛用于谷歌的内部代码库。
线程安全性分析的工作原理非常类似于多线程程序的类型系统。除了声明数据的类型(例如int、float等),程序员还可以(可选地)声明如何在多线程环境中控制对数据的访问。例如,如果foo被互斥量mu保护,那么当一段代码读写foo而没有先锁住mu时,分析就会发出警告。类似地,如果有一些特定的例程只应该由GUI线程调用,那么如果其他线程调用这些例程,分析将发出警告。
#include "Mutex.h"
class BanckAccout {
Mutex mu; // 定义一个互斥锁对象
int balance GUARDED_BY(mu); // 使用 GUARDED_BY 宏标注 balance 变量被 mu 互斥锁保护
// 存款实现,不需要额外的锁保护
void depositImpl(int amount) { balance += amount; }
// 取款实现,需要 mu 互斥锁保护
void withdrawImpl(int amount) REQUIRES(mu) {
balance -= amount; // OK. Caller 必须先持有 mu 互斥锁
}
public: // 取款,需要先持有 mu 互斥锁
void withdraw(int amount) {
mu.Lock(); // 加锁
withdrawImpl(amount); // OK. 此时已经获得了 mu 互斥锁
} // WARNING! 未解锁 mu 互斥锁
// 转账
void
transferFrom(BanckAccout& b, int amount) {
mu.Lock(); // 加锁
b.withdrawImpl(amount); // WARNING! 调用 withdrawImpl() 需要使用 b.mu 互斥锁
depositImpl(amount); // OK. depositImpl() 不需要额外的锁保护
mu.Unlock(); // 解锁
}
};
这个例子演示了分析背后的基本概念。GUARDED_BY属性声明了一个线程必须先锁定mu,然后才能读写以达到balance,从而确保递增和递减操作是原子的。类似地,require声明调用线程必须在调用withdraw之前锁定mu。因为假定调用者锁定了mu,所以在方法体中修改balance是安全的。
depositImpl()方法没有要求,因此分析会发出一个警告。线程安全分析不是过程间的,因此调用者需求必须显式声明。在transferFrom()中也有一个警告,因为尽管该方法锁定了this->mu,但它没有锁定b.mu。分析表明,这是两个独立的互斥量,位于两个不同的对象中。
最后,在withdraw()方法中有一个警告,因为它无法解锁mu。每个锁都必须有一个对应的开锁,分析将检测到双锁和双开锁。函数允许获取锁而不释放锁(反之亦然),但必须这样注释(使用acquire /RELEASE)。
xiaqiu@xz:~/TDD/build$ clang -c -Wthread-safety ../day23.cpp -std=c++2a
../day23.cpp:8:35: warning: writing variable 'balance' requires holding mutex 'mu' exclusively [-Wthread-safety-analysis]
void depositImpl(int amount) { balance += amount; }
^
../day23.cpp:19:4: warning: mutex 'mu' is still held at the end of function [-Wthread-safety-analysis]
} // WARNING! 未解锁 mu 互斥锁
^
../day23.cpp:17:10: note: mutex acquired here
mu.Lock(); // 加锁
^
../day23.cpp:25:9: warning: calling function 'withdrawImpl' requires holding mutex 'b.mu' exclusively [-Wthread-safety-precise]
b.withdrawImpl(amount); // WARNING! 调用 withdrawImpl() 需要使用 b.mu 互斥锁
^
../day23.cpp:25:9: note: found near match 'mu'
3 warnings generated.
Testability
需求和挑战
需求
-
代码必须具有非常高的测试覆盖率
代码具有非常高的测试覆盖率,意味着代码的每一行,每一条分支,在测试中都被至少执行了一次。这种覆盖率的代码,可以保证在很大程度上具备高质量和稳定性,可以尽量避免这些代码在生产环境中出现难以预料的行为。
高测试覆盖率的代码,应该是经过充分测试和验证的。使用覆盖率工具对代码进行测试可以帮助系统开发人员在开发阶段就发现潜在的缺陷和错误,能够及时修复问题,提高代码质量和可靠性。同时,高测试覆盖率还能够帮助开发人员在软件维护阶段快速定位问题重新修复,减少了软件维护的难度和成本。
对于具有高测试覆盖率的代码,建议遵循以下几点:- 编写足够的测试用例,以覆盖代码的每一行和每一条分支。
- 对于边界情况需要进行特殊处理,并编写相应的测试用例进行验证。
- 对于分支语句,包括 if、switch 等语句,需要编写针对不同情况的测试用例。需要特别注意测试用例是否可以涵盖所有可能的情况。
- 代码需要使用代码覆盖率测试工具进行测试。这些工具可以帮助开发人员评估代码是否具有足够的测试覆盖率。
- 需要持续不断地进行测试,并及时更新测试用例以保证高测试覆盖率。开发人员需要对代码进行频繁的测试并及时反馈测试结果,解决可能存在的问题。
需要注意的是,高测试覆盖率并不意味着代码没有缺陷或错误。测试覆盖率工具可帮助开发人员发现潜在的问题,但也有可能会忽略某些不易发现的问题。因此,开发人员需要综合运用各种测试方法和分析工具,确保代码质量和稳定性。
-
必须人工检查覆盖间隙的安全性
在多线程程序中,如果没有正确地进行同步保护,可能会导致数据竞争等问题,这些问题可能不容易被测试覆盖率工具发现。因此,在保证高测试覆盖率的基础上,还需要对可能存在的竞争问题进行人工检查,以确保程序在多线程环境下的安全性。
覆盖间隙指的是代码中在不同线程之间可能发生竞态条件的代码部分。这些覆盖间隙可能由于同步保护不正确而导致数据错误、崩溃等问题。在进行人工检查时,需要仔细查看程序中的同步保护代码,以确保程序在多线程条件下的正确性。
人工检查覆盖间隙的安全性可以考虑以下几点:- 需要确保多线程程序中的所有共享数据都能够正确地受到保护,并且能够避免不必要的竞争情况。
- 需要仔细检查程序中使用的锁、互斥对象等同步机制,确保其正确性和完整性。
- 需要仔细检查程序中的数据定义和使用方式,确保每个数据的读写正确性。需要特别关注对于指针、共享数据结构等数据类型的使用
- 需要确保未对被竞争访问的数据进行读写操作,并能够避免同一数据被多个线程同时访问而发生竞态条件。
- 需要适当地使用同步保护机制,如锁、条件变量等,以减少多线程中的竞争情况,从而确保程序的正确性。
需要注意的是,虽然人工检查可以发现一些测试覆盖率工具难以发现的竞态问题,但是这种检查并不是完全可靠的。因此,开发人员应该使用多种检测工具和技术来检查多线程程序的安全性,以确保程序的正确性、可靠性和稳定性。
- 错误处理代码必须在测试用例中执行,这可能很难实现
故障注入的经典方法
宏:污染生产代码,难以扩展
代码注入是在程序运行时将代码插入到现有代码中,以改变程序的行为。宏在代码注入中被广泛使用,但是如果使用不当,也可能导致生产代码污染且难以扩展。
以下是一个使用宏注入代码,污染生产代码且难以扩展的C++代码例子:
#define CALL_MY_FUNCTION() \
my_function()
class MyClass {
public:
void do_something() {
// 原本的业务逻辑
// ...
// 在某个位置注入代码
CALL_MY_FUNCTION();
// 原本的业务逻辑
// ...
}
};
void my_function() {
// 新添加的代码,可能是用于故障注入或其他目的
// ...
}
int main() {
// 正常的生产代码,用于初始化系统和启动程序
// ...
MyClass obj;
obj.do_something(); // 调用 MyClass 的成员函数
// 正常的生产代码,用于关闭程序和释放资源
// ...
return 0;
}
在上面的代码中,使用宏 CALL_MY_FUNCTION() 在 MyClass::do_something() 函数中注入了代码,以调用另一个函数 my_function()。这种方式可能会导致生产代码被污染,因为注入的代码会改变原有的程序逻辑。此外,如果需要添加新的测试场景或更改测试策略,则需要修改宏代码或其他函数的声明和定义,这可能会增加代码的复杂性和维护难度。
用mock库替换共享库:不会污染生产代码,但很难维护,并不总是适用
用mock库替换共享库是一种在测试过程中替换依赖项的技术,以便更轻松地对系统进行单元测试。与在代码中添加宏或代码注入不同,这种方法不会直接污染生产代码,因为代码依赖关系在测试代码中被改变,而不是在生产代码中。
然而,用mock库替换共享库可能很难维护,因为它需要在测试代码和生产代码之间维护一致性。例如,如果共享库中的接口发生了更改,mock库也必须相应地进行更改,以避免造成测试和生产环境之间的不一致性。
此外,并不是所有的依赖项都可以用mock库进行替换或模拟。例如,某些依赖项可能需要与外部硬件或API交互,这些依赖项可能无法用mock实现。
用mock库替换共享库是一种不会直接污染生产代码的测试技术,但在维护和适用性方面存在一些挑战。需要根据实际情况进行权衡和决策,以选择适合自己项目的测试技术。
c++的故障注入方法:模板
- 应该注入故障的函数的类包装器。
- 避免在这个包装器中使用逻辑,因为这个逻辑需要再次测试。
- 使用模板参数注入所需的包装器。
- 使用typedef从用户那里隐藏失败注入。
- 在测试代码中,使用模拟包装器作为模板参数。
#include <pthread.h>
#include <stdexcept>
// 使用 PThreadWrapper 来包装 pthread_create 函数
struct PThreadWrapper {
static int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg) {
return pthread_create(thread, attr, start_routine, arg);
}
};
// 使用 PThreadWrapperMock 来伪造“故障”发生
struct PThreadWrapperMock {
static int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void*), void* arg) {
return -1;
}
};
// 通过模板元编程和别名进行故障注入和封装
namespace detail {
template <class ImplT>
class Thread {
public:
Thread() {
// 委托实际操作给具体的实现类 PThreadWrapper 或 PThreadWrapperMock
if (ImplT::pthread_create(&t, nullptr, f, nullptr)) {
// 线程创建失败时抛出运行时异常
throw std::runtime_error("Thread creation failed");
}
}
pthread_t t;
private:
// 定义一个静态函数作为线程执行的函数
// 此处的实现并不重要,只需要保证它存在即可
static void* f(void* arg) {
return nullptr;
}
};
} // namespace detail
// 使用 typedef 包装别名,以方便使用
using Thread = detail::Thread<PThreadWrapper>;
int main() {
// 生产代码
Thread thread;
// 测试代码
detail::Thread<PThreadWrapperMock> test_thread;
return 0;
}
c++故障注入方法:多态性
c++的失败注入方法:
#include <iostream>
// 定义多态接口类
class FailureInjector {
public:
virtual int doSomething() = 0; // 基类纯虚函数
};
// 实现函数的真实调用
class RealImplementation : public FailureInjector {
public:
int doSomething() override {
// 真实实现的逻辑代码
return 1;
}
};
// 实现注入故障的模拟调用
class FailureInjectionMock : public FailureInjector {
public:
int doSomething() override {
// 模拟代码示例:在真实调用之前抛出异常
throw std::runtime_error("Injected failure");
}
};
// 工厂类,根据配置返回相应的实现
class FailureInjectorFactory {
public:
static FailureInjector* createFailureInjector(bool isMock) {
if (isMock) {
return new FailureInjectionMock();
} else {
return new RealImplementation();
}
}
};
// 示例:使用工厂类获取注入故障的实现,并调用
int main() {
bool useMock = true; // 根据配置选择是否使用故障注入
FailureInjector* injector = FailureInjectorFactory::createFailureInjector(useMock);
try {
int result = injector->doSomething(); // 调用接口
std::cout << "Result: " << result << std::endl;
} catch (std::runtime_error e) {
std::cout << "Failed with error: " << e.what() << std::endl;
}
delete injector;
return 0;
}