用 C++ 写多线程算法,如果程序设计过程中完全不用 new、delete、malloc、free 等动态申请、释放内存,理论上讲,变量内存会完全分配在栈上。如果不用全局变量和指针引用,理论上讲,算法可以实现无锁设计。
但问题来了,如果算法用 OOP 方法设计,数据能保证是完全分配在栈上吗?这里我们梳理一下这个问题,研究一下如何写出高效率的多线程无锁算法。
假设你已经清楚什么是堆,什么是栈。如果需要在堆上创建对象,要么使用new运算符,要么使用malloc系列函数。这点没有异议。真正有异议的是下面的代码:
Object obj;
此时,obj是在栈上分配的吗?要回答这个问题,我们首先要理解这个语句是什么意思。这个语句就是代表着,在栈上创建对象吗?其实,这行语句的含义是,使对象obj具有“自动存储(automatic storage)”的性质。所谓“自动存储”,意思是这个对象的存储位置取决于其声明所在的上下文。
class Class
{
Object obj;
};
Class *pClass = new Class;
指针pClass所指向的对象在堆上分配空间。因为Object obj;语句的含义是“自动存储”,所以,pClass->obj也是在堆上创建的。
理解了这一点,再来看下面的语句:
Object *pObj;
pObj = new Object;
Object *pObj;代表,指针pObj是自动存储的,仅此而已,没有任何其它含义。而下面一行语句则指出,这个指针所指向的对象是在堆上面分配的。如果这两行语句出现在一个函数内部,意味着当函数结束时,pObj会被销毁,但是它指向的对象不会。因此,为了继续使用这个对象,通常我们会在函数最后添加一个return语句,或者使用一个传出参数。否则的话,这个在堆上创建的对象就没有指针指向它,也就是说,这个对象造成了内存泄露。
并不是说指针指向的对象都是在堆上创建的。下面的代码则使用指针指向一个在栈上创建的对象:
Object obj;
Object *pObj = &obj;
至此,我们解释了函数内部的变量和成员变量。还有两类变量:全局变量和static变量。它们即不在堆上创建,也不在栈上创建。它们有自己的内存空间,是除堆和栈以外的数据区。也就是说,当Object obj即不在函数内部,又不是类的成员变量时,这个对象会在全局数据段创建,同理适用于static变量。对于指针Object *pObj;,如果这个语句出现在函数内部或类的成员变量,正如我们前面所说的,这个指针是自动存储的。但是,如果这个语句是在类的外部,它就是在全局数据段创建的。虽然它指向的对象可能在堆上创建,也可能在栈上创建。
第一点才是我们需要着重考虑的。由于栈的特性,如果你需要一个具有比其所在的上下文更长的生命周期的变量,只能在堆上创建它。所以,我们的推荐是:只要能在栈上创建对象,就在栈上创建;否则的话,如果你不得不需要更长的生命周期,只能选择堆上创建。这是由于在栈上的对象不需要我们手动管理内存。有经验的开发人员都会对内存管理感到头疼,我们就是要避免这种情况的发生。总的来说,我们更多推荐选择在栈上创建对象。
但是,有些情况,即便你在栈上创建了对象,它还是会占用堆的空间。考虑如下代码:
void func
{
std::vector v;
}
对象v是在栈上创建的。但是,STL 的vector类其实是在堆上面存储数据的(这点可以查看源代码)。因此,只有对象v本身是在栈上的,它所管理的数据(这些数据大多数时候都会远大于其本身的大小)还是保存在堆上。
关于第二点性能,有影响,不过一般可以忽略不计。确切的说,一般情况下你不需要考虑性能问题,除非它真的是一个问题。
首先,**在堆上创建对象需要追踪内存的可用区域。这个算法是由操作系统提供,通常不会是常量时间的。**当内存出现大量碎片,或者几乎用到 100% 内存时,这个过程会变得更久。与此相比,栈分配是常量时间的。其次,栈的大小是固定的,并且远小于堆的大小。所以,如果你需要分配很大的对象,或者很多很多小对象,一般而言,堆是更好的选择。如果你分配的对象大小超出栈的大小,通常会抛出一个异常。尽管很罕见,但是有时候也的确会发生。有关性能方面的问题,更多出现在嵌入式开发中:频繁地分配、释放内存可能造成碎片问题。
现代操作系统中,堆和栈都可以映射到虚拟内存中。在 32 位 Linux,我们可以把一个 2G 的数据放入堆中,而在 Mac OS 中,栈可能会限制为 65M。
总的来说,关于究竟在堆上,还是在栈上创建对象,首要考虑你所需要的生命周期。当性能真正成为瓶颈的时候,才去考虑性能的问题。堆和栈是提供给开发者的两个不同的工具,不存在一个放之四海而皆准的规则告诉你,一个对象必须放在堆中还是在栈中。选择权在开发者手中,决定权在开发者的经验中。
内存管理是操作系统提供的核心功能之一。C++的内存管理,必须建立在操作系统的基础上。
长期以来,在计算机系统中,内存都是一种紧缺和宝贵的资源,应用程序必须在载入内存后才能执行。早期,在内存空间不够大时,同时运行的应用程序的数量会受到很大的限制,甚至当某个应用程序在某个运行时所需内存超过物理内存时,应用程序就会无法运行。现代操作系统(Windows、Linux)通过引入虚拟内存进行内存管理,解决了应用程序在内存不足时不能运行的问题。
本质上,虚拟内存就是要让一个程序的代码和数据在没有全部载入内存时即可运行。运行过程中,当执行到尚未载入内存的代码,或者要访问还没有载入到内存的数据时,虚拟内存管理器动态地将相应的代码或数据从硬盘载入到内存中。而且在通常情况下,虚拟内存管理器也会相应地先将内存中某些代码或数据置换到硬盘中,为即将载入的代码或数据腾出空间。
因为内存和硬盘间的数据传输相对于代码执行非常慢,因此虚拟内存管理器在保证工作正确的前提下还必须考虑效率因素,如需要优化置换算法,尽量避免将要被执行的代码或访问的数据刚被置换出内存,而很久没有访问的代码或数据却一直驻留在内存中。虚拟内存管理器还需要将驻留在内存中的各个进程的代码数据维持在一个合理的数量上,并且根据进程性能的表现动态调整,使得程序运行时将涉及的磁盘IO次数降到尽可能低,以提高程序的运行性能。
Win32虚拟内存管理器为每一个Win32进程提供了进程私有并且基于页的4GB(32bit)大小的线性虚拟地址空间。
进程私有即每个进程只能访问属于自己的内存空间,而无法访问属于其它进程的地址空间,也不用担心自己的地址空间被其它进程看到(父子进程例外,比如调试器利用父子进程关系来访问被被调试进程的地址空间)。进程运行时用到的dll并没有属于自己的地址空间,而是其所属进程的虚拟地址空间,dll的全局数据,以及通过dll函数申请的内存都是从调用其进程的虚拟地址空间开辟的。
基于页是指虚拟地址空间被划分为多个称为页的单元,页的大小由底层处理器决定,x86架构处理器中页的大小为4KB。页是Win32虚拟内存管理器处理的最小单元,相应的物理内存也被划分为多个页。虚拟内存地址空间的申请和释放,以及内存和磁盘的数据传输或置换都是以页为最小单位进行的。
4GB大小意味着进程中的地址取值范围可以从0x00000000到0xFFFFFFFF,Win32将低区的2GB留给进程使用,高区的2GB留给系统使用。
Win32中用来辅助实现虚拟内存的硬盘文件称为调页文件,可以有16个,调页文件用来存放被虚拟内存管理器置换出内存的数据。当调页文件的数据再次被进程访问时,虚拟内存管理器会将其从调页文件中置换进内存,进程可以正确对其访问。用户可以自己配置调页文件,出于空间利用效率和性能的考虑,程序代码不会被修改(包括exe和dll),所以当其所在页被置换出内存时,并不会被写进调页文件中,而是直接抛弃。当再次被需要时,虚拟内存管理器直接从存放程序代码的exe或dll文件中找到并调入内存。另外,对exe和dll文件中包含的只读数据的处理与程序代码处理相同,不会在调页文件中开辟空间存储。
当进程执行某段代码或访问某些数据,而代码或数据还不在内存中时,称为缺页错误。缺页错误的原因很多,最常见的是代码和数据被虚拟内存管理器置换出内存,虚拟内存管理器会在代码被执行或数据被访问前将其调入内存。内存置换对开发人员来说是透明的,大大简化了开发人员的工作。但调页错误涉及磁盘IO,大量的调页错误会大大降低程序的总体性能,因此需要了解缺页错误的主要原因和规避方法。
因为频繁的调页操作引起的磁盘IO会大大降低程序的运行效率,因此对每一个进程,虚拟内存管理器都会将一定量的内存页驻留在物理内存中,并跟踪其执行的性能指标,并动态调整驻留的内存页数量。Win32中驻留在物理内存中的内存页称为进程的工作集(working set),进程的工作集可以通过任务管理器查看,内存使用列即为工作集大小。
工作集是会动态变化的,进程初始时只有很少的代码页和数据页被调入物理内存。当执行到未被调入内存的代码或访问到尚未调入内存的数据时,相应代码页或数据页会被调入物理内存,工作集也会随之增加。但工作集不能无限增加,系统为每个进程设定了一个最小工作集和最大工作集,当工作集达到最大工作集大小,进程需要再次调入新页到物理内存时,虚拟内存管理器会架构原来工作集中某些内存页先置换出物理内存,然后再将需要调入的新页调入内存。
因为工作集的页驻留在物理内存中,对工作集页的访问不会涉及磁盘IO,因此速度非常快。如果访问的代码或数据不在工作集中,会引发额外的磁盘IO,从而降低程序的执行效率。极端情况下会出现所谓的颠簸或抖动(thrashing),即程序的大部分执行时间都花在调页操作上,而不是执行代码上。
Linux的内存管理主要分为两部分,一部分负责物理内存的申请与释放,物理内存的申请与释放的最小单位为页,在IA32中,页的大小为4KB;另一部分负责处理虚拟内存,虚拟内存的主要操作包括虚拟地址空间与物理地址空间的映射,物理内存页与磁盘页之间的置换等。
一个32位Linux进程的地址空间为4GB,其中高位1GB,即0XC0000000–0XFFFFFFFF,为内核空间,低位3GB,即0X00000000–0XBFFFFFFF为用户地址空间。用户地址空间进一步被分为程序代码区、数据区(包括初始化数据区DATA和未初始化数据区BSS)、堆和栈。程序代码区占据最低端,往上是初始化数据区DATA和未初始化数据区BSS。代码区存放应用程序的机器代码,运行过程中代码不能修改,因此代码区内存为只读,且大小固定。数据区中存放应用程序的全局数据,静态数据和常量字符串,数据区大小也是固定的。
堆从未初始化数据区开始,向上端动态增长,增长过程中虚拟地址值变大;栈从高位地址开始,向下动态增长,虚拟地址值变小。
堆是应用程序在运行过程中动态申请的内存空间,如通过malloc/new动态生成对象或开辟内存空间时,最终会调用系统调用brk来动态调整数据区的大小。当申请的动态内存区域使用完毕,需要开发者明确使用相应的free/delete对申请的动态内存空间进行释放,free/delete最终也会使用brk系统调用调整数据区的大小。
栈是用来存放函数的传入参数、临时变量以及返回地址等数据,不需要通过malloc/new开辟空间,栈的增长与缩减是因为函数的调用与返回,不需要开发人员操作,没有内存泄漏的危险。
初始化数据区存放的是编译期就能够知道由程序设定初始值的全局变量及静态变量等,其初始值必须保存在最终生成的二进制文件中,并且在程序运行时会原封不动地将此区域映射到进程的初始化数据区。如果一个全局变量或静态变量在源代码中没有被赋初始值,在程序启动后,在第一次被赋值前,其初始值为0,本质上是有初始值的,其初始值为0。但当最终生成二进制文件时,未初始化数据区不会占据对应变量总大小的区域,而是只用一个值进行标识其未初始化数据区的总大小。如一个程序的代码指令有100KB,所有初始化数据总大小为100KB,所有未初始化数据总大小为150KB,则在最终生成的二进制文件中代码区有100KB,接着是100KB的初始化数据区,然后是4字节的大小空间,用于标记未初始化数据区大小,其值为150X1024,用于节省磁盘空间。但在进程虚拟地址空间中,对应未初始化数据区的大小必须是150KB,因为在程序运行时,程序必须真正能够访问到变量中的每一个,即当程序启动时,当检测到二进制文件中未初始化数据区的值为150X1024,则系统会开辟出150KB大小的区域作为进程的未初始化数据区并同时使用0对其进行初始化。
物理内存是用来存放代码指令与供代码指令操作的数据的最终场所,因此物理内存的管理是内存管理系统极其重要的任务。Linux使用页分配器(page allocator)来管理物理内存,页分配器负责分配和回收所有的物理内存页(物理内存的分配与回收的最小单位为4KB大小的页)。
页分配器的核心算法称为兄弟堆算法(buddy-heap algorithm),算法思想是每个物理内存区域都会有一个与之相邻的所谓兄弟区域,当两个区域被回收后,会被合并成为一个区域。如果被合并区域的相邻区域也被回收后,会被进一步合并为更大的区域。当有物理内存请求到来时,页分配器会首先检测是否有大小与之一致的区域。如果有,直接使用找到的匹配区域满足请求;如果没有,则找到更大的一个区域,并继续划分,直到分出的区域能够满足请求。为了配合兄弟堆算法,必须有链表来记录自由的物理内存区域,对于每个相同大小的自由区域,会有一个链表将其连接,每种大小的区域都会有一个链表对其进行管理。自由区域的大小都是2的幂。
当有一个8KB大小的内存请求到来,当前最小可供分配的区域为64KB,此时64KB会被划分为两个32KB,继而将低位的32KB继续划分为两个16KB大小的区域,再将最低位的16KB大小区域划分为两个8KB大小的区域,然后分配高位的8KB区域满足请求。
虚拟内存管理器的主要任务是维护应用程序的虚拟地址空间使用信息,如哪些区域已经被使用(映射),是否有磁盘文件作为备份存储。如果有,每个区域对应在磁盘的哪个区域,另外一个重要功能就是调页,如程序访问某些尚未调至物理内存的数据时,虚拟内存管理器负责定位数据,并将其置换进物理内存。如果物理内存此时没有自由页,还需要将物理内存中的某些页先置换出去。
用来维护应用程序的虚拟地址空间使用信息的数据结构是vm_area_struct。每个vm_area_struct结构体都描述了一个进程虚拟地址空间中被分配的区域,当vm_area_struct个数不超过32个时,被连接成为一个链表;当超过32个时,所有的vm_area_struct会被组织为一棵自平衡二叉树,利于提高查询速度。当程序通过某个指针访问某个数据时,系统会查询vm_area_struct树,如果发现指针没有落在任何一个vm_area_struct所表示的区域内,则判定指针所代表的地址没有被分配,即非法的指针访问。
当通过程序的指针访问某个数据时,因为指针本质是一个虚拟地址值,因此虚拟地址值必须被转化为物理地址值,才能真正访问其所指代的数据。
Linux使用三层映射策略将一个虚拟地址映射为一个物理地址。与Windows相比,多了Middle层,当对于IA32体系,Middle层没有用,因此Linux与Windows相同。
函数局部变量、函数参数分配在栈上。
void func()
{
Obj myObj;
...
}
函数传递引用参数时,尽量加上 const 修饰符,这样就不会有副作用,也不用加锁。
void func(const Obj& myObj)
{
...
}
尽管我们可以避免很多线程件的共享冲突,但有一点总无法回避,所有线程必须在同一台电脑的内存空间运行,频繁在堆上申请和释放内存,会导致性能劣化,可靠性降低。