原文: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.87.3870&rep=rep1&type=pdf
Abstract
动态内存分配器(malloc/free)在多线程环境下依靠互斥锁来保护共享数据的一致性。使用锁在性能,可用性,健壮性,程序灵活性方面有很多缺点。Lock-free的内存分配器能消除线程延迟或被杀死以及CPU的调度策略对程序的性能影响。这篇paper呈上了一个完整的无锁内存分配器。它的实现只使用被广泛支持的操作系统API和硬件原子指令。即使出现线程中断(thead termination)或死机故障(crash-failure),它也是可用的。由于是无锁的,它避免了死锁的情况。另外,我们的分配器是高度可扩展的,我们把空间溢出限制为常数因子,同时能够避免false sharing.另外,我们的分配器有优越的并发性能和极低的延迟。
1. Introduction
动态内存分配函数在多线程应用中广泛使用,应用范围从商业数据库,web服务器到数据挖掘,科学性质应用。目前的分配器使用互斥锁来保证线程安全。
Lock-free同步(synchronization)是一个合适的实现线程安全的替换方案。它有以下优点:
免疫死锁
异步信号安全(asynv-signal-safety):如果使用了互斥锁,他们将不会使用信号,因为无法保证异步信号安全。假设一个线程持有用户级的锁,这时它接受到一个信号,信号处理程序调用分配器,该程序也会请求同样的互斥锁,但是被中断的线程正持有这个锁。信号处理程序等待被中断的线程释放互斥锁,但是,在信号处理程序完成前,线程不会恢复。这就造成了死锁。因此,在使用互斥锁后,这些实现智能屏蔽中断或者在malloc,free时使用内核级别的锁。但是这样会损失很多性能。相反,一个无锁分配器能够保证异步信号安全,而不损失任何性能。
容忍优先倒置(tolerance to priority inversion):用户级别的锁很容易因为优先倒置导致死锁。高优先的线程A等待一个低优先的线程B释放锁,但是直到高优先的A完成前,低优先的线程B不会被调度。无锁同步能够无视线程的调度策略。
Kill-tolerant availability:一个无锁对象能够免疫死锁,即使在任意多的线程在操作它时被kill。这对于高可用的服务器很有用,这能使程序容忍不频繁的进程损失,来缓解临时的资源短缺。
抢占容忍(preemption-tolerance):当一个线程持有互斥锁时,其他线程抢占了处理器,但是由于抢占线程也在等待同样的锁,因此抢占线程不能执行下去。从持有锁的线程被抢占,到处理器重新调度持有锁的线程完成任务并施放锁,这之间的时间相当于是被浪费的时间。而无锁同步不在意线程的调度情况。
每个线程从自己的堆上请求内存,并释放块(blocks)到自己的堆上。然而,这是难以接受的解决方案,因为这导致了无限的内存消耗。即使这个程序的内存需求实际上很小。其他不可接受的特点,包括需要初始化很大一片地址空间,人为设置总体大小。限制特定线程或特定内存块大小的地址的预请求区域。可接受的方案应该是广泛适用且节省空间的,不应该强加对地址空间的使用限制。
为了建设我们的无锁分配器只使用简单的当前主流处理器支持的原子指令,我们把malloc和free分解为几个原子操作,组织分配器的数据结构
2. 原子指令
我们只需处理器支持Compare-and-swap(CAS)或者load-linked和store-conditionnal(LL/SC)的组合两者之一即可。像fetch-and-add或者swap都可以由CAS或LL/SC实现。
3. 实现
这个实现是在64位地址空间的。32位的版本会更加简单。同时64位的CAS操作兼容32位架构。
首先,我们来介绍这个分配器的结构。大型blocks将直接从OS请求,并释放到OS。对于小型blocks。较大的superblocks(比如16KB)组成heap.每一个superblock分割为多个大小相等的block。superblock根据block的size分布在size classes中。每个size class包含多个processor heap(即procheap结构),heap的数量与处理器的数量成正比。每个processor heap最多有一个active状态的superblock。active状态的superblock包含一个及以上数量的可用block。当用户请求一个可用block,superblock将会把一个可用block设为保留状态,以此保证线程能通过processor heap的地址访问到block。每一个superblock对应一个descriptor结构。每一个被请求的block包含8字节的指向superblock的前缀。在第一次调用malloc时,size classes结构和procheap结构(about 16 KB for a processor machine)将被请求并初始化。
调用mallock时,procheap一般已经有一个活跃状态的superblock了。线程原子地读取指向active superblock(descriptor结构)的指针,并保留一个block。然后,线程原子地从superblock中pop一个block,并更新descriptor结构。调用free时,push一个被释放的block到原先superblock的可用block的列表中,并更新descriptor结构。
之前我们讲述了三个主要结构:size class结构(保存),procheap结构,和descriptor结构。这里,还有两个辅助结构,anchor结构和active结构。anchor是decriptor的辅助结构。Active是procheap的辅助结构。如果proheap结构中的active域不为NULL,那就说明当前有active状态的superblock可用。当调用malloc,线程读取active结构,原子地对credits减1,然后验证active superblock是否仍然为active。
// Superblock descriptor structure typedef anchor : // fits in one atomic block unsigned avail:10, //index of the first available block in the superblock count:10, //count holds the number of unreserved blocks in the superblock state:2, //state holds the state of the superblock tag:42; //is used to prevent the ABA problem // state codes ACTIVE=0 FULL=1 PARTIAL=2 EMPTY=3 typedef descriptor : anchor Anchor; descriptor* Next; void* sb; // pointer to superblock procheap* heap; // pointer to owner procheap unsigned sz; // block size unsigned maxcount; // superblock size/sz // Processor heap structure typedef active : unsigned ptr:58, //a pointer to the descriptor of the active superblock owned by the processor heap credits:6; // number of blocks available for reservation in the active superblock less one typedef procheap : active Active; // initially NULL descriptor* Partial; // initially NULL sizeclass* sc; // pointer to parent sizeclass // Size class structure typedef sizeclass : descList Partial; // initially empty unsigned sz; // block size unsigned sbsize; // superblock size
在descriptor结构中的Anchor域包含有原子更新的子域。子域avail持有这个super block中第一个可用内存块的index。子域count持有superblock中已使用的内存块的个数。子域state持有superblock的状态。子域tag用来避免ABA问题。
处理器堆结构中的active域是指向该处理器拥有的当前活跃的superblock的descriptor的指针。如果active的值不为NULL,就保证了活跃superblock有至少一个内存块可用。子域credits持有活跃superblock可用内存块数减1,如果credits的值为n,那么superblock包含n+1个可用内存块。在一个典型的malloc操作中(比如当active!=NULL and credits>0),线程在验证活跃superblock仍然有效后,读取active然后原子地对credits减1。
superblock有以下四种状态:active,full,partial,empty。ACTIVE表示这个superblock在这个堆中是活跃superblock,或者一个线程正试图将它安装为active。FULL表示这个superblock中所有block都已经被分配或被保留。PARTIAL表示这个superblock是非ACTIVE的,并且包含未保留的可用的block。EMPTY表示如果free这个superblock是安全操作。
malloc算法.
如果block的size很大,block将直接从OS请求内存,它的前缀被设置为与size相关。不然,相应的堆用被请求的block大小和线程id标记。然后,线程试图以下述顺序请求block。
- 从堆的活跃superblock请求一个block.
- 如果没有活跃的superblock,试图从PARTIAL的superblock请求一个block.
- 如果都没有,那么请求一个新的superblock并尝试设置了ACTIVE
void* malloc(sz) { // Use sz and thread id to find heap. heap = find heap(sz); if (!heap) // Large block Allocate block from OS and return its address. while(1) { addr = MallocFromActive(heap); if (addr) return addr; addr = MallocFromPartial(heap); if (addr) return addr; addr = MallocFromNewSB(heap); if (addr) return addr; } }
Malloc form active superblock算法
绝大多数malloc请求经由此算法返回。此算法主要分为两步。第一步(代码1-6)请求读取指向active superblock的指针然后原子地对active结构中的credits域减1。对credits减1这个动作保留出1个block,然后检查active superblock是否仍然有效。在CAS成功之后,线程保证了1个block被保留且可用。第二步(代码7-18)对LIFO(即栈)进行lock-free的pop操作。线程从anchor.avail域中读取第一个可用block的index,然后读取下一个可用block的index。最后验证之前读取的第二个index确实为现在栈中的第二个index,然后把指针指向第二个可用block。
仅当anchor.avail等于oldanchor.avail时才验证为有效。线程X从anchor.avail从读取了值A,从*addr中读取了值B。读取B后,其他线程抢占了CPU并pop了block A,又pop了block B,最后push了block A回来。之后,线程A恢复了,并执行CAS。如果没有anchor.tag域,CAS将会发现anchor等于oldanchor并错误地执行swap操作,实际上,第二个可用block已经不是block B了。为了防止这个ABA问题,我们使用了IBM经典的tag机制。每当pop时,我们都对anchor.tag加1。这样,在上述情况发生时,由于anchor.tag不等于oldanchor.tag,CAS操作将会正确地返回false,程序返回循环顶部。tag的位数(bits)必须足够大,因为它只能递增。使用LL/SC原子操作能在原理层面避免ABA问题。
13-17行,线程检查这次操作是否提取了最后一个credit。如果确实是最后一个(credit==0),那么检查superblock是否有更多可用的block(由于descriptor.maxcount大于MAXCREDITS或者有block刚被free)。如果有更多block可用(count>0),线程将尽可能多地保留block。否则(count=0),将这个superblock声明为FULL
当这个线程提取了credit,它会试着执行UpdateActive函数来更新Active结构。多线程同时提取向一个superblock提取credit并没有ABA问题。最后,线程存储descriptor结构的地址到新请求的block 的最前部,以便于当block之后被free时能确定这个block来自于哪个superblock。每个block包含8字节的前部。
在行6的CAS成功后,行18的CAS成功前,superblock的状态可能由ACTIVE变为FULL或PARTIAL,或者变为另一个procheap的active superblock(但是必须是同一个size class)。但是这些都对原线程没有影响。
void* MallocFromActive(heap) { do { // First step: reserve block newactive = oldactive = heap->Active; if (!oldactive) return NULL; if (oldactive.credits == 0) newactive = NULL; else newactive.credits--; } until CAS(&heap->Active,oldactive,newactive); // Second step: pop block desc = mask credits(oldactive); do { // state may be ACTIVE, PARTIAL or FULL newanchor = oldanchor = desc->Anchor; addr = desc->sb+oldanchor.avail*desc->sz; next = *(unsigned*)addr; newanchor.avail = next; newanchor.tag++; if (oldactive.credits == 0) { // state must be ACTIVE if (oldanchor.count == 0) newanchor.state = FULL; else { morecredits = min(oldanchor.count,MAXCREDITS); newanchor.count -= morecredits; } } } until CAS(&desc->Anchor,oldanchor,newanchor); if (oldactive.credits==0 && oldanchor.count>0) UpdateActive(heap,desc,morecredits); *addr = desc; return addr+EIGHTBYTES; }
UpdateActive
当heap上没有active superblock时,调用UpdateActive会将desc->sb重新注册为当前active的superblock。然而,在重新注册之前,其他线程有可能注册了一个新的superblock。如果发生后述的情况,当前线程必须返回credit,并把superblock设为PARTIAL,再放入procheap.partial中,以供将来使用。
UpdateActive(heap,desc,morecredits) { newactive = desc; newactive.credits = morecredits-1; if CAS(&heap->Active,NULL,newactive) return; // Someone installed another active sb // Return credits to sb and make it partial do { newanchor = oldanchor = desc->Anchor; newanchor.count += morecredits; newanchor.state = PARTIAL; } until CAS(&desc->Anchor,oldanchor,newanchor); HeapPutPartial(desc); }
MallocFromPartial
当线程发现procheap.active==NULL时,它会调用mallocFromPartial。线程试图调用HeapGetPartial来获取PARTIAL状态的superblock。如果成功获取了一个superblock,它会尽可能多地保留block,在行10的CAS成功之后,线程成功保留了多个block。然后从它保留的block中pop一个出来供该线程使用,如果还有多余的block,就将这个superblock设为active,否则设为FULL。
在HeapGetPartial中,线程首先试图从procheap.partial从提取superblock,如果提取不到,那么从对应的size class的partial list中提取。
void* MallocFromPartial(heap) { retry: desc = HeapGetPartial(heap); if (!desc) return NULL; desc->heap = heap; do { // reserve blocks newanchor = oldanchor = desc->Anchor; if (oldanchor.state == EMPTY) { DescRetire(desc); goto retry; } // oldanchor state must be PARTIAL // oldanchor count must be > 0 morecredits = min(oldanchor.count-1,MAXCREDITS); newanchor.count -= morecredits+1; newanchor.state = (morecredits > 0) ? ACTIVE : FULL; } until CAS(&desc->Anchor,oldanchor,newanchor); { // pop reserved block newanchor = oldanchor = desc->Anchor; addr = desc->sb+oldanchor.avail*desc->sz; newanchor.avail = *(unsigned*)addr; newanchor.tag++; } until CAS(&desc->Anchor,oldanchor,newanchor); if (morecredits > 0) UpdateActive(heap,desc,morecredits); *addr = desc; return addr+EIGHTBYTES; } descriptor* HeapGetPartial(heap) { do { desc = heap->Partial; if (desc == NULL) return ListGetPartial(heap->sc); } until CAS(&heap->Partial,desc,NULL); return desc; }
Malloc from new superblock
如果线程找不到PARTIAL状态的superblock,它将调用mallocFromNewSB。线程调用DescAlloc请求一个新的descriptor,然后初始化这个descriptor(行2-11)。最后,用CAS尝试在procHeap.active注册这个superblock。当注册失败,说明有新的active superblock已经注册,那么就删除该线程生成的这个desciptor和对应的superblock,去使用那个已注册的active superblock。当然你也可以使用自己注册的这个superblock,并设置为PARTIAL。我们为了避免太多的PARTIAL superblock和因此产生的不必要的碎片,我们更倾向于直接free这个superblock。
在内存一致性(memory consistency)弱于顺序一致性(sequential consistency)的系统中,处理器可能无序地执行和观察内存访问,内存屏障可以用来确保内存访问的顺序。行12的内存屏障确保在该superblock注册成功前,相应的descriptor结构同步到其他处理器。如果没有这个内存屏障,在CAS之后,其他处理器上的线程可能读到过时的值。
void* MallocFromNewSB(heap) { desc = DescAlloc(); desc->sb = AllocNewSB(heap->sc->sbsize); Organize blocks in a linked list starting with index 0. desc->heap = heap; desc->Anchor.avail = 1; desc->sz = heap->sc->sz; desc->maxcount = heap->sc->sbsize/desc->sz; newactive = desc; newactive.credits = min(desc->maxcount-1,MAXCREDITS)-1; desc->Anchor.count = (desc->maxcount-1)-(newactive.credits+1); desc->Anchor.state = ACTIVE; memory fence. if CAS((&heap->Active,NULL,newactive) { addr = desc->sb; *addr = desc; return addr+EIGHTBYTES; } else { Free the superblock desc->sb. DescRetire(desc); return NULL; } }
后面还有些free算法和性能检测, 就不翻译了